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