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    CatppuccinLatte,
14    GruvboxDark,
15    Dracula,
16    Nord,
17    TokyoNight,
18}
19
20impl ThemeMode {
21    pub const ALL: &'static [ThemeMode] = &[
22        ThemeMode::Dark,
23        ThemeMode::Light,
24        ThemeMode::CatppuccinMocha,
25        ThemeMode::CatppuccinLatte,
26        ThemeMode::GruvboxDark,
27        ThemeMode::Dracula,
28        ThemeMode::Nord,
29        ThemeMode::TokyoNight,
30    ];
31
32    /// The next theme in the cycle — what `t` steps through.
33    pub fn toggled(self) -> Self {
34        let idx = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
35        Self::ALL[(idx + 1) % Self::ALL.len()]
36    }
37
38    pub fn name(&self) -> &'static str {
39        match self {
40            ThemeMode::Dark => "Dark (terminal colors)",
41            ThemeMode::Light => "Light",
42            ThemeMode::CatppuccinMocha => "Catppuccin Mocha",
43            ThemeMode::CatppuccinLatte => "Catppuccin Latte",
44            ThemeMode::GruvboxDark => "Gruvbox Dark",
45            ThemeMode::Dracula => "Dracula",
46            ThemeMode::Nord => "Nord",
47            ThemeMode::TokyoNight => "Tokyo Night",
48        }
49    }
50
51    /// Stable id, same kebab-case form the --theme flag accepts.
52    pub fn id(&self) -> &'static str {
53        match self {
54            ThemeMode::Dark => "dark",
55            ThemeMode::Light => "light",
56            ThemeMode::CatppuccinMocha => "catppuccin-mocha",
57            ThemeMode::CatppuccinLatte => "catppuccin-latte",
58            ThemeMode::GruvboxDark => "gruvbox",
59            ThemeMode::Dracula => "dracula",
60            ThemeMode::Nord => "nord",
61            ThemeMode::TokyoNight => "tokyo-night",
62        }
63    }
64
65    pub fn from_id(id: &str) -> Option<Self> {
66        Self::ALL.iter().copied().find(|t| t.id() == id)
67    }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub enum Panel {
72    Clusters,
73    Jobs,
74    Pipelines,
75    Warehouses,
76    Dashboards,
77    Catalog,
78    Secrets,
79}
80
81impl Panel {
82    pub const ALL: &'static [Panel] = &[
83        Panel::Clusters,
84        Panel::Jobs,
85        Panel::Pipelines,
86        Panel::Warehouses,
87        Panel::Dashboards,
88        Panel::Catalog,
89        Panel::Secrets,
90    ];
91
92    pub fn title(&self) -> &'static str {
93        match self {
94            Panel::Clusters => "Compute",
95            Panel::Jobs => "Lakeflow Jobs",
96            Panel::Pipelines => "Lakeflow Pipelines",
97            Panel::Warehouses => "SQL Warehouses",
98            Panel::Dashboards => "AI/BI Dashboards",
99            Panel::Catalog => "Unity Catalog",
100            Panel::Secrets => "Secret Scopes",
101        }
102    }
103
104    pub fn icon(&self) -> &'static str {
105        match self {
106            Panel::Clusters => "⌬",
107            Panel::Jobs => "⟳",
108            Panel::Pipelines => "⋙",
109            Panel::Warehouses => "⌁",
110            Panel::Dashboards => "▦",
111            Panel::Catalog => "⧉",
112            Panel::Secrets => "◈",
113        }
114    }
115
116    /// Stable id used in the config file.
117    pub fn id(&self) -> &'static str {
118        match self {
119            Panel::Clusters => "compute",
120            Panel::Jobs => "jobs",
121            Panel::Pipelines => "pipelines",
122            Panel::Warehouses => "warehouses",
123            Panel::Dashboards => "dashboards",
124            Panel::Catalog => "catalog",
125            Panel::Secrets => "secrets",
126        }
127    }
128
129    /// The databricks CLI command group whose `get <id>` returns item details.
130    pub fn cli_group(&self) -> &'static str {
131        match self {
132            Panel::Clusters => "clusters",
133            Panel::Jobs => "jobs",
134            Panel::Pipelines => "pipelines",
135            Panel::Warehouses => "warehouses",
136            Panel::Dashboards => "lakeview",
137            Panel::Catalog => "tables",
138            Panel::Secrets => "secrets",
139        }
140    }
141}
142
143pub struct Detail {
144    pub panel: Panel,
145    pub name: String,
146    pub id: String,
147    /// Item kind for Unity Catalog leaves (TABLE / VIEW / VOLUME).
148    pub kind: Option<String>,
149    /// Heading of the activity section ("Recent activity", "Access", ...).
150    pub section: &'static str,
151    /// None while the fetch is in flight.
152    pub data: Option<DetailData>,
153    /// Toggles between the formatted summary and the raw JSON.
154    pub show_raw: bool,
155    pub scroll: u16,
156}
157
158/// Full-screen sample-data view for a Unity Catalog table or view.
159pub struct Preview {
160    pub name: String,
161    /// Display name and id of the warehouse running the query.
162    pub warehouse: String,
163    pub warehouse_id: String,
164    /// None while the query runs; then rows or an error.
165    pub data: Option<Result<crate::shape::TableData, String>>,
166    /// Top visible row in the grid; the inspected row in record view.
167    pub scroll: usize,
168    /// First visible column, as an index into the filtered column list.
169    pub col: usize,
170    /// Case-insensitive substring filter over column names — the way
171    /// through a 500-column table.
172    pub filter: String,
173    pub filter_entry: bool,
174    /// Transposed view: one row, fields stacked vertically.
175    pub record: bool,
176    /// Field scroll within the record view.
177    pub rscroll: u16,
178}
179
180impl Preview {
181    /// Indices of columns whose name matches the filter (all when empty).
182    pub fn visible_cols(&self) -> Vec<usize> {
183        let Some(Ok(t)) = &self.data else {
184            return Vec::new();
185        };
186        let q = self.filter.to_lowercase();
187        (0..t.headers.len())
188            .filter(|&i| q.is_empty() || t.headers[i].to_lowercase().contains(&q))
189            .collect()
190    }
191}
192
193/// What a confirmed warehouse choice should run.
194enum PickTarget {
195    Preview(String),
196    Cost,
197    Lineage(String),
198    Sql(String),
199}
200
201/// Free-form SQL prompt with results, backed by the preview machinery.
202pub struct SqlConsole {
203    pub input: String,
204    /// Caret position in `input`, counted in characters.
205    pub cursor: usize,
206    /// Display name of the warehouse the last query ran on.
207    pub warehouse: String,
208    pub running: bool,
209    pub data: Option<Result<crate::shape::TableData, String>>,
210    /// The statement that produced `data`.
211    pub last_sql: String,
212    pub scroll: usize,
213    /// First visible result column (shift+←/→ pages wide results).
214    pub col: usize,
215}
216
217/// Where console history lives; one statement per line, oldest first.
218fn history_path() -> Option<std::path::PathBuf> {
219    let home = std::env::var_os("HOME")?;
220    Some(
221        std::path::PathBuf::from(home)
222            .join(".config")
223            .join("databricks-tui")
224            .join("history"),
225    )
226}
227
228fn load_history() -> Vec<String> {
229    history_path()
230        .and_then(|p| std::fs::read_to_string(p).ok())
231        .map(|s| {
232            s.lines()
233                .filter(|l| !l.trim().is_empty())
234                .map(str::to_string)
235                .collect()
236        })
237        .unwrap_or_default()
238}
239
240fn save_history(history: &[String]) {
241    let Some(path) = history_path() else {
242        return;
243    };
244    if let Some(dir) = path.parent() {
245        let _ = std::fs::create_dir_all(dir);
246        crate::config::restrict(dir, 0o700);
247    }
248    // Keep the tail; nobody scrolls back 200 queries.
249    let tail: Vec<&str> = history
250        .iter()
251        .rev()
252        .take(200)
253        .rev()
254        .map(String::as_str)
255        .collect();
256    // Queries can hold sensitive literals — owner-only, like shell history.
257    let _ = std::fs::write(&path, tail.join("\n") + "\n");
258    crate::config::restrict(&path, 0o600);
259}
260
261/// True when every char of `needle` appears in `haystack` in order.
262fn subsequence(haystack: &str, needle: &str) -> bool {
263    let mut chars = haystack.chars();
264    needle.chars().all(|n| chars.any(|h| h == n))
265}
266
267/// Byte offset of the `cursor`th character in `input`.
268fn byte_at(input: &str, cursor: usize) -> usize {
269    input
270        .char_indices()
271        .nth(cursor)
272        .map(|(i, _)| i)
273        .unwrap_or(input.len())
274}
275
276/// Overlay for choosing which SQL warehouse runs a query.
277pub struct WhPicker {
278    pub index: usize,
279    target: PickTarget,
280}
281
282/// Full-screen DBU usage view backed by system.billing.usage.
283pub struct CostView {
284    pub warehouse: String,
285    pub data: Option<Result<fetchers::cost::CostData, String>>,
286}
287
288/// Drill-down into a single job run or pipeline update, layered over
289/// the owning detail view.
290pub struct RunView {
291    /// Panel::Jobs (runs) or Panel::Pipelines (updates).
292    pub panel: Panel,
293    pub owner_name: String,
294    /// Job id or pipeline id the runs belong to.
295    owner_id: String,
296    /// Recent runs newest-first: (run_id, status, age).
297    pub runs: Vec<(String, Status, String)>,
298    /// Which of `runs` is shown.
299    pub idx: usize,
300    pub data: Option<DetailData>,
301    pub show_raw: bool,
302    pub scroll: u16,
303    /// True while the shown run is still executing — drives auto-refresh.
304    pub live: bool,
305    /// Full per-task output/logs, fetched on demand via `o`.
306    pub output: Option<String>,
307    pub show_output: bool,
308    fetched_at: Instant,
309}
310
311/// (recent runs, detail of the newest, still-executing flag)
312type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
313
314enum RunUpdate {
315    Opened(Result<RunOpened, String>),
316    Detail(DetailData, bool),
317    Output(String),
318}
319
320/// One unhealthy resource, pointing back at its pane and item.
321pub struct Problem {
322    pub panel: usize,
323    pub name: String,
324    pub status: Status,
325    pub note: String,
326}
327
328/// Overlay collecting everything currently failing across the panes.
329pub struct Problems {
330    pub items: Vec<Problem>,
331    pub index: usize,
332}
333
334/// A pending destructive/mutating action awaiting a y/n keystroke.
335pub struct Confirm {
336    pub message: String,
337    args: Vec<String>,
338}
339
340enum Update {
341    Panel(usize, Result<Shape, String>),
342    Badge(Option<Shape>),
343}
344
345const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
346
347pub struct App {
348    pub focus: Panel,
349    pub theme: ThemeMode,
350    pub zoomed: bool,
351    pub shapes: Vec<Option<Shape>>,
352    pub user_badge: Option<Shape>,
353    pub error: Option<String>,
354    pub refresh_interval: Duration,
355    last_refresh: Instant,
356    pub loading: bool,
357    pub detail: Option<Detail>,
358    pub confirm: Option<Confirm>,
359    pub flash: Option<(String, Instant)>,
360    pub selected: [usize; 7],
361    pub host: Option<String>,
362    /// Available profiles from ~/.databrickscfg and the active one.
363    pub profiles: Vec<String>,
364    pub profile: Option<String>,
365    /// When Some, the workspace picker overlay is open at this index.
366    pub picker: Option<usize>,
367    /// When Some, the problems overlay is open.
368    pub problems: Option<Problems>,
369    /// Current position in the Unity Catalog tree: [], [catalog] or [catalog, schema].
370    pub uc_path: Vec<String>,
371    uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
372    pub preview: Option<Preview>,
373    preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
374    pub wh_picker: Option<WhPicker>,
375    /// Session-remembered (id, name) of the warehouse used for previews.
376    pub preview_warehouse: Option<(String, String)>,
377    pub cost: Option<CostView>,
378    #[allow(clippy::type_complexity)]
379    cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
380    /// Numeric id of the current workspace, resolved lazily for cost
381    /// scoping and cached for the session.
382    workspace_id: Option<String>,
383    pub sql: Option<SqlConsole>,
384    sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
385    /// Past console statements, oldest first; persisted across sessions.
386    sql_history: Vec<String>,
387    /// Position while cycling history with ↑/↓; None = editing a new line.
388    hist_idx: Option<usize>,
389    /// The unfinished statement stashed when history browsing starts.
390    hist_draft: String,
391    /// Ctrl+R incremental search: (query, nth-newest match shown).
392    pub hist_search: Option<(String, usize)>,
393    pub run_view: Option<RunView>,
394    run_rx: Option<oneshot::Receiver<RunUpdate>>,
395    pending: Option<mpsc::UnboundedReceiver<Update>>,
396    detail_rx: Option<oneshot::Receiver<DetailData>>,
397    action_rx: Option<oneshot::Receiver<Result<String, String>>>,
398    host_rx: Option<oneshot::Receiver<Option<String>>>,
399    in_flight: usize,
400    spinner_frame: usize,
401    /// Splash screen deadline; None once dismissed.
402    pub splash_until: Option<Instant>,
403    /// When each pane last received fresh data — drives the title flash.
404    pub updated_at: [Option<Instant>; 7],
405    /// Per-pane search filter; empty string means no filter.
406    pub filters: [String; 7],
407    /// True while the user is typing a filter for the focused pane.
408    pub filter_entry: bool,
409    /// Persisted preferences (theme, warehouse per profile).
410    pub config: crate::config::Config,
411    /// Failed item names per pane at the last refresh — None until the
412    /// pane has loaded once, so the first load never alerts.
413    failed_seen: [Option<std::collections::HashSet<String>>; 7],
414    /// Ctrl+P fuzzy jump overlay.
415    pub jump: Option<Jump>,
416    /// Canonical pane indices in display order.
417    pub pane_order: Vec<usize>,
418    /// Hidden flag per canonical pane index.
419    pub hidden: [bool; 7],
420    /// When Some, the pane-arrangement overlay is open at this position.
421    pub pane_cfg: Option<usize>,
422    /// True while the `?` help overlay is open.
423    pub help: bool,
424    /// Scroll offset of the help overlay.
425    pub help_scroll: u16,
426    /// Statement id of the in-flight console query, for cancellation.
427    sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
428    /// Drilled-into secret scope; None = the scopes listing.
429    pub secret_scope: Option<String>,
430    secrets_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
431    /// Create-scope / add-secret input form.
432    pub secret_form: Option<SecretForm>,
433}
434
435/// Ctrl+P overlay: fuzzy-search everything loaded, Enter jumps to it.
436pub struct Jump {
437    pub query: String,
438    pub index: usize,
439}
440
441/// Two-step input: a scope name, or a key then a (masked) value.
442pub struct SecretForm {
443    /// Scope the secret goes into; None = creating a new scope.
444    pub scope: Option<String>,
445    pub key: String,
446    pub value: String,
447    /// 0 = typing the name/key, 1 = typing the value.
448    pub stage: u8,
449}
450
451impl App {
452    pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
453        let mut app = Self {
454            focus: Panel::Clusters,
455            theme,
456            zoomed: false,
457            shapes: vec![None; 7],
458            user_badge: None,
459            error: None,
460            refresh_interval: Duration::from_secs(refresh_secs),
461            last_refresh: Instant::now()
462                .checked_sub(Duration::from_secs(refresh_secs + 1))
463                .unwrap_or(Instant::now()),
464            loading: false,
465            detail: None,
466            confirm: None,
467            flash: None,
468            selected: [0; 7],
469            host: None,
470            profiles: Vec::new(),
471            profile: None,
472            picker: None,
473            problems: None,
474            uc_path: Vec::new(),
475            uc_rx: None,
476            preview: None,
477            preview_rx: None,
478            wh_picker: None,
479            preview_warehouse: None,
480            cost: None,
481            cost_rx: None,
482            workspace_id: None,
483            sql: None,
484            sql_rx: None,
485            sql_history: load_history(),
486            hist_idx: None,
487            hist_draft: String::new(),
488            hist_search: None,
489            run_view: None,
490            run_rx: None,
491            pending: None,
492            detail_rx: None,
493            action_rx: None,
494            host_rx: None,
495            in_flight: 0,
496            spinner_frame: 0,
497            splash_until: Some(Instant::now() + Duration::from_millis(1600)),
498            updated_at: [None; 7],
499            filters: Default::default(),
500            filter_entry: false,
501            config: crate::config::Config::load(),
502            failed_seen: Default::default(),
503            jump: None,
504            pane_order: (0..7).collect(),
505            hidden: [false; 7],
506            pane_cfg: None,
507            help: false,
508            help_scroll: 0,
509            sql_stmt: None,
510            secret_scope: None,
511            secrets_rx: None,
512            secret_form: None,
513        };
514        app.load_pane_prefs();
515        app
516    }
517
518    /// Applies pane order/visibility from the config file.
519    fn load_pane_prefs(&mut self) {
520        let idx_of = |id: &str| Panel::ALL.iter().position(|p| p.id() == id);
521        let mut order: Vec<usize> = self
522            .config
523            .pane_order
524            .iter()
525            .filter_map(|id| idx_of(id))
526            .collect();
527        for i in 0..7 {
528            if !order.contains(&i) {
529                order.push(i);
530            }
531        }
532        self.pane_order = order;
533        for id in &self.config.hidden_panes {
534            if let Some(i) = idx_of(id) {
535                self.hidden[i] = true;
536            }
537        }
538        self.ensure_focus_visible();
539    }
540
541    fn persist_panes(&mut self) {
542        self.config.pane_order = self
543            .pane_order
544            .iter()
545            .map(|&i| Panel::ALL[i].id().to_string())
546            .collect();
547        self.config.hidden_panes = (0..7)
548            .filter(|&i| self.hidden[i])
549            .map(|i| Panel::ALL[i].id().to_string())
550            .collect();
551        self.config.save();
552    }
553
554    /// Canonical pane indices currently shown, in display order.
555    pub fn visible_panes(&self) -> Vec<usize> {
556        self.pane_order
557            .iter()
558            .copied()
559            .filter(|&i| !self.hidden[i])
560            .collect()
561    }
562
563    /// Moves focus off a hidden pane onto the first visible one.
564    fn ensure_focus_visible(&mut self) {
565        let visible = self.visible_panes();
566        let focus_idx = Panel::ALL
567            .iter()
568            .position(|p| p == &self.focus)
569            .unwrap_or(0);
570        if !visible.contains(&focus_idx) {
571            if let Some(&first) = visible.first() {
572                self.focus = Panel::ALL[first];
573            }
574        }
575    }
576
577    /// Unhides a pane (used when a jump targets it).
578    fn reveal_pane(&mut self, idx: usize) {
579        if self.hidden[idx] {
580            self.hidden[idx] = false;
581            self.persist_panes();
582        }
583    }
584
585    pub fn open_pane_cfg(&mut self) {
586        self.pane_cfg = Some(0);
587    }
588
589    pub fn pane_cfg_next(&mut self) {
590        if let Some(i) = self.pane_cfg {
591            self.pane_cfg = Some((i + 1).min(6));
592        }
593    }
594
595    pub fn pane_cfg_prev(&mut self) {
596        if let Some(i) = self.pane_cfg {
597            self.pane_cfg = Some(i.saturating_sub(1));
598        }
599    }
600
601    /// Space in the overlay: toggles visibility of the selected pane
602    /// (refusing to hide the last visible one).
603    pub fn pane_cfg_toggle(&mut self) {
604        let Some(pos) = self.pane_cfg else {
605            return;
606        };
607        let idx = self.pane_order[pos];
608        if !self.hidden[idx] && self.visible_panes().len() == 1 {
609            self.flash = Some((
610                "✗ at least one pane has to stay visible".to_string(),
611                Instant::now(),
612            ));
613            return;
614        }
615        self.hidden[idx] = !self.hidden[idx];
616        self.ensure_focus_visible();
617        self.persist_panes();
618    }
619
620    /// J/K in the overlay: moves the selected pane down/up in the order.
621    pub fn pane_cfg_move(&mut self, delta: i32) {
622        let Some(pos) = self.pane_cfg else {
623            return;
624        };
625        let new = if delta < 0 {
626            pos.saturating_sub(1)
627        } else {
628            (pos + 1).min(6)
629        };
630        if new != pos {
631            self.pane_order.swap(pos, new);
632            self.pane_cfg = Some(new);
633            self.persist_panes();
634        }
635    }
636
637    /// Flashes (and rings the bell) when a resource fails between one
638    /// refresh and the next.
639    fn alert_new_failures(&mut self, idx: usize) {
640        // Catalog "error rows" are listing problems, not runtime failures.
641        if idx >= 5 {
642            return;
643        }
644        let Some(Shape::List(items)) = &self.shapes[idx] else {
645            return;
646        };
647        let failed: std::collections::HashSet<String> = items
648            .iter()
649            .filter(|it| {
650                matches!(it.status, Status::Failed)
651                    || it
652                        .history
653                        .last()
654                        .is_some_and(|s| matches!(s, Status::Failed))
655            })
656            .map(|it| it.name.clone())
657            .collect();
658        if let Some(prev) = &self.failed_seen[idx] {
659            let mut newly: Vec<&String> = failed.difference(prev).collect();
660            if !newly.is_empty() {
661                newly.sort();
662                let extra = if newly.len() > 1 {
663                    format!(" (+{} more)", newly.len() - 1)
664                } else {
665                    String::new()
666                };
667                self.flash = Some((
668                    format!("✗ {}{extra} just failed — ! to inspect", newly[0]),
669                    Instant::now(),
670                ));
671                // Bell so a backgrounded terminal (or tmux) flags it too.
672                print!("\x07");
673                let _ = std::io::Write::flush(&mut std::io::stdout());
674            }
675        }
676        self.failed_seen[idx] = Some(failed);
677    }
678
679    /// Remembers the current theme across sessions.
680    pub fn persist_theme(&mut self) {
681        self.config.theme = Some(self.theme.id().to_string());
682        self.config.save();
683    }
684
685    /// Restores the remembered warehouse for the active profile.
686    pub fn restore_warehouse_pref(&mut self) {
687        let profile = self.profile.as_deref().unwrap_or("DEFAULT");
688        self.preview_warehouse = self.config.warehouses.get(profile).cloned();
689    }
690
691    pub fn splash_active(&self) -> bool {
692        self.splash_until
693            .map(|t| Instant::now() < t)
694            .unwrap_or(false)
695    }
696
697    pub fn dismiss_splash(&mut self) {
698        self.splash_until = None;
699    }
700
701    /// True while any pane's data just landed — keeps the flash decaying.
702    pub fn any_fresh(&self) -> bool {
703        self.updated_at
704            .iter()
705            .flatten()
706            .any(|t| t.elapsed() < Duration::from_millis(1200))
707    }
708
709    pub fn open_picker(&mut self) {
710        if self.profiles.is_empty() {
711            return;
712        }
713        let current = self
714            .profile
715            .as_deref()
716            .and_then(|p| self.profiles.iter().position(|n| n == p))
717            .unwrap_or(0);
718        self.picker = Some(current);
719    }
720
721    pub fn picker_next(&mut self) {
722        if let Some(i) = self.picker {
723            self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
724        }
725    }
726
727    pub fn picker_prev(&mut self) {
728        if let Some(i) = self.picker {
729            self.picker = Some(i.saturating_sub(1));
730        }
731    }
732
733    /// Confirms the picker selection; returns the new CLI handle to use.
734    pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
735        let idx = self.picker.take()?;
736        let name = self.profiles.get(idx)?.clone();
737        let profile_arg = if name == "DEFAULT" {
738            None
739        } else {
740            Some(name.clone())
741        };
742        self.profile = Some(name);
743
744        // Drop all workspace-specific state; panes go back to loading.
745        self.shapes = vec![None; 7];
746        self.user_badge = None;
747        self.host = None;
748        self.selected = [0; 7];
749        self.detail = None;
750        self.detail_rx = None;
751        self.confirm = None;
752        self.problems = None;
753        self.uc_path.clear();
754        self.uc_rx = None;
755        self.secret_scope = None;
756        self.secrets_rx = None;
757        self.secret_form = None;
758        self.preview = None;
759        self.preview_rx = None;
760        self.wh_picker = None;
761        self.preview_warehouse = None;
762        self.cost = None;
763        self.cost_rx = None;
764        self.workspace_id = None;
765        self.sql = None;
766        self.sql_rx = None;
767        self.run_view = None;
768        self.run_rx = None;
769        self.pending = None;
770        self.in_flight = 0;
771        self.loading = false;
772        self.zoomed = false;
773        self.filters = Default::default();
774        self.filter_entry = false;
775        self.failed_seen = Default::default();
776        self.jump = None;
777        self.sql_stmt = None;
778        self.restore_warehouse_pref();
779
780        Some(Arc::new(DatabricksCli::new(profile_arg)))
781    }
782
783    pub fn open_jump(&mut self) {
784        self.jump = Some(Jump {
785            query: String::new(),
786            index: 0,
787        });
788    }
789
790    /// Everything loaded that matches the jump query, best first:
791    /// (panel index, item name, kind/status label). Substring matches
792    /// rank above in-order subsequence matches.
793    pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
794        let Some(jump) = &self.jump else {
795            return Vec::new();
796        };
797        let q = jump.query.to_lowercase();
798        let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
799        for (i, shape) in self.shapes.iter().enumerate() {
800            let Some(Shape::List(items)) = shape else {
801                continue;
802            };
803            for it in items {
804                let name = it.name.to_lowercase();
805                let rank = if q.is_empty() || name.contains(&q) {
806                    0
807                } else if subsequence(&name, &q) {
808                    1
809                } else {
810                    continue;
811                };
812                scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
813            }
814        }
815        scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
816        scored
817            .into_iter()
818            .take(12)
819            .map(|(_, i, name, label)| (i, name, label))
820            .collect()
821    }
822
823    pub fn jump_push(&mut self, c: char) {
824        if let Some(j) = &mut self.jump {
825            j.query.push(c);
826            j.index = 0;
827        }
828    }
829
830    pub fn jump_pop(&mut self) {
831        if let Some(j) = &mut self.jump {
832            j.query.pop();
833            j.index = 0;
834        }
835    }
836
837    pub fn jump_next(&mut self) {
838        let len = self.jump_matches().len();
839        if let Some(j) = &mut self.jump {
840            j.index = (j.index + 1).min(len.saturating_sub(1));
841        }
842    }
843
844    pub fn jump_prev(&mut self) {
845        if let Some(j) = &mut self.jump {
846            j.index = j.index.saturating_sub(1);
847        }
848    }
849
850    /// Jumps focus and selection to the highlighted match.
851    pub fn jump_go(&mut self) {
852        let matches = self.jump_matches();
853        let Some(jump) = self.jump.take() else {
854            return;
855        };
856        let Some((panel_idx, name, _)) = matches.get(jump.index) else {
857            return;
858        };
859        self.reveal_pane(*panel_idx);
860        self.focus = Panel::ALL[*panel_idx];
861        self.filters[*panel_idx].clear();
862        if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
863            if let Some(pos) = items.iter().position(|i| &i.name == name) {
864                self.selected[*panel_idx] = pos;
865            }
866        }
867    }
868
869    /// Collects everything unhealthy across the loaded panes: items whose
870    /// status is failed, or whose most recent run failed.
871    pub fn open_problems(&mut self) {
872        let mut items = Vec::new();
873        for (i, shape) in self.shapes.iter().enumerate() {
874            let Some(Shape::List(list)) = shape else {
875                continue;
876            };
877            for it in list {
878                let failed_now = matches!(it.status, Status::Failed);
879                let failed_last = it
880                    .history
881                    .last()
882                    .is_some_and(|s| matches!(s, Status::Failed));
883                if failed_now || failed_last {
884                    let note = if failed_now {
885                        it.detail.clone().unwrap_or_default()
886                    } else {
887                        "latest run failed".to_string()
888                    };
889                    items.push(Problem {
890                        panel: i,
891                        name: it.name.clone(),
892                        status: it.status.clone(),
893                        note,
894                    });
895                }
896            }
897        }
898        self.problems = Some(Problems { items, index: 0 });
899    }
900
901    pub fn problems_next(&mut self) {
902        if let Some(pr) = &mut self.problems {
903            pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
904        }
905    }
906
907    pub fn problems_prev(&mut self) {
908        if let Some(pr) = &mut self.problems {
909            pr.index = pr.index.saturating_sub(1);
910        }
911    }
912
913    /// Jumps focus and selection to the highlighted problem's pane item.
914    pub fn problems_jump(&mut self) {
915        let Some(pr) = self.problems.take() else {
916            return;
917        };
918        let Some(problem) = pr.items.get(pr.index) else {
919            return;
920        };
921        self.reveal_pane(problem.panel);
922        self.focus = Panel::ALL[problem.panel];
923        // The pane's filter could hide the item we're jumping to.
924        self.filters[problem.panel].clear();
925        if let Some(Shape::List(list)) = &self.shapes[problem.panel] {
926            if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
927                self.selected[problem.panel] = pos;
928            }
929        }
930    }
931
932    /// Resolves the workspace host in the background — `auth describe` can
933    /// take seconds when it refreshes tokens, so it must not block the loop.
934    pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
935        let (tx, rx) = oneshot::channel();
936        self.host_rx = Some(rx);
937        let cli = Arc::clone(cli);
938        tokio::spawn(async move {
939            let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
940                json["details"]["host"]
941                    .as_str()
942                    .or_else(|| json["host"].as_str())
943                    .map(str::to_string)
944            });
945            let _ = tx.send(host);
946        });
947    }
948
949    pub fn poll_host(&mut self) {
950        if let Some(rx) = &mut self.host_rx {
951            match rx.try_recv() {
952                Ok(host) => {
953                    self.host = host;
954                    self.host_rx = None;
955                }
956                Err(oneshot::error::TryRecvError::Empty) => {}
957                Err(oneshot::error::TryRecvError::Closed) => {
958                    self.host_rx = None;
959                }
960            }
961        }
962    }
963
964    fn focus_index(&self) -> usize {
965        Panel::ALL
966            .iter()
967            .position(|p| p == &self.focus)
968            .unwrap_or(0)
969    }
970
971    fn list_len(&self, idx: usize) -> usize {
972        match &self.shapes[idx] {
973            Some(Shape::List(items)) => items
974                .iter()
975                .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
976                .count(),
977            _ => 0,
978        }
979    }
980
981    /// Selection index for a panel, clamped to the current list length.
982    pub fn selection(&self, idx: usize) -> usize {
983        self.selected[idx].min(self.list_len(idx).saturating_sub(1))
984    }
985
986    pub fn select_next(&mut self) {
987        let idx = self.focus_index();
988        let len = self.list_len(idx);
989        if len > 0 {
990            self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
991        }
992    }
993
994    pub fn select_prev(&mut self) {
995        let idx = self.focus_index();
996        self.selected[idx] = self.selection(idx).saturating_sub(1);
997    }
998
999    /// The currently highlighted item in the focused panel, respecting
1000    /// the pane's filter — the nth *visible* item, like the UI shows.
1001    fn selected_item(&self) -> Option<&crate::shape::ListItem> {
1002        let idx = self.focus_index();
1003        match &self.shapes[idx] {
1004            Some(Shape::List(items)) => items
1005                .iter()
1006                .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
1007                .nth(self.selection(idx)),
1008            _ => None,
1009        }
1010    }
1011
1012    /// Opens filter entry for the focused pane, starting from scratch.
1013    pub fn filter_start(&mut self) {
1014        let idx = self.focus_index();
1015        self.filters[idx].clear();
1016        self.selected[idx] = 0;
1017        self.filter_entry = true;
1018    }
1019
1020    pub fn filter_push(&mut self, c: char) {
1021        let idx = self.focus_index();
1022        self.filters[idx].push(c);
1023        self.selected[idx] = 0;
1024    }
1025
1026    pub fn filter_pop(&mut self) {
1027        let idx = self.focus_index();
1028        self.filters[idx].pop();
1029        self.selected[idx] = 0;
1030    }
1031
1032    /// Keeps the filter applied and returns keys to normal navigation.
1033    pub fn filter_accept(&mut self) {
1034        self.filter_entry = false;
1035    }
1036
1037    pub fn filter_clear(&mut self) {
1038        let idx = self.focus_index();
1039        self.filters[idx].clear();
1040        self.selected[idx] = 0;
1041        self.filter_entry = false;
1042    }
1043
1044    /// The focused pane's filter, if any.
1045    pub fn active_filter(&self) -> &str {
1046        &self.filters[self.focus_index()]
1047    }
1048
1049    pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
1050        let Some(item) = self.selected_item() else {
1051            return;
1052        };
1053        let Some(id) = item.id.clone() else {
1054            return;
1055        };
1056        let kind = match &item.status {
1057            Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
1058            _ => None,
1059        };
1060        let section = match self.focus {
1061            Panel::Dashboards => "Contents",
1062            Panel::Catalog => "Columns",
1063            Panel::Warehouses => "Recent queries",
1064            _ => "Recent activity",
1065        };
1066        self.detail = Some(Detail {
1067            panel: self.focus,
1068            name: item.name.clone(),
1069            id: id.clone(),
1070            kind,
1071            section,
1072            data: None,
1073            show_raw: false,
1074            scroll: 0,
1075        });
1076
1077        let (tx, rx) = oneshot::channel();
1078        self.detail_rx = Some(rx);
1079        let cli = Arc::clone(cli);
1080        let kind = self.detail.as_ref().unwrap().kind.clone();
1081        // Files in volumes get a content peek instead of an API `get`.
1082        if kind.as_deref() == Some("FILE") {
1083            if let Some(d) = &mut self.detail {
1084                d.section = "File head";
1085            }
1086            tokio::spawn(async move {
1087                let data = fetchers::catalog::file_peek(&cli, &id).await;
1088                let _ = tx.send(data);
1089            });
1090            return;
1091        }
1092        let group = match &kind {
1093            Some(k) if k == "VOLUME" => "volumes",
1094            _ => self.focus.cli_group(),
1095        };
1096        // Tables get extra facts from DESCRIBE DETAIL when a warehouse
1097        // is already remembered — free depth, no picker interruption.
1098        let warehouse = match &kind {
1099            Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
1100            _ => None,
1101        };
1102        tokio::spawn(async move {
1103            let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
1104            let _ = tx.send(data);
1105        });
1106    }
1107
1108    /// Descends one level in the Unity Catalog tree. Returns false when the
1109    /// selection is a leaf (caller should open the detail view instead).
1110    pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1111        if self.focus != Panel::Catalog {
1112            return false;
1113        }
1114        let Some(item) = self.selected_item() else {
1115            return self.uc_path.is_empty(); // empty root pane: swallow the key
1116        };
1117        // Below the schema level only volumes and their directories are
1118        // containers; tables/views fall through to the detail view.
1119        if self.uc_path.len() >= 2 {
1120            let drillable =
1121                matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
1122            if !drillable {
1123                return false;
1124            }
1125        }
1126        self.uc_path.push(item.name.clone());
1127        self.refresh_catalog(cli);
1128        true
1129    }
1130
1131    /// Ascends one level; returns false if already at the catalog root.
1132    pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1133        if self.focus != Panel::Catalog || self.uc_path.is_empty() {
1134            return false;
1135        }
1136        self.uc_path.pop();
1137        self.refresh_catalog(cli);
1138        true
1139    }
1140
1141    fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
1142        self.shapes[5] = None;
1143        self.selected[5] = 0;
1144        // A filter typed at one level would silently hide the next.
1145        self.filters[5].clear();
1146        let (tx, rx) = oneshot::channel();
1147        self.uc_rx = Some(rx);
1148        let cli = Arc::clone(cli);
1149        let path = self.uc_path.clone();
1150        tokio::spawn(async move {
1151            let result = fetchers::catalog::fetch(&cli, &path)
1152                .await
1153                .map_err(|e| format!("{e:#}"));
1154            let _ = tx.send(result);
1155        });
1156    }
1157
1158    /// Enter in the Secrets pane: descend from a scope into its keys.
1159    /// On a key it does nothing — secret values are never displayed —
1160    /// but still returns true so Enter never opens a (bogus) detail view.
1161    pub fn secrets_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1162        if self.focus != Panel::Secrets {
1163            return false;
1164        }
1165        // Already inside a scope: the selection is a key, nothing to open.
1166        if self.secret_scope.is_some() {
1167            return true;
1168        }
1169        let Some(item) = self.selected_item() else {
1170            return true; // empty pane: swallow the key
1171        };
1172        self.secret_scope = Some(item.name.clone());
1173        self.refresh_secrets(cli);
1174        true
1175    }
1176
1177    /// Backspace inside a scope: back to the scopes listing.
1178    pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1179        if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1180            return false;
1181        }
1182        self.secret_scope = None;
1183        self.refresh_secrets(cli);
1184        true
1185    }
1186
1187    fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1188        let idx = 6;
1189        self.shapes[idx] = None;
1190        self.selected[idx] = 0;
1191        self.filters[idx].clear();
1192        let (tx, rx) = oneshot::channel();
1193        self.secrets_rx = Some(rx);
1194        let cli = Arc::clone(cli);
1195        let scope = self.secret_scope.clone();
1196        tokio::spawn(async move {
1197            let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1198                .await
1199                .map_err(|e| format!("{e:#}"));
1200            let _ = tx.send(result);
1201        });
1202    }
1203
1204    pub fn poll_secrets(&mut self) -> bool {
1205        let Some(rx) = &mut self.secrets_rx else {
1206            return false;
1207        };
1208        match rx.try_recv() {
1209            Ok(result) => {
1210                self.shapes[6] = Some(match result {
1211                    Ok(shape) => shape,
1212                    Err(e) => Shape::Text(format!("✗ {e}")),
1213                });
1214                self.updated_at[6] = Some(Instant::now());
1215                self.secrets_rx = None;
1216                true
1217            }
1218            Err(oneshot::error::TryRecvError::Empty) => false,
1219            Err(oneshot::error::TryRecvError::Closed) => {
1220                self.secrets_rx = None;
1221                true
1222            }
1223        }
1224    }
1225
1226    /// `a` in the secrets pane: create a scope (top level) or add a
1227    /// secret (inside a scope).
1228    pub fn open_secret_form(&mut self) {
1229        if self.focus != Panel::Secrets {
1230            return;
1231        }
1232        self.secret_form = Some(SecretForm {
1233            scope: self.secret_scope.clone(),
1234            key: String::new(),
1235            value: String::new(),
1236            stage: 0,
1237        });
1238    }
1239
1240    pub fn secret_form_push(&mut self, c: char) {
1241        if let Some(form) = &mut self.secret_form {
1242            if form.stage == 0 {
1243                form.key.push(c);
1244            } else {
1245                form.value.push(c);
1246            }
1247        }
1248    }
1249
1250    pub fn secret_form_pop(&mut self) {
1251        if let Some(form) = &mut self.secret_form {
1252            if form.stage == 0 {
1253                form.key.pop();
1254            } else {
1255                form.value.pop();
1256            }
1257        }
1258    }
1259
1260    /// Enter in the form: advance to the value stage, or submit.
1261    pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1262        let Some(form) = &mut self.secret_form else {
1263            return;
1264        };
1265        if form.key.trim().is_empty() {
1266            return;
1267        }
1268        match (form.scope.clone(), form.stage) {
1269            // New scope: single field.
1270            (None, _) => {
1271                let name = form.key.trim().to_string();
1272                self.secret_form = None;
1273                self.run_secret_action(
1274                    cli,
1275                    format!("Create scope “{name}”"),
1276                    vec!["secrets".into(), "create-scope".into(), name],
1277                );
1278            }
1279            // Secret: key first, then value.
1280            (Some(_), 0) => form.stage = 1,
1281            (Some(scope), _) => {
1282                let key = form.key.trim().to_string();
1283                let value = form.value.clone();
1284                self.secret_form = None;
1285                self.run_secret_action(
1286                    cli,
1287                    format!("Put secret “{key}” in “{scope}”"),
1288                    vec![
1289                        "secrets".into(),
1290                        "put-secret".into(),
1291                        scope,
1292                        key,
1293                        "--string-value".into(),
1294                        value,
1295                    ],
1296                );
1297            }
1298        }
1299    }
1300
1301    /// Runs a secrets mutation right away (the form itself was the
1302    /// deliberate step); the usual action poll refreshes the panes.
1303    fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1304        self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1305        let (tx, rx) = oneshot::channel();
1306        self.action_rx = Some(rx);
1307        let cli = Arc::clone(cli);
1308        tokio::spawn(async move {
1309            let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1310            let result = match cli.run_action(&arg_refs).await {
1311                Ok(()) => Ok(format!("✓ {label} — done")),
1312                Err(e) => Err(format!("✗ {e:#}")),
1313            };
1314            let _ = tx.send(result);
1315        });
1316    }
1317
1318    /// `x` in the secrets pane: delete the selected scope or key.
1319    pub fn request_secret_delete(&mut self) {
1320        if self.focus != Panel::Secrets {
1321            return;
1322        }
1323        let Some(item) = self.selected_item() else {
1324            return;
1325        };
1326        let name = item.name.clone();
1327        let (message, args) = match &self.secret_scope {
1328            None => (
1329                format!("Delete scope “{name}” and all its secrets?"),
1330                vec!["secrets".to_string(), "delete-scope".to_string(), name],
1331            ),
1332            Some(scope) => (
1333                format!("Delete secret “{name}” from “{scope}”?"),
1334                vec![
1335                    "secrets".to_string(),
1336                    "delete-secret".to_string(),
1337                    scope.clone(),
1338                    name,
1339                ],
1340            ),
1341        };
1342        self.confirm = Some(Confirm { message, args });
1343    }
1344
1345    /// `g` in the secrets pane: the scope's ACLs.
1346    fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1347        let scope = match &self.secret_scope {
1348            Some(s) => Some(s.clone()),
1349            None => self.selected_item().map(|i| i.name.clone()),
1350        };
1351        let Some(scope) = scope else {
1352            return;
1353        };
1354        self.detail = Some(Detail {
1355            panel: Panel::Secrets,
1356            name: scope.clone(),
1357            id: scope.clone(),
1358            kind: None,
1359            section: "Access",
1360            data: None,
1361            show_raw: false,
1362            scroll: 0,
1363        });
1364        let (tx, rx) = oneshot::channel();
1365        self.detail_rx = Some(rx);
1366        let cli = Arc::clone(cli);
1367        tokio::spawn(async move {
1368            let acl_args = ["secrets", "list-acls", &scope];
1369            let data = match cli.run(&acl_args).await {
1370                Ok(json) => {
1371                    let raw =
1372                        serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1373                    // The CLI unwraps to a bare array; REST wraps in "items".
1374                    let acls = json
1375                        .as_array()
1376                        .cloned()
1377                        .or_else(|| json["items"].as_array().cloned())
1378                        .unwrap_or_default();
1379                    let activity: Vec<(Status, String)> = acls
1380                        .iter()
1381                        .map(|a| {
1382                            let principal = a["principal"].as_str().unwrap_or("?");
1383                            let perm = a["permission"].as_str().unwrap_or("?");
1384                            let status = if perm == "MANAGE" {
1385                                Status::Success
1386                            } else {
1387                                Status::Unknown(String::new())
1388                            };
1389                            (status, format!("{principal}  ·  {perm}"))
1390                        })
1391                        .collect();
1392                    DetailData {
1393                        summary: vec![("Scope".to_string(), scope.clone())],
1394                        activity,
1395                        raw,
1396                    }
1397                }
1398                Err(e) => DetailData {
1399                    summary: Vec::new(),
1400                    activity: Vec::new(),
1401                    raw: format!("{e:#}"),
1402                },
1403            };
1404            let _ = tx.send(data);
1405        });
1406    }
1407
1408    /// Looks up a resource's display name by id in the loaded panes —
1409    /// lets the cost view show "nightly-etl" instead of a job id.
1410    pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1411        let idx = match kind {
1412            "cluster" => 0,
1413            "job" => 1,
1414            "warehouse" => 3,
1415            _ => return None,
1416        };
1417        match &self.shapes[idx] {
1418            Some(Shape::List(items)) => items
1419                .iter()
1420                .find(|i| i.id.as_deref() == Some(id))
1421                .map(|i| i.name.clone()),
1422            _ => None,
1423        }
1424    }
1425
1426    /// All known warehouses as (name, id, running).
1427    pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1428        let Some(Shape::List(items)) = &self.shapes[3] else {
1429            return Vec::new();
1430        };
1431        items
1432            .iter()
1433            .filter_map(|i| {
1434                let id = i.id.clone()?;
1435                Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1436            })
1437            .collect()
1438    }
1439
1440    /// Runs a sample-data query for the selected table or view. With
1441    /// `force_pick` (or several warehouses and no remembered choice) a
1442    /// warehouse picker opens first.
1443    pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
1444        if self.focus != Panel::Catalog {
1445            return;
1446        }
1447        let Some(item) = self.selected_item() else {
1448            return;
1449        };
1450        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1451            return;
1452        }
1453        let Some(full_name) = item.id.clone() else {
1454            return;
1455        };
1456        let warehouses = self.warehouses();
1457        if warehouses.is_empty() {
1458            self.flash = Some((
1459                "✗ no SQL warehouse available for previews".to_string(),
1460                Instant::now(),
1461            ));
1462            return;
1463        }
1464        if !force_pick {
1465            if let Some((id, name)) = self.preview_warehouse.clone() {
1466                self.start_preview_query(cli, full_name, id, name);
1467                return;
1468            }
1469            if let [(name, id, _)] = warehouses.as_slice() {
1470                self.preview_warehouse = Some((id.clone(), name.clone()));
1471                self.start_preview_query(cli, full_name, id.clone(), name.clone());
1472                return;
1473            }
1474        }
1475        // Default the cursor to the remembered choice, else a running warehouse.
1476        let index = self
1477            .preview_warehouse
1478            .as_ref()
1479            .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1480            .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1481            .unwrap_or(0);
1482        self.wh_picker = Some(WhPicker {
1483            index,
1484            target: PickTarget::Preview(full_name),
1485        });
1486    }
1487
1488    /// Opens the DBU usage view, resolving a warehouse like previews do.
1489    pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1490        let warehouses = self.warehouses();
1491        if warehouses.is_empty() {
1492            self.flash = Some((
1493                "✗ no SQL warehouse available to query system tables".to_string(),
1494                Instant::now(),
1495            ));
1496            return;
1497        }
1498        if let Some((id, name)) = self.preview_warehouse.clone() {
1499            self.start_cost_query(cli, id, name);
1500            return;
1501        }
1502        if let [(name, id, _)] = warehouses.as_slice() {
1503            self.preview_warehouse = Some((id.clone(), name.clone()));
1504            self.start_cost_query(cli, id.clone(), name.clone());
1505            return;
1506        }
1507        let index = warehouses
1508            .iter()
1509            .position(|(_, _, running)| *running)
1510            .unwrap_or(0);
1511        self.wh_picker = Some(WhPicker {
1512            index,
1513            target: PickTarget::Cost,
1514        });
1515    }
1516
1517    fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1518        self.cost = Some(CostView {
1519            warehouse: name,
1520            data: None,
1521        });
1522        let (tx, rx) = oneshot::channel();
1523        self.cost_rx = Some(rx);
1524        let cli = Arc::clone(cli);
1525        let host = self.host.clone();
1526        let cached_ws = self.workspace_id.clone();
1527        tokio::spawn(async move {
1528            // Scope usage to this workspace; resolved once, then cached.
1529            let ws = match (cached_ws, host) {
1530                (Some(w), _) => Some(w),
1531                (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1532                (None, None) => None,
1533            };
1534            let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1535            let _ = tx.send((result, ws));
1536        });
1537    }
1538
1539    /// Opens the lineage view for the selected table/view; needs a
1540    /// warehouse since lineage lives in system tables.
1541    pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1542        if self.focus != Panel::Catalog {
1543            return;
1544        }
1545        let Some(item) = self.selected_item() else {
1546            return;
1547        };
1548        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1549            return;
1550        }
1551        let Some(full_name) = item.id.clone() else {
1552            return;
1553        };
1554        let warehouses = self.warehouses();
1555        if warehouses.is_empty() {
1556            self.flash = Some((
1557                "✗ no SQL warehouse available to query lineage".to_string(),
1558                Instant::now(),
1559            ));
1560            return;
1561        }
1562        if let Some((id, _)) = self.preview_warehouse.clone() {
1563            self.start_lineage_query(cli, full_name, id);
1564            return;
1565        }
1566        if let [(name, id, _)] = warehouses.as_slice() {
1567            self.preview_warehouse = Some((id.clone(), name.clone()));
1568            let id = id.clone();
1569            self.start_lineage_query(cli, full_name, id);
1570            return;
1571        }
1572        let index = warehouses
1573            .iter()
1574            .position(|(_, _, running)| *running)
1575            .unwrap_or(0);
1576        self.wh_picker = Some(WhPicker {
1577            index,
1578            target: PickTarget::Lineage(full_name),
1579        });
1580    }
1581
1582    fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1583        self.detail = Some(Detail {
1584            panel: Panel::Catalog,
1585            name: full_name.clone(),
1586            id: full_name.clone(),
1587            kind: None,
1588            section: "Lineage",
1589            data: None,
1590            show_raw: false,
1591            scroll: 0,
1592        });
1593        let (tx, rx) = oneshot::channel();
1594        self.detail_rx = Some(rx);
1595        let cli = Arc::clone(cli);
1596        tokio::spawn(async move {
1597            let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1598            let _ = tx.send(data);
1599        });
1600    }
1601
1602    pub fn close_cost(&mut self) {
1603        self.cost = None;
1604        self.cost_rx = None;
1605    }
1606
1607    /// The fully-qualified name of the selected catalog-pane table/view.
1608    fn selected_table_fqn(&self) -> Option<String> {
1609        if self.focus != Panel::Catalog {
1610            return None;
1611        }
1612        let item = self.selected_item()?;
1613        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1614            return None;
1615        }
1616        item.id.clone()
1617    }
1618
1619    /// Opens the SQL console. With a table/view selected in the catalog
1620    /// pane, the prompt starts as an editable query against it.
1621    pub fn open_sql(&mut self) {
1622        if self.sql.is_none() {
1623            let input = self
1624                .selected_table_fqn()
1625                .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1626                .unwrap_or_default();
1627            self.sql = Some(SqlConsole {
1628                cursor: input.chars().count(),
1629                input,
1630                warehouse: String::new(),
1631                running: false,
1632                data: None,
1633                last_sql: String::new(),
1634                scroll: 0,
1635                col: 0,
1636            });
1637        }
1638    }
1639
1640    pub fn close_sql(&mut self) {
1641        self.sql = None;
1642        self.sql_rx = None;
1643        self.hist_idx = None;
1644        self.hist_draft.clear();
1645        self.hist_search = None;
1646    }
1647
1648    /// The statement currently in the prompt.
1649    pub fn sql_input(&self) -> Option<String> {
1650        self.sql.as_ref().map(|c| c.input.clone())
1651    }
1652
1653    /// Replaces the prompt contents (after an $EDITOR round-trip).
1654    pub fn sql_set_input(&mut self, s: &str) {
1655        if let Some(console) = &mut self.sql {
1656            console.input = s.to_string();
1657            console.cursor = console.input.chars().count();
1658        }
1659    }
1660
1661    /// The history entry the active Ctrl+R search currently matches.
1662    pub fn hist_search_current(&self) -> Option<&String> {
1663        let (query, nth) = self.hist_search.as_ref()?;
1664        self.sql_history
1665            .iter()
1666            .rev()
1667            .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
1668            .nth(*nth)
1669    }
1670
1671    pub fn hist_search_start(&mut self) {
1672        if self.sql.is_some() {
1673            self.hist_search = Some((String::new(), 0));
1674        }
1675    }
1676
1677    pub fn hist_search_push(&mut self, c: char) {
1678        if let Some((query, nth)) = &mut self.hist_search {
1679            query.push(c);
1680            *nth = 0;
1681        }
1682    }
1683
1684    pub fn hist_search_pop(&mut self) {
1685        if let Some((query, nth)) = &mut self.hist_search {
1686            query.pop();
1687            *nth = 0;
1688        }
1689    }
1690
1691    /// Ctrl+R again: step to the next older match.
1692    pub fn hist_search_older(&mut self) {
1693        let Some((query, nth)) = &self.hist_search else {
1694            return;
1695        };
1696        let q = query.to_lowercase();
1697        let matches = self
1698            .sql_history
1699            .iter()
1700            .filter(|h| h.to_lowercase().contains(&q))
1701            .count();
1702        if nth + 1 < matches {
1703            if let Some((_, n)) = &mut self.hist_search {
1704                *n += 1;
1705            }
1706        }
1707    }
1708
1709    pub fn hist_search_accept(&mut self) {
1710        if let Some(stmt) = self.hist_search_current().cloned() {
1711            self.sql_set_input(&stmt);
1712        }
1713        self.hist_search = None;
1714    }
1715
1716    pub fn hist_search_cancel(&mut self) {
1717        self.hist_search = None;
1718    }
1719
1720    pub fn sql_push(&mut self, c: char) {
1721        if let Some(console) = &mut self.sql {
1722            let at = byte_at(&console.input, console.cursor);
1723            console.input.insert(at, c);
1724            console.cursor += 1;
1725        }
1726    }
1727
1728    /// Backspace: deletes the character before the caret.
1729    pub fn sql_pop(&mut self) {
1730        if let Some(console) = &mut self.sql {
1731            if console.cursor > 0 {
1732                let at = byte_at(&console.input, console.cursor - 1);
1733                console.input.remove(at);
1734                console.cursor -= 1;
1735            }
1736        }
1737    }
1738
1739    /// Delete: removes the character under the caret.
1740    pub fn sql_delete(&mut self) {
1741        if let Some(console) = &mut self.sql {
1742            if console.cursor < console.input.chars().count() {
1743                let at = byte_at(&console.input, console.cursor);
1744                console.input.remove(at);
1745            }
1746        }
1747    }
1748
1749    pub fn sql_left(&mut self) {
1750        if let Some(console) = &mut self.sql {
1751            console.cursor = console.cursor.saturating_sub(1);
1752        }
1753    }
1754
1755    pub fn sql_right(&mut self) {
1756        if let Some(console) = &mut self.sql {
1757            console.cursor = (console.cursor + 1).min(console.input.chars().count());
1758        }
1759    }
1760
1761    /// ↑ at the prompt: step back through history, stashing the draft.
1762    pub fn sql_hist_prev(&mut self) {
1763        let Some(console) = &mut self.sql else {
1764            return;
1765        };
1766        if self.sql_history.is_empty() {
1767            return;
1768        }
1769        let idx = match self.hist_idx {
1770            None => {
1771                self.hist_draft = console.input.clone();
1772                self.sql_history.len() - 1
1773            }
1774            Some(i) => i.saturating_sub(1),
1775        };
1776        self.hist_idx = Some(idx);
1777        console.input = self.sql_history[idx].clone();
1778        console.cursor = console.input.chars().count();
1779    }
1780
1781    /// ↓ at the prompt: step forward, back to the stashed draft at the end.
1782    pub fn sql_hist_next(&mut self) {
1783        let Some(console) = &mut self.sql else {
1784            return;
1785        };
1786        let Some(idx) = self.hist_idx else {
1787            return;
1788        };
1789        if idx + 1 < self.sql_history.len() {
1790            self.hist_idx = Some(idx + 1);
1791            console.input = self.sql_history[idx + 1].clone();
1792        } else {
1793            self.hist_idx = None;
1794            console.input = self.hist_draft.clone();
1795        }
1796        console.cursor = console.input.chars().count();
1797    }
1798
1799    pub fn sql_home(&mut self) {
1800        if let Some(console) = &mut self.sql {
1801            console.cursor = 0;
1802        }
1803    }
1804
1805    pub fn sql_end(&mut self) {
1806        if let Some(console) = &mut self.sql {
1807            console.cursor = console.input.chars().count();
1808        }
1809    }
1810
1811    pub fn sql_scroll(&mut self, delta: i32) {
1812        if let Some(console) = &mut self.sql {
1813            let max = match &console.data {
1814                Some(Ok(t)) => t.rows.len().saturating_sub(1),
1815                _ => 0,
1816            };
1817            console.scroll = if delta < 0 {
1818                console.scroll.saturating_sub(delta.unsigned_abs() as usize)
1819            } else {
1820                (console.scroll + delta as usize).min(max)
1821            };
1822        }
1823    }
1824
1825    /// Shift+←/→ in the console: page result columns.
1826    pub fn sql_cols(&mut self, delta: i32) {
1827        if let Some(console) = &mut self.sql {
1828            let n = match &console.data {
1829                Some(Ok(t)) => t.headers.len(),
1830                _ => 0,
1831            };
1832            console.col = if delta < 0 {
1833                console.col.saturating_sub(1)
1834            } else {
1835                (console.col + 1).min(n.saturating_sub(1))
1836            };
1837        }
1838    }
1839
1840    /// Runs the typed statement, resolving a warehouse like previews do.
1841    pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
1842        let Some(console) = &self.sql else {
1843            return;
1844        };
1845        if console.running {
1846            return;
1847        }
1848        let query = console.input.trim().to_string();
1849        if query.is_empty() {
1850            return;
1851        }
1852        // Remember the statement (skipping immediate repeats) and reset
1853        // any in-progress history browsing.
1854        if self.sql_history.last() != Some(&query) {
1855            self.sql_history.push(query.clone());
1856            save_history(&self.sql_history);
1857        }
1858        self.hist_idx = None;
1859        self.hist_draft.clear();
1860        let warehouses = self.warehouses();
1861        if warehouses.is_empty() {
1862            self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
1863            return;
1864        }
1865        if let Some((id, name)) = self.preview_warehouse.clone() {
1866            self.start_sql_query(cli, query, id, name);
1867            return;
1868        }
1869        if let [(name, id, _)] = warehouses.as_slice() {
1870            self.preview_warehouse = Some((id.clone(), name.clone()));
1871            self.start_sql_query(cli, query, id.clone(), name.clone());
1872            return;
1873        }
1874        let index = warehouses
1875            .iter()
1876            .position(|(_, _, running)| *running)
1877            .unwrap_or(0);
1878        self.wh_picker = Some(WhPicker {
1879            index,
1880            target: PickTarget::Sql(query),
1881        });
1882    }
1883
1884    fn start_sql_query(
1885        &mut self,
1886        cli: &Arc<DatabricksCli>,
1887        query: String,
1888        id: String,
1889        name: String,
1890    ) {
1891        if let Some(console) = &mut self.sql {
1892            console.running = true;
1893            console.warehouse = name;
1894            console.scroll = 0;
1895            console.last_sql = query.clone();
1896        }
1897        // Published by the task once submitted, so Esc can cancel it.
1898        let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
1899        self.sql_stmt = Some(std::sync::Arc::clone(&handle));
1900        let (tx, rx) = oneshot::channel();
1901        self.sql_rx = Some(rx);
1902        let cli = Arc::clone(cli);
1903        tokio::spawn(async move {
1904            let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
1905            let _ = tx.send(result);
1906        });
1907    }
1908
1909    /// Writes a result set to a timestamped CSV in the working directory
1910    /// and flashes the path.
1911    fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
1912        let stamp = std::time::SystemTime::now()
1913            .duration_since(std::time::UNIX_EPOCH)
1914            .map(|d| d.as_secs())
1915            .unwrap_or(0);
1916        let slug: String = label
1917            .chars()
1918            .map(|c| if c.is_alphanumeric() { c } else { '-' })
1919            .collect::<String>()
1920            .trim_matches('-')
1921            .chars()
1922            .take(40)
1923            .collect();
1924        let name = format!("databricks-{slug}-{stamp}.csv");
1925        let msg = match std::fs::write(&name, data.to_csv()) {
1926            Ok(()) => {
1927                let cwd = std::env::current_dir()
1928                    .map(|d| d.display().to_string())
1929                    .unwrap_or_default();
1930                format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
1931            }
1932            Err(e) => format!("✗ export failed: {e}"),
1933        };
1934        self.flash = Some((msg, Instant::now()));
1935    }
1936
1937    /// Ctrl+S in the console: export the current results.
1938    pub fn sql_export(&mut self) {
1939        if let Some(SqlConsole {
1940            data: Some(Ok(data)),
1941            last_sql,
1942            ..
1943        }) = &self.sql
1944        {
1945            let (label, data) = (last_sql.clone(), data.clone());
1946            self.export_csv(&label, &data);
1947        }
1948    }
1949
1950    /// ←/→ in a preview: page columns in the grid, switch rows in
1951    /// record view.
1952    pub fn preview_h(&mut self, delta: i32) {
1953        let Some(pv) = &mut self.preview else {
1954            return;
1955        };
1956        if pv.record {
1957            let max = match &pv.data {
1958                Some(Ok(t)) => t.rows.len().saturating_sub(1),
1959                _ => 0,
1960            };
1961            pv.scroll = if delta < 0 {
1962                pv.scroll.saturating_sub(1)
1963            } else {
1964                (pv.scroll + 1).min(max)
1965            };
1966            pv.rscroll = 0;
1967        } else {
1968            let cols = pv.visible_cols().len();
1969            pv.col = if delta < 0 {
1970                pv.col.saturating_sub(1)
1971            } else {
1972                (pv.col + 1).min(cols.saturating_sub(1))
1973            };
1974        }
1975    }
1976
1977    /// `v`/enter in a preview: transposed view of the top visible row.
1978    pub fn preview_toggle_record(&mut self) {
1979        if let Some(pv) = &mut self.preview {
1980            if matches!(&pv.data, Some(Ok(t)) if !t.rows.is_empty()) {
1981                pv.record = !pv.record;
1982                pv.rscroll = 0;
1983            }
1984        }
1985    }
1986
1987    pub fn preview_filter_start(&mut self) {
1988        if let Some(pv) = &mut self.preview {
1989            pv.filter.clear();
1990            pv.filter_entry = true;
1991            pv.col = 0;
1992            pv.rscroll = 0;
1993        }
1994    }
1995
1996    pub fn preview_filter_push(&mut self, c: char) {
1997        if let Some(pv) = &mut self.preview {
1998            pv.filter.push(c);
1999            pv.col = 0;
2000            pv.rscroll = 0;
2001        }
2002    }
2003
2004    pub fn preview_filter_pop(&mut self) {
2005        if let Some(pv) = &mut self.preview {
2006            pv.filter.pop();
2007            pv.col = 0;
2008        }
2009    }
2010
2011    pub fn preview_filter_accept(&mut self) {
2012        if let Some(pv) = &mut self.preview {
2013            pv.filter_entry = false;
2014        }
2015    }
2016
2017    pub fn preview_filter_clear(&mut self) {
2018        if let Some(pv) = &mut self.preview {
2019            pv.filter.clear();
2020            pv.filter_entry = false;
2021            pv.col = 0;
2022            pv.rscroll = 0;
2023        }
2024    }
2025
2026    /// `e` in a table preview: export the sampled rows.
2027    pub fn preview_export(&mut self) {
2028        if let Some(Preview {
2029            data: Some(Ok(data)),
2030            name,
2031            ..
2032        }) = &self.preview
2033        {
2034            let (label, data) = (name.clone(), data.clone());
2035            self.export_csv(&label, &data);
2036        }
2037    }
2038
2039    pub fn poll_sql(&mut self) -> bool {
2040        let Some(rx) = &mut self.sql_rx else {
2041            return false;
2042        };
2043        match rx.try_recv() {
2044            Ok(result) => {
2045                // A warehouse that errors shouldn't stay the session
2046                // default — but a user-canceled statement is not its fault.
2047                if let Err(e) = &result {
2048                    if e != "statement canceled" {
2049                        self.preview_warehouse = None;
2050                    }
2051                }
2052                if let Some(console) = &mut self.sql {
2053                    console.running = false;
2054                    console.data = Some(result);
2055                    console.col = 0;
2056                }
2057                self.sql_rx = None;
2058                self.sql_stmt = None;
2059                true
2060            }
2061            Err(oneshot::error::TryRecvError::Empty) => false,
2062            Err(oneshot::error::TryRecvError::Closed) => {
2063                if let Some(console) = &mut self.sql {
2064                    console.running = false;
2065                }
2066                self.sql_rx = None;
2067                true
2068            }
2069        }
2070    }
2071
2072    pub fn poll_cost(&mut self) -> bool {
2073        let Some(rx) = &mut self.cost_rx else {
2074            return false;
2075        };
2076        match rx.try_recv() {
2077            Ok((result, ws)) => {
2078                if result.is_err() {
2079                    self.preview_warehouse = None;
2080                }
2081                if ws.is_some() {
2082                    self.workspace_id = ws;
2083                }
2084                if let Some(cv) = &mut self.cost {
2085                    cv.data = Some(result);
2086                }
2087                self.cost_rx = None;
2088                true
2089            }
2090            Err(oneshot::error::TryRecvError::Empty) => false,
2091            Err(oneshot::error::TryRecvError::Closed) => {
2092                self.cost_rx = None;
2093                true
2094            }
2095        }
2096    }
2097
2098    pub fn wh_picker_next(&mut self) {
2099        let len = self.warehouses().len();
2100        if let Some(p) = &mut self.wh_picker {
2101            p.index = (p.index + 1).min(len.saturating_sub(1));
2102        }
2103    }
2104
2105    pub fn wh_picker_prev(&mut self) {
2106        if let Some(p) = &mut self.wh_picker {
2107            p.index = p.index.saturating_sub(1);
2108        }
2109    }
2110
2111    pub fn wh_picker_cancel(&mut self) {
2112        self.wh_picker = None;
2113    }
2114
2115    /// Confirms the warehouse choice, remembers it, and starts the preview.
2116    pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
2117        let Some(picker) = self.wh_picker.take() else {
2118            return;
2119        };
2120        let warehouses = self.warehouses();
2121        let Some((name, id, _)) = warehouses.get(picker.index) else {
2122            return;
2123        };
2124        self.preview_warehouse = Some((id.clone(), name.clone()));
2125        // An explicit choice is worth remembering across sessions.
2126        let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
2127        self.config
2128            .warehouses
2129            .insert(profile, (id.clone(), name.clone()));
2130        self.config.save();
2131        match picker.target {
2132            PickTarget::Preview(table) => {
2133                self.start_preview_query(cli, table, id.clone(), name.clone())
2134            }
2135            PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
2136            PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
2137            PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
2138        }
2139    }
2140
2141    fn start_preview_query(
2142        &mut self,
2143        cli: &Arc<DatabricksCli>,
2144        full_name: String,
2145        warehouse_id: String,
2146        warehouse_name: String,
2147    ) {
2148        self.preview = Some(Preview {
2149            name: full_name.clone(),
2150            warehouse: warehouse_name,
2151            warehouse_id: warehouse_id.clone(),
2152            data: None,
2153            scroll: 0,
2154            col: 0,
2155            filter: String::new(),
2156            filter_entry: false,
2157            record: false,
2158            rscroll: 0,
2159        });
2160        let (tx, rx) = oneshot::channel();
2161        self.preview_rx = Some(rx);
2162        let cli = Arc::clone(cli);
2163        tokio::spawn(async move {
2164            let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
2165            let _ = tx.send(result);
2166        });
2167    }
2168
2169    pub fn close_preview(&mut self) {
2170        self.preview = None;
2171        self.preview_rx = None;
2172    }
2173
2174    pub fn poll_preview(&mut self) -> bool {
2175        let Some(rx) = &mut self.preview_rx else {
2176            return false;
2177        };
2178        match rx.try_recv() {
2179            Ok(result) => {
2180                // A warehouse that errors shouldn't stay the session default.
2181                if result.is_err() {
2182                    self.preview_warehouse = None;
2183                }
2184                if let Some(pv) = &mut self.preview {
2185                    pv.data = Some(result);
2186                }
2187                self.preview_rx = None;
2188                true
2189            }
2190            Err(oneshot::error::TryRecvError::Empty) => false,
2191            Err(oneshot::error::TryRecvError::Closed) => {
2192                self.preview_rx = None;
2193                true
2194            }
2195        }
2196    }
2197
2198    pub fn preview_scroll(&mut self, delta: i32) {
2199        if let Some(pv) = &mut self.preview {
2200            // Record view: j/k walk the fields, not the rows.
2201            if pv.record {
2202                let max = pv.visible_cols().len().saturating_sub(1) as u16;
2203                pv.rscroll = if delta < 0 {
2204                    pv.rscroll.saturating_sub(delta.unsigned_abs() as u16)
2205                } else {
2206                    pv.rscroll.saturating_add(delta as u16).min(max)
2207                };
2208                return;
2209            }
2210            let max = match &pv.data {
2211                Some(Ok(t)) => t.rows.len().saturating_sub(1),
2212                _ => 0,
2213            };
2214            pv.scroll = if delta < 0 {
2215                pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
2216            } else {
2217                (pv.scroll + delta as usize).min(max)
2218            };
2219        }
2220    }
2221
2222    pub fn poll_uc(&mut self) -> bool {
2223        let Some(rx) = &mut self.uc_rx else {
2224            return false;
2225        };
2226        match rx.try_recv() {
2227            Ok(result) => {
2228                self.shapes[5] = Some(match result {
2229                    Ok(shape) => shape,
2230                    Err(e) => Shape::Text(format!("✗ {e}")),
2231                });
2232                self.updated_at[5] = Some(Instant::now());
2233                self.uc_rx = None;
2234                true
2235            }
2236            Err(oneshot::error::TryRecvError::Empty) => false,
2237            Err(oneshot::error::TryRecvError::Closed) => {
2238                self.uc_rx = None;
2239                true
2240            }
2241        }
2242    }
2243
2244    /// Opens the access view for the selected item: effective UC grants
2245    /// or the workspace object ACL.
2246    pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
2247        if self.focus == Panel::Secrets {
2248            return self.open_secret_acls(cli);
2249        }
2250        let Some(item) = self.selected_item() else {
2251            return;
2252        };
2253        let Some(id) = item.id.clone() else {
2254            return;
2255        };
2256        let (uc, object_type): (bool, &'static str) = match self.focus {
2257            Panel::Catalog => match &item.status {
2258                Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
2259                Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
2260                Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
2261                Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
2262                _ => return,
2263            },
2264            Panel::Clusters => (false, "clusters"),
2265            Panel::Jobs => (false, "jobs"),
2266            Panel::Pipelines => (false, "pipelines"),
2267            Panel::Warehouses => (false, "warehouses"),
2268            Panel::Dashboards => (false, "dashboards"),
2269            // Secrets ACLs are handled by open_secret_acls above.
2270            Panel::Secrets => return,
2271        };
2272        self.detail = Some(Detail {
2273            panel: self.focus,
2274            name: item.name.clone(),
2275            id: id.clone(),
2276            kind: None,
2277            section: "Access",
2278            data: None,
2279            show_raw: false,
2280            scroll: 0,
2281        });
2282        let (tx, rx) = oneshot::channel();
2283        self.detail_rx = Some(rx);
2284        let cli = Arc::clone(cli);
2285        tokio::spawn(async move {
2286            let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
2287            let _ = tx.send(data);
2288        });
2289    }
2290
2291    /// Drills from an open job or pipeline detail into its most recent
2292    /// run/update.
2293    pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
2294        let Some(d) = &self.detail else {
2295            return;
2296        };
2297        let panel = d.panel;
2298        if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
2299            return;
2300        }
2301        let owner_id = d.id.clone();
2302        self.run_view = Some(RunView {
2303            panel,
2304            owner_name: d.name.clone(),
2305            owner_id: owner_id.clone(),
2306            runs: Vec::new(),
2307            idx: 0,
2308            data: None,
2309            show_raw: false,
2310            scroll: 0,
2311            live: false,
2312            output: None,
2313            show_output: false,
2314            fetched_at: Instant::now(),
2315        });
2316        let (tx, rx) = oneshot::channel();
2317        self.run_rx = Some(rx);
2318        let cli = Arc::clone(cli);
2319        tokio::spawn(async move {
2320            let result = async {
2321                let runs = if panel == Panel::Jobs {
2322                    fetchers::runs::list(&cli, &owner_id).await?
2323                } else {
2324                    fetchers::updates::list(&cli, &owner_id).await?
2325                };
2326                let Some((run_id, _, _)) = runs.first().cloned() else {
2327                    return Err("no runs recorded yet".to_string());
2328                };
2329                let (data, live) = if panel == Panel::Jobs {
2330                    fetchers::runs::fetch(&cli, &run_id).await
2331                } else {
2332                    fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2333                };
2334                Ok((runs, data, live))
2335            }
2336            .await;
2337            let _ = tx.send(RunUpdate::Opened(result));
2338        });
2339    }
2340
2341    pub fn close_run(&mut self) {
2342        self.run_view = None;
2343        self.run_rx = None;
2344    }
2345
2346    /// Moves to an older (delta > 0) or newer (delta < 0) run.
2347    pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
2348        if self.run_rx.is_some() {
2349            return;
2350        }
2351        let Some(rv) = &mut self.run_view else {
2352            return;
2353        };
2354        if rv.runs.is_empty() {
2355            return;
2356        }
2357        let new = if delta < 0 {
2358            rv.idx.saturating_sub(delta.unsigned_abs() as usize)
2359        } else {
2360            (rv.idx + delta as usize).min(rv.runs.len() - 1)
2361        };
2362        if new == rv.idx {
2363            return;
2364        }
2365        rv.idx = new;
2366        rv.data = None;
2367        rv.scroll = 0;
2368        rv.show_raw = false;
2369        rv.output = None;
2370        rv.show_output = false;
2371        let run_id = rv.runs[new].0.clone();
2372        self.start_run_fetch(cli, run_id);
2373    }
2374
2375    fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
2376        let Some(rv) = &self.run_view else {
2377            return;
2378        };
2379        let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
2380        let (tx, rx) = oneshot::channel();
2381        self.run_rx = Some(rx);
2382        let cli = Arc::clone(cli);
2383        tokio::spawn(async move {
2384            let (data, live) = if panel == Panel::Jobs {
2385                fetchers::runs::fetch(&cli, &run_id).await
2386            } else {
2387                fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2388            };
2389            let _ = tx.send(RunUpdate::Detail(data, live));
2390        });
2391    }
2392
2393    /// `o` in the run view: toggles the full output/log view, fetching
2394    /// all task outputs on first use.
2395    pub fn run_toggle_output(&mut self, cli: &Arc<DatabricksCli>) {
2396        let Some(rv) = &mut self.run_view else {
2397            return;
2398        };
2399        if rv.panel != Panel::Jobs {
2400            self.flash = Some((
2401                "✗ output view is for job runs — pipeline events are already inline".to_string(),
2402                Instant::now(),
2403            ));
2404            return;
2405        }
2406        if rv.show_output {
2407            rv.show_output = false;
2408            rv.scroll = 0;
2409            return;
2410        }
2411        if rv.output.is_none() && self.run_rx.is_some() {
2412            self.flash = Some((
2413                "⏳ run still loading — try again in a moment".to_string(),
2414                Instant::now(),
2415            ));
2416            return;
2417        }
2418        rv.show_output = true;
2419        rv.scroll = 0;
2420        if rv.output.is_some() {
2421            return;
2422        }
2423        let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() else {
2424            return;
2425        };
2426        let (tx, rx) = oneshot::channel();
2427        self.run_rx = Some(rx);
2428        let cli = Arc::clone(cli);
2429        tokio::spawn(async move {
2430            let text = fetchers::runs::full_output(&cli, &run_id).await;
2431            let _ = tx.send(RunUpdate::Output(text));
2432        });
2433    }
2434
2435    /// `r` in the run view: rerun only the failed tasks of the shown run.
2436    pub fn request_run_repair(&mut self) {
2437        let Some(rv) = &self.run_view else {
2438            return;
2439        };
2440        if rv.panel != Panel::Jobs {
2441            self.flash = Some((
2442                "✗ repair applies to job runs only".to_string(),
2443                Instant::now(),
2444            ));
2445            return;
2446        }
2447        if rv.live {
2448            self.flash = Some((
2449                "✗ run is still executing — cancel it first (s)".to_string(),
2450                Instant::now(),
2451            ));
2452            return;
2453        }
2454        let Some((run_id, status, _)) = rv.runs.get(rv.idx) else {
2455            return;
2456        };
2457        if matches!(status, Status::Success) {
2458            self.flash = Some((
2459                "✗ run succeeded — nothing to repair".to_string(),
2460                Instant::now(),
2461            ));
2462            return;
2463        }
2464        self.confirm = Some(Confirm {
2465            message: format!(
2466                "Repair run {run_id} of “{}” (reruns only the failed tasks)?",
2467                rv.owner_name
2468            ),
2469            args: vec![
2470                "jobs".to_string(),
2471                "repair-run".to_string(),
2472                run_id.clone(),
2473                "--rerun-all-failed-tasks".to_string(),
2474            ],
2475        });
2476    }
2477
2478    pub fn run_toggle_raw(&mut self) {
2479        if let Some(rv) = &mut self.run_view {
2480            rv.show_raw = !rv.show_raw;
2481            rv.scroll = 0;
2482        }
2483    }
2484
2485    pub fn run_scroll(&mut self, delta: i32) {
2486        if let Some(rv) = &mut self.run_view {
2487            rv.scroll = if delta < 0 {
2488                rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
2489            } else {
2490                rv.scroll.saturating_add(delta as u16)
2491            };
2492        }
2493    }
2494
2495    /// Applies run fetch results; also re-polls a live run every few
2496    /// seconds so an executing run's tasks update on their own.
2497    pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2498        if let Some(rx) = &mut self.run_rx {
2499            match rx.try_recv() {
2500                Ok(update) => {
2501                    self.run_rx = None;
2502                    if let Some(rv) = &mut self.run_view {
2503                        match update {
2504                            RunUpdate::Opened(Ok((runs, data, live))) => {
2505                                rv.runs = runs;
2506                                rv.idx = 0;
2507                                rv.data = Some(data);
2508                                rv.live = live;
2509                            }
2510                            RunUpdate::Opened(Err(e)) => {
2511                                rv.data = Some(DetailData {
2512                                    summary: Vec::new(),
2513                                    activity: Vec::new(),
2514                                    raw: format!("✗ {e}"),
2515                                });
2516                                rv.live = false;
2517                            }
2518                            RunUpdate::Detail(data, live) => {
2519                                rv.data = Some(data);
2520                                rv.live = live;
2521                            }
2522                            RunUpdate::Output(text) => {
2523                                rv.output = Some(text);
2524                            }
2525                        }
2526                        rv.fetched_at = Instant::now();
2527                    }
2528                    true
2529                }
2530                Err(oneshot::error::TryRecvError::Empty) => false,
2531                Err(oneshot::error::TryRecvError::Closed) => {
2532                    self.run_rx = None;
2533                    true
2534                }
2535            }
2536        } else if let Some(rv) = &self.run_view {
2537            // No auto-refresh while reading logs — it would churn run_rx.
2538            if rv.live
2539                && !rv.show_output
2540                && rv.data.is_some()
2541                && rv.fetched_at.elapsed() >= Duration::from_secs(5)
2542            {
2543                if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
2544                    self.start_run_fetch(cli, run_id);
2545                }
2546            }
2547            false
2548        } else {
2549            false
2550        }
2551    }
2552
2553    pub fn close_detail(&mut self) {
2554        self.detail = None;
2555        self.detail_rx = None;
2556    }
2557
2558    pub fn toggle_raw(&mut self) {
2559        if let Some(d) = &mut self.detail {
2560            d.show_raw = !d.show_raw;
2561            d.scroll = 0;
2562        }
2563    }
2564
2565    /// Applies a finished detail fetch; returns true if the UI should redraw.
2566    pub fn poll_detail(&mut self) -> bool {
2567        let Some(rx) = &mut self.detail_rx else {
2568            return false;
2569        };
2570        match rx.try_recv() {
2571            Ok(data) => {
2572                if let Some(d) = &mut self.detail {
2573                    d.data = Some(data);
2574                }
2575                self.detail_rx = None;
2576                true
2577            }
2578            Err(oneshot::error::TryRecvError::Empty) => false,
2579            Err(oneshot::error::TryRecvError::Closed) => {
2580                self.detail_rx = None;
2581                true
2582            }
2583        }
2584    }
2585
2586    pub fn detail_scroll(&mut self, delta: i32) {
2587        if let Some(d) = &mut self.detail {
2588            let max = match &d.data {
2589                Some(data) if d.show_raw => data.raw.lines().count(),
2590                Some(data) => data.summary.len() + data.activity.len() + 3,
2591                None => 0,
2592            } as u16;
2593            d.scroll = if delta < 0 {
2594                d.scroll.saturating_sub(delta.unsigned_abs() as u16)
2595            } else {
2596                (d.scroll + delta as u16).min(max.saturating_sub(1))
2597            };
2598        }
2599    }
2600
2601    /// Prepares a contextual action for the selected item, pending confirmation:
2602    /// start/stop for clusters, warehouses and pipelines, run-now for jobs.
2603    pub fn request_action(&mut self) {
2604        // Dashboards, Unity Catalog and secrets have no start/stop/run semantics.
2605        if matches!(
2606            self.focus,
2607            Panel::Dashboards | Panel::Catalog | Panel::Secrets
2608        ) {
2609            return;
2610        }
2611        let Some(item) = self.selected_item() else {
2612            return;
2613        };
2614        let Some(id) = item.id.clone() else {
2615            return;
2616        };
2617        let name = item.name.clone();
2618        let active = matches!(
2619            item.status,
2620            Status::Running | Status::Pending | Status::Success
2621        );
2622        let group = self.focus.cli_group();
2623        let (verb, action): (&str, &str) = match self.focus {
2624            Panel::Jobs => ("Run", "run-now"),
2625            Panel::Clusters if active => ("Stop", "delete"),
2626            Panel::Pipelines if active => ("Stop", "stop"),
2627            Panel::Pipelines => ("Start update for", "start-update"),
2628            _ if active => ("Stop", "stop"),
2629            _ => ("Start", "start"),
2630        };
2631        self.confirm = Some(Confirm {
2632            message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
2633            args: vec![group.to_string(), action.to_string(), id],
2634        });
2635    }
2636
2637    /// `s` in the run view: cancel the shown run/update after a confirm.
2638    pub fn request_run_cancel(&mut self) {
2639        let Some(rv) = &self.run_view else {
2640            return;
2641        };
2642        if !rv.live {
2643            self.flash = Some((
2644                "✗ nothing to cancel — this run already finished".to_string(),
2645                Instant::now(),
2646            ));
2647            return;
2648        }
2649        let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
2650            return;
2651        };
2652        let (message, args) = if rv.panel == Panel::Jobs {
2653            (
2654                format!("Cancel run {run_id} of “{}”?", rv.owner_name),
2655                vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
2656            )
2657        } else {
2658            (
2659                format!("Stop “{}” (cancels the active update)?", rv.owner_name),
2660                vec![
2661                    "pipelines".to_string(),
2662                    "stop".to_string(),
2663                    rv.owner_id.clone(),
2664                ],
2665            )
2666        };
2667        self.confirm = Some(Confirm { message, args });
2668    }
2669
2670    /// Cancels the in-flight console statement server-side; the polling
2671    /// task then sees CANCELED and surfaces it in the results pane.
2672    pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
2673        let id = self
2674            .sql_stmt
2675            .as_ref()
2676            .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
2677        let Some(id) = id else {
2678            self.flash = Some((
2679                "✗ statement not submitted yet — try again in a moment".to_string(),
2680                Instant::now(),
2681            ));
2682            return;
2683        };
2684        let cli = Arc::clone(cli);
2685        tokio::spawn(async move {
2686            let path = format!("/api/2.0/sql/statements/{id}/cancel");
2687            let _ = cli.run_action(&["api", "post", &path]).await;
2688        });
2689        self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
2690    }
2691
2692    pub fn cancel_confirm(&mut self) {
2693        self.confirm = None;
2694    }
2695
2696    pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
2697        let Some(c) = self.confirm.take() else {
2698            return;
2699        };
2700        let base = c.message.trim_end_matches('?').to_string();
2701        self.flash = Some((format!("⏳ {base}…"), Instant::now()));
2702
2703        let (tx, rx) = oneshot::channel();
2704        self.action_rx = Some(rx);
2705        let cli = Arc::clone(cli);
2706        tokio::spawn(async move {
2707            let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
2708            let result = match cli.run_action(&args).await {
2709                Ok(()) => Ok(format!("✓ {base} — done")),
2710                Err(e) => Err(format!("✗ {e:#}")),
2711            };
2712            let _ = tx.send(result);
2713        });
2714    }
2715
2716    /// Applies a finished action; refreshes on success. Returns true on change.
2717    pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2718        let Some(rx) = &mut self.action_rx else {
2719            return false;
2720        };
2721        match rx.try_recv() {
2722            Ok(result) => {
2723                let ok = result.is_ok();
2724                self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
2725                self.action_rx = None;
2726                if ok {
2727                    self.start_refresh(cli);
2728                    // A confirmed action from the run view (cancel/repair)
2729                    // changes the shown run — reflect it without a manual nav.
2730                    if self.run_rx.is_none() {
2731                        let current = self
2732                            .run_view
2733                            .as_ref()
2734                            .and_then(|rv| rv.runs.get(rv.idx).cloned());
2735                        if let Some((run_id, _, _)) = current {
2736                            self.start_run_fetch(cli, run_id);
2737                        }
2738                    }
2739                }
2740                true
2741            }
2742            Err(oneshot::error::TryRecvError::Empty) => false,
2743            Err(oneshot::error::TryRecvError::Closed) => {
2744                self.action_rx = None;
2745                true
2746            }
2747        }
2748    }
2749
2750    /// Drops the flash message once it has been visible long enough.
2751    pub fn expire_flash(&mut self) -> bool {
2752        if let Some((_, since)) = &self.flash {
2753            if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
2754                self.flash = None;
2755                return true;
2756            }
2757        }
2758        false
2759    }
2760
2761    /// Opens the selected item (or the open detail view) in the workspace web UI.
2762    pub fn open_in_browser(&self) {
2763        let Some(host) = &self.host else {
2764            return;
2765        };
2766        let (panel, id) = match &self.detail {
2767            Some(d) => (d.panel, Some(d.id.clone())),
2768            None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
2769        };
2770        let Some(id) = id else {
2771            return;
2772        };
2773        let path = match panel {
2774            Panel::Clusters => format!("compute/clusters/{id}"),
2775            Panel::Jobs => format!("jobs/{id}"),
2776            Panel::Pipelines => format!("pipelines/{id}"),
2777            Panel::Warehouses => format!("sql/warehouses/{id}"),
2778            Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
2779            Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
2780            // Secret scopes have no workspace-UI page.
2781            Panel::Secrets => return,
2782        };
2783        let url = format!("{}/{}", host.trim_end_matches('/'), path);
2784        #[cfg(target_os = "macos")]
2785        let opener = "open";
2786        #[cfg(not(target_os = "macos"))]
2787        let opener = "xdg-open";
2788        let _ = std::process::Command::new(opener).arg(url).spawn();
2789    }
2790
2791    /// Counts of (ok, pending, failed, idle) items across all panels.
2792    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
2793        let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
2794        for shape in self.shapes.iter().flatten() {
2795            if let Shape::List(items) = shape {
2796                for item in items {
2797                    match item.status {
2798                        Status::Running | Status::Success => ok += 1,
2799                        Status::Pending => pending += 1,
2800                        Status::Failed => failed += 1,
2801                        Status::Stopped => idle += 1,
2802                        Status::Unknown(_) => {}
2803                    }
2804                }
2805            }
2806        }
2807        (ok, pending, failed, idle)
2808    }
2809
2810    pub fn last_refresh_age(&self) -> Duration {
2811        self.last_refresh.elapsed()
2812    }
2813
2814    pub fn spinner(&self) -> &'static str {
2815        SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
2816    }
2817
2818    pub fn spinner_frame(&self) -> usize {
2819        self.spinner_frame
2820    }
2821
2822    /// True whenever any background work is in flight — the loop uses this
2823    /// to keep spinners ticking, not just during panel refreshes.
2824    pub fn busy(&self) -> bool {
2825        self.loading
2826            || self.detail_rx.is_some()
2827            || self.action_rx.is_some()
2828            || self.preview_rx.is_some()
2829            || self.cost_rx.is_some()
2830            || self.sql_rx.is_some()
2831            || self.run_rx.is_some()
2832    }
2833
2834    pub fn tick_spinner(&mut self) {
2835        self.spinner_frame = self.spinner_frame.wrapping_add(1);
2836    }
2837
2838    pub fn toggle_zoom(&mut self) {
2839        self.zoomed = !self.zoomed;
2840    }
2841
2842    pub fn focus_next(&mut self) {
2843        self.cycle_focus(1);
2844    }
2845
2846    pub fn focus_prev(&mut self) {
2847        self.cycle_focus(-1);
2848    }
2849
2850    /// Cycles focus through the visible panes in display order.
2851    fn cycle_focus(&mut self, delta: i32) {
2852        let visible = self.visible_panes();
2853        if visible.is_empty() {
2854            return;
2855        }
2856        let focus_idx = Panel::ALL
2857            .iter()
2858            .position(|p| p == &self.focus)
2859            .unwrap_or(0);
2860        let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
2861        let n = visible.len() as i32;
2862        let next = ((pos as i32 + delta) % n + n) % n;
2863        self.focus = Panel::ALL[visible[next as usize]];
2864    }
2865
2866    pub fn needs_refresh(&self) -> bool {
2867        !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
2868    }
2869
2870    pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
2871        if self.loading {
2872            return;
2873        }
2874        self.loading = true;
2875        self.error = None;
2876        self.last_refresh = Instant::now();
2877
2878        let (tx, rx) = mpsc::unbounded_channel();
2879        self.pending = Some(rx);
2880        self.in_flight = 8;
2881
2882        // One task per source so each panel updates as soon as its fetch lands,
2883        // instead of waiting for the slowest of the five.
2884        macro_rules! spawn_fetch {
2885            ($update:expr, $fetch:path) => {{
2886                let cli = Arc::clone(cli);
2887                let tx = tx.clone();
2888                tokio::spawn(async move {
2889                    let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
2890                    let _ = tx.send($update(result));
2891                });
2892            }};
2893        }
2894
2895        spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
2896        spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
2897        spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
2898        spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
2899        spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
2900        spawn_fetch!(
2901            |s: Result<Shape, String>| Update::Badge(s.ok()),
2902            fetchers::current_user::fetch
2903        );
2904        {
2905            let cli = Arc::clone(cli);
2906            let tx = tx.clone();
2907            let path = self.uc_path.clone();
2908            tokio::spawn(async move {
2909                let result = fetchers::catalog::fetch(&cli, &path)
2910                    .await
2911                    .map_err(|e| format!("{e:#}"));
2912                let _ = tx.send(Update::Panel(5, result));
2913            });
2914        }
2915        {
2916            let cli = Arc::clone(cli);
2917            let tx = tx.clone();
2918            let scope = self.secret_scope.clone();
2919            tokio::spawn(async move {
2920                let result = fetchers::secrets::fetch(&cli, scope.as_deref())
2921                    .await
2922                    .map_err(|e| format!("{e:#}"));
2923                let _ = tx.send(Update::Panel(6, result));
2924            });
2925        }
2926    }
2927
2928    /// Applies any fetch results that have arrived; returns true if the UI should redraw.
2929    pub fn poll_refresh(&mut self) -> bool {
2930        let Some(rx) = &mut self.pending else {
2931            return false;
2932        };
2933        let mut changed = false;
2934        let mut updated_panes: Vec<usize> = Vec::new();
2935        loop {
2936            match rx.try_recv() {
2937                Ok(Update::Panel(i, result)) => {
2938                    match result {
2939                        Ok(mut shape) => {
2940                            // Active work floats to the top of every pane
2941                            // except the catalog, which stays browsable
2942                            // in its natural (alphabetical) order.
2943                            if i != 5 {
2944                                if let Shape::List(items) = &mut shape {
2945                                    items.sort_by_key(|it| {
2946                                        (it.status.rank(), it.history.is_empty())
2947                                    });
2948                                }
2949                            }
2950                            self.shapes[i] = Some(shape);
2951                            self.updated_at[i] = Some(Instant::now());
2952                            updated_panes.push(i);
2953                        }
2954                        // Keep previous data on failure so panels don't blank
2955                        // out — but surface the error if there's nothing yet.
2956                        Err(e) => {
2957                            if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
2958                                self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
2959                            }
2960                        }
2961                    }
2962                    self.in_flight -= 1;
2963                    changed = true;
2964                }
2965                Ok(Update::Badge(badge)) => {
2966                    if badge.is_some() {
2967                        self.user_badge = badge;
2968                    }
2969                    self.in_flight -= 1;
2970                    changed = true;
2971                }
2972                Err(mpsc::error::TryRecvError::Empty) => break,
2973                Err(mpsc::error::TryRecvError::Disconnected) => {
2974                    self.in_flight = 0;
2975                    break;
2976                }
2977            }
2978        }
2979        for i in updated_panes {
2980            self.alert_new_failures(i);
2981        }
2982        if self.in_flight == 0 {
2983            self.loading = false;
2984            self.pending = None;
2985            changed = true;
2986        }
2987        changed
2988    }
2989}