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}
79
80impl Panel {
81    pub const ALL: &'static [Panel] = &[
82        Panel::Clusters,
83        Panel::Jobs,
84        Panel::Pipelines,
85        Panel::Warehouses,
86        Panel::Dashboards,
87        Panel::Catalog,
88    ];
89
90    pub fn title(&self) -> &'static str {
91        match self {
92            Panel::Clusters => "Compute",
93            Panel::Jobs => "Lakeflow Jobs",
94            Panel::Pipelines => "Lakeflow Pipelines",
95            Panel::Warehouses => "SQL Warehouses",
96            Panel::Dashboards => "AI/BI Dashboards",
97            Panel::Catalog => "Unity Catalog",
98        }
99    }
100
101    pub fn icon(&self) -> &'static str {
102        match self {
103            Panel::Clusters => "⌬",
104            Panel::Jobs => "⟳",
105            Panel::Pipelines => "⋙",
106            Panel::Warehouses => "⌁",
107            Panel::Dashboards => "▦",
108            Panel::Catalog => "⧉",
109        }
110    }
111
112    /// The databricks CLI command group whose `get <id>` returns item details.
113    pub fn cli_group(&self) -> &'static str {
114        match self {
115            Panel::Clusters => "clusters",
116            Panel::Jobs => "jobs",
117            Panel::Pipelines => "pipelines",
118            Panel::Warehouses => "warehouses",
119            Panel::Dashboards => "lakeview",
120            Panel::Catalog => "tables",
121        }
122    }
123}
124
125pub struct Detail {
126    pub panel: Panel,
127    pub name: String,
128    pub id: String,
129    /// Item kind for Unity Catalog leaves (TABLE / VIEW / VOLUME).
130    pub kind: Option<String>,
131    /// Heading of the activity section ("Recent activity", "Access", ...).
132    pub section: &'static str,
133    /// None while the fetch is in flight.
134    pub data: Option<DetailData>,
135    /// Toggles between the formatted summary and the raw JSON.
136    pub show_raw: bool,
137    pub scroll: u16,
138}
139
140/// Full-screen sample-data view for a Unity Catalog table or view.
141pub struct Preview {
142    pub name: String,
143    /// Display name and id of the warehouse running the query.
144    pub warehouse: String,
145    pub warehouse_id: String,
146    /// None while the query runs; then rows or an error.
147    pub data: Option<Result<crate::shape::TableData, String>>,
148    pub scroll: usize,
149}
150
151/// What a confirmed warehouse choice should run.
152enum PickTarget {
153    Preview(String),
154    Cost,
155    Lineage(String),
156    Sql(String),
157}
158
159/// Free-form SQL prompt with results, backed by the preview machinery.
160pub struct SqlConsole {
161    pub input: String,
162    /// Caret position in `input`, counted in characters.
163    pub cursor: usize,
164    /// Display name of the warehouse the last query ran on.
165    pub warehouse: String,
166    pub running: bool,
167    pub data: Option<Result<crate::shape::TableData, String>>,
168    /// The statement that produced `data`.
169    pub last_sql: String,
170    pub scroll: usize,
171}
172
173/// Where console history lives; one statement per line, oldest first.
174fn history_path() -> Option<std::path::PathBuf> {
175    let home = std::env::var_os("HOME")?;
176    Some(
177        std::path::PathBuf::from(home)
178            .join(".config")
179            .join("databricks-tui")
180            .join("history"),
181    )
182}
183
184fn load_history() -> Vec<String> {
185    history_path()
186        .and_then(|p| std::fs::read_to_string(p).ok())
187        .map(|s| {
188            s.lines()
189                .filter(|l| !l.trim().is_empty())
190                .map(str::to_string)
191                .collect()
192        })
193        .unwrap_or_default()
194}
195
196fn save_history(history: &[String]) {
197    let Some(path) = history_path() else {
198        return;
199    };
200    if let Some(dir) = path.parent() {
201        let _ = std::fs::create_dir_all(dir);
202        crate::config::restrict(dir, 0o700);
203    }
204    // Keep the tail; nobody scrolls back 200 queries.
205    let tail: Vec<&str> = history
206        .iter()
207        .rev()
208        .take(200)
209        .rev()
210        .map(String::as_str)
211        .collect();
212    // Queries can hold sensitive literals — owner-only, like shell history.
213    let _ = std::fs::write(&path, tail.join("\n") + "\n");
214    crate::config::restrict(&path, 0o600);
215}
216
217/// True when every char of `needle` appears in `haystack` in order.
218fn subsequence(haystack: &str, needle: &str) -> bool {
219    let mut chars = haystack.chars();
220    needle.chars().all(|n| chars.any(|h| h == n))
221}
222
223/// Byte offset of the `cursor`th character in `input`.
224fn byte_at(input: &str, cursor: usize) -> usize {
225    input
226        .char_indices()
227        .nth(cursor)
228        .map(|(i, _)| i)
229        .unwrap_or(input.len())
230}
231
232/// Overlay for choosing which SQL warehouse runs a query.
233pub struct WhPicker {
234    pub index: usize,
235    target: PickTarget,
236}
237
238/// Full-screen DBU usage view backed by system.billing.usage.
239pub struct CostView {
240    pub warehouse: String,
241    pub data: Option<Result<fetchers::cost::CostData, String>>,
242}
243
244/// Drill-down into a single job run or pipeline update, layered over
245/// the owning detail view.
246pub struct RunView {
247    /// Panel::Jobs (runs) or Panel::Pipelines (updates).
248    pub panel: Panel,
249    pub owner_name: String,
250    /// Job id or pipeline id the runs belong to.
251    owner_id: String,
252    /// Recent runs newest-first: (run_id, status, age).
253    pub runs: Vec<(String, Status, String)>,
254    /// Which of `runs` is shown.
255    pub idx: usize,
256    pub data: Option<DetailData>,
257    pub show_raw: bool,
258    pub scroll: u16,
259    /// True while the shown run is still executing — drives auto-refresh.
260    pub live: bool,
261    fetched_at: Instant,
262}
263
264/// (recent runs, detail of the newest, still-executing flag)
265type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
266
267enum RunUpdate {
268    Opened(Result<RunOpened, String>),
269    Detail(DetailData, bool),
270}
271
272/// One unhealthy resource, pointing back at its pane and item.
273pub struct Problem {
274    pub panel: usize,
275    pub name: String,
276    pub status: Status,
277    pub note: String,
278}
279
280/// Overlay collecting everything currently failing across the panes.
281pub struct Problems {
282    pub items: Vec<Problem>,
283    pub index: usize,
284}
285
286/// A pending destructive/mutating action awaiting a y/n keystroke.
287pub struct Confirm {
288    pub message: String,
289    args: Vec<String>,
290}
291
292enum Update {
293    Panel(usize, Result<Shape, String>),
294    Badge(Option<Shape>),
295}
296
297const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
298
299pub struct App {
300    pub focus: Panel,
301    pub theme: ThemeMode,
302    pub zoomed: bool,
303    pub shapes: Vec<Option<Shape>>,
304    pub user_badge: Option<Shape>,
305    pub error: Option<String>,
306    pub refresh_interval: Duration,
307    last_refresh: Instant,
308    pub loading: bool,
309    pub detail: Option<Detail>,
310    pub confirm: Option<Confirm>,
311    pub flash: Option<(String, Instant)>,
312    pub selected: [usize; 6],
313    pub host: Option<String>,
314    /// Available profiles from ~/.databrickscfg and the active one.
315    pub profiles: Vec<String>,
316    pub profile: Option<String>,
317    /// When Some, the workspace picker overlay is open at this index.
318    pub picker: Option<usize>,
319    /// When Some, the problems overlay is open.
320    pub problems: Option<Problems>,
321    /// Current position in the Unity Catalog tree: [], [catalog] or [catalog, schema].
322    pub uc_path: Vec<String>,
323    uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
324    pub preview: Option<Preview>,
325    preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
326    pub wh_picker: Option<WhPicker>,
327    /// Session-remembered (id, name) of the warehouse used for previews.
328    pub preview_warehouse: Option<(String, String)>,
329    pub cost: Option<CostView>,
330    #[allow(clippy::type_complexity)]
331    cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
332    /// Numeric id of the current workspace, resolved lazily for cost
333    /// scoping and cached for the session.
334    workspace_id: Option<String>,
335    pub sql: Option<SqlConsole>,
336    sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
337    /// Past console statements, oldest first; persisted across sessions.
338    sql_history: Vec<String>,
339    /// Position while cycling history with ↑/↓; None = editing a new line.
340    hist_idx: Option<usize>,
341    /// The unfinished statement stashed when history browsing starts.
342    hist_draft: String,
343    /// Ctrl+R incremental search: (query, nth-newest match shown).
344    pub hist_search: Option<(String, usize)>,
345    pub run_view: Option<RunView>,
346    run_rx: Option<oneshot::Receiver<RunUpdate>>,
347    pending: Option<mpsc::UnboundedReceiver<Update>>,
348    detail_rx: Option<oneshot::Receiver<DetailData>>,
349    action_rx: Option<oneshot::Receiver<Result<String, String>>>,
350    host_rx: Option<oneshot::Receiver<Option<String>>>,
351    in_flight: usize,
352    spinner_frame: usize,
353    /// Splash screen deadline; None once dismissed.
354    pub splash_until: Option<Instant>,
355    /// When each pane last received fresh data — drives the title flash.
356    pub updated_at: [Option<Instant>; 6],
357    /// Per-pane search filter; empty string means no filter.
358    pub filters: [String; 6],
359    /// True while the user is typing a filter for the focused pane.
360    pub filter_entry: bool,
361    /// Persisted preferences (theme, warehouse per profile).
362    pub config: crate::config::Config,
363    /// Failed item names per pane at the last refresh — None until the
364    /// pane has loaded once, so the first load never alerts.
365    failed_seen: [Option<std::collections::HashSet<String>>; 6],
366    /// Ctrl+P fuzzy jump overlay.
367    pub jump: Option<Jump>,
368    /// Statement id of the in-flight console query, for cancellation.
369    sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
370}
371
372/// Ctrl+P overlay: fuzzy-search everything loaded, Enter jumps to it.
373pub struct Jump {
374    pub query: String,
375    pub index: usize,
376}
377
378impl App {
379    pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
380        Self {
381            focus: Panel::Clusters,
382            theme,
383            zoomed: false,
384            shapes: vec![None; 6],
385            user_badge: None,
386            error: None,
387            refresh_interval: Duration::from_secs(refresh_secs),
388            last_refresh: Instant::now()
389                .checked_sub(Duration::from_secs(refresh_secs + 1))
390                .unwrap_or(Instant::now()),
391            loading: false,
392            detail: None,
393            confirm: None,
394            flash: None,
395            selected: [0; 6],
396            host: None,
397            profiles: Vec::new(),
398            profile: None,
399            picker: None,
400            problems: None,
401            uc_path: Vec::new(),
402            uc_rx: None,
403            preview: None,
404            preview_rx: None,
405            wh_picker: None,
406            preview_warehouse: None,
407            cost: None,
408            cost_rx: None,
409            workspace_id: None,
410            sql: None,
411            sql_rx: None,
412            sql_history: load_history(),
413            hist_idx: None,
414            hist_draft: String::new(),
415            hist_search: None,
416            run_view: None,
417            run_rx: None,
418            pending: None,
419            detail_rx: None,
420            action_rx: None,
421            host_rx: None,
422            in_flight: 0,
423            spinner_frame: 0,
424            splash_until: Some(Instant::now() + Duration::from_millis(1600)),
425            updated_at: [None; 6],
426            filters: Default::default(),
427            filter_entry: false,
428            config: crate::config::Config::load(),
429            failed_seen: Default::default(),
430            jump: None,
431            sql_stmt: None,
432        }
433    }
434
435    /// Flashes (and rings the bell) when a resource fails between one
436    /// refresh and the next.
437    fn alert_new_failures(&mut self, idx: usize) {
438        // Catalog "error rows" are listing problems, not runtime failures.
439        if idx == 5 {
440            return;
441        }
442        let Some(Shape::List(items)) = &self.shapes[idx] else {
443            return;
444        };
445        let failed: std::collections::HashSet<String> = items
446            .iter()
447            .filter(|it| {
448                matches!(it.status, Status::Failed)
449                    || it
450                        .history
451                        .last()
452                        .is_some_and(|s| matches!(s, Status::Failed))
453            })
454            .map(|it| it.name.clone())
455            .collect();
456        if let Some(prev) = &self.failed_seen[idx] {
457            let mut newly: Vec<&String> = failed.difference(prev).collect();
458            if !newly.is_empty() {
459                newly.sort();
460                let extra = if newly.len() > 1 {
461                    format!(" (+{} more)", newly.len() - 1)
462                } else {
463                    String::new()
464                };
465                self.flash = Some((
466                    format!("✗ {}{extra} just failed — ! to inspect", newly[0]),
467                    Instant::now(),
468                ));
469                // Bell so a backgrounded terminal (or tmux) flags it too.
470                print!("\x07");
471                let _ = std::io::Write::flush(&mut std::io::stdout());
472            }
473        }
474        self.failed_seen[idx] = Some(failed);
475    }
476
477    /// Remembers the current theme across sessions.
478    pub fn persist_theme(&mut self) {
479        self.config.theme = Some(self.theme.id().to_string());
480        self.config.save();
481    }
482
483    /// Restores the remembered warehouse for the active profile.
484    pub fn restore_warehouse_pref(&mut self) {
485        let profile = self.profile.as_deref().unwrap_or("DEFAULT");
486        self.preview_warehouse = self.config.warehouses.get(profile).cloned();
487    }
488
489    pub fn splash_active(&self) -> bool {
490        self.splash_until
491            .map(|t| Instant::now() < t)
492            .unwrap_or(false)
493    }
494
495    pub fn dismiss_splash(&mut self) {
496        self.splash_until = None;
497    }
498
499    /// True while any pane's data just landed — keeps the flash decaying.
500    pub fn any_fresh(&self) -> bool {
501        self.updated_at
502            .iter()
503            .flatten()
504            .any(|t| t.elapsed() < Duration::from_millis(1200))
505    }
506
507    pub fn open_picker(&mut self) {
508        if self.profiles.is_empty() {
509            return;
510        }
511        let current = self
512            .profile
513            .as_deref()
514            .and_then(|p| self.profiles.iter().position(|n| n == p))
515            .unwrap_or(0);
516        self.picker = Some(current);
517    }
518
519    pub fn picker_next(&mut self) {
520        if let Some(i) = self.picker {
521            self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
522        }
523    }
524
525    pub fn picker_prev(&mut self) {
526        if let Some(i) = self.picker {
527            self.picker = Some(i.saturating_sub(1));
528        }
529    }
530
531    /// Confirms the picker selection; returns the new CLI handle to use.
532    pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
533        let idx = self.picker.take()?;
534        let name = self.profiles.get(idx)?.clone();
535        let profile_arg = if name == "DEFAULT" {
536            None
537        } else {
538            Some(name.clone())
539        };
540        self.profile = Some(name);
541
542        // Drop all workspace-specific state; panes go back to loading.
543        self.shapes = vec![None; 6];
544        self.user_badge = None;
545        self.host = None;
546        self.selected = [0; 6];
547        self.detail = None;
548        self.detail_rx = None;
549        self.confirm = None;
550        self.problems = None;
551        self.uc_path.clear();
552        self.uc_rx = None;
553        self.preview = None;
554        self.preview_rx = None;
555        self.wh_picker = None;
556        self.preview_warehouse = None;
557        self.cost = None;
558        self.cost_rx = None;
559        self.workspace_id = None;
560        self.sql = None;
561        self.sql_rx = None;
562        self.run_view = None;
563        self.run_rx = None;
564        self.pending = None;
565        self.in_flight = 0;
566        self.loading = false;
567        self.zoomed = false;
568        self.filters = Default::default();
569        self.filter_entry = false;
570        self.failed_seen = Default::default();
571        self.jump = None;
572        self.sql_stmt = None;
573        self.restore_warehouse_pref();
574
575        Some(Arc::new(DatabricksCli::new(profile_arg)))
576    }
577
578    pub fn open_jump(&mut self) {
579        self.jump = Some(Jump {
580            query: String::new(),
581            index: 0,
582        });
583    }
584
585    /// Everything loaded that matches the jump query, best first:
586    /// (panel index, item name, kind/status label). Substring matches
587    /// rank above in-order subsequence matches.
588    pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
589        let Some(jump) = &self.jump else {
590            return Vec::new();
591        };
592        let q = jump.query.to_lowercase();
593        let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
594        for (i, shape) in self.shapes.iter().enumerate() {
595            let Some(Shape::List(items)) = shape else {
596                continue;
597            };
598            for it in items {
599                let name = it.name.to_lowercase();
600                let rank = if q.is_empty() || name.contains(&q) {
601                    0
602                } else if subsequence(&name, &q) {
603                    1
604                } else {
605                    continue;
606                };
607                scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
608            }
609        }
610        scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
611        scored
612            .into_iter()
613            .take(12)
614            .map(|(_, i, name, label)| (i, name, label))
615            .collect()
616    }
617
618    pub fn jump_push(&mut self, c: char) {
619        if let Some(j) = &mut self.jump {
620            j.query.push(c);
621            j.index = 0;
622        }
623    }
624
625    pub fn jump_pop(&mut self) {
626        if let Some(j) = &mut self.jump {
627            j.query.pop();
628            j.index = 0;
629        }
630    }
631
632    pub fn jump_next(&mut self) {
633        let len = self.jump_matches().len();
634        if let Some(j) = &mut self.jump {
635            j.index = (j.index + 1).min(len.saturating_sub(1));
636        }
637    }
638
639    pub fn jump_prev(&mut self) {
640        if let Some(j) = &mut self.jump {
641            j.index = j.index.saturating_sub(1);
642        }
643    }
644
645    /// Jumps focus and selection to the highlighted match.
646    pub fn jump_go(&mut self) {
647        let matches = self.jump_matches();
648        let Some(jump) = self.jump.take() else {
649            return;
650        };
651        let Some((panel_idx, name, _)) = matches.get(jump.index) else {
652            return;
653        };
654        self.focus = Panel::ALL[*panel_idx];
655        self.filters[*panel_idx].clear();
656        if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
657            if let Some(pos) = items.iter().position(|i| &i.name == name) {
658                self.selected[*panel_idx] = pos;
659            }
660        }
661    }
662
663    /// Collects everything unhealthy across the loaded panes: items whose
664    /// status is failed, or whose most recent run failed.
665    pub fn open_problems(&mut self) {
666        let mut items = Vec::new();
667        for (i, shape) in self.shapes.iter().enumerate() {
668            let Some(Shape::List(list)) = shape else {
669                continue;
670            };
671            for it in list {
672                let failed_now = matches!(it.status, Status::Failed);
673                let failed_last = it
674                    .history
675                    .last()
676                    .is_some_and(|s| matches!(s, Status::Failed));
677                if failed_now || failed_last {
678                    let note = if failed_now {
679                        it.detail.clone().unwrap_or_default()
680                    } else {
681                        "latest run failed".to_string()
682                    };
683                    items.push(Problem {
684                        panel: i,
685                        name: it.name.clone(),
686                        status: it.status.clone(),
687                        note,
688                    });
689                }
690            }
691        }
692        self.problems = Some(Problems { items, index: 0 });
693    }
694
695    pub fn problems_next(&mut self) {
696        if let Some(pr) = &mut self.problems {
697            pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
698        }
699    }
700
701    pub fn problems_prev(&mut self) {
702        if let Some(pr) = &mut self.problems {
703            pr.index = pr.index.saturating_sub(1);
704        }
705    }
706
707    /// Jumps focus and selection to the highlighted problem's pane item.
708    pub fn problems_jump(&mut self) {
709        let Some(pr) = self.problems.take() else {
710            return;
711        };
712        let Some(problem) = pr.items.get(pr.index) else {
713            return;
714        };
715        self.focus = Panel::ALL[problem.panel];
716        // The pane's filter could hide the item we're jumping to.
717        self.filters[problem.panel].clear();
718        if let Some(Shape::List(list)) = &self.shapes[problem.panel] {
719            if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
720                self.selected[problem.panel] = pos;
721            }
722        }
723    }
724
725    /// Resolves the workspace host in the background — `auth describe` can
726    /// take seconds when it refreshes tokens, so it must not block the loop.
727    pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
728        let (tx, rx) = oneshot::channel();
729        self.host_rx = Some(rx);
730        let cli = Arc::clone(cli);
731        tokio::spawn(async move {
732            let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
733                json["details"]["host"]
734                    .as_str()
735                    .or_else(|| json["host"].as_str())
736                    .map(str::to_string)
737            });
738            let _ = tx.send(host);
739        });
740    }
741
742    pub fn poll_host(&mut self) {
743        if let Some(rx) = &mut self.host_rx {
744            match rx.try_recv() {
745                Ok(host) => {
746                    self.host = host;
747                    self.host_rx = None;
748                }
749                Err(oneshot::error::TryRecvError::Empty) => {}
750                Err(oneshot::error::TryRecvError::Closed) => {
751                    self.host_rx = None;
752                }
753            }
754        }
755    }
756
757    fn focus_index(&self) -> usize {
758        Panel::ALL
759            .iter()
760            .position(|p| p == &self.focus)
761            .unwrap_or(0)
762    }
763
764    fn list_len(&self, idx: usize) -> usize {
765        match &self.shapes[idx] {
766            Some(Shape::List(items)) => items
767                .iter()
768                .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
769                .count(),
770            _ => 0,
771        }
772    }
773
774    /// Selection index for a panel, clamped to the current list length.
775    pub fn selection(&self, idx: usize) -> usize {
776        self.selected[idx].min(self.list_len(idx).saturating_sub(1))
777    }
778
779    pub fn select_next(&mut self) {
780        let idx = self.focus_index();
781        let len = self.list_len(idx);
782        if len > 0 {
783            self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
784        }
785    }
786
787    pub fn select_prev(&mut self) {
788        let idx = self.focus_index();
789        self.selected[idx] = self.selection(idx).saturating_sub(1);
790    }
791
792    /// The currently highlighted item in the focused panel, respecting
793    /// the pane's filter — the nth *visible* item, like the UI shows.
794    fn selected_item(&self) -> Option<&crate::shape::ListItem> {
795        let idx = self.focus_index();
796        match &self.shapes[idx] {
797            Some(Shape::List(items)) => items
798                .iter()
799                .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
800                .nth(self.selection(idx)),
801            _ => None,
802        }
803    }
804
805    /// Opens filter entry for the focused pane, starting from scratch.
806    pub fn filter_start(&mut self) {
807        let idx = self.focus_index();
808        self.filters[idx].clear();
809        self.selected[idx] = 0;
810        self.filter_entry = true;
811    }
812
813    pub fn filter_push(&mut self, c: char) {
814        let idx = self.focus_index();
815        self.filters[idx].push(c);
816        self.selected[idx] = 0;
817    }
818
819    pub fn filter_pop(&mut self) {
820        let idx = self.focus_index();
821        self.filters[idx].pop();
822        self.selected[idx] = 0;
823    }
824
825    /// Keeps the filter applied and returns keys to normal navigation.
826    pub fn filter_accept(&mut self) {
827        self.filter_entry = false;
828    }
829
830    pub fn filter_clear(&mut self) {
831        let idx = self.focus_index();
832        self.filters[idx].clear();
833        self.selected[idx] = 0;
834        self.filter_entry = false;
835    }
836
837    /// The focused pane's filter, if any.
838    pub fn active_filter(&self) -> &str {
839        &self.filters[self.focus_index()]
840    }
841
842    pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
843        let Some(item) = self.selected_item() else {
844            return;
845        };
846        let Some(id) = item.id.clone() else {
847            return;
848        };
849        let kind = match &item.status {
850            Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
851            _ => None,
852        };
853        let section = match self.focus {
854            Panel::Dashboards => "Contents",
855            Panel::Catalog => "Columns",
856            Panel::Warehouses => "Recent queries",
857            _ => "Recent activity",
858        };
859        self.detail = Some(Detail {
860            panel: self.focus,
861            name: item.name.clone(),
862            id: id.clone(),
863            kind,
864            section,
865            data: None,
866            show_raw: false,
867            scroll: 0,
868        });
869
870        let (tx, rx) = oneshot::channel();
871        self.detail_rx = Some(rx);
872        let cli = Arc::clone(cli);
873        let kind = self.detail.as_ref().unwrap().kind.clone();
874        // Files in volumes get a content peek instead of an API `get`.
875        if kind.as_deref() == Some("FILE") {
876            if let Some(d) = &mut self.detail {
877                d.section = "File head";
878            }
879            tokio::spawn(async move {
880                let data = fetchers::catalog::file_peek(&cli, &id).await;
881                let _ = tx.send(data);
882            });
883            return;
884        }
885        let group = match &kind {
886            Some(k) if k == "VOLUME" => "volumes",
887            _ => self.focus.cli_group(),
888        };
889        // Tables get extra facts from DESCRIBE DETAIL when a warehouse
890        // is already remembered — free depth, no picker interruption.
891        let warehouse = match &kind {
892            Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
893            _ => None,
894        };
895        tokio::spawn(async move {
896            let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
897            let _ = tx.send(data);
898        });
899    }
900
901    /// Descends one level in the Unity Catalog tree. Returns false when the
902    /// selection is a leaf (caller should open the detail view instead).
903    pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
904        if self.focus != Panel::Catalog {
905            return false;
906        }
907        let Some(item) = self.selected_item() else {
908            return self.uc_path.is_empty(); // empty root pane: swallow the key
909        };
910        // Below the schema level only volumes and their directories are
911        // containers; tables/views fall through to the detail view.
912        if self.uc_path.len() >= 2 {
913            let drillable =
914                matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
915            if !drillable {
916                return false;
917            }
918        }
919        self.uc_path.push(item.name.clone());
920        self.refresh_catalog(cli);
921        true
922    }
923
924    /// Ascends one level; returns false if already at the catalog root.
925    pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
926        if self.focus != Panel::Catalog || self.uc_path.is_empty() {
927            return false;
928        }
929        self.uc_path.pop();
930        self.refresh_catalog(cli);
931        true
932    }
933
934    fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
935        self.shapes[5] = None;
936        self.selected[5] = 0;
937        // A filter typed at one level would silently hide the next.
938        self.filters[5].clear();
939        let (tx, rx) = oneshot::channel();
940        self.uc_rx = Some(rx);
941        let cli = Arc::clone(cli);
942        let path = self.uc_path.clone();
943        tokio::spawn(async move {
944            let result = fetchers::catalog::fetch(&cli, &path)
945                .await
946                .map_err(|e| format!("{e:#}"));
947            let _ = tx.send(result);
948        });
949    }
950
951    /// Looks up a resource's display name by id in the loaded panes —
952    /// lets the cost view show "nightly-etl" instead of a job id.
953    pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
954        let idx = match kind {
955            "cluster" => 0,
956            "job" => 1,
957            "warehouse" => 3,
958            _ => return None,
959        };
960        match &self.shapes[idx] {
961            Some(Shape::List(items)) => items
962                .iter()
963                .find(|i| i.id.as_deref() == Some(id))
964                .map(|i| i.name.clone()),
965            _ => None,
966        }
967    }
968
969    /// All known warehouses as (name, id, running).
970    pub fn warehouses(&self) -> Vec<(String, String, bool)> {
971        let Some(Shape::List(items)) = &self.shapes[3] else {
972            return Vec::new();
973        };
974        items
975            .iter()
976            .filter_map(|i| {
977                let id = i.id.clone()?;
978                Some((i.name.clone(), id, matches!(i.status, Status::Running)))
979            })
980            .collect()
981    }
982
983    /// Runs a sample-data query for the selected table or view. With
984    /// `force_pick` (or several warehouses and no remembered choice) a
985    /// warehouse picker opens first.
986    pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
987        if self.focus != Panel::Catalog {
988            return;
989        }
990        let Some(item) = self.selected_item() else {
991            return;
992        };
993        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
994            return;
995        }
996        let Some(full_name) = item.id.clone() else {
997            return;
998        };
999        let warehouses = self.warehouses();
1000        if warehouses.is_empty() {
1001            self.flash = Some((
1002                "✗ no SQL warehouse available for previews".to_string(),
1003                Instant::now(),
1004            ));
1005            return;
1006        }
1007        if !force_pick {
1008            if let Some((id, name)) = self.preview_warehouse.clone() {
1009                self.start_preview_query(cli, full_name, id, name);
1010                return;
1011            }
1012            if let [(name, id, _)] = warehouses.as_slice() {
1013                self.preview_warehouse = Some((id.clone(), name.clone()));
1014                self.start_preview_query(cli, full_name, id.clone(), name.clone());
1015                return;
1016            }
1017        }
1018        // Default the cursor to the remembered choice, else a running warehouse.
1019        let index = self
1020            .preview_warehouse
1021            .as_ref()
1022            .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1023            .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1024            .unwrap_or(0);
1025        self.wh_picker = Some(WhPicker {
1026            index,
1027            target: PickTarget::Preview(full_name),
1028        });
1029    }
1030
1031    /// Opens the DBU usage view, resolving a warehouse like previews do.
1032    pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1033        let warehouses = self.warehouses();
1034        if warehouses.is_empty() {
1035            self.flash = Some((
1036                "✗ no SQL warehouse available to query system tables".to_string(),
1037                Instant::now(),
1038            ));
1039            return;
1040        }
1041        if let Some((id, name)) = self.preview_warehouse.clone() {
1042            self.start_cost_query(cli, id, name);
1043            return;
1044        }
1045        if let [(name, id, _)] = warehouses.as_slice() {
1046            self.preview_warehouse = Some((id.clone(), name.clone()));
1047            self.start_cost_query(cli, id.clone(), name.clone());
1048            return;
1049        }
1050        let index = warehouses
1051            .iter()
1052            .position(|(_, _, running)| *running)
1053            .unwrap_or(0);
1054        self.wh_picker = Some(WhPicker {
1055            index,
1056            target: PickTarget::Cost,
1057        });
1058    }
1059
1060    fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1061        self.cost = Some(CostView {
1062            warehouse: name,
1063            data: None,
1064        });
1065        let (tx, rx) = oneshot::channel();
1066        self.cost_rx = Some(rx);
1067        let cli = Arc::clone(cli);
1068        let host = self.host.clone();
1069        let cached_ws = self.workspace_id.clone();
1070        tokio::spawn(async move {
1071            // Scope usage to this workspace; resolved once, then cached.
1072            let ws = match (cached_ws, host) {
1073                (Some(w), _) => Some(w),
1074                (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1075                (None, None) => None,
1076            };
1077            let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1078            let _ = tx.send((result, ws));
1079        });
1080    }
1081
1082    /// Opens the lineage view for the selected table/view; needs a
1083    /// warehouse since lineage lives in system tables.
1084    pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1085        if self.focus != Panel::Catalog {
1086            return;
1087        }
1088        let Some(item) = self.selected_item() else {
1089            return;
1090        };
1091        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1092            return;
1093        }
1094        let Some(full_name) = item.id.clone() else {
1095            return;
1096        };
1097        let warehouses = self.warehouses();
1098        if warehouses.is_empty() {
1099            self.flash = Some((
1100                "✗ no SQL warehouse available to query lineage".to_string(),
1101                Instant::now(),
1102            ));
1103            return;
1104        }
1105        if let Some((id, _)) = self.preview_warehouse.clone() {
1106            self.start_lineage_query(cli, full_name, id);
1107            return;
1108        }
1109        if let [(name, id, _)] = warehouses.as_slice() {
1110            self.preview_warehouse = Some((id.clone(), name.clone()));
1111            let id = id.clone();
1112            self.start_lineage_query(cli, full_name, id);
1113            return;
1114        }
1115        let index = warehouses
1116            .iter()
1117            .position(|(_, _, running)| *running)
1118            .unwrap_or(0);
1119        self.wh_picker = Some(WhPicker {
1120            index,
1121            target: PickTarget::Lineage(full_name),
1122        });
1123    }
1124
1125    fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1126        self.detail = Some(Detail {
1127            panel: Panel::Catalog,
1128            name: full_name.clone(),
1129            id: full_name.clone(),
1130            kind: None,
1131            section: "Lineage",
1132            data: None,
1133            show_raw: false,
1134            scroll: 0,
1135        });
1136        let (tx, rx) = oneshot::channel();
1137        self.detail_rx = Some(rx);
1138        let cli = Arc::clone(cli);
1139        tokio::spawn(async move {
1140            let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1141            let _ = tx.send(data);
1142        });
1143    }
1144
1145    pub fn close_cost(&mut self) {
1146        self.cost = None;
1147        self.cost_rx = None;
1148    }
1149
1150    /// The fully-qualified name of the selected catalog-pane table/view.
1151    fn selected_table_fqn(&self) -> Option<String> {
1152        if self.focus != Panel::Catalog {
1153            return None;
1154        }
1155        let item = self.selected_item()?;
1156        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1157            return None;
1158        }
1159        item.id.clone()
1160    }
1161
1162    /// Opens the SQL console. With a table/view selected in the catalog
1163    /// pane, the prompt starts as an editable query against it.
1164    pub fn open_sql(&mut self) {
1165        if self.sql.is_none() {
1166            let input = self
1167                .selected_table_fqn()
1168                .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1169                .unwrap_or_default();
1170            self.sql = Some(SqlConsole {
1171                cursor: input.chars().count(),
1172                input,
1173                warehouse: String::new(),
1174                running: false,
1175                data: None,
1176                last_sql: String::new(),
1177                scroll: 0,
1178            });
1179        }
1180    }
1181
1182    pub fn close_sql(&mut self) {
1183        self.sql = None;
1184        self.sql_rx = None;
1185        self.hist_idx = None;
1186        self.hist_draft.clear();
1187        self.hist_search = None;
1188    }
1189
1190    /// The statement currently in the prompt.
1191    pub fn sql_input(&self) -> Option<String> {
1192        self.sql.as_ref().map(|c| c.input.clone())
1193    }
1194
1195    /// Replaces the prompt contents (after an $EDITOR round-trip).
1196    pub fn sql_set_input(&mut self, s: &str) {
1197        if let Some(console) = &mut self.sql {
1198            console.input = s.to_string();
1199            console.cursor = console.input.chars().count();
1200        }
1201    }
1202
1203    /// The history entry the active Ctrl+R search currently matches.
1204    pub fn hist_search_current(&self) -> Option<&String> {
1205        let (query, nth) = self.hist_search.as_ref()?;
1206        self.sql_history
1207            .iter()
1208            .rev()
1209            .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
1210            .nth(*nth)
1211    }
1212
1213    pub fn hist_search_start(&mut self) {
1214        if self.sql.is_some() {
1215            self.hist_search = Some((String::new(), 0));
1216        }
1217    }
1218
1219    pub fn hist_search_push(&mut self, c: char) {
1220        if let Some((query, nth)) = &mut self.hist_search {
1221            query.push(c);
1222            *nth = 0;
1223        }
1224    }
1225
1226    pub fn hist_search_pop(&mut self) {
1227        if let Some((query, nth)) = &mut self.hist_search {
1228            query.pop();
1229            *nth = 0;
1230        }
1231    }
1232
1233    /// Ctrl+R again: step to the next older match.
1234    pub fn hist_search_older(&mut self) {
1235        let Some((query, nth)) = &self.hist_search else {
1236            return;
1237        };
1238        let q = query.to_lowercase();
1239        let matches = self
1240            .sql_history
1241            .iter()
1242            .filter(|h| h.to_lowercase().contains(&q))
1243            .count();
1244        if nth + 1 < matches {
1245            if let Some((_, n)) = &mut self.hist_search {
1246                *n += 1;
1247            }
1248        }
1249    }
1250
1251    pub fn hist_search_accept(&mut self) {
1252        if let Some(stmt) = self.hist_search_current().cloned() {
1253            self.sql_set_input(&stmt);
1254        }
1255        self.hist_search = None;
1256    }
1257
1258    pub fn hist_search_cancel(&mut self) {
1259        self.hist_search = None;
1260    }
1261
1262    pub fn sql_push(&mut self, c: char) {
1263        if let Some(console) = &mut self.sql {
1264            let at = byte_at(&console.input, console.cursor);
1265            console.input.insert(at, c);
1266            console.cursor += 1;
1267        }
1268    }
1269
1270    /// Backspace: deletes the character before the caret.
1271    pub fn sql_pop(&mut self) {
1272        if let Some(console) = &mut self.sql {
1273            if console.cursor > 0 {
1274                let at = byte_at(&console.input, console.cursor - 1);
1275                console.input.remove(at);
1276                console.cursor -= 1;
1277            }
1278        }
1279    }
1280
1281    /// Delete: removes the character under the caret.
1282    pub fn sql_delete(&mut self) {
1283        if let Some(console) = &mut self.sql {
1284            if console.cursor < console.input.chars().count() {
1285                let at = byte_at(&console.input, console.cursor);
1286                console.input.remove(at);
1287            }
1288        }
1289    }
1290
1291    pub fn sql_left(&mut self) {
1292        if let Some(console) = &mut self.sql {
1293            console.cursor = console.cursor.saturating_sub(1);
1294        }
1295    }
1296
1297    pub fn sql_right(&mut self) {
1298        if let Some(console) = &mut self.sql {
1299            console.cursor = (console.cursor + 1).min(console.input.chars().count());
1300        }
1301    }
1302
1303    /// ↑ at the prompt: step back through history, stashing the draft.
1304    pub fn sql_hist_prev(&mut self) {
1305        let Some(console) = &mut self.sql else {
1306            return;
1307        };
1308        if self.sql_history.is_empty() {
1309            return;
1310        }
1311        let idx = match self.hist_idx {
1312            None => {
1313                self.hist_draft = console.input.clone();
1314                self.sql_history.len() - 1
1315            }
1316            Some(i) => i.saturating_sub(1),
1317        };
1318        self.hist_idx = Some(idx);
1319        console.input = self.sql_history[idx].clone();
1320        console.cursor = console.input.chars().count();
1321    }
1322
1323    /// ↓ at the prompt: step forward, back to the stashed draft at the end.
1324    pub fn sql_hist_next(&mut self) {
1325        let Some(console) = &mut self.sql else {
1326            return;
1327        };
1328        let Some(idx) = self.hist_idx else {
1329            return;
1330        };
1331        if idx + 1 < self.sql_history.len() {
1332            self.hist_idx = Some(idx + 1);
1333            console.input = self.sql_history[idx + 1].clone();
1334        } else {
1335            self.hist_idx = None;
1336            console.input = self.hist_draft.clone();
1337        }
1338        console.cursor = console.input.chars().count();
1339    }
1340
1341    pub fn sql_home(&mut self) {
1342        if let Some(console) = &mut self.sql {
1343            console.cursor = 0;
1344        }
1345    }
1346
1347    pub fn sql_end(&mut self) {
1348        if let Some(console) = &mut self.sql {
1349            console.cursor = console.input.chars().count();
1350        }
1351    }
1352
1353    pub fn sql_scroll(&mut self, delta: i32) {
1354        if let Some(console) = &mut self.sql {
1355            let max = match &console.data {
1356                Some(Ok(t)) => t.rows.len().saturating_sub(1),
1357                _ => 0,
1358            };
1359            console.scroll = if delta < 0 {
1360                console.scroll.saturating_sub(delta.unsigned_abs() as usize)
1361            } else {
1362                (console.scroll + delta as usize).min(max)
1363            };
1364        }
1365    }
1366
1367    /// Runs the typed statement, resolving a warehouse like previews do.
1368    pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
1369        let Some(console) = &self.sql else {
1370            return;
1371        };
1372        if console.running {
1373            return;
1374        }
1375        let query = console.input.trim().to_string();
1376        if query.is_empty() {
1377            return;
1378        }
1379        // Remember the statement (skipping immediate repeats) and reset
1380        // any in-progress history browsing.
1381        if self.sql_history.last() != Some(&query) {
1382            self.sql_history.push(query.clone());
1383            save_history(&self.sql_history);
1384        }
1385        self.hist_idx = None;
1386        self.hist_draft.clear();
1387        let warehouses = self.warehouses();
1388        if warehouses.is_empty() {
1389            self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
1390            return;
1391        }
1392        if let Some((id, name)) = self.preview_warehouse.clone() {
1393            self.start_sql_query(cli, query, id, name);
1394            return;
1395        }
1396        if let [(name, id, _)] = warehouses.as_slice() {
1397            self.preview_warehouse = Some((id.clone(), name.clone()));
1398            self.start_sql_query(cli, query, id.clone(), name.clone());
1399            return;
1400        }
1401        let index = warehouses
1402            .iter()
1403            .position(|(_, _, running)| *running)
1404            .unwrap_or(0);
1405        self.wh_picker = Some(WhPicker {
1406            index,
1407            target: PickTarget::Sql(query),
1408        });
1409    }
1410
1411    fn start_sql_query(
1412        &mut self,
1413        cli: &Arc<DatabricksCli>,
1414        query: String,
1415        id: String,
1416        name: String,
1417    ) {
1418        if let Some(console) = &mut self.sql {
1419            console.running = true;
1420            console.warehouse = name;
1421            console.scroll = 0;
1422            console.last_sql = query.clone();
1423        }
1424        // Published by the task once submitted, so Esc can cancel it.
1425        let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
1426        self.sql_stmt = Some(std::sync::Arc::clone(&handle));
1427        let (tx, rx) = oneshot::channel();
1428        self.sql_rx = Some(rx);
1429        let cli = Arc::clone(cli);
1430        tokio::spawn(async move {
1431            let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
1432            let _ = tx.send(result);
1433        });
1434    }
1435
1436    /// Writes a result set to a timestamped CSV in the working directory
1437    /// and flashes the path.
1438    fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
1439        let stamp = std::time::SystemTime::now()
1440            .duration_since(std::time::UNIX_EPOCH)
1441            .map(|d| d.as_secs())
1442            .unwrap_or(0);
1443        let slug: String = label
1444            .chars()
1445            .map(|c| if c.is_alphanumeric() { c } else { '-' })
1446            .collect::<String>()
1447            .trim_matches('-')
1448            .chars()
1449            .take(40)
1450            .collect();
1451        let name = format!("databricks-{slug}-{stamp}.csv");
1452        let msg = match std::fs::write(&name, data.to_csv()) {
1453            Ok(()) => {
1454                let cwd = std::env::current_dir()
1455                    .map(|d| d.display().to_string())
1456                    .unwrap_or_default();
1457                format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
1458            }
1459            Err(e) => format!("✗ export failed: {e}"),
1460        };
1461        self.flash = Some((msg, Instant::now()));
1462    }
1463
1464    /// Ctrl+S in the console: export the current results.
1465    pub fn sql_export(&mut self) {
1466        if let Some(SqlConsole {
1467            data: Some(Ok(data)),
1468            last_sql,
1469            ..
1470        }) = &self.sql
1471        {
1472            let (label, data) = (last_sql.clone(), data.clone());
1473            self.export_csv(&label, &data);
1474        }
1475    }
1476
1477    /// `e` in a table preview: export the sampled rows.
1478    pub fn preview_export(&mut self) {
1479        if let Some(Preview {
1480            data: Some(Ok(data)),
1481            name,
1482            ..
1483        }) = &self.preview
1484        {
1485            let (label, data) = (name.clone(), data.clone());
1486            self.export_csv(&label, &data);
1487        }
1488    }
1489
1490    pub fn poll_sql(&mut self) -> bool {
1491        let Some(rx) = &mut self.sql_rx else {
1492            return false;
1493        };
1494        match rx.try_recv() {
1495            Ok(result) => {
1496                // A warehouse that errors shouldn't stay the session
1497                // default — but a user-canceled statement is not its fault.
1498                if let Err(e) = &result {
1499                    if e != "statement canceled" {
1500                        self.preview_warehouse = None;
1501                    }
1502                }
1503                if let Some(console) = &mut self.sql {
1504                    console.running = false;
1505                    console.data = Some(result);
1506                }
1507                self.sql_rx = None;
1508                self.sql_stmt = None;
1509                true
1510            }
1511            Err(oneshot::error::TryRecvError::Empty) => false,
1512            Err(oneshot::error::TryRecvError::Closed) => {
1513                if let Some(console) = &mut self.sql {
1514                    console.running = false;
1515                }
1516                self.sql_rx = None;
1517                true
1518            }
1519        }
1520    }
1521
1522    pub fn poll_cost(&mut self) -> bool {
1523        let Some(rx) = &mut self.cost_rx else {
1524            return false;
1525        };
1526        match rx.try_recv() {
1527            Ok((result, ws)) => {
1528                if result.is_err() {
1529                    self.preview_warehouse = None;
1530                }
1531                if ws.is_some() {
1532                    self.workspace_id = ws;
1533                }
1534                if let Some(cv) = &mut self.cost {
1535                    cv.data = Some(result);
1536                }
1537                self.cost_rx = None;
1538                true
1539            }
1540            Err(oneshot::error::TryRecvError::Empty) => false,
1541            Err(oneshot::error::TryRecvError::Closed) => {
1542                self.cost_rx = None;
1543                true
1544            }
1545        }
1546    }
1547
1548    pub fn wh_picker_next(&mut self) {
1549        let len = self.warehouses().len();
1550        if let Some(p) = &mut self.wh_picker {
1551            p.index = (p.index + 1).min(len.saturating_sub(1));
1552        }
1553    }
1554
1555    pub fn wh_picker_prev(&mut self) {
1556        if let Some(p) = &mut self.wh_picker {
1557            p.index = p.index.saturating_sub(1);
1558        }
1559    }
1560
1561    pub fn wh_picker_cancel(&mut self) {
1562        self.wh_picker = None;
1563    }
1564
1565    /// Confirms the warehouse choice, remembers it, and starts the preview.
1566    pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
1567        let Some(picker) = self.wh_picker.take() else {
1568            return;
1569        };
1570        let warehouses = self.warehouses();
1571        let Some((name, id, _)) = warehouses.get(picker.index) else {
1572            return;
1573        };
1574        self.preview_warehouse = Some((id.clone(), name.clone()));
1575        // An explicit choice is worth remembering across sessions.
1576        let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
1577        self.config
1578            .warehouses
1579            .insert(profile, (id.clone(), name.clone()));
1580        self.config.save();
1581        match picker.target {
1582            PickTarget::Preview(table) => {
1583                self.start_preview_query(cli, table, id.clone(), name.clone())
1584            }
1585            PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
1586            PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
1587            PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
1588        }
1589    }
1590
1591    fn start_preview_query(
1592        &mut self,
1593        cli: &Arc<DatabricksCli>,
1594        full_name: String,
1595        warehouse_id: String,
1596        warehouse_name: String,
1597    ) {
1598        self.preview = Some(Preview {
1599            name: full_name.clone(),
1600            warehouse: warehouse_name,
1601            warehouse_id: warehouse_id.clone(),
1602            data: None,
1603            scroll: 0,
1604        });
1605        let (tx, rx) = oneshot::channel();
1606        self.preview_rx = Some(rx);
1607        let cli = Arc::clone(cli);
1608        tokio::spawn(async move {
1609            let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
1610            let _ = tx.send(result);
1611        });
1612    }
1613
1614    pub fn close_preview(&mut self) {
1615        self.preview = None;
1616        self.preview_rx = None;
1617    }
1618
1619    pub fn poll_preview(&mut self) -> bool {
1620        let Some(rx) = &mut self.preview_rx else {
1621            return false;
1622        };
1623        match rx.try_recv() {
1624            Ok(result) => {
1625                // A warehouse that errors shouldn't stay the session default.
1626                if result.is_err() {
1627                    self.preview_warehouse = None;
1628                }
1629                if let Some(pv) = &mut self.preview {
1630                    pv.data = Some(result);
1631                }
1632                self.preview_rx = None;
1633                true
1634            }
1635            Err(oneshot::error::TryRecvError::Empty) => false,
1636            Err(oneshot::error::TryRecvError::Closed) => {
1637                self.preview_rx = None;
1638                true
1639            }
1640        }
1641    }
1642
1643    pub fn preview_scroll(&mut self, delta: i32) {
1644        if let Some(pv) = &mut self.preview {
1645            let max = match &pv.data {
1646                Some(Ok(t)) => t.rows.len().saturating_sub(1),
1647                _ => 0,
1648            };
1649            pv.scroll = if delta < 0 {
1650                pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
1651            } else {
1652                (pv.scroll + delta as usize).min(max)
1653            };
1654        }
1655    }
1656
1657    pub fn poll_uc(&mut self) -> bool {
1658        let Some(rx) = &mut self.uc_rx else {
1659            return false;
1660        };
1661        match rx.try_recv() {
1662            Ok(result) => {
1663                self.shapes[5] = Some(match result {
1664                    Ok(shape) => shape,
1665                    Err(e) => Shape::Text(format!("✗ {e}")),
1666                });
1667                self.updated_at[5] = Some(Instant::now());
1668                self.uc_rx = None;
1669                true
1670            }
1671            Err(oneshot::error::TryRecvError::Empty) => false,
1672            Err(oneshot::error::TryRecvError::Closed) => {
1673                self.uc_rx = None;
1674                true
1675            }
1676        }
1677    }
1678
1679    /// Opens the access view for the selected item: effective UC grants
1680    /// or the workspace object ACL.
1681    pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
1682        let Some(item) = self.selected_item() else {
1683            return;
1684        };
1685        let Some(id) = item.id.clone() else {
1686            return;
1687        };
1688        let (uc, object_type): (bool, &'static str) = match self.focus {
1689            Panel::Catalog => match &item.status {
1690                Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
1691                Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
1692                Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
1693                Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
1694                _ => return,
1695            },
1696            Panel::Clusters => (false, "clusters"),
1697            Panel::Jobs => (false, "jobs"),
1698            Panel::Pipelines => (false, "pipelines"),
1699            Panel::Warehouses => (false, "warehouses"),
1700            Panel::Dashboards => (false, "dashboards"),
1701        };
1702        self.detail = Some(Detail {
1703            panel: self.focus,
1704            name: item.name.clone(),
1705            id: id.clone(),
1706            kind: None,
1707            section: "Access",
1708            data: None,
1709            show_raw: false,
1710            scroll: 0,
1711        });
1712        let (tx, rx) = oneshot::channel();
1713        self.detail_rx = Some(rx);
1714        let cli = Arc::clone(cli);
1715        tokio::spawn(async move {
1716            let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
1717            let _ = tx.send(data);
1718        });
1719    }
1720
1721    /// Drills from an open job or pipeline detail into its most recent
1722    /// run/update.
1723    pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
1724        let Some(d) = &self.detail else {
1725            return;
1726        };
1727        let panel = d.panel;
1728        if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
1729            return;
1730        }
1731        let owner_id = d.id.clone();
1732        self.run_view = Some(RunView {
1733            panel,
1734            owner_name: d.name.clone(),
1735            owner_id: owner_id.clone(),
1736            runs: Vec::new(),
1737            idx: 0,
1738            data: None,
1739            show_raw: false,
1740            scroll: 0,
1741            live: false,
1742            fetched_at: Instant::now(),
1743        });
1744        let (tx, rx) = oneshot::channel();
1745        self.run_rx = Some(rx);
1746        let cli = Arc::clone(cli);
1747        tokio::spawn(async move {
1748            let result = async {
1749                let runs = if panel == Panel::Jobs {
1750                    fetchers::runs::list(&cli, &owner_id).await?
1751                } else {
1752                    fetchers::updates::list(&cli, &owner_id).await?
1753                };
1754                let Some((run_id, _, _)) = runs.first().cloned() else {
1755                    return Err("no runs recorded yet".to_string());
1756                };
1757                let (data, live) = if panel == Panel::Jobs {
1758                    fetchers::runs::fetch(&cli, &run_id).await
1759                } else {
1760                    fetchers::updates::fetch(&cli, &owner_id, &run_id).await
1761                };
1762                Ok((runs, data, live))
1763            }
1764            .await;
1765            let _ = tx.send(RunUpdate::Opened(result));
1766        });
1767    }
1768
1769    pub fn close_run(&mut self) {
1770        self.run_view = None;
1771        self.run_rx = None;
1772    }
1773
1774    /// Moves to an older (delta > 0) or newer (delta < 0) run.
1775    pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
1776        if self.run_rx.is_some() {
1777            return;
1778        }
1779        let Some(rv) = &mut self.run_view else {
1780            return;
1781        };
1782        if rv.runs.is_empty() {
1783            return;
1784        }
1785        let new = if delta < 0 {
1786            rv.idx.saturating_sub(delta.unsigned_abs() as usize)
1787        } else {
1788            (rv.idx + delta as usize).min(rv.runs.len() - 1)
1789        };
1790        if new == rv.idx {
1791            return;
1792        }
1793        rv.idx = new;
1794        rv.data = None;
1795        rv.scroll = 0;
1796        rv.show_raw = false;
1797        let run_id = rv.runs[new].0.clone();
1798        self.start_run_fetch(cli, run_id);
1799    }
1800
1801    fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
1802        let Some(rv) = &self.run_view else {
1803            return;
1804        };
1805        let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
1806        let (tx, rx) = oneshot::channel();
1807        self.run_rx = Some(rx);
1808        let cli = Arc::clone(cli);
1809        tokio::spawn(async move {
1810            let (data, live) = if panel == Panel::Jobs {
1811                fetchers::runs::fetch(&cli, &run_id).await
1812            } else {
1813                fetchers::updates::fetch(&cli, &owner_id, &run_id).await
1814            };
1815            let _ = tx.send(RunUpdate::Detail(data, live));
1816        });
1817    }
1818
1819    pub fn run_toggle_raw(&mut self) {
1820        if let Some(rv) = &mut self.run_view {
1821            rv.show_raw = !rv.show_raw;
1822            rv.scroll = 0;
1823        }
1824    }
1825
1826    pub fn run_scroll(&mut self, delta: i32) {
1827        if let Some(rv) = &mut self.run_view {
1828            rv.scroll = if delta < 0 {
1829                rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
1830            } else {
1831                rv.scroll.saturating_add(delta as u16)
1832            };
1833        }
1834    }
1835
1836    /// Applies run fetch results; also re-polls a live run every few
1837    /// seconds so an executing run's tasks update on their own.
1838    pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1839        if let Some(rx) = &mut self.run_rx {
1840            match rx.try_recv() {
1841                Ok(update) => {
1842                    self.run_rx = None;
1843                    if let Some(rv) = &mut self.run_view {
1844                        match update {
1845                            RunUpdate::Opened(Ok((runs, data, live))) => {
1846                                rv.runs = runs;
1847                                rv.idx = 0;
1848                                rv.data = Some(data);
1849                                rv.live = live;
1850                            }
1851                            RunUpdate::Opened(Err(e)) => {
1852                                rv.data = Some(DetailData {
1853                                    summary: Vec::new(),
1854                                    activity: Vec::new(),
1855                                    raw: format!("✗ {e}"),
1856                                });
1857                                rv.live = false;
1858                            }
1859                            RunUpdate::Detail(data, live) => {
1860                                rv.data = Some(data);
1861                                rv.live = live;
1862                            }
1863                        }
1864                        rv.fetched_at = Instant::now();
1865                    }
1866                    true
1867                }
1868                Err(oneshot::error::TryRecvError::Empty) => false,
1869                Err(oneshot::error::TryRecvError::Closed) => {
1870                    self.run_rx = None;
1871                    true
1872                }
1873            }
1874        } else if let Some(rv) = &self.run_view {
1875            if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
1876                if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
1877                    self.start_run_fetch(cli, run_id);
1878                }
1879            }
1880            false
1881        } else {
1882            false
1883        }
1884    }
1885
1886    pub fn close_detail(&mut self) {
1887        self.detail = None;
1888        self.detail_rx = None;
1889    }
1890
1891    pub fn toggle_raw(&mut self) {
1892        if let Some(d) = &mut self.detail {
1893            d.show_raw = !d.show_raw;
1894            d.scroll = 0;
1895        }
1896    }
1897
1898    /// Applies a finished detail fetch; returns true if the UI should redraw.
1899    pub fn poll_detail(&mut self) -> bool {
1900        let Some(rx) = &mut self.detail_rx else {
1901            return false;
1902        };
1903        match rx.try_recv() {
1904            Ok(data) => {
1905                if let Some(d) = &mut self.detail {
1906                    d.data = Some(data);
1907                }
1908                self.detail_rx = None;
1909                true
1910            }
1911            Err(oneshot::error::TryRecvError::Empty) => false,
1912            Err(oneshot::error::TryRecvError::Closed) => {
1913                self.detail_rx = None;
1914                true
1915            }
1916        }
1917    }
1918
1919    pub fn detail_scroll(&mut self, delta: i32) {
1920        if let Some(d) = &mut self.detail {
1921            let max = match &d.data {
1922                Some(data) if d.show_raw => data.raw.lines().count(),
1923                Some(data) => data.summary.len() + data.activity.len() + 3,
1924                None => 0,
1925            } as u16;
1926            d.scroll = if delta < 0 {
1927                d.scroll.saturating_sub(delta.unsigned_abs() as u16)
1928            } else {
1929                (d.scroll + delta as u16).min(max.saturating_sub(1))
1930            };
1931        }
1932    }
1933
1934    /// Prepares a contextual action for the selected item, pending confirmation:
1935    /// start/stop for clusters, warehouses and pipelines, run-now for jobs.
1936    pub fn request_action(&mut self) {
1937        // Dashboards and Unity Catalog objects have no start/stop/run semantics.
1938        if matches!(self.focus, Panel::Dashboards | Panel::Catalog) {
1939            return;
1940        }
1941        let Some(item) = self.selected_item() else {
1942            return;
1943        };
1944        let Some(id) = item.id.clone() else {
1945            return;
1946        };
1947        let name = item.name.clone();
1948        let active = matches!(
1949            item.status,
1950            Status::Running | Status::Pending | Status::Success
1951        );
1952        let group = self.focus.cli_group();
1953        let (verb, action): (&str, &str) = match self.focus {
1954            Panel::Jobs => ("Run", "run-now"),
1955            Panel::Clusters if active => ("Stop", "delete"),
1956            Panel::Pipelines if active => ("Stop", "stop"),
1957            Panel::Pipelines => ("Start update for", "start-update"),
1958            _ if active => ("Stop", "stop"),
1959            _ => ("Start", "start"),
1960        };
1961        self.confirm = Some(Confirm {
1962            message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
1963            args: vec![group.to_string(), action.to_string(), id],
1964        });
1965    }
1966
1967    /// `s` in the run view: cancel the shown run/update after a confirm.
1968    pub fn request_run_cancel(&mut self) {
1969        let Some(rv) = &self.run_view else {
1970            return;
1971        };
1972        if !rv.live {
1973            self.flash = Some((
1974                "✗ nothing to cancel — this run already finished".to_string(),
1975                Instant::now(),
1976            ));
1977            return;
1978        }
1979        let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
1980            return;
1981        };
1982        let (message, args) = if rv.panel == Panel::Jobs {
1983            (
1984                format!("Cancel run {run_id} of “{}”?", rv.owner_name),
1985                vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
1986            )
1987        } else {
1988            (
1989                format!("Stop “{}” (cancels the active update)?", rv.owner_name),
1990                vec![
1991                    "pipelines".to_string(),
1992                    "stop".to_string(),
1993                    rv.owner_id.clone(),
1994                ],
1995            )
1996        };
1997        self.confirm = Some(Confirm { message, args });
1998    }
1999
2000    /// Cancels the in-flight console statement server-side; the polling
2001    /// task then sees CANCELED and surfaces it in the results pane.
2002    pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
2003        let id = self
2004            .sql_stmt
2005            .as_ref()
2006            .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
2007        let Some(id) = id else {
2008            self.flash = Some((
2009                "✗ statement not submitted yet — try again in a moment".to_string(),
2010                Instant::now(),
2011            ));
2012            return;
2013        };
2014        let cli = Arc::clone(cli);
2015        tokio::spawn(async move {
2016            let path = format!("/api/2.0/sql/statements/{id}/cancel");
2017            let _ = cli.run_action(&["api", "post", &path]).await;
2018        });
2019        self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
2020    }
2021
2022    pub fn cancel_confirm(&mut self) {
2023        self.confirm = None;
2024    }
2025
2026    pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
2027        let Some(c) = self.confirm.take() else {
2028            return;
2029        };
2030        let base = c.message.trim_end_matches('?').to_string();
2031        self.flash = Some((format!("⏳ {base}…"), Instant::now()));
2032
2033        let (tx, rx) = oneshot::channel();
2034        self.action_rx = Some(rx);
2035        let cli = Arc::clone(cli);
2036        tokio::spawn(async move {
2037            let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
2038            let result = match cli.run_action(&args).await {
2039                Ok(()) => Ok(format!("✓ {base} — done")),
2040                Err(e) => Err(format!("✗ {e:#}")),
2041            };
2042            let _ = tx.send(result);
2043        });
2044    }
2045
2046    /// Applies a finished action; refreshes on success. Returns true on change.
2047    pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2048        let Some(rx) = &mut self.action_rx else {
2049            return false;
2050        };
2051        match rx.try_recv() {
2052            Ok(result) => {
2053                let ok = result.is_ok();
2054                self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
2055                self.action_rx = None;
2056                if ok {
2057                    self.start_refresh(cli);
2058                }
2059                true
2060            }
2061            Err(oneshot::error::TryRecvError::Empty) => false,
2062            Err(oneshot::error::TryRecvError::Closed) => {
2063                self.action_rx = None;
2064                true
2065            }
2066        }
2067    }
2068
2069    /// Drops the flash message once it has been visible long enough.
2070    pub fn expire_flash(&mut self) -> bool {
2071        if let Some((_, since)) = &self.flash {
2072            if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
2073                self.flash = None;
2074                return true;
2075            }
2076        }
2077        false
2078    }
2079
2080    /// Opens the selected item (or the open detail view) in the workspace web UI.
2081    pub fn open_in_browser(&self) {
2082        let Some(host) = &self.host else {
2083            return;
2084        };
2085        let (panel, id) = match &self.detail {
2086            Some(d) => (d.panel, Some(d.id.clone())),
2087            None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
2088        };
2089        let Some(id) = id else {
2090            return;
2091        };
2092        let path = match panel {
2093            Panel::Clusters => format!("compute/clusters/{id}"),
2094            Panel::Jobs => format!("jobs/{id}"),
2095            Panel::Pipelines => format!("pipelines/{id}"),
2096            Panel::Warehouses => format!("sql/warehouses/{id}"),
2097            Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
2098            Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
2099        };
2100        let url = format!("{}/{}", host.trim_end_matches('/'), path);
2101        #[cfg(target_os = "macos")]
2102        let opener = "open";
2103        #[cfg(not(target_os = "macos"))]
2104        let opener = "xdg-open";
2105        let _ = std::process::Command::new(opener).arg(url).spawn();
2106    }
2107
2108    /// Counts of (ok, pending, failed, idle) items across all panels.
2109    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
2110        let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
2111        for shape in self.shapes.iter().flatten() {
2112            if let Shape::List(items) = shape {
2113                for item in items {
2114                    match item.status {
2115                        Status::Running | Status::Success => ok += 1,
2116                        Status::Pending => pending += 1,
2117                        Status::Failed => failed += 1,
2118                        Status::Stopped => idle += 1,
2119                        Status::Unknown(_) => {}
2120                    }
2121                }
2122            }
2123        }
2124        (ok, pending, failed, idle)
2125    }
2126
2127    pub fn last_refresh_age(&self) -> Duration {
2128        self.last_refresh.elapsed()
2129    }
2130
2131    pub fn spinner(&self) -> &'static str {
2132        SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
2133    }
2134
2135    pub fn spinner_frame(&self) -> usize {
2136        self.spinner_frame
2137    }
2138
2139    /// True whenever any background work is in flight — the loop uses this
2140    /// to keep spinners ticking, not just during panel refreshes.
2141    pub fn busy(&self) -> bool {
2142        self.loading
2143            || self.detail_rx.is_some()
2144            || self.action_rx.is_some()
2145            || self.preview_rx.is_some()
2146            || self.cost_rx.is_some()
2147            || self.sql_rx.is_some()
2148            || self.run_rx.is_some()
2149    }
2150
2151    pub fn tick_spinner(&mut self) {
2152        self.spinner_frame = self.spinner_frame.wrapping_add(1);
2153    }
2154
2155    pub fn toggle_zoom(&mut self) {
2156        self.zoomed = !self.zoomed;
2157    }
2158
2159    pub fn focus_next(&mut self) {
2160        let idx = Panel::ALL
2161            .iter()
2162            .position(|p| p == &self.focus)
2163            .unwrap_or(0);
2164        self.focus = Panel::ALL[(idx + 1) % Panel::ALL.len()];
2165    }
2166
2167    pub fn focus_prev(&mut self) {
2168        let idx = Panel::ALL
2169            .iter()
2170            .position(|p| p == &self.focus)
2171            .unwrap_or(0);
2172        self.focus = Panel::ALL[(idx + Panel::ALL.len() - 1) % Panel::ALL.len()];
2173    }
2174
2175    pub fn needs_refresh(&self) -> bool {
2176        !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
2177    }
2178
2179    pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
2180        if self.loading {
2181            return;
2182        }
2183        self.loading = true;
2184        self.error = None;
2185        self.last_refresh = Instant::now();
2186
2187        let (tx, rx) = mpsc::unbounded_channel();
2188        self.pending = Some(rx);
2189        self.in_flight = 7;
2190
2191        // One task per source so each panel updates as soon as its fetch lands,
2192        // instead of waiting for the slowest of the five.
2193        macro_rules! spawn_fetch {
2194            ($update:expr, $fetch:path) => {{
2195                let cli = Arc::clone(cli);
2196                let tx = tx.clone();
2197                tokio::spawn(async move {
2198                    let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
2199                    let _ = tx.send($update(result));
2200                });
2201            }};
2202        }
2203
2204        spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
2205        spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
2206        spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
2207        spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
2208        spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
2209        spawn_fetch!(
2210            |s: Result<Shape, String>| Update::Badge(s.ok()),
2211            fetchers::current_user::fetch
2212        );
2213        {
2214            let cli = Arc::clone(cli);
2215            let tx = tx.clone();
2216            let path = self.uc_path.clone();
2217            tokio::spawn(async move {
2218                let result = fetchers::catalog::fetch(&cli, &path)
2219                    .await
2220                    .map_err(|e| format!("{e:#}"));
2221                let _ = tx.send(Update::Panel(5, result));
2222            });
2223        }
2224    }
2225
2226    /// Applies any fetch results that have arrived; returns true if the UI should redraw.
2227    pub fn poll_refresh(&mut self) -> bool {
2228        let Some(rx) = &mut self.pending else {
2229            return false;
2230        };
2231        let mut changed = false;
2232        let mut updated_panes: Vec<usize> = Vec::new();
2233        loop {
2234            match rx.try_recv() {
2235                Ok(Update::Panel(i, result)) => {
2236                    match result {
2237                        Ok(mut shape) => {
2238                            // Active work floats to the top of every pane
2239                            // except the catalog, which stays browsable
2240                            // in its natural (alphabetical) order.
2241                            if i != 5 {
2242                                if let Shape::List(items) = &mut shape {
2243                                    items.sort_by_key(|it| {
2244                                        (it.status.rank(), it.history.is_empty())
2245                                    });
2246                                }
2247                            }
2248                            self.shapes[i] = Some(shape);
2249                            self.updated_at[i] = Some(Instant::now());
2250                            updated_panes.push(i);
2251                        }
2252                        // Keep previous data on failure so panels don't blank
2253                        // out — but surface the error if there's nothing yet.
2254                        Err(e) => {
2255                            if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
2256                                self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
2257                            }
2258                        }
2259                    }
2260                    self.in_flight -= 1;
2261                    changed = true;
2262                }
2263                Ok(Update::Badge(badge)) => {
2264                    if badge.is_some() {
2265                        self.user_badge = badge;
2266                    }
2267                    self.in_flight -= 1;
2268                    changed = true;
2269                }
2270                Err(mpsc::error::TryRecvError::Empty) => break,
2271                Err(mpsc::error::TryRecvError::Disconnected) => {
2272                    self.in_flight = 0;
2273                    break;
2274                }
2275            }
2276        }
2277        for i in updated_panes {
2278            self.alert_new_failures(i);
2279        }
2280        if self.in_flight == 0 {
2281            self.loading = false;
2282            self.pending = None;
2283            changed = true;
2284        }
2285        changed
2286    }
2287}