Skip to main content

databricks_tui/
app.rs

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