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