Skip to main content

databricks_tui/
app.rs

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