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 in the Secrets pane: descend from a scope into its keys.
1129    /// On a key it does nothing — secret values are never displayed —
1130    /// but still returns true so Enter never opens a (bogus) detail view.
1131    pub fn secrets_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1132        if self.focus != Panel::Secrets {
1133            return false;
1134        }
1135        // Already inside a scope: the selection is a key, nothing to open.
1136        if self.secret_scope.is_some() {
1137            return true;
1138        }
1139        let Some(item) = self.selected_item() else {
1140            return true; // empty pane: swallow the key
1141        };
1142        self.secret_scope = Some(item.name.clone());
1143        self.refresh_secrets(cli);
1144        true
1145    }
1146
1147    /// Backspace inside a scope: back to the scopes listing.
1148    pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1149        if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1150            return false;
1151        }
1152        self.secret_scope = None;
1153        self.refresh_secrets(cli);
1154        true
1155    }
1156
1157    fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1158        let idx = 6;
1159        self.shapes[idx] = None;
1160        self.selected[idx] = 0;
1161        self.filters[idx].clear();
1162        let (tx, rx) = oneshot::channel();
1163        self.secrets_rx = Some(rx);
1164        let cli = Arc::clone(cli);
1165        let scope = self.secret_scope.clone();
1166        tokio::spawn(async move {
1167            let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1168                .await
1169                .map_err(|e| format!("{e:#}"));
1170            let _ = tx.send(result);
1171        });
1172    }
1173
1174    pub fn poll_secrets(&mut self) -> bool {
1175        let Some(rx) = &mut self.secrets_rx else {
1176            return false;
1177        };
1178        match rx.try_recv() {
1179            Ok(result) => {
1180                self.shapes[6] = Some(match result {
1181                    Ok(shape) => shape,
1182                    Err(e) => Shape::Text(format!("✗ {e}")),
1183                });
1184                self.updated_at[6] = Some(Instant::now());
1185                self.secrets_rx = None;
1186                true
1187            }
1188            Err(oneshot::error::TryRecvError::Empty) => false,
1189            Err(oneshot::error::TryRecvError::Closed) => {
1190                self.secrets_rx = None;
1191                true
1192            }
1193        }
1194    }
1195
1196    /// `a` in the secrets pane: create a scope (top level) or add a
1197    /// secret (inside a scope).
1198    pub fn open_secret_form(&mut self) {
1199        if self.focus != Panel::Secrets {
1200            return;
1201        }
1202        self.secret_form = Some(SecretForm {
1203            scope: self.secret_scope.clone(),
1204            key: String::new(),
1205            value: String::new(),
1206            stage: 0,
1207        });
1208    }
1209
1210    pub fn secret_form_push(&mut self, c: char) {
1211        if let Some(form) = &mut self.secret_form {
1212            if form.stage == 0 {
1213                form.key.push(c);
1214            } else {
1215                form.value.push(c);
1216            }
1217        }
1218    }
1219
1220    pub fn secret_form_pop(&mut self) {
1221        if let Some(form) = &mut self.secret_form {
1222            if form.stage == 0 {
1223                form.key.pop();
1224            } else {
1225                form.value.pop();
1226            }
1227        }
1228    }
1229
1230    /// Enter in the form: advance to the value stage, or submit.
1231    pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1232        let Some(form) = &mut self.secret_form else {
1233            return;
1234        };
1235        if form.key.trim().is_empty() {
1236            return;
1237        }
1238        match (form.scope.clone(), form.stage) {
1239            // New scope: single field.
1240            (None, _) => {
1241                let name = form.key.trim().to_string();
1242                self.secret_form = None;
1243                self.run_secret_action(
1244                    cli,
1245                    format!("Create scope “{name}”"),
1246                    vec!["secrets".into(), "create-scope".into(), name],
1247                );
1248            }
1249            // Secret: key first, then value.
1250            (Some(_), 0) => form.stage = 1,
1251            (Some(scope), _) => {
1252                let key = form.key.trim().to_string();
1253                let value = form.value.clone();
1254                self.secret_form = None;
1255                self.run_secret_action(
1256                    cli,
1257                    format!("Put secret “{key}” in “{scope}”"),
1258                    vec![
1259                        "secrets".into(),
1260                        "put-secret".into(),
1261                        scope,
1262                        key,
1263                        "--string-value".into(),
1264                        value,
1265                    ],
1266                );
1267            }
1268        }
1269    }
1270
1271    /// Runs a secrets mutation right away (the form itself was the
1272    /// deliberate step); the usual action poll refreshes the panes.
1273    fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1274        self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1275        let (tx, rx) = oneshot::channel();
1276        self.action_rx = Some(rx);
1277        let cli = Arc::clone(cli);
1278        tokio::spawn(async move {
1279            let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1280            let result = match cli.run_action(&arg_refs).await {
1281                Ok(()) => Ok(format!("✓ {label} — done")),
1282                Err(e) => Err(format!("✗ {e:#}")),
1283            };
1284            let _ = tx.send(result);
1285        });
1286    }
1287
1288    /// `x` in the secrets pane: delete the selected scope or key.
1289    pub fn request_secret_delete(&mut self) {
1290        if self.focus != Panel::Secrets {
1291            return;
1292        }
1293        let Some(item) = self.selected_item() else {
1294            return;
1295        };
1296        let name = item.name.clone();
1297        let (message, args) = match &self.secret_scope {
1298            None => (
1299                format!("Delete scope “{name}” and all its secrets?"),
1300                vec!["secrets".to_string(), "delete-scope".to_string(), name],
1301            ),
1302            Some(scope) => (
1303                format!("Delete secret “{name}” from “{scope}”?"),
1304                vec![
1305                    "secrets".to_string(),
1306                    "delete-secret".to_string(),
1307                    scope.clone(),
1308                    name,
1309                ],
1310            ),
1311        };
1312        self.confirm = Some(Confirm { message, args });
1313    }
1314
1315    /// `g` in the secrets pane: the scope's ACLs.
1316    fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1317        let scope = match &self.secret_scope {
1318            Some(s) => Some(s.clone()),
1319            None => self.selected_item().map(|i| i.name.clone()),
1320        };
1321        let Some(scope) = scope else {
1322            return;
1323        };
1324        self.detail = Some(Detail {
1325            panel: Panel::Secrets,
1326            name: scope.clone(),
1327            id: scope.clone(),
1328            kind: None,
1329            section: "Access",
1330            data: None,
1331            show_raw: false,
1332            scroll: 0,
1333        });
1334        let (tx, rx) = oneshot::channel();
1335        self.detail_rx = Some(rx);
1336        let cli = Arc::clone(cli);
1337        tokio::spawn(async move {
1338            let acl_args = ["secrets", "list-acls", &scope];
1339            let data = match cli.run(&acl_args).await {
1340                Ok(json) => {
1341                    let raw =
1342                        serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1343                    // The CLI unwraps to a bare array; REST wraps in "items".
1344                    let acls = json
1345                        .as_array()
1346                        .cloned()
1347                        .or_else(|| json["items"].as_array().cloned())
1348                        .unwrap_or_default();
1349                    let activity: Vec<(Status, String)> = acls
1350                        .iter()
1351                        .map(|a| {
1352                            let principal = a["principal"].as_str().unwrap_or("?");
1353                            let perm = a["permission"].as_str().unwrap_or("?");
1354                            let status = if perm == "MANAGE" {
1355                                Status::Success
1356                            } else {
1357                                Status::Unknown(String::new())
1358                            };
1359                            (status, format!("{principal}  ·  {perm}"))
1360                        })
1361                        .collect();
1362                    DetailData {
1363                        summary: vec![("Scope".to_string(), scope.clone())],
1364                        activity,
1365                        raw,
1366                    }
1367                }
1368                Err(e) => DetailData {
1369                    summary: Vec::new(),
1370                    activity: Vec::new(),
1371                    raw: format!("{e:#}"),
1372                },
1373            };
1374            let _ = tx.send(data);
1375        });
1376    }
1377
1378    /// Looks up a resource's display name by id in the loaded panes —
1379    /// lets the cost view show "nightly-etl" instead of a job id.
1380    pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1381        let idx = match kind {
1382            "cluster" => 0,
1383            "job" => 1,
1384            "warehouse" => 3,
1385            _ => return None,
1386        };
1387        match &self.shapes[idx] {
1388            Some(Shape::List(items)) => items
1389                .iter()
1390                .find(|i| i.id.as_deref() == Some(id))
1391                .map(|i| i.name.clone()),
1392            _ => None,
1393        }
1394    }
1395
1396    /// All known warehouses as (name, id, running).
1397    pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1398        let Some(Shape::List(items)) = &self.shapes[3] else {
1399            return Vec::new();
1400        };
1401        items
1402            .iter()
1403            .filter_map(|i| {
1404                let id = i.id.clone()?;
1405                Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1406            })
1407            .collect()
1408    }
1409
1410    /// Runs a sample-data query for the selected table or view. With
1411    /// `force_pick` (or several warehouses and no remembered choice) a
1412    /// warehouse picker opens first.
1413    pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
1414        if self.focus != Panel::Catalog {
1415            return;
1416        }
1417        let Some(item) = self.selected_item() else {
1418            return;
1419        };
1420        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1421            return;
1422        }
1423        let Some(full_name) = item.id.clone() else {
1424            return;
1425        };
1426        let warehouses = self.warehouses();
1427        if warehouses.is_empty() {
1428            self.flash = Some((
1429                "✗ no SQL warehouse available for previews".to_string(),
1430                Instant::now(),
1431            ));
1432            return;
1433        }
1434        if !force_pick {
1435            if let Some((id, name)) = self.preview_warehouse.clone() {
1436                self.start_preview_query(cli, full_name, id, name);
1437                return;
1438            }
1439            if let [(name, id, _)] = warehouses.as_slice() {
1440                self.preview_warehouse = Some((id.clone(), name.clone()));
1441                self.start_preview_query(cli, full_name, id.clone(), name.clone());
1442                return;
1443            }
1444        }
1445        // Default the cursor to the remembered choice, else a running warehouse.
1446        let index = self
1447            .preview_warehouse
1448            .as_ref()
1449            .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1450            .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1451            .unwrap_or(0);
1452        self.wh_picker = Some(WhPicker {
1453            index,
1454            target: PickTarget::Preview(full_name),
1455        });
1456    }
1457
1458    /// Opens the DBU usage view, resolving a warehouse like previews do.
1459    pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1460        let warehouses = self.warehouses();
1461        if warehouses.is_empty() {
1462            self.flash = Some((
1463                "✗ no SQL warehouse available to query system tables".to_string(),
1464                Instant::now(),
1465            ));
1466            return;
1467        }
1468        if let Some((id, name)) = self.preview_warehouse.clone() {
1469            self.start_cost_query(cli, id, name);
1470            return;
1471        }
1472        if let [(name, id, _)] = warehouses.as_slice() {
1473            self.preview_warehouse = Some((id.clone(), name.clone()));
1474            self.start_cost_query(cli, id.clone(), name.clone());
1475            return;
1476        }
1477        let index = warehouses
1478            .iter()
1479            .position(|(_, _, running)| *running)
1480            .unwrap_or(0);
1481        self.wh_picker = Some(WhPicker {
1482            index,
1483            target: PickTarget::Cost,
1484        });
1485    }
1486
1487    fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1488        self.cost = Some(CostView {
1489            warehouse: name,
1490            data: None,
1491        });
1492        let (tx, rx) = oneshot::channel();
1493        self.cost_rx = Some(rx);
1494        let cli = Arc::clone(cli);
1495        let host = self.host.clone();
1496        let cached_ws = self.workspace_id.clone();
1497        tokio::spawn(async move {
1498            // Scope usage to this workspace; resolved once, then cached.
1499            let ws = match (cached_ws, host) {
1500                (Some(w), _) => Some(w),
1501                (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1502                (None, None) => None,
1503            };
1504            let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1505            let _ = tx.send((result, ws));
1506        });
1507    }
1508
1509    /// Opens the lineage view for the selected table/view; needs a
1510    /// warehouse since lineage lives in system tables.
1511    pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1512        if self.focus != Panel::Catalog {
1513            return;
1514        }
1515        let Some(item) = self.selected_item() else {
1516            return;
1517        };
1518        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1519            return;
1520        }
1521        let Some(full_name) = item.id.clone() else {
1522            return;
1523        };
1524        let warehouses = self.warehouses();
1525        if warehouses.is_empty() {
1526            self.flash = Some((
1527                "✗ no SQL warehouse available to query lineage".to_string(),
1528                Instant::now(),
1529            ));
1530            return;
1531        }
1532        if let Some((id, _)) = self.preview_warehouse.clone() {
1533            self.start_lineage_query(cli, full_name, id);
1534            return;
1535        }
1536        if let [(name, id, _)] = warehouses.as_slice() {
1537            self.preview_warehouse = Some((id.clone(), name.clone()));
1538            let id = id.clone();
1539            self.start_lineage_query(cli, full_name, id);
1540            return;
1541        }
1542        let index = warehouses
1543            .iter()
1544            .position(|(_, _, running)| *running)
1545            .unwrap_or(0);
1546        self.wh_picker = Some(WhPicker {
1547            index,
1548            target: PickTarget::Lineage(full_name),
1549        });
1550    }
1551
1552    fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1553        self.detail = Some(Detail {
1554            panel: Panel::Catalog,
1555            name: full_name.clone(),
1556            id: full_name.clone(),
1557            kind: None,
1558            section: "Lineage",
1559            data: None,
1560            show_raw: false,
1561            scroll: 0,
1562        });
1563        let (tx, rx) = oneshot::channel();
1564        self.detail_rx = Some(rx);
1565        let cli = Arc::clone(cli);
1566        tokio::spawn(async move {
1567            let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1568            let _ = tx.send(data);
1569        });
1570    }
1571
1572    pub fn close_cost(&mut self) {
1573        self.cost = None;
1574        self.cost_rx = None;
1575    }
1576
1577    /// The fully-qualified name of the selected catalog-pane table/view.
1578    fn selected_table_fqn(&self) -> Option<String> {
1579        if self.focus != Panel::Catalog {
1580            return None;
1581        }
1582        let item = self.selected_item()?;
1583        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1584            return None;
1585        }
1586        item.id.clone()
1587    }
1588
1589    /// Opens the SQL console. With a table/view selected in the catalog
1590    /// pane, the prompt starts as an editable query against it.
1591    pub fn open_sql(&mut self) {
1592        if self.sql.is_none() {
1593            let input = self
1594                .selected_table_fqn()
1595                .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1596                .unwrap_or_default();
1597            self.sql = Some(SqlConsole {
1598                cursor: input.chars().count(),
1599                input,
1600                warehouse: String::new(),
1601                running: false,
1602                data: None,
1603                last_sql: String::new(),
1604                scroll: 0,
1605            });
1606        }
1607    }
1608
1609    pub fn close_sql(&mut self) {
1610        self.sql = None;
1611        self.sql_rx = None;
1612        self.hist_idx = None;
1613        self.hist_draft.clear();
1614        self.hist_search = None;
1615    }
1616
1617    /// The statement currently in the prompt.
1618    pub fn sql_input(&self) -> Option<String> {
1619        self.sql.as_ref().map(|c| c.input.clone())
1620    }
1621
1622    /// Replaces the prompt contents (after an $EDITOR round-trip).
1623    pub fn sql_set_input(&mut self, s: &str) {
1624        if let Some(console) = &mut self.sql {
1625            console.input = s.to_string();
1626            console.cursor = console.input.chars().count();
1627        }
1628    }
1629
1630    /// The history entry the active Ctrl+R search currently matches.
1631    pub fn hist_search_current(&self) -> Option<&String> {
1632        let (query, nth) = self.hist_search.as_ref()?;
1633        self.sql_history
1634            .iter()
1635            .rev()
1636            .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
1637            .nth(*nth)
1638    }
1639
1640    pub fn hist_search_start(&mut self) {
1641        if self.sql.is_some() {
1642            self.hist_search = Some((String::new(), 0));
1643        }
1644    }
1645
1646    pub fn hist_search_push(&mut self, c: char) {
1647        if let Some((query, nth)) = &mut self.hist_search {
1648            query.push(c);
1649            *nth = 0;
1650        }
1651    }
1652
1653    pub fn hist_search_pop(&mut self) {
1654        if let Some((query, nth)) = &mut self.hist_search {
1655            query.pop();
1656            *nth = 0;
1657        }
1658    }
1659
1660    /// Ctrl+R again: step to the next older match.
1661    pub fn hist_search_older(&mut self) {
1662        let Some((query, nth)) = &self.hist_search else {
1663            return;
1664        };
1665        let q = query.to_lowercase();
1666        let matches = self
1667            .sql_history
1668            .iter()
1669            .filter(|h| h.to_lowercase().contains(&q))
1670            .count();
1671        if nth + 1 < matches {
1672            if let Some((_, n)) = &mut self.hist_search {
1673                *n += 1;
1674            }
1675        }
1676    }
1677
1678    pub fn hist_search_accept(&mut self) {
1679        if let Some(stmt) = self.hist_search_current().cloned() {
1680            self.sql_set_input(&stmt);
1681        }
1682        self.hist_search = None;
1683    }
1684
1685    pub fn hist_search_cancel(&mut self) {
1686        self.hist_search = None;
1687    }
1688
1689    pub fn sql_push(&mut self, c: char) {
1690        if let Some(console) = &mut self.sql {
1691            let at = byte_at(&console.input, console.cursor);
1692            console.input.insert(at, c);
1693            console.cursor += 1;
1694        }
1695    }
1696
1697    /// Backspace: deletes the character before the caret.
1698    pub fn sql_pop(&mut self) {
1699        if let Some(console) = &mut self.sql {
1700            if console.cursor > 0 {
1701                let at = byte_at(&console.input, console.cursor - 1);
1702                console.input.remove(at);
1703                console.cursor -= 1;
1704            }
1705        }
1706    }
1707
1708    /// Delete: removes the character under the caret.
1709    pub fn sql_delete(&mut self) {
1710        if let Some(console) = &mut self.sql {
1711            if console.cursor < console.input.chars().count() {
1712                let at = byte_at(&console.input, console.cursor);
1713                console.input.remove(at);
1714            }
1715        }
1716    }
1717
1718    pub fn sql_left(&mut self) {
1719        if let Some(console) = &mut self.sql {
1720            console.cursor = console.cursor.saturating_sub(1);
1721        }
1722    }
1723
1724    pub fn sql_right(&mut self) {
1725        if let Some(console) = &mut self.sql {
1726            console.cursor = (console.cursor + 1).min(console.input.chars().count());
1727        }
1728    }
1729
1730    /// ↑ at the prompt: step back through history, stashing the draft.
1731    pub fn sql_hist_prev(&mut self) {
1732        let Some(console) = &mut self.sql else {
1733            return;
1734        };
1735        if self.sql_history.is_empty() {
1736            return;
1737        }
1738        let idx = match self.hist_idx {
1739            None => {
1740                self.hist_draft = console.input.clone();
1741                self.sql_history.len() - 1
1742            }
1743            Some(i) => i.saturating_sub(1),
1744        };
1745        self.hist_idx = Some(idx);
1746        console.input = self.sql_history[idx].clone();
1747        console.cursor = console.input.chars().count();
1748    }
1749
1750    /// ↓ at the prompt: step forward, back to the stashed draft at the end.
1751    pub fn sql_hist_next(&mut self) {
1752        let Some(console) = &mut self.sql else {
1753            return;
1754        };
1755        let Some(idx) = self.hist_idx else {
1756            return;
1757        };
1758        if idx + 1 < self.sql_history.len() {
1759            self.hist_idx = Some(idx + 1);
1760            console.input = self.sql_history[idx + 1].clone();
1761        } else {
1762            self.hist_idx = None;
1763            console.input = self.hist_draft.clone();
1764        }
1765        console.cursor = console.input.chars().count();
1766    }
1767
1768    pub fn sql_home(&mut self) {
1769        if let Some(console) = &mut self.sql {
1770            console.cursor = 0;
1771        }
1772    }
1773
1774    pub fn sql_end(&mut self) {
1775        if let Some(console) = &mut self.sql {
1776            console.cursor = console.input.chars().count();
1777        }
1778    }
1779
1780    pub fn sql_scroll(&mut self, delta: i32) {
1781        if let Some(console) = &mut self.sql {
1782            let max = match &console.data {
1783                Some(Ok(t)) => t.rows.len().saturating_sub(1),
1784                _ => 0,
1785            };
1786            console.scroll = if delta < 0 {
1787                console.scroll.saturating_sub(delta.unsigned_abs() as usize)
1788            } else {
1789                (console.scroll + delta as usize).min(max)
1790            };
1791        }
1792    }
1793
1794    /// Runs the typed statement, resolving a warehouse like previews do.
1795    pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
1796        let Some(console) = &self.sql else {
1797            return;
1798        };
1799        if console.running {
1800            return;
1801        }
1802        let query = console.input.trim().to_string();
1803        if query.is_empty() {
1804            return;
1805        }
1806        // Remember the statement (skipping immediate repeats) and reset
1807        // any in-progress history browsing.
1808        if self.sql_history.last() != Some(&query) {
1809            self.sql_history.push(query.clone());
1810            save_history(&self.sql_history);
1811        }
1812        self.hist_idx = None;
1813        self.hist_draft.clear();
1814        let warehouses = self.warehouses();
1815        if warehouses.is_empty() {
1816            self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
1817            return;
1818        }
1819        if let Some((id, name)) = self.preview_warehouse.clone() {
1820            self.start_sql_query(cli, query, id, name);
1821            return;
1822        }
1823        if let [(name, id, _)] = warehouses.as_slice() {
1824            self.preview_warehouse = Some((id.clone(), name.clone()));
1825            self.start_sql_query(cli, query, id.clone(), name.clone());
1826            return;
1827        }
1828        let index = warehouses
1829            .iter()
1830            .position(|(_, _, running)| *running)
1831            .unwrap_or(0);
1832        self.wh_picker = Some(WhPicker {
1833            index,
1834            target: PickTarget::Sql(query),
1835        });
1836    }
1837
1838    fn start_sql_query(
1839        &mut self,
1840        cli: &Arc<DatabricksCli>,
1841        query: String,
1842        id: String,
1843        name: String,
1844    ) {
1845        if let Some(console) = &mut self.sql {
1846            console.running = true;
1847            console.warehouse = name;
1848            console.scroll = 0;
1849            console.last_sql = query.clone();
1850        }
1851        // Published by the task once submitted, so Esc can cancel it.
1852        let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
1853        self.sql_stmt = Some(std::sync::Arc::clone(&handle));
1854        let (tx, rx) = oneshot::channel();
1855        self.sql_rx = Some(rx);
1856        let cli = Arc::clone(cli);
1857        tokio::spawn(async move {
1858            let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
1859            let _ = tx.send(result);
1860        });
1861    }
1862
1863    /// Writes a result set to a timestamped CSV in the working directory
1864    /// and flashes the path.
1865    fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
1866        let stamp = std::time::SystemTime::now()
1867            .duration_since(std::time::UNIX_EPOCH)
1868            .map(|d| d.as_secs())
1869            .unwrap_or(0);
1870        let slug: String = label
1871            .chars()
1872            .map(|c| if c.is_alphanumeric() { c } else { '-' })
1873            .collect::<String>()
1874            .trim_matches('-')
1875            .chars()
1876            .take(40)
1877            .collect();
1878        let name = format!("databricks-{slug}-{stamp}.csv");
1879        let msg = match std::fs::write(&name, data.to_csv()) {
1880            Ok(()) => {
1881                let cwd = std::env::current_dir()
1882                    .map(|d| d.display().to_string())
1883                    .unwrap_or_default();
1884                format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
1885            }
1886            Err(e) => format!("✗ export failed: {e}"),
1887        };
1888        self.flash = Some((msg, Instant::now()));
1889    }
1890
1891    /// Ctrl+S in the console: export the current results.
1892    pub fn sql_export(&mut self) {
1893        if let Some(SqlConsole {
1894            data: Some(Ok(data)),
1895            last_sql,
1896            ..
1897        }) = &self.sql
1898        {
1899            let (label, data) = (last_sql.clone(), data.clone());
1900            self.export_csv(&label, &data);
1901        }
1902    }
1903
1904    /// `e` in a table preview: export the sampled rows.
1905    pub fn preview_export(&mut self) {
1906        if let Some(Preview {
1907            data: Some(Ok(data)),
1908            name,
1909            ..
1910        }) = &self.preview
1911        {
1912            let (label, data) = (name.clone(), data.clone());
1913            self.export_csv(&label, &data);
1914        }
1915    }
1916
1917    pub fn poll_sql(&mut self) -> bool {
1918        let Some(rx) = &mut self.sql_rx else {
1919            return false;
1920        };
1921        match rx.try_recv() {
1922            Ok(result) => {
1923                // A warehouse that errors shouldn't stay the session
1924                // default — but a user-canceled statement is not its fault.
1925                if let Err(e) = &result {
1926                    if e != "statement canceled" {
1927                        self.preview_warehouse = None;
1928                    }
1929                }
1930                if let Some(console) = &mut self.sql {
1931                    console.running = false;
1932                    console.data = Some(result);
1933                }
1934                self.sql_rx = None;
1935                self.sql_stmt = None;
1936                true
1937            }
1938            Err(oneshot::error::TryRecvError::Empty) => false,
1939            Err(oneshot::error::TryRecvError::Closed) => {
1940                if let Some(console) = &mut self.sql {
1941                    console.running = false;
1942                }
1943                self.sql_rx = None;
1944                true
1945            }
1946        }
1947    }
1948
1949    pub fn poll_cost(&mut self) -> bool {
1950        let Some(rx) = &mut self.cost_rx else {
1951            return false;
1952        };
1953        match rx.try_recv() {
1954            Ok((result, ws)) => {
1955                if result.is_err() {
1956                    self.preview_warehouse = None;
1957                }
1958                if ws.is_some() {
1959                    self.workspace_id = ws;
1960                }
1961                if let Some(cv) = &mut self.cost {
1962                    cv.data = Some(result);
1963                }
1964                self.cost_rx = None;
1965                true
1966            }
1967            Err(oneshot::error::TryRecvError::Empty) => false,
1968            Err(oneshot::error::TryRecvError::Closed) => {
1969                self.cost_rx = None;
1970                true
1971            }
1972        }
1973    }
1974
1975    pub fn wh_picker_next(&mut self) {
1976        let len = self.warehouses().len();
1977        if let Some(p) = &mut self.wh_picker {
1978            p.index = (p.index + 1).min(len.saturating_sub(1));
1979        }
1980    }
1981
1982    pub fn wh_picker_prev(&mut self) {
1983        if let Some(p) = &mut self.wh_picker {
1984            p.index = p.index.saturating_sub(1);
1985        }
1986    }
1987
1988    pub fn wh_picker_cancel(&mut self) {
1989        self.wh_picker = None;
1990    }
1991
1992    /// Confirms the warehouse choice, remembers it, and starts the preview.
1993    pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
1994        let Some(picker) = self.wh_picker.take() else {
1995            return;
1996        };
1997        let warehouses = self.warehouses();
1998        let Some((name, id, _)) = warehouses.get(picker.index) else {
1999            return;
2000        };
2001        self.preview_warehouse = Some((id.clone(), name.clone()));
2002        // An explicit choice is worth remembering across sessions.
2003        let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
2004        self.config
2005            .warehouses
2006            .insert(profile, (id.clone(), name.clone()));
2007        self.config.save();
2008        match picker.target {
2009            PickTarget::Preview(table) => {
2010                self.start_preview_query(cli, table, id.clone(), name.clone())
2011            }
2012            PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
2013            PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
2014            PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
2015        }
2016    }
2017
2018    fn start_preview_query(
2019        &mut self,
2020        cli: &Arc<DatabricksCli>,
2021        full_name: String,
2022        warehouse_id: String,
2023        warehouse_name: String,
2024    ) {
2025        self.preview = Some(Preview {
2026            name: full_name.clone(),
2027            warehouse: warehouse_name,
2028            warehouse_id: warehouse_id.clone(),
2029            data: None,
2030            scroll: 0,
2031        });
2032        let (tx, rx) = oneshot::channel();
2033        self.preview_rx = Some(rx);
2034        let cli = Arc::clone(cli);
2035        tokio::spawn(async move {
2036            let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
2037            let _ = tx.send(result);
2038        });
2039    }
2040
2041    pub fn close_preview(&mut self) {
2042        self.preview = None;
2043        self.preview_rx = None;
2044    }
2045
2046    pub fn poll_preview(&mut self) -> bool {
2047        let Some(rx) = &mut self.preview_rx else {
2048            return false;
2049        };
2050        match rx.try_recv() {
2051            Ok(result) => {
2052                // A warehouse that errors shouldn't stay the session default.
2053                if result.is_err() {
2054                    self.preview_warehouse = None;
2055                }
2056                if let Some(pv) = &mut self.preview {
2057                    pv.data = Some(result);
2058                }
2059                self.preview_rx = None;
2060                true
2061            }
2062            Err(oneshot::error::TryRecvError::Empty) => false,
2063            Err(oneshot::error::TryRecvError::Closed) => {
2064                self.preview_rx = None;
2065                true
2066            }
2067        }
2068    }
2069
2070    pub fn preview_scroll(&mut self, delta: i32) {
2071        if let Some(pv) = &mut self.preview {
2072            let max = match &pv.data {
2073                Some(Ok(t)) => t.rows.len().saturating_sub(1),
2074                _ => 0,
2075            };
2076            pv.scroll = if delta < 0 {
2077                pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
2078            } else {
2079                (pv.scroll + delta as usize).min(max)
2080            };
2081        }
2082    }
2083
2084    pub fn poll_uc(&mut self) -> bool {
2085        let Some(rx) = &mut self.uc_rx else {
2086            return false;
2087        };
2088        match rx.try_recv() {
2089            Ok(result) => {
2090                self.shapes[5] = Some(match result {
2091                    Ok(shape) => shape,
2092                    Err(e) => Shape::Text(format!("✗ {e}")),
2093                });
2094                self.updated_at[5] = Some(Instant::now());
2095                self.uc_rx = None;
2096                true
2097            }
2098            Err(oneshot::error::TryRecvError::Empty) => false,
2099            Err(oneshot::error::TryRecvError::Closed) => {
2100                self.uc_rx = None;
2101                true
2102            }
2103        }
2104    }
2105
2106    /// Opens the access view for the selected item: effective UC grants
2107    /// or the workspace object ACL.
2108    pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
2109        if self.focus == Panel::Secrets {
2110            return self.open_secret_acls(cli);
2111        }
2112        let Some(item) = self.selected_item() else {
2113            return;
2114        };
2115        let Some(id) = item.id.clone() else {
2116            return;
2117        };
2118        let (uc, object_type): (bool, &'static str) = match self.focus {
2119            Panel::Catalog => match &item.status {
2120                Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
2121                Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
2122                Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
2123                Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
2124                _ => return,
2125            },
2126            Panel::Clusters => (false, "clusters"),
2127            Panel::Jobs => (false, "jobs"),
2128            Panel::Pipelines => (false, "pipelines"),
2129            Panel::Warehouses => (false, "warehouses"),
2130            Panel::Dashboards => (false, "dashboards"),
2131            // Secrets ACLs are handled by open_secret_acls above.
2132            Panel::Secrets => return,
2133        };
2134        self.detail = Some(Detail {
2135            panel: self.focus,
2136            name: item.name.clone(),
2137            id: id.clone(),
2138            kind: None,
2139            section: "Access",
2140            data: None,
2141            show_raw: false,
2142            scroll: 0,
2143        });
2144        let (tx, rx) = oneshot::channel();
2145        self.detail_rx = Some(rx);
2146        let cli = Arc::clone(cli);
2147        tokio::spawn(async move {
2148            let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
2149            let _ = tx.send(data);
2150        });
2151    }
2152
2153    /// Drills from an open job or pipeline detail into its most recent
2154    /// run/update.
2155    pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
2156        let Some(d) = &self.detail else {
2157            return;
2158        };
2159        let panel = d.panel;
2160        if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
2161            return;
2162        }
2163        let owner_id = d.id.clone();
2164        self.run_view = Some(RunView {
2165            panel,
2166            owner_name: d.name.clone(),
2167            owner_id: owner_id.clone(),
2168            runs: Vec::new(),
2169            idx: 0,
2170            data: None,
2171            show_raw: false,
2172            scroll: 0,
2173            live: false,
2174            fetched_at: Instant::now(),
2175        });
2176        let (tx, rx) = oneshot::channel();
2177        self.run_rx = Some(rx);
2178        let cli = Arc::clone(cli);
2179        tokio::spawn(async move {
2180            let result = async {
2181                let runs = if panel == Panel::Jobs {
2182                    fetchers::runs::list(&cli, &owner_id).await?
2183                } else {
2184                    fetchers::updates::list(&cli, &owner_id).await?
2185                };
2186                let Some((run_id, _, _)) = runs.first().cloned() else {
2187                    return Err("no runs recorded yet".to_string());
2188                };
2189                let (data, live) = if panel == Panel::Jobs {
2190                    fetchers::runs::fetch(&cli, &run_id).await
2191                } else {
2192                    fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2193                };
2194                Ok((runs, data, live))
2195            }
2196            .await;
2197            let _ = tx.send(RunUpdate::Opened(result));
2198        });
2199    }
2200
2201    pub fn close_run(&mut self) {
2202        self.run_view = None;
2203        self.run_rx = None;
2204    }
2205
2206    /// Moves to an older (delta > 0) or newer (delta < 0) run.
2207    pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
2208        if self.run_rx.is_some() {
2209            return;
2210        }
2211        let Some(rv) = &mut self.run_view else {
2212            return;
2213        };
2214        if rv.runs.is_empty() {
2215            return;
2216        }
2217        let new = if delta < 0 {
2218            rv.idx.saturating_sub(delta.unsigned_abs() as usize)
2219        } else {
2220            (rv.idx + delta as usize).min(rv.runs.len() - 1)
2221        };
2222        if new == rv.idx {
2223            return;
2224        }
2225        rv.idx = new;
2226        rv.data = None;
2227        rv.scroll = 0;
2228        rv.show_raw = false;
2229        let run_id = rv.runs[new].0.clone();
2230        self.start_run_fetch(cli, run_id);
2231    }
2232
2233    fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
2234        let Some(rv) = &self.run_view else {
2235            return;
2236        };
2237        let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
2238        let (tx, rx) = oneshot::channel();
2239        self.run_rx = Some(rx);
2240        let cli = Arc::clone(cli);
2241        tokio::spawn(async move {
2242            let (data, live) = if panel == Panel::Jobs {
2243                fetchers::runs::fetch(&cli, &run_id).await
2244            } else {
2245                fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2246            };
2247            let _ = tx.send(RunUpdate::Detail(data, live));
2248        });
2249    }
2250
2251    pub fn run_toggle_raw(&mut self) {
2252        if let Some(rv) = &mut self.run_view {
2253            rv.show_raw = !rv.show_raw;
2254            rv.scroll = 0;
2255        }
2256    }
2257
2258    pub fn run_scroll(&mut self, delta: i32) {
2259        if let Some(rv) = &mut self.run_view {
2260            rv.scroll = if delta < 0 {
2261                rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
2262            } else {
2263                rv.scroll.saturating_add(delta as u16)
2264            };
2265        }
2266    }
2267
2268    /// Applies run fetch results; also re-polls a live run every few
2269    /// seconds so an executing run's tasks update on their own.
2270    pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2271        if let Some(rx) = &mut self.run_rx {
2272            match rx.try_recv() {
2273                Ok(update) => {
2274                    self.run_rx = None;
2275                    if let Some(rv) = &mut self.run_view {
2276                        match update {
2277                            RunUpdate::Opened(Ok((runs, data, live))) => {
2278                                rv.runs = runs;
2279                                rv.idx = 0;
2280                                rv.data = Some(data);
2281                                rv.live = live;
2282                            }
2283                            RunUpdate::Opened(Err(e)) => {
2284                                rv.data = Some(DetailData {
2285                                    summary: Vec::new(),
2286                                    activity: Vec::new(),
2287                                    raw: format!("✗ {e}"),
2288                                });
2289                                rv.live = false;
2290                            }
2291                            RunUpdate::Detail(data, live) => {
2292                                rv.data = Some(data);
2293                                rv.live = live;
2294                            }
2295                        }
2296                        rv.fetched_at = Instant::now();
2297                    }
2298                    true
2299                }
2300                Err(oneshot::error::TryRecvError::Empty) => false,
2301                Err(oneshot::error::TryRecvError::Closed) => {
2302                    self.run_rx = None;
2303                    true
2304                }
2305            }
2306        } else if let Some(rv) = &self.run_view {
2307            if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
2308                if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
2309                    self.start_run_fetch(cli, run_id);
2310                }
2311            }
2312            false
2313        } else {
2314            false
2315        }
2316    }
2317
2318    pub fn close_detail(&mut self) {
2319        self.detail = None;
2320        self.detail_rx = None;
2321    }
2322
2323    pub fn toggle_raw(&mut self) {
2324        if let Some(d) = &mut self.detail {
2325            d.show_raw = !d.show_raw;
2326            d.scroll = 0;
2327        }
2328    }
2329
2330    /// Applies a finished detail fetch; returns true if the UI should redraw.
2331    pub fn poll_detail(&mut self) -> bool {
2332        let Some(rx) = &mut self.detail_rx else {
2333            return false;
2334        };
2335        match rx.try_recv() {
2336            Ok(data) => {
2337                if let Some(d) = &mut self.detail {
2338                    d.data = Some(data);
2339                }
2340                self.detail_rx = None;
2341                true
2342            }
2343            Err(oneshot::error::TryRecvError::Empty) => false,
2344            Err(oneshot::error::TryRecvError::Closed) => {
2345                self.detail_rx = None;
2346                true
2347            }
2348        }
2349    }
2350
2351    pub fn detail_scroll(&mut self, delta: i32) {
2352        if let Some(d) = &mut self.detail {
2353            let max = match &d.data {
2354                Some(data) if d.show_raw => data.raw.lines().count(),
2355                Some(data) => data.summary.len() + data.activity.len() + 3,
2356                None => 0,
2357            } as u16;
2358            d.scroll = if delta < 0 {
2359                d.scroll.saturating_sub(delta.unsigned_abs() as u16)
2360            } else {
2361                (d.scroll + delta as u16).min(max.saturating_sub(1))
2362            };
2363        }
2364    }
2365
2366    /// Prepares a contextual action for the selected item, pending confirmation:
2367    /// start/stop for clusters, warehouses and pipelines, run-now for jobs.
2368    pub fn request_action(&mut self) {
2369        // Dashboards, Unity Catalog and secrets have no start/stop/run semantics.
2370        if matches!(
2371            self.focus,
2372            Panel::Dashboards | Panel::Catalog | Panel::Secrets
2373        ) {
2374            return;
2375        }
2376        let Some(item) = self.selected_item() else {
2377            return;
2378        };
2379        let Some(id) = item.id.clone() else {
2380            return;
2381        };
2382        let name = item.name.clone();
2383        let active = matches!(
2384            item.status,
2385            Status::Running | Status::Pending | Status::Success
2386        );
2387        let group = self.focus.cli_group();
2388        let (verb, action): (&str, &str) = match self.focus {
2389            Panel::Jobs => ("Run", "run-now"),
2390            Panel::Clusters if active => ("Stop", "delete"),
2391            Panel::Pipelines if active => ("Stop", "stop"),
2392            Panel::Pipelines => ("Start update for", "start-update"),
2393            _ if active => ("Stop", "stop"),
2394            _ => ("Start", "start"),
2395        };
2396        self.confirm = Some(Confirm {
2397            message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
2398            args: vec![group.to_string(), action.to_string(), id],
2399        });
2400    }
2401
2402    /// `s` in the run view: cancel the shown run/update after a confirm.
2403    pub fn request_run_cancel(&mut self) {
2404        let Some(rv) = &self.run_view else {
2405            return;
2406        };
2407        if !rv.live {
2408            self.flash = Some((
2409                "✗ nothing to cancel — this run already finished".to_string(),
2410                Instant::now(),
2411            ));
2412            return;
2413        }
2414        let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
2415            return;
2416        };
2417        let (message, args) = if rv.panel == Panel::Jobs {
2418            (
2419                format!("Cancel run {run_id} of “{}”?", rv.owner_name),
2420                vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
2421            )
2422        } else {
2423            (
2424                format!("Stop “{}” (cancels the active update)?", rv.owner_name),
2425                vec![
2426                    "pipelines".to_string(),
2427                    "stop".to_string(),
2428                    rv.owner_id.clone(),
2429                ],
2430            )
2431        };
2432        self.confirm = Some(Confirm { message, args });
2433    }
2434
2435    /// Cancels the in-flight console statement server-side; the polling
2436    /// task then sees CANCELED and surfaces it in the results pane.
2437    pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
2438        let id = self
2439            .sql_stmt
2440            .as_ref()
2441            .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
2442        let Some(id) = id else {
2443            self.flash = Some((
2444                "✗ statement not submitted yet — try again in a moment".to_string(),
2445                Instant::now(),
2446            ));
2447            return;
2448        };
2449        let cli = Arc::clone(cli);
2450        tokio::spawn(async move {
2451            let path = format!("/api/2.0/sql/statements/{id}/cancel");
2452            let _ = cli.run_action(&["api", "post", &path]).await;
2453        });
2454        self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
2455    }
2456
2457    pub fn cancel_confirm(&mut self) {
2458        self.confirm = None;
2459    }
2460
2461    pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
2462        let Some(c) = self.confirm.take() else {
2463            return;
2464        };
2465        let base = c.message.trim_end_matches('?').to_string();
2466        self.flash = Some((format!("⏳ {base}…"), Instant::now()));
2467
2468        let (tx, rx) = oneshot::channel();
2469        self.action_rx = Some(rx);
2470        let cli = Arc::clone(cli);
2471        tokio::spawn(async move {
2472            let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
2473            let result = match cli.run_action(&args).await {
2474                Ok(()) => Ok(format!("✓ {base} — done")),
2475                Err(e) => Err(format!("✗ {e:#}")),
2476            };
2477            let _ = tx.send(result);
2478        });
2479    }
2480
2481    /// Applies a finished action; refreshes on success. Returns true on change.
2482    pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2483        let Some(rx) = &mut self.action_rx else {
2484            return false;
2485        };
2486        match rx.try_recv() {
2487            Ok(result) => {
2488                let ok = result.is_ok();
2489                self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
2490                self.action_rx = None;
2491                if ok {
2492                    self.start_refresh(cli);
2493                }
2494                true
2495            }
2496            Err(oneshot::error::TryRecvError::Empty) => false,
2497            Err(oneshot::error::TryRecvError::Closed) => {
2498                self.action_rx = None;
2499                true
2500            }
2501        }
2502    }
2503
2504    /// Drops the flash message once it has been visible long enough.
2505    pub fn expire_flash(&mut self) -> bool {
2506        if let Some((_, since)) = &self.flash {
2507            if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
2508                self.flash = None;
2509                return true;
2510            }
2511        }
2512        false
2513    }
2514
2515    /// Opens the selected item (or the open detail view) in the workspace web UI.
2516    pub fn open_in_browser(&self) {
2517        let Some(host) = &self.host else {
2518            return;
2519        };
2520        let (panel, id) = match &self.detail {
2521            Some(d) => (d.panel, Some(d.id.clone())),
2522            None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
2523        };
2524        let Some(id) = id else {
2525            return;
2526        };
2527        let path = match panel {
2528            Panel::Clusters => format!("compute/clusters/{id}"),
2529            Panel::Jobs => format!("jobs/{id}"),
2530            Panel::Pipelines => format!("pipelines/{id}"),
2531            Panel::Warehouses => format!("sql/warehouses/{id}"),
2532            Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
2533            Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
2534            // Secret scopes have no workspace-UI page.
2535            Panel::Secrets => return,
2536        };
2537        let url = format!("{}/{}", host.trim_end_matches('/'), path);
2538        #[cfg(target_os = "macos")]
2539        let opener = "open";
2540        #[cfg(not(target_os = "macos"))]
2541        let opener = "xdg-open";
2542        let _ = std::process::Command::new(opener).arg(url).spawn();
2543    }
2544
2545    /// Counts of (ok, pending, failed, idle) items across all panels.
2546    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
2547        let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
2548        for shape in self.shapes.iter().flatten() {
2549            if let Shape::List(items) = shape {
2550                for item in items {
2551                    match item.status {
2552                        Status::Running | Status::Success => ok += 1,
2553                        Status::Pending => pending += 1,
2554                        Status::Failed => failed += 1,
2555                        Status::Stopped => idle += 1,
2556                        Status::Unknown(_) => {}
2557                    }
2558                }
2559            }
2560        }
2561        (ok, pending, failed, idle)
2562    }
2563
2564    pub fn last_refresh_age(&self) -> Duration {
2565        self.last_refresh.elapsed()
2566    }
2567
2568    pub fn spinner(&self) -> &'static str {
2569        SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
2570    }
2571
2572    pub fn spinner_frame(&self) -> usize {
2573        self.spinner_frame
2574    }
2575
2576    /// True whenever any background work is in flight — the loop uses this
2577    /// to keep spinners ticking, not just during panel refreshes.
2578    pub fn busy(&self) -> bool {
2579        self.loading
2580            || self.detail_rx.is_some()
2581            || self.action_rx.is_some()
2582            || self.preview_rx.is_some()
2583            || self.cost_rx.is_some()
2584            || self.sql_rx.is_some()
2585            || self.run_rx.is_some()
2586    }
2587
2588    pub fn tick_spinner(&mut self) {
2589        self.spinner_frame = self.spinner_frame.wrapping_add(1);
2590    }
2591
2592    pub fn toggle_zoom(&mut self) {
2593        self.zoomed = !self.zoomed;
2594    }
2595
2596    pub fn focus_next(&mut self) {
2597        self.cycle_focus(1);
2598    }
2599
2600    pub fn focus_prev(&mut self) {
2601        self.cycle_focus(-1);
2602    }
2603
2604    /// Cycles focus through the visible panes in display order.
2605    fn cycle_focus(&mut self, delta: i32) {
2606        let visible = self.visible_panes();
2607        if visible.is_empty() {
2608            return;
2609        }
2610        let focus_idx = Panel::ALL
2611            .iter()
2612            .position(|p| p == &self.focus)
2613            .unwrap_or(0);
2614        let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
2615        let n = visible.len() as i32;
2616        let next = ((pos as i32 + delta) % n + n) % n;
2617        self.focus = Panel::ALL[visible[next as usize]];
2618    }
2619
2620    pub fn needs_refresh(&self) -> bool {
2621        !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
2622    }
2623
2624    pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
2625        if self.loading {
2626            return;
2627        }
2628        self.loading = true;
2629        self.error = None;
2630        self.last_refresh = Instant::now();
2631
2632        let (tx, rx) = mpsc::unbounded_channel();
2633        self.pending = Some(rx);
2634        self.in_flight = 8;
2635
2636        // One task per source so each panel updates as soon as its fetch lands,
2637        // instead of waiting for the slowest of the five.
2638        macro_rules! spawn_fetch {
2639            ($update:expr, $fetch:path) => {{
2640                let cli = Arc::clone(cli);
2641                let tx = tx.clone();
2642                tokio::spawn(async move {
2643                    let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
2644                    let _ = tx.send($update(result));
2645                });
2646            }};
2647        }
2648
2649        spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
2650        spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
2651        spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
2652        spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
2653        spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
2654        spawn_fetch!(
2655            |s: Result<Shape, String>| Update::Badge(s.ok()),
2656            fetchers::current_user::fetch
2657        );
2658        {
2659            let cli = Arc::clone(cli);
2660            let tx = tx.clone();
2661            let path = self.uc_path.clone();
2662            tokio::spawn(async move {
2663                let result = fetchers::catalog::fetch(&cli, &path)
2664                    .await
2665                    .map_err(|e| format!("{e:#}"));
2666                let _ = tx.send(Update::Panel(5, result));
2667            });
2668        }
2669        {
2670            let cli = Arc::clone(cli);
2671            let tx = tx.clone();
2672            let scope = self.secret_scope.clone();
2673            tokio::spawn(async move {
2674                let result = fetchers::secrets::fetch(&cli, scope.as_deref())
2675                    .await
2676                    .map_err(|e| format!("{e:#}"));
2677                let _ = tx.send(Update::Panel(6, result));
2678            });
2679        }
2680    }
2681
2682    /// Applies any fetch results that have arrived; returns true if the UI should redraw.
2683    pub fn poll_refresh(&mut self) -> bool {
2684        let Some(rx) = &mut self.pending else {
2685            return false;
2686        };
2687        let mut changed = false;
2688        let mut updated_panes: Vec<usize> = Vec::new();
2689        loop {
2690            match rx.try_recv() {
2691                Ok(Update::Panel(i, result)) => {
2692                    match result {
2693                        Ok(mut shape) => {
2694                            // Active work floats to the top of every pane
2695                            // except the catalog, which stays browsable
2696                            // in its natural (alphabetical) order.
2697                            if i != 5 {
2698                                if let Shape::List(items) = &mut shape {
2699                                    items.sort_by_key(|it| {
2700                                        (it.status.rank(), it.history.is_empty())
2701                                    });
2702                                }
2703                            }
2704                            self.shapes[i] = Some(shape);
2705                            self.updated_at[i] = Some(Instant::now());
2706                            updated_panes.push(i);
2707                        }
2708                        // Keep previous data on failure so panels don't blank
2709                        // out — but surface the error if there's nothing yet.
2710                        Err(e) => {
2711                            if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
2712                                self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
2713                            }
2714                        }
2715                    }
2716                    self.in_flight -= 1;
2717                    changed = true;
2718                }
2719                Ok(Update::Badge(badge)) => {
2720                    if badge.is_some() {
2721                        self.user_badge = badge;
2722                    }
2723                    self.in_flight -= 1;
2724                    changed = true;
2725                }
2726                Err(mpsc::error::TryRecvError::Empty) => break,
2727                Err(mpsc::error::TryRecvError::Disconnected) => {
2728                    self.in_flight = 0;
2729                    break;
2730                }
2731            }
2732        }
2733        for i in updated_panes {
2734            self.alert_new_failures(i);
2735        }
2736        if self.in_flight == 0 {
2737            self.loading = false;
2738            self.pending = None;
2739            changed = true;
2740        }
2741        changed
2742    }
2743}