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    /// Top visible row in the grid; the inspected row in record view.
167    pub scroll: usize,
168    /// First visible column, as an index into the filtered column list.
169    pub col: usize,
170    /// Case-insensitive substring filter over column names — the way
171    /// through a 500-column table.
172    pub filter: String,
173    pub filter_entry: bool,
174    /// Transposed view: one row, fields stacked vertically.
175    pub record: bool,
176    /// Field scroll within the record view.
177    pub rscroll: u16,
178}
179
180impl Preview {
181    /// Indices of columns whose name matches the filter (all when empty).
182    pub fn visible_cols(&self) -> Vec<usize> {
183        let Some(Ok(t)) = &self.data else {
184            return Vec::new();
185        };
186        let q = self.filter.to_lowercase();
187        (0..t.headers.len())
188            .filter(|&i| q.is_empty() || t.headers[i].to_lowercase().contains(&q))
189            .collect()
190    }
191}
192
193/// What a confirmed warehouse choice should run.
194enum PickTarget {
195    Preview(String),
196    Cost,
197    Lineage(String),
198    Sql(String),
199}
200
201/// Free-form SQL prompt with results, backed by the preview machinery.
202pub struct SqlConsole {
203    pub input: String,
204    /// Caret position in `input`, counted in characters.
205    pub cursor: usize,
206    /// Display name of the warehouse the last query ran on.
207    pub warehouse: String,
208    pub running: bool,
209    pub data: Option<Result<crate::shape::TableData, String>>,
210    /// The statement that produced `data`.
211    pub last_sql: String,
212    pub scroll: usize,
213    /// First visible result column (shift+←/→ pages wide results).
214    pub col: usize,
215}
216
217/// Tab-completion popup over the SQL prompt, backed by lazily-cached
218/// Unity Catalog names.
219pub struct SqlComplete {
220    /// Candidates for the segment being completed; empty while loading.
221    pub items: Vec<String>,
222    /// The candidate currently inserted into the prompt.
223    pub index: usize,
224    /// Char offset in the input where the completed segment starts —
225    /// the popup anchors under it.
226    pub seg_start: usize,
227    /// The typed prefix, restored on esc.
228    prefix: String,
229    /// Dotted context before the segment ("" for a bare word).
230    context: String,
231    /// True while names are being fetched from the workspace.
232    pub loading: bool,
233}
234
235/// Completed alongside catalog names when a bare word is typed.
236const SQL_KEYWORDS: &[&str] = &[
237    "SELECT",
238    "FROM",
239    "WHERE",
240    "GROUP BY",
241    "ORDER BY",
242    "HAVING",
243    "LIMIT",
244    "JOIN",
245    "LEFT JOIN",
246    "INNER JOIN",
247    "FULL OUTER JOIN",
248    "CROSS JOIN",
249    "ON",
250    "AS",
251    "AND",
252    "OR",
253    "NOT",
254    "IN",
255    "IS",
256    "NULL",
257    "DISTINCT",
258    "COUNT",
259    "SUM",
260    "AVG",
261    "MIN",
262    "MAX",
263    "UNION",
264    "UNION ALL",
265    "INSERT INTO",
266    "VALUES",
267    "UPDATE",
268    "SET",
269    "DELETE",
270    "CREATE",
271    "DROP",
272    "ALTER",
273    "SHOW",
274    "DESCRIBE",
275    "EXPLAIN",
276    "WITH",
277    "CASE",
278    "WHEN",
279    "THEN",
280    "ELSE",
281    "END",
282    "BETWEEN",
283    "LIKE",
284    "CAST",
285    "OVER",
286    "PARTITION BY",
287];
288
289/// The dotted identifier ending at the caret: (char offset where its
290/// last segment starts, context path before the last dot, typed prefix).
291fn token_at_cursor(input: &str, cursor: usize) -> (usize, String, String) {
292    let chars: Vec<char> = input.chars().collect();
293    let cursor = cursor.min(chars.len());
294    let mut start = cursor;
295    while start > 0 && (chars[start - 1].is_alphanumeric() || matches!(chars[start - 1], '_' | '.'))
296    {
297        start -= 1;
298    }
299    let token: String = chars[start..cursor].iter().collect();
300    match token.rfind('.') {
301        Some(dot) => {
302            let context = token[..dot].to_string();
303            let prefix = token[dot + 1..].to_string();
304            (start + context.chars().count() + 1, context, prefix)
305        }
306        None => (start, String::new(), token),
307    }
308}
309
310/// The first table referenced by a FROM clause, when fully qualified —
311/// its columns join the candidates for bare words.
312fn from_table(input: &str) -> Option<String> {
313    let pos = input.to_lowercase().find("from ")?;
314    // get(): lowercasing can shift byte offsets in non-ASCII input.
315    let rest = input.get(pos + 5..)?.trim_start();
316    let table: String = rest
317        .chars()
318        .take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '.'))
319        .collect();
320    (table.matches('.').count() == 2 && !table.ends_with('.')).then_some(table)
321}
322
323/// Where console history lives; one statement per line, oldest first.
324fn history_path() -> Option<std::path::PathBuf> {
325    let home = std::env::var_os("HOME")?;
326    Some(
327        std::path::PathBuf::from(home)
328            .join(".config")
329            .join("databricks-tui")
330            .join("history"),
331    )
332}
333
334fn load_history() -> Vec<String> {
335    history_path()
336        .and_then(|p| std::fs::read_to_string(p).ok())
337        .map(|s| {
338            s.lines()
339                .filter(|l| !l.trim().is_empty())
340                .map(str::to_string)
341                .collect()
342        })
343        .unwrap_or_default()
344}
345
346fn save_history(history: &[String]) {
347    let Some(path) = history_path() else {
348        return;
349    };
350    if let Some(dir) = path.parent() {
351        let _ = std::fs::create_dir_all(dir);
352        crate::config::restrict(dir, 0o700);
353    }
354    // Keep the tail; nobody scrolls back 200 queries.
355    let tail: Vec<&str> = history
356        .iter()
357        .rev()
358        .take(200)
359        .rev()
360        .map(String::as_str)
361        .collect();
362    // Queries can hold sensitive literals — owner-only, like shell history.
363    let _ = std::fs::write(&path, tail.join("\n") + "\n");
364    crate::config::restrict(&path, 0o600);
365}
366
367/// True when every char of `needle` appears in `haystack` in order.
368fn subsequence(haystack: &str, needle: &str) -> bool {
369    let mut chars = haystack.chars();
370    needle.chars().all(|n| chars.any(|h| h == n))
371}
372
373/// Byte offset of the `cursor`th character in `input`.
374fn byte_at(input: &str, cursor: usize) -> usize {
375    input
376        .char_indices()
377        .nth(cursor)
378        .map(|(i, _)| i)
379        .unwrap_or(input.len())
380}
381
382/// Overlay for choosing which SQL warehouse runs a query.
383pub struct WhPicker {
384    pub index: usize,
385    target: PickTarget,
386}
387
388/// Full-screen DBU usage view backed by system.billing.usage.
389pub struct CostView {
390    pub warehouse: String,
391    pub data: Option<Result<fetchers::cost::CostData, String>>,
392}
393
394/// Drill-down into a single job run or pipeline update, layered over
395/// the owning detail view.
396pub struct RunView {
397    /// Panel::Jobs (runs) or Panel::Pipelines (updates).
398    pub panel: Panel,
399    pub owner_name: String,
400    /// Job id or pipeline id the runs belong to.
401    owner_id: String,
402    /// Recent runs newest-first: (run_id, status, age).
403    pub runs: Vec<(String, Status, String)>,
404    /// Which of `runs` is shown.
405    pub idx: usize,
406    pub data: Option<DetailData>,
407    pub show_raw: bool,
408    pub scroll: u16,
409    /// True while the shown run is still executing — drives auto-refresh.
410    pub live: bool,
411    /// Full per-task output/logs, fetched on demand via `o`.
412    pub output: Option<String>,
413    pub show_output: bool,
414    /// Gantt view of per-task execution windows; sticky across h/l so
415    /// runs can be compared.
416    pub show_timeline: bool,
417    fetched_at: Instant,
418}
419
420/// (recent runs, detail of the newest, still-executing flag)
421type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
422
423enum RunUpdate {
424    Opened(Result<RunOpened, String>),
425    Detail(DetailData, bool),
426    Output(String),
427}
428
429/// One unhealthy resource, pointing back at its pane and item.
430pub struct Problem {
431    pub panel: usize,
432    pub name: String,
433    pub status: Status,
434    pub note: String,
435}
436
437/// Overlay collecting everything currently failing across the panes.
438pub struct Problems {
439    pub items: Vec<Problem>,
440    pub index: usize,
441}
442
443/// A pending destructive/mutating action awaiting a y/n keystroke.
444pub struct Confirm {
445    pub message: String,
446    args: Vec<String>,
447}
448
449enum Update {
450    Panel(usize, Result<Shape, String>),
451    Badge(Option<Shape>),
452}
453
454const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
455
456pub struct App {
457    pub focus: Panel,
458    pub theme: ThemeMode,
459    pub zoomed: bool,
460    pub shapes: Vec<Option<Shape>>,
461    pub user_badge: Option<Shape>,
462    pub error: Option<String>,
463    pub refresh_interval: Duration,
464    last_refresh: Instant,
465    pub loading: bool,
466    pub detail: Option<Detail>,
467    pub confirm: Option<Confirm>,
468    pub flash: Option<(String, Instant)>,
469    pub selected: [usize; 7],
470    pub host: Option<String>,
471    /// Available profiles from ~/.databrickscfg and the active one.
472    pub profiles: Vec<String>,
473    pub profile: Option<String>,
474    /// When Some, the workspace picker overlay is open at this index.
475    pub picker: Option<usize>,
476    /// When Some, the problems overlay is open.
477    pub problems: Option<Problems>,
478    /// Current position in the Unity Catalog tree: [], [catalog] or [catalog, schema].
479    pub uc_path: Vec<String>,
480    uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
481    pub preview: Option<Preview>,
482    preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
483    pub wh_picker: Option<WhPicker>,
484    /// Session-remembered (id, name) of the warehouse used for previews.
485    pub preview_warehouse: Option<(String, String)>,
486    pub cost: Option<CostView>,
487    #[allow(clippy::type_complexity)]
488    cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
489    /// Numeric id of the current workspace, resolved lazily for cost
490    /// scoping and cached for the session.
491    workspace_id: Option<String>,
492    pub sql: Option<SqlConsole>,
493    sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
494    /// Past console statements, oldest first; persisted across sessions.
495    sql_history: Vec<String>,
496    /// Position while cycling history with ↑/↓; None = editing a new line.
497    hist_idx: Option<usize>,
498    /// The unfinished statement stashed when history browsing starts.
499    hist_draft: String,
500    /// Ctrl+R incremental search: (query, nth-newest match shown).
501    pub hist_search: Option<(String, usize)>,
502    pub run_view: Option<RunView>,
503    run_rx: Option<oneshot::Receiver<RunUpdate>>,
504    pending: Option<mpsc::UnboundedReceiver<Update>>,
505    detail_rx: Option<oneshot::Receiver<DetailData>>,
506    action_rx: Option<oneshot::Receiver<Result<String, String>>>,
507    host_rx: Option<oneshot::Receiver<Option<String>>>,
508    in_flight: usize,
509    spinner_frame: usize,
510    /// Splash screen deadline; None once dismissed.
511    pub splash_until: Option<Instant>,
512    /// When each pane last received fresh data — drives the title flash.
513    pub updated_at: [Option<Instant>; 7],
514    /// Per-pane search filter; empty string means no filter.
515    pub filters: [String; 7],
516    /// True while the user is typing a filter for the focused pane.
517    pub filter_entry: bool,
518    /// Persisted preferences (theme, warehouse per profile).
519    pub config: crate::config::Config,
520    /// Failed item names per pane at the last refresh — None until the
521    /// pane has loaded once, so the first load never alerts.
522    failed_seen: [Option<std::collections::HashSet<String>>; 7],
523    /// Ctrl+P fuzzy jump overlay.
524    pub jump: Option<Jump>,
525    /// Canonical pane indices in display order.
526    pub pane_order: Vec<usize>,
527    /// Hidden flag per canonical pane index.
528    pub hidden: [bool; 7],
529    /// When Some, the pane-arrangement overlay is open at this position.
530    pub pane_cfg: Option<usize>,
531    /// True while the `?` help overlay is open.
532    pub help: bool,
533    /// Scroll offset of the help overlay.
534    pub help_scroll: u16,
535    /// Statement id of the in-flight console query, for cancellation.
536    sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
537    /// Tab-completion popup state for the SQL prompt.
538    pub sql_complete: Option<SqlComplete>,
539    /// Unity Catalog names for completion, keyed by dotted path ("" →
540    /// catalogs, "cat" → schemas, …). Filled lazily, kept per workspace.
541    uc_names: std::collections::HashMap<String, Vec<String>>,
542    #[allow(clippy::type_complexity)]
543    uc_names_rx: Option<oneshot::Receiver<(String, Result<Vec<String>, String>)>>,
544    /// Drilled-into secret scope; None = the scopes listing.
545    pub secret_scope: Option<String>,
546    secrets_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
547    /// Create-scope / add-secret input form.
548    pub secret_form: Option<SecretForm>,
549}
550
551/// Ctrl+P overlay: fuzzy-search everything loaded, Enter jumps to it.
552pub struct Jump {
553    pub query: String,
554    pub index: usize,
555}
556
557/// Two-step input: a scope name, or a key then a (masked) value.
558pub struct SecretForm {
559    /// Scope the secret goes into; None = creating a new scope.
560    pub scope: Option<String>,
561    pub key: String,
562    pub value: String,
563    /// 0 = typing the name/key, 1 = typing the value.
564    pub stage: u8,
565}
566
567impl App {
568    pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
569        let mut app = Self {
570            focus: Panel::Clusters,
571            theme,
572            zoomed: false,
573            shapes: vec![None; 7],
574            user_badge: None,
575            error: None,
576            refresh_interval: Duration::from_secs(refresh_secs),
577            last_refresh: Instant::now()
578                .checked_sub(Duration::from_secs(refresh_secs + 1))
579                .unwrap_or(Instant::now()),
580            loading: false,
581            detail: None,
582            confirm: None,
583            flash: None,
584            selected: [0; 7],
585            host: None,
586            profiles: Vec::new(),
587            profile: None,
588            picker: None,
589            problems: None,
590            uc_path: Vec::new(),
591            uc_rx: None,
592            preview: None,
593            preview_rx: None,
594            wh_picker: None,
595            preview_warehouse: None,
596            cost: None,
597            cost_rx: None,
598            workspace_id: None,
599            sql: None,
600            sql_rx: None,
601            sql_history: load_history(),
602            hist_idx: None,
603            hist_draft: String::new(),
604            hist_search: None,
605            run_view: None,
606            run_rx: None,
607            pending: None,
608            detail_rx: None,
609            action_rx: None,
610            host_rx: None,
611            in_flight: 0,
612            spinner_frame: 0,
613            splash_until: Some(Instant::now() + Duration::from_millis(1600)),
614            updated_at: [None; 7],
615            filters: Default::default(),
616            filter_entry: false,
617            config: crate::config::Config::load(),
618            failed_seen: Default::default(),
619            jump: None,
620            pane_order: (0..7).collect(),
621            hidden: [false; 7],
622            pane_cfg: None,
623            help: false,
624            help_scroll: 0,
625            sql_stmt: None,
626            sql_complete: None,
627            uc_names: Default::default(),
628            uc_names_rx: None,
629            secret_scope: None,
630            secrets_rx: None,
631            secret_form: None,
632        };
633        app.load_pane_prefs();
634        app
635    }
636
637    /// Applies pane order/visibility from the config file.
638    fn load_pane_prefs(&mut self) {
639        let idx_of = |id: &str| Panel::ALL.iter().position(|p| p.id() == id);
640        let mut order: Vec<usize> = self
641            .config
642            .pane_order
643            .iter()
644            .filter_map(|id| idx_of(id))
645            .collect();
646        for i in 0..7 {
647            if !order.contains(&i) {
648                order.push(i);
649            }
650        }
651        self.pane_order = order;
652        for id in &self.config.hidden_panes {
653            if let Some(i) = idx_of(id) {
654                self.hidden[i] = true;
655            }
656        }
657        self.ensure_focus_visible();
658    }
659
660    fn persist_panes(&mut self) {
661        self.config.pane_order = self
662            .pane_order
663            .iter()
664            .map(|&i| Panel::ALL[i].id().to_string())
665            .collect();
666        self.config.hidden_panes = (0..7)
667            .filter(|&i| self.hidden[i])
668            .map(|i| Panel::ALL[i].id().to_string())
669            .collect();
670        self.config.save();
671    }
672
673    /// Canonical pane indices currently shown, in display order.
674    pub fn visible_panes(&self) -> Vec<usize> {
675        self.pane_order
676            .iter()
677            .copied()
678            .filter(|&i| !self.hidden[i])
679            .collect()
680    }
681
682    /// Moves focus off a hidden pane onto the first visible one.
683    fn ensure_focus_visible(&mut self) {
684        let visible = self.visible_panes();
685        let focus_idx = Panel::ALL
686            .iter()
687            .position(|p| p == &self.focus)
688            .unwrap_or(0);
689        if !visible.contains(&focus_idx) {
690            if let Some(&first) = visible.first() {
691                self.focus = Panel::ALL[first];
692            }
693        }
694    }
695
696    /// Unhides a pane (used when a jump targets it).
697    fn reveal_pane(&mut self, idx: usize) {
698        if self.hidden[idx] {
699            self.hidden[idx] = false;
700            self.persist_panes();
701        }
702    }
703
704    pub fn open_pane_cfg(&mut self) {
705        self.pane_cfg = Some(0);
706    }
707
708    pub fn pane_cfg_next(&mut self) {
709        if let Some(i) = self.pane_cfg {
710            self.pane_cfg = Some((i + 1).min(6));
711        }
712    }
713
714    pub fn pane_cfg_prev(&mut self) {
715        if let Some(i) = self.pane_cfg {
716            self.pane_cfg = Some(i.saturating_sub(1));
717        }
718    }
719
720    /// Space in the overlay: toggles visibility of the selected pane
721    /// (refusing to hide the last visible one).
722    pub fn pane_cfg_toggle(&mut self) {
723        let Some(pos) = self.pane_cfg else {
724            return;
725        };
726        let idx = self.pane_order[pos];
727        if !self.hidden[idx] && self.visible_panes().len() == 1 {
728            self.flash = Some((
729                "✗ at least one pane has to stay visible".to_string(),
730                Instant::now(),
731            ));
732            return;
733        }
734        self.hidden[idx] = !self.hidden[idx];
735        self.ensure_focus_visible();
736        self.persist_panes();
737    }
738
739    /// J/K in the overlay: moves the selected pane down/up in the order.
740    pub fn pane_cfg_move(&mut self, delta: i32) {
741        let Some(pos) = self.pane_cfg else {
742            return;
743        };
744        let new = if delta < 0 {
745            pos.saturating_sub(1)
746        } else {
747            (pos + 1).min(6)
748        };
749        if new != pos {
750            self.pane_order.swap(pos, new);
751            self.pane_cfg = Some(new);
752            self.persist_panes();
753        }
754    }
755
756    /// Flashes (and rings the bell) when a resource fails between one
757    /// refresh and the next.
758    fn alert_new_failures(&mut self, idx: usize) {
759        // Catalog "error rows" are listing problems, not runtime failures.
760        if idx >= 5 {
761            return;
762        }
763        let Some(Shape::List(items)) = &self.shapes[idx] else {
764            return;
765        };
766        let failed: std::collections::HashSet<String> = items
767            .iter()
768            .filter(|it| {
769                matches!(it.status, Status::Failed)
770                    || it
771                        .history
772                        .last()
773                        .is_some_and(|s| matches!(s, Status::Failed))
774            })
775            .map(|it| it.name.clone())
776            .collect();
777        if let Some(prev) = &self.failed_seen[idx] {
778            let mut newly: Vec<&String> = failed.difference(prev).collect();
779            if !newly.is_empty() {
780                newly.sort();
781                let extra = if newly.len() > 1 {
782                    format!(" (+{} more)", newly.len() - 1)
783                } else {
784                    String::new()
785                };
786                self.flash = Some((
787                    format!("✗ {}{extra} just failed — ! to inspect", newly[0]),
788                    Instant::now(),
789                ));
790                // Bell so a backgrounded terminal (or tmux) flags it too.
791                print!("\x07");
792                let _ = std::io::Write::flush(&mut std::io::stdout());
793            }
794        }
795        self.failed_seen[idx] = Some(failed);
796    }
797
798    /// Remembers the current theme across sessions.
799    pub fn persist_theme(&mut self) {
800        self.config.theme = Some(self.theme.id().to_string());
801        self.config.save();
802    }
803
804    /// Restores the remembered warehouse for the active profile.
805    pub fn restore_warehouse_pref(&mut self) {
806        let profile = self.profile.as_deref().unwrap_or("DEFAULT");
807        self.preview_warehouse = self.config.warehouses.get(profile).cloned();
808    }
809
810    pub fn splash_active(&self) -> bool {
811        self.splash_until
812            .map(|t| Instant::now() < t)
813            .unwrap_or(false)
814    }
815
816    pub fn dismiss_splash(&mut self) {
817        self.splash_until = None;
818    }
819
820    /// True while any pane's data just landed — keeps the flash decaying.
821    pub fn any_fresh(&self) -> bool {
822        self.updated_at
823            .iter()
824            .flatten()
825            .any(|t| t.elapsed() < Duration::from_millis(1200))
826    }
827
828    pub fn open_picker(&mut self) {
829        if self.profiles.is_empty() {
830            return;
831        }
832        let current = self
833            .profile
834            .as_deref()
835            .and_then(|p| self.profiles.iter().position(|n| n == p))
836            .unwrap_or(0);
837        self.picker = Some(current);
838    }
839
840    pub fn picker_next(&mut self) {
841        if let Some(i) = self.picker {
842            self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
843        }
844    }
845
846    pub fn picker_prev(&mut self) {
847        if let Some(i) = self.picker {
848            self.picker = Some(i.saturating_sub(1));
849        }
850    }
851
852    /// Confirms the picker selection; returns the new CLI handle to use.
853    pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
854        let idx = self.picker.take()?;
855        let name = self.profiles.get(idx)?.clone();
856        let profile_arg = if name == "DEFAULT" {
857            None
858        } else {
859            Some(name.clone())
860        };
861        self.profile = Some(name);
862
863        // Drop all workspace-specific state; panes go back to loading.
864        self.shapes = vec![None; 7];
865        self.user_badge = None;
866        self.host = None;
867        self.selected = [0; 7];
868        self.detail = None;
869        self.detail_rx = None;
870        self.confirm = None;
871        self.problems = None;
872        self.uc_path.clear();
873        self.uc_rx = None;
874        self.secret_scope = None;
875        self.secrets_rx = None;
876        self.secret_form = None;
877        self.preview = None;
878        self.preview_rx = None;
879        self.wh_picker = None;
880        self.preview_warehouse = None;
881        self.cost = None;
882        self.cost_rx = None;
883        self.workspace_id = None;
884        self.sql = None;
885        self.sql_rx = None;
886        self.run_view = None;
887        self.run_rx = None;
888        self.pending = None;
889        self.in_flight = 0;
890        self.loading = false;
891        self.zoomed = false;
892        self.filters = Default::default();
893        self.filter_entry = false;
894        self.failed_seen = Default::default();
895        self.jump = None;
896        self.sql_stmt = None;
897        self.sql_complete = None;
898        self.uc_names.clear();
899        self.uc_names_rx = None;
900        self.restore_warehouse_pref();
901
902        Some(Arc::new(DatabricksCli::new(profile_arg)))
903    }
904
905    pub fn open_jump(&mut self) {
906        self.jump = Some(Jump {
907            query: String::new(),
908            index: 0,
909        });
910    }
911
912    /// Everything loaded that matches the jump query, best first:
913    /// (panel index, item name, kind/status label). Substring matches
914    /// rank above in-order subsequence matches.
915    pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
916        let Some(jump) = &self.jump else {
917            return Vec::new();
918        };
919        let q = jump.query.to_lowercase();
920        let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
921        for (i, shape) in self.shapes.iter().enumerate() {
922            let Some(Shape::List(items)) = shape else {
923                continue;
924            };
925            for it in items {
926                let name = it.name.to_lowercase();
927                let rank = if q.is_empty() || name.contains(&q) {
928                    0
929                } else if subsequence(&name, &q) {
930                    1
931                } else {
932                    continue;
933                };
934                scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
935            }
936        }
937        scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
938        scored
939            .into_iter()
940            .take(12)
941            .map(|(_, i, name, label)| (i, name, label))
942            .collect()
943    }
944
945    pub fn jump_push(&mut self, c: char) {
946        if let Some(j) = &mut self.jump {
947            j.query.push(c);
948            j.index = 0;
949        }
950    }
951
952    pub fn jump_pop(&mut self) {
953        if let Some(j) = &mut self.jump {
954            j.query.pop();
955            j.index = 0;
956        }
957    }
958
959    pub fn jump_next(&mut self) {
960        let len = self.jump_matches().len();
961        if let Some(j) = &mut self.jump {
962            j.index = (j.index + 1).min(len.saturating_sub(1));
963        }
964    }
965
966    pub fn jump_prev(&mut self) {
967        if let Some(j) = &mut self.jump {
968            j.index = j.index.saturating_sub(1);
969        }
970    }
971
972    /// Jumps focus and selection to the highlighted match.
973    pub fn jump_go(&mut self) {
974        let matches = self.jump_matches();
975        let Some(jump) = self.jump.take() else {
976            return;
977        };
978        let Some((panel_idx, name, _)) = matches.get(jump.index) else {
979            return;
980        };
981        self.reveal_pane(*panel_idx);
982        self.focus = Panel::ALL[*panel_idx];
983        self.filters[*panel_idx].clear();
984        if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
985            if let Some(pos) = items.iter().position(|i| &i.name == name) {
986                self.selected[*panel_idx] = pos;
987            }
988        }
989    }
990
991    /// Collects everything unhealthy across the loaded panes: items whose
992    /// status is failed, or whose most recent run failed.
993    pub fn open_problems(&mut self) {
994        let mut items = Vec::new();
995        for (i, shape) in self.shapes.iter().enumerate() {
996            let Some(Shape::List(list)) = shape else {
997                continue;
998            };
999            for it in list {
1000                let failed_now = matches!(it.status, Status::Failed);
1001                let failed_last = it
1002                    .history
1003                    .last()
1004                    .is_some_and(|s| matches!(s, Status::Failed));
1005                if failed_now || failed_last {
1006                    let note = if failed_now {
1007                        it.detail.clone().unwrap_or_default()
1008                    } else {
1009                        "latest run failed".to_string()
1010                    };
1011                    items.push(Problem {
1012                        panel: i,
1013                        name: it.name.clone(),
1014                        status: it.status.clone(),
1015                        note,
1016                    });
1017                }
1018            }
1019        }
1020        self.problems = Some(Problems { items, index: 0 });
1021    }
1022
1023    pub fn problems_next(&mut self) {
1024        if let Some(pr) = &mut self.problems {
1025            pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
1026        }
1027    }
1028
1029    pub fn problems_prev(&mut self) {
1030        if let Some(pr) = &mut self.problems {
1031            pr.index = pr.index.saturating_sub(1);
1032        }
1033    }
1034
1035    /// Jumps focus and selection to the highlighted problem's pane item.
1036    pub fn problems_jump(&mut self) {
1037        let Some(pr) = self.problems.take() else {
1038            return;
1039        };
1040        let Some(problem) = pr.items.get(pr.index) else {
1041            return;
1042        };
1043        self.reveal_pane(problem.panel);
1044        self.focus = Panel::ALL[problem.panel];
1045        // The pane's filter could hide the item we're jumping to.
1046        self.filters[problem.panel].clear();
1047        if let Some(Shape::List(list)) = &self.shapes[problem.panel] {
1048            if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
1049                self.selected[problem.panel] = pos;
1050            }
1051        }
1052    }
1053
1054    /// Resolves the workspace host in the background — `auth describe` can
1055    /// take seconds when it refreshes tokens, so it must not block the loop.
1056    pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
1057        let (tx, rx) = oneshot::channel();
1058        self.host_rx = Some(rx);
1059        let cli = Arc::clone(cli);
1060        tokio::spawn(async move {
1061            let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
1062                json["details"]["host"]
1063                    .as_str()
1064                    .or_else(|| json["host"].as_str())
1065                    .map(str::to_string)
1066            });
1067            let _ = tx.send(host);
1068        });
1069    }
1070
1071    pub fn poll_host(&mut self) {
1072        if let Some(rx) = &mut self.host_rx {
1073            match rx.try_recv() {
1074                Ok(host) => {
1075                    self.host = host;
1076                    self.host_rx = None;
1077                }
1078                Err(oneshot::error::TryRecvError::Empty) => {}
1079                Err(oneshot::error::TryRecvError::Closed) => {
1080                    self.host_rx = None;
1081                }
1082            }
1083        }
1084    }
1085
1086    fn focus_index(&self) -> usize {
1087        Panel::ALL
1088            .iter()
1089            .position(|p| p == &self.focus)
1090            .unwrap_or(0)
1091    }
1092
1093    fn list_len(&self, idx: usize) -> usize {
1094        match &self.shapes[idx] {
1095            Some(Shape::List(items)) => items
1096                .iter()
1097                .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
1098                .count(),
1099            _ => 0,
1100        }
1101    }
1102
1103    /// Selection index for a panel, clamped to the current list length.
1104    pub fn selection(&self, idx: usize) -> usize {
1105        self.selected[idx].min(self.list_len(idx).saturating_sub(1))
1106    }
1107
1108    pub fn select_next(&mut self) {
1109        let idx = self.focus_index();
1110        let len = self.list_len(idx);
1111        if len > 0 {
1112            self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
1113        }
1114    }
1115
1116    pub fn select_prev(&mut self) {
1117        let idx = self.focus_index();
1118        self.selected[idx] = self.selection(idx).saturating_sub(1);
1119    }
1120
1121    /// The currently highlighted item in the focused panel, respecting
1122    /// the pane's filter — the nth *visible* item, like the UI shows.
1123    fn selected_item(&self) -> Option<&crate::shape::ListItem> {
1124        let idx = self.focus_index();
1125        match &self.shapes[idx] {
1126            Some(Shape::List(items)) => items
1127                .iter()
1128                .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
1129                .nth(self.selection(idx)),
1130            _ => None,
1131        }
1132    }
1133
1134    /// Opens filter entry for the focused pane, starting from scratch.
1135    pub fn filter_start(&mut self) {
1136        let idx = self.focus_index();
1137        self.filters[idx].clear();
1138        self.selected[idx] = 0;
1139        self.filter_entry = true;
1140    }
1141
1142    pub fn filter_push(&mut self, c: char) {
1143        let idx = self.focus_index();
1144        self.filters[idx].push(c);
1145        self.selected[idx] = 0;
1146    }
1147
1148    pub fn filter_pop(&mut self) {
1149        let idx = self.focus_index();
1150        self.filters[idx].pop();
1151        self.selected[idx] = 0;
1152    }
1153
1154    /// Keeps the filter applied and returns keys to normal navigation.
1155    pub fn filter_accept(&mut self) {
1156        self.filter_entry = false;
1157    }
1158
1159    pub fn filter_clear(&mut self) {
1160        let idx = self.focus_index();
1161        self.filters[idx].clear();
1162        self.selected[idx] = 0;
1163        self.filter_entry = false;
1164    }
1165
1166    /// The focused pane's filter, if any.
1167    pub fn active_filter(&self) -> &str {
1168        &self.filters[self.focus_index()]
1169    }
1170
1171    pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
1172        let Some(item) = self.selected_item() else {
1173            return;
1174        };
1175        let Some(id) = item.id.clone() else {
1176            return;
1177        };
1178        let kind = match &item.status {
1179            Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
1180            _ => None,
1181        };
1182        let section = match self.focus {
1183            Panel::Dashboards => "Contents",
1184            Panel::Catalog => "Columns",
1185            Panel::Warehouses => "Recent queries",
1186            _ => "Recent activity",
1187        };
1188        self.detail = Some(Detail {
1189            panel: self.focus,
1190            name: item.name.clone(),
1191            id: id.clone(),
1192            kind,
1193            section,
1194            data: None,
1195            show_raw: false,
1196            scroll: 0,
1197        });
1198
1199        let (tx, rx) = oneshot::channel();
1200        self.detail_rx = Some(rx);
1201        let cli = Arc::clone(cli);
1202        let kind = self.detail.as_ref().unwrap().kind.clone();
1203        // Files in volumes get a content peek instead of an API `get`.
1204        if kind.as_deref() == Some("FILE") {
1205            if let Some(d) = &mut self.detail {
1206                d.section = "File head";
1207            }
1208            tokio::spawn(async move {
1209                let data = fetchers::catalog::file_peek(&cli, &id).await;
1210                let _ = tx.send(data);
1211            });
1212            return;
1213        }
1214        let group = match &kind {
1215            Some(k) if k == "VOLUME" => "volumes",
1216            _ => self.focus.cli_group(),
1217        };
1218        // Tables get extra facts from DESCRIBE DETAIL when a warehouse
1219        // is already remembered — free depth, no picker interruption.
1220        let warehouse = match &kind {
1221            Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
1222            _ => None,
1223        };
1224        tokio::spawn(async move {
1225            let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
1226            let _ = tx.send(data);
1227        });
1228    }
1229
1230    /// Descends one level in the Unity Catalog tree. Returns false when the
1231    /// selection is a leaf (caller should open the detail view instead).
1232    pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1233        if self.focus != Panel::Catalog {
1234            return false;
1235        }
1236        let Some(item) = self.selected_item() else {
1237            return self.uc_path.is_empty(); // empty root pane: swallow the key
1238        };
1239        // Below the schema level only volumes and their directories are
1240        // containers; tables/views fall through to the detail view.
1241        if self.uc_path.len() >= 2 {
1242            let drillable =
1243                matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
1244            if !drillable {
1245                return false;
1246            }
1247        }
1248        self.uc_path.push(item.name.clone());
1249        self.refresh_catalog(cli);
1250        true
1251    }
1252
1253    /// Ascends one level; returns false if already at the catalog root.
1254    pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1255        if self.focus != Panel::Catalog || self.uc_path.is_empty() {
1256            return false;
1257        }
1258        self.uc_path.pop();
1259        self.refresh_catalog(cli);
1260        true
1261    }
1262
1263    fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
1264        self.shapes[5] = None;
1265        self.selected[5] = 0;
1266        // A filter typed at one level would silently hide the next.
1267        self.filters[5].clear();
1268        let (tx, rx) = oneshot::channel();
1269        self.uc_rx = Some(rx);
1270        let cli = Arc::clone(cli);
1271        let path = self.uc_path.clone();
1272        tokio::spawn(async move {
1273            let result = fetchers::catalog::fetch(&cli, &path)
1274                .await
1275                .map_err(|e| format!("{e:#}"));
1276            let _ = tx.send(result);
1277        });
1278    }
1279
1280    /// Enter in the Secrets pane: descend from a scope into its keys.
1281    /// On a key it does nothing — secret values are never displayed —
1282    /// but still returns true so Enter never opens a (bogus) detail view.
1283    pub fn secrets_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1284        if self.focus != Panel::Secrets {
1285            return false;
1286        }
1287        // Already inside a scope: the selection is a key, nothing to open.
1288        if self.secret_scope.is_some() {
1289            return true;
1290        }
1291        let Some(item) = self.selected_item() else {
1292            return true; // empty pane: swallow the key
1293        };
1294        self.secret_scope = Some(item.name.clone());
1295        self.refresh_secrets(cli);
1296        true
1297    }
1298
1299    /// Backspace inside a scope: back to the scopes listing.
1300    pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1301        if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1302            return false;
1303        }
1304        self.secret_scope = None;
1305        self.refresh_secrets(cli);
1306        true
1307    }
1308
1309    fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1310        let idx = 6;
1311        self.shapes[idx] = None;
1312        self.selected[idx] = 0;
1313        self.filters[idx].clear();
1314        let (tx, rx) = oneshot::channel();
1315        self.secrets_rx = Some(rx);
1316        let cli = Arc::clone(cli);
1317        let scope = self.secret_scope.clone();
1318        tokio::spawn(async move {
1319            let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1320                .await
1321                .map_err(|e| format!("{e:#}"));
1322            let _ = tx.send(result);
1323        });
1324    }
1325
1326    pub fn poll_secrets(&mut self) -> bool {
1327        let Some(rx) = &mut self.secrets_rx else {
1328            return false;
1329        };
1330        match rx.try_recv() {
1331            Ok(result) => {
1332                self.shapes[6] = Some(match result {
1333                    Ok(shape) => shape,
1334                    Err(e) => Shape::Text(format!("✗ {e}")),
1335                });
1336                self.updated_at[6] = Some(Instant::now());
1337                self.secrets_rx = None;
1338                true
1339            }
1340            Err(oneshot::error::TryRecvError::Empty) => false,
1341            Err(oneshot::error::TryRecvError::Closed) => {
1342                self.secrets_rx = None;
1343                true
1344            }
1345        }
1346    }
1347
1348    /// `a` in the secrets pane: create a scope (top level) or add a
1349    /// secret (inside a scope).
1350    pub fn open_secret_form(&mut self) {
1351        if self.focus != Panel::Secrets {
1352            return;
1353        }
1354        self.secret_form = Some(SecretForm {
1355            scope: self.secret_scope.clone(),
1356            key: String::new(),
1357            value: String::new(),
1358            stage: 0,
1359        });
1360    }
1361
1362    pub fn secret_form_push(&mut self, c: char) {
1363        if let Some(form) = &mut self.secret_form {
1364            if form.stage == 0 {
1365                form.key.push(c);
1366            } else {
1367                form.value.push(c);
1368            }
1369        }
1370    }
1371
1372    pub fn secret_form_pop(&mut self) {
1373        if let Some(form) = &mut self.secret_form {
1374            if form.stage == 0 {
1375                form.key.pop();
1376            } else {
1377                form.value.pop();
1378            }
1379        }
1380    }
1381
1382    /// Enter in the form: advance to the value stage, or submit.
1383    pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1384        let Some(form) = &mut self.secret_form else {
1385            return;
1386        };
1387        if form.key.trim().is_empty() {
1388            return;
1389        }
1390        match (form.scope.clone(), form.stage) {
1391            // New scope: single field.
1392            (None, _) => {
1393                let name = form.key.trim().to_string();
1394                self.secret_form = None;
1395                self.run_secret_action(
1396                    cli,
1397                    format!("Create scope “{name}”"),
1398                    vec!["secrets".into(), "create-scope".into(), name],
1399                );
1400            }
1401            // Secret: key first, then value.
1402            (Some(_), 0) => form.stage = 1,
1403            (Some(scope), _) => {
1404                let key = form.key.trim().to_string();
1405                let value = form.value.clone();
1406                self.secret_form = None;
1407                self.run_secret_action(
1408                    cli,
1409                    format!("Put secret “{key}” in “{scope}”"),
1410                    vec![
1411                        "secrets".into(),
1412                        "put-secret".into(),
1413                        scope,
1414                        key,
1415                        "--string-value".into(),
1416                        value,
1417                    ],
1418                );
1419            }
1420        }
1421    }
1422
1423    /// Runs a secrets mutation right away (the form itself was the
1424    /// deliberate step); the usual action poll refreshes the panes.
1425    fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1426        self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1427        let (tx, rx) = oneshot::channel();
1428        self.action_rx = Some(rx);
1429        let cli = Arc::clone(cli);
1430        tokio::spawn(async move {
1431            let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1432            let result = match cli.run_action(&arg_refs).await {
1433                Ok(()) => Ok(format!("✓ {label} — done")),
1434                Err(e) => Err(format!("✗ {e:#}")),
1435            };
1436            let _ = tx.send(result);
1437        });
1438    }
1439
1440    /// `x` in the secrets pane: delete the selected scope or key.
1441    pub fn request_secret_delete(&mut self) {
1442        if self.focus != Panel::Secrets {
1443            return;
1444        }
1445        let Some(item) = self.selected_item() else {
1446            return;
1447        };
1448        let name = item.name.clone();
1449        let (message, args) = match &self.secret_scope {
1450            None => (
1451                format!("Delete scope “{name}” and all its secrets?"),
1452                vec!["secrets".to_string(), "delete-scope".to_string(), name],
1453            ),
1454            Some(scope) => (
1455                format!("Delete secret “{name}” from “{scope}”?"),
1456                vec![
1457                    "secrets".to_string(),
1458                    "delete-secret".to_string(),
1459                    scope.clone(),
1460                    name,
1461                ],
1462            ),
1463        };
1464        self.confirm = Some(Confirm { message, args });
1465    }
1466
1467    /// `g` in the secrets pane: the scope's ACLs.
1468    fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1469        let scope = match &self.secret_scope {
1470            Some(s) => Some(s.clone()),
1471            None => self.selected_item().map(|i| i.name.clone()),
1472        };
1473        let Some(scope) = scope else {
1474            return;
1475        };
1476        self.detail = Some(Detail {
1477            panel: Panel::Secrets,
1478            name: scope.clone(),
1479            id: scope.clone(),
1480            kind: None,
1481            section: "Access",
1482            data: None,
1483            show_raw: false,
1484            scroll: 0,
1485        });
1486        let (tx, rx) = oneshot::channel();
1487        self.detail_rx = Some(rx);
1488        let cli = Arc::clone(cli);
1489        tokio::spawn(async move {
1490            let acl_args = ["secrets", "list-acls", &scope];
1491            let data = match cli.run(&acl_args).await {
1492                Ok(json) => {
1493                    let raw =
1494                        serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1495                    // The CLI unwraps to a bare array; REST wraps in "items".
1496                    let acls = json
1497                        .as_array()
1498                        .cloned()
1499                        .or_else(|| json["items"].as_array().cloned())
1500                        .unwrap_or_default();
1501                    let activity: Vec<(Status, String)> = acls
1502                        .iter()
1503                        .map(|a| {
1504                            let principal = a["principal"].as_str().unwrap_or("?");
1505                            let perm = a["permission"].as_str().unwrap_or("?");
1506                            let status = if perm == "MANAGE" {
1507                                Status::Success
1508                            } else {
1509                                Status::Unknown(String::new())
1510                            };
1511                            (status, format!("{principal}  ·  {perm}"))
1512                        })
1513                        .collect();
1514                    DetailData {
1515                        summary: vec![("Scope".to_string(), scope.clone())],
1516                        activity,
1517                        raw,
1518                    }
1519                }
1520                Err(e) => DetailData {
1521                    summary: Vec::new(),
1522                    activity: Vec::new(),
1523                    raw: format!("{e:#}"),
1524                },
1525            };
1526            let _ = tx.send(data);
1527        });
1528    }
1529
1530    /// Looks up a resource's display name by id in the loaded panes —
1531    /// lets the cost view show "nightly-etl" instead of a job id.
1532    pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1533        let idx = match kind {
1534            "cluster" => 0,
1535            "job" => 1,
1536            "warehouse" => 3,
1537            _ => return None,
1538        };
1539        match &self.shapes[idx] {
1540            Some(Shape::List(items)) => items
1541                .iter()
1542                .find(|i| i.id.as_deref() == Some(id))
1543                .map(|i| i.name.clone()),
1544            _ => None,
1545        }
1546    }
1547
1548    /// All known warehouses as (name, id, running).
1549    pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1550        let Some(Shape::List(items)) = &self.shapes[3] else {
1551            return Vec::new();
1552        };
1553        items
1554            .iter()
1555            .filter_map(|i| {
1556                let id = i.id.clone()?;
1557                Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1558            })
1559            .collect()
1560    }
1561
1562    /// Runs a sample-data query for the selected table or view. With
1563    /// `force_pick` (or several warehouses and no remembered choice) a
1564    /// warehouse picker opens first.
1565    pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
1566        if self.focus != Panel::Catalog {
1567            return;
1568        }
1569        let Some(item) = self.selected_item() else {
1570            return;
1571        };
1572        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1573            return;
1574        }
1575        let Some(full_name) = item.id.clone() else {
1576            return;
1577        };
1578        let warehouses = self.warehouses();
1579        if warehouses.is_empty() {
1580            self.flash = Some((
1581                "✗ no SQL warehouse available for previews".to_string(),
1582                Instant::now(),
1583            ));
1584            return;
1585        }
1586        if !force_pick {
1587            if let Some((id, name)) = self.preview_warehouse.clone() {
1588                self.start_preview_query(cli, full_name, id, name);
1589                return;
1590            }
1591            if let [(name, id, _)] = warehouses.as_slice() {
1592                self.preview_warehouse = Some((id.clone(), name.clone()));
1593                self.start_preview_query(cli, full_name, id.clone(), name.clone());
1594                return;
1595            }
1596        }
1597        // Default the cursor to the remembered choice, else a running warehouse.
1598        let index = self
1599            .preview_warehouse
1600            .as_ref()
1601            .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1602            .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1603            .unwrap_or(0);
1604        self.wh_picker = Some(WhPicker {
1605            index,
1606            target: PickTarget::Preview(full_name),
1607        });
1608    }
1609
1610    /// Opens the DBU usage view, resolving a warehouse like previews do.
1611    pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1612        let warehouses = self.warehouses();
1613        if warehouses.is_empty() {
1614            self.flash = Some((
1615                "✗ no SQL warehouse available to query system tables".to_string(),
1616                Instant::now(),
1617            ));
1618            return;
1619        }
1620        if let Some((id, name)) = self.preview_warehouse.clone() {
1621            self.start_cost_query(cli, id, name);
1622            return;
1623        }
1624        if let [(name, id, _)] = warehouses.as_slice() {
1625            self.preview_warehouse = Some((id.clone(), name.clone()));
1626            self.start_cost_query(cli, id.clone(), name.clone());
1627            return;
1628        }
1629        let index = warehouses
1630            .iter()
1631            .position(|(_, _, running)| *running)
1632            .unwrap_or(0);
1633        self.wh_picker = Some(WhPicker {
1634            index,
1635            target: PickTarget::Cost,
1636        });
1637    }
1638
1639    fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1640        self.cost = Some(CostView {
1641            warehouse: name,
1642            data: None,
1643        });
1644        let (tx, rx) = oneshot::channel();
1645        self.cost_rx = Some(rx);
1646        let cli = Arc::clone(cli);
1647        let host = self.host.clone();
1648        let cached_ws = self.workspace_id.clone();
1649        tokio::spawn(async move {
1650            // Scope usage to this workspace; resolved once, then cached.
1651            let ws = match (cached_ws, host) {
1652                (Some(w), _) => Some(w),
1653                (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1654                (None, None) => None,
1655            };
1656            let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1657            let _ = tx.send((result, ws));
1658        });
1659    }
1660
1661    /// Opens the lineage view for the selected table/view; needs a
1662    /// warehouse since lineage lives in system tables.
1663    pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1664        if self.focus != Panel::Catalog {
1665            return;
1666        }
1667        let Some(item) = self.selected_item() else {
1668            return;
1669        };
1670        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1671            return;
1672        }
1673        let Some(full_name) = item.id.clone() else {
1674            return;
1675        };
1676        let warehouses = self.warehouses();
1677        if warehouses.is_empty() {
1678            self.flash = Some((
1679                "✗ no SQL warehouse available to query lineage".to_string(),
1680                Instant::now(),
1681            ));
1682            return;
1683        }
1684        if let Some((id, _)) = self.preview_warehouse.clone() {
1685            self.start_lineage_query(cli, full_name, id);
1686            return;
1687        }
1688        if let [(name, id, _)] = warehouses.as_slice() {
1689            self.preview_warehouse = Some((id.clone(), name.clone()));
1690            let id = id.clone();
1691            self.start_lineage_query(cli, full_name, id);
1692            return;
1693        }
1694        let index = warehouses
1695            .iter()
1696            .position(|(_, _, running)| *running)
1697            .unwrap_or(0);
1698        self.wh_picker = Some(WhPicker {
1699            index,
1700            target: PickTarget::Lineage(full_name),
1701        });
1702    }
1703
1704    fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1705        self.detail = Some(Detail {
1706            panel: Panel::Catalog,
1707            name: full_name.clone(),
1708            id: full_name.clone(),
1709            kind: None,
1710            section: "Lineage",
1711            data: None,
1712            show_raw: false,
1713            scroll: 0,
1714        });
1715        let (tx, rx) = oneshot::channel();
1716        self.detail_rx = Some(rx);
1717        let cli = Arc::clone(cli);
1718        tokio::spawn(async move {
1719            let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1720            let _ = tx.send(data);
1721        });
1722    }
1723
1724    pub fn close_cost(&mut self) {
1725        self.cost = None;
1726        self.cost_rx = None;
1727    }
1728
1729    /// The fully-qualified name of the selected catalog-pane table/view.
1730    fn selected_table_fqn(&self) -> Option<String> {
1731        if self.focus != Panel::Catalog {
1732            return None;
1733        }
1734        let item = self.selected_item()?;
1735        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1736            return None;
1737        }
1738        item.id.clone()
1739    }
1740
1741    /// Opens the SQL console. With a table/view selected in the catalog
1742    /// pane, the prompt starts as an editable query against it.
1743    pub fn open_sql(&mut self) {
1744        if self.sql.is_none() {
1745            let input = self
1746                .selected_table_fqn()
1747                .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1748                .unwrap_or_default();
1749            self.sql = Some(SqlConsole {
1750                cursor: input.chars().count(),
1751                input,
1752                warehouse: String::new(),
1753                running: false,
1754                data: None,
1755                last_sql: String::new(),
1756                scroll: 0,
1757                col: 0,
1758            });
1759        }
1760    }
1761
1762    pub fn close_sql(&mut self) {
1763        self.sql = None;
1764        self.sql_rx = None;
1765        self.hist_idx = None;
1766        self.hist_draft.clear();
1767        self.hist_search = None;
1768        self.sql_complete = None;
1769    }
1770
1771    /// Tab in the SQL prompt: complete catalog / schema / table / column
1772    /// names from the workspace, fetching each level once per session.
1773    /// A repeat press cycles to the next candidate.
1774    pub fn sql_tab(&mut self, cli: &Arc<DatabricksCli>) {
1775        match &self.sql_complete {
1776            Some(c) if c.loading => return,
1777            Some(_) => {
1778                self.sql_complete_next(1);
1779                return;
1780            }
1781            None => {}
1782        }
1783        let Some(console) = &self.sql else {
1784            return;
1785        };
1786        let (seg_start, context, prefix) = token_at_cursor(&console.input, console.cursor);
1787        // Which cache entry is missing: column names of the FROM table
1788        // matter most for a bare word, then the level being dotted into.
1789        let missing = if context.is_empty() {
1790            from_table(&console.input)
1791                .filter(|fqn| !self.uc_names.contains_key(fqn))
1792                .or_else(|| (!self.uc_names.contains_key("")).then(String::new))
1793        } else {
1794            (!self.uc_names.contains_key(&context)).then(|| context.clone())
1795        };
1796        let loading = missing.is_some();
1797        self.sql_complete = Some(SqlComplete {
1798            items: Vec::new(),
1799            index: 0,
1800            seg_start,
1801            prefix,
1802            context,
1803            loading,
1804        });
1805        match missing {
1806            Some(path) => self.fetch_uc_names(cli, path),
1807            None => self.sql_complete_fill(),
1808        }
1809    }
1810
1811    /// Builds the candidate list from the cache and inserts the first
1812    /// match; single matches complete silently, no popup.
1813    fn sql_complete_fill(&mut self) {
1814        let Some(console) = &self.sql else {
1815            self.sql_complete = None;
1816            return;
1817        };
1818        let input = console.input.clone();
1819        let Some(comp) = &self.sql_complete else {
1820            return;
1821        };
1822        let q = comp.prefix.to_lowercase();
1823        let mut items: Vec<String> = Vec::new();
1824        if comp.context.is_empty() {
1825            if let Some(cols) = from_table(&input).and_then(|fqn| self.uc_names.get(&fqn)) {
1826                items.extend(
1827                    cols.iter()
1828                        .filter(|n| n.to_lowercase().starts_with(&q))
1829                        .cloned(),
1830                );
1831            }
1832            if let Some(cats) = self.uc_names.get("") {
1833                items.extend(
1834                    cats.iter()
1835                        .filter(|n| n.to_lowercase().starts_with(&q))
1836                        .cloned(),
1837                );
1838            }
1839            if !q.is_empty() {
1840                items.extend(
1841                    SQL_KEYWORDS
1842                        .iter()
1843                        .filter(|k| k.to_lowercase().starts_with(&q))
1844                        .map(|k| k.to_string()),
1845                );
1846            }
1847        } else if let Some(names) = self.uc_names.get(&comp.context) {
1848            items.extend(
1849                names
1850                    .iter()
1851                    .filter(|n| n.to_lowercase().starts_with(&q))
1852                    .cloned(),
1853            );
1854        }
1855        items.dedup();
1856        if items.is_empty() {
1857            self.sql_complete = None;
1858            self.flash = Some(("no completions".to_string(), Instant::now()));
1859            return;
1860        }
1861        let single = items.len() == 1;
1862        if let Some(comp) = &mut self.sql_complete {
1863            comp.items = items;
1864            comp.index = 0;
1865        }
1866        self.sql_complete_apply();
1867        if single {
1868            self.sql_complete = None;
1869        }
1870    }
1871
1872    /// Replaces the completed segment with the current candidate.
1873    fn sql_complete_apply(&mut self) {
1874        let Some(comp) = &self.sql_complete else {
1875            return;
1876        };
1877        let candidate = comp.items[comp.index].clone();
1878        let plain = candidate
1879            .chars()
1880            .all(|c| c.is_alphanumeric() || matches!(c, '_' | ' '));
1881        let text = if plain {
1882            candidate
1883        } else {
1884            format!("`{candidate}`")
1885        };
1886        self.sql_replace_segment(comp.seg_start, &text);
1887    }
1888
1889    /// Overwrites seg_start..caret with `text`, caret landing at its end.
1890    fn sql_replace_segment(&mut self, seg_start: usize, text: &str) {
1891        if let Some(console) = &mut self.sql {
1892            let from = byte_at(&console.input, seg_start);
1893            let to = byte_at(&console.input, console.cursor);
1894            console.input.replace_range(from..to, text);
1895            console.cursor = seg_start + text.chars().count();
1896        }
1897    }
1898
1899    /// Tab / shift+tab with the popup open: cycle candidates.
1900    pub fn sql_complete_next(&mut self, delta: i32) {
1901        let Some(comp) = &mut self.sql_complete else {
1902            return;
1903        };
1904        if comp.items.is_empty() {
1905            return;
1906        }
1907        let n = comp.items.len() as i32;
1908        comp.index = (comp.index as i32 + delta).rem_euclid(n) as usize;
1909        self.sql_complete_apply();
1910    }
1911
1912    /// Esc: restore the typed prefix and close the popup.
1913    pub fn sql_complete_cancel(&mut self) {
1914        if let Some(comp) = &self.sql_complete {
1915            let (seg_start, prefix) = (comp.seg_start, comp.prefix.clone());
1916            self.sql_replace_segment(seg_start, &prefix);
1917        }
1918        self.sql_complete = None;
1919    }
1920
1921    /// Keeps the inserted candidate and closes the popup.
1922    pub fn sql_complete_accept(&mut self) {
1923        self.sql_complete = None;
1924    }
1925
1926    fn fetch_uc_names(&mut self, cli: &Arc<DatabricksCli>, path: String) {
1927        let (tx, rx) = oneshot::channel();
1928        self.uc_names_rx = Some(rx);
1929        let cli = Arc::clone(cli);
1930        tokio::spawn(async move {
1931            let names = fetchers::catalog::names(&cli, &path)
1932                .await
1933                .map_err(|e| format!("{e:#}"));
1934            let _ = tx.send((path, names));
1935        });
1936    }
1937
1938    /// Caches fetched completion names and fills the waiting popup.
1939    pub fn poll_uc_names(&mut self) -> bool {
1940        let Some(rx) = &mut self.uc_names_rx else {
1941            return false;
1942        };
1943        match rx.try_recv() {
1944            Ok((path, result)) => {
1945                self.uc_names_rx = None;
1946                match result {
1947                    Ok(names) => {
1948                        self.uc_names.insert(path, names);
1949                        if self.sql_complete.as_ref().is_some_and(|c| c.loading) {
1950                            if let Some(c) = &mut self.sql_complete {
1951                                c.loading = false;
1952                            }
1953                            self.sql_complete_fill();
1954                        }
1955                    }
1956                    Err(e) => {
1957                        self.sql_complete = None;
1958                        let first = e.lines().next().unwrap_or("fetch failed").to_string();
1959                        self.flash = Some((format!("✗ completions: {first}"), Instant::now()));
1960                    }
1961                }
1962                true
1963            }
1964            Err(oneshot::error::TryRecvError::Empty) => false,
1965            Err(oneshot::error::TryRecvError::Closed) => {
1966                self.uc_names_rx = None;
1967                self.sql_complete = None;
1968                true
1969            }
1970        }
1971    }
1972
1973    /// The statement currently in the prompt.
1974    pub fn sql_input(&self) -> Option<String> {
1975        self.sql.as_ref().map(|c| c.input.clone())
1976    }
1977
1978    /// Replaces the prompt contents (after an $EDITOR round-trip).
1979    pub fn sql_set_input(&mut self, s: &str) {
1980        if let Some(console) = &mut self.sql {
1981            console.input = s.to_string();
1982            console.cursor = console.input.chars().count();
1983        }
1984    }
1985
1986    /// The history entry the active Ctrl+R search currently matches.
1987    pub fn hist_search_current(&self) -> Option<&String> {
1988        let (query, nth) = self.hist_search.as_ref()?;
1989        self.sql_history
1990            .iter()
1991            .rev()
1992            .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
1993            .nth(*nth)
1994    }
1995
1996    pub fn hist_search_start(&mut self) {
1997        if self.sql.is_some() {
1998            self.hist_search = Some((String::new(), 0));
1999        }
2000    }
2001
2002    pub fn hist_search_push(&mut self, c: char) {
2003        if let Some((query, nth)) = &mut self.hist_search {
2004            query.push(c);
2005            *nth = 0;
2006        }
2007    }
2008
2009    pub fn hist_search_pop(&mut self) {
2010        if let Some((query, nth)) = &mut self.hist_search {
2011            query.pop();
2012            *nth = 0;
2013        }
2014    }
2015
2016    /// Ctrl+R again: step to the next older match.
2017    pub fn hist_search_older(&mut self) {
2018        let Some((query, nth)) = &self.hist_search else {
2019            return;
2020        };
2021        let q = query.to_lowercase();
2022        let matches = self
2023            .sql_history
2024            .iter()
2025            .filter(|h| h.to_lowercase().contains(&q))
2026            .count();
2027        if nth + 1 < matches {
2028            if let Some((_, n)) = &mut self.hist_search {
2029                *n += 1;
2030            }
2031        }
2032    }
2033
2034    pub fn hist_search_accept(&mut self) {
2035        if let Some(stmt) = self.hist_search_current().cloned() {
2036            self.sql_set_input(&stmt);
2037        }
2038        self.hist_search = None;
2039    }
2040
2041    pub fn hist_search_cancel(&mut self) {
2042        self.hist_search = None;
2043    }
2044
2045    pub fn sql_push(&mut self, c: char) {
2046        if let Some(console) = &mut self.sql {
2047            let at = byte_at(&console.input, console.cursor);
2048            console.input.insert(at, c);
2049            console.cursor += 1;
2050        }
2051    }
2052
2053    /// Backspace: deletes the character before the caret.
2054    pub fn sql_pop(&mut self) {
2055        if let Some(console) = &mut self.sql {
2056            if console.cursor > 0 {
2057                let at = byte_at(&console.input, console.cursor - 1);
2058                console.input.remove(at);
2059                console.cursor -= 1;
2060            }
2061        }
2062    }
2063
2064    /// Delete: removes the character under the caret.
2065    pub fn sql_delete(&mut self) {
2066        if let Some(console) = &mut self.sql {
2067            if console.cursor < console.input.chars().count() {
2068                let at = byte_at(&console.input, console.cursor);
2069                console.input.remove(at);
2070            }
2071        }
2072    }
2073
2074    pub fn sql_left(&mut self) {
2075        if let Some(console) = &mut self.sql {
2076            console.cursor = console.cursor.saturating_sub(1);
2077        }
2078    }
2079
2080    pub fn sql_right(&mut self) {
2081        if let Some(console) = &mut self.sql {
2082            console.cursor = (console.cursor + 1).min(console.input.chars().count());
2083        }
2084    }
2085
2086    /// ↑ at the prompt: step back through history, stashing the draft.
2087    pub fn sql_hist_prev(&mut self) {
2088        let Some(console) = &mut self.sql else {
2089            return;
2090        };
2091        if self.sql_history.is_empty() {
2092            return;
2093        }
2094        let idx = match self.hist_idx {
2095            None => {
2096                self.hist_draft = console.input.clone();
2097                self.sql_history.len() - 1
2098            }
2099            Some(i) => i.saturating_sub(1),
2100        };
2101        self.hist_idx = Some(idx);
2102        console.input = self.sql_history[idx].clone();
2103        console.cursor = console.input.chars().count();
2104    }
2105
2106    /// ↓ at the prompt: step forward, back to the stashed draft at the end.
2107    pub fn sql_hist_next(&mut self) {
2108        let Some(console) = &mut self.sql else {
2109            return;
2110        };
2111        let Some(idx) = self.hist_idx else {
2112            return;
2113        };
2114        if idx + 1 < self.sql_history.len() {
2115            self.hist_idx = Some(idx + 1);
2116            console.input = self.sql_history[idx + 1].clone();
2117        } else {
2118            self.hist_idx = None;
2119            console.input = self.hist_draft.clone();
2120        }
2121        console.cursor = console.input.chars().count();
2122    }
2123
2124    pub fn sql_home(&mut self) {
2125        if let Some(console) = &mut self.sql {
2126            console.cursor = 0;
2127        }
2128    }
2129
2130    pub fn sql_end(&mut self) {
2131        if let Some(console) = &mut self.sql {
2132            console.cursor = console.input.chars().count();
2133        }
2134    }
2135
2136    pub fn sql_scroll(&mut self, delta: i32) {
2137        if let Some(console) = &mut self.sql {
2138            let max = match &console.data {
2139                Some(Ok(t)) => t.rows.len().saturating_sub(1),
2140                _ => 0,
2141            };
2142            console.scroll = if delta < 0 {
2143                console.scroll.saturating_sub(delta.unsigned_abs() as usize)
2144            } else {
2145                (console.scroll + delta as usize).min(max)
2146            };
2147        }
2148    }
2149
2150    /// Shift+←/→ in the console: page result columns.
2151    pub fn sql_cols(&mut self, delta: i32) {
2152        if let Some(console) = &mut self.sql {
2153            let n = match &console.data {
2154                Some(Ok(t)) => t.headers.len(),
2155                _ => 0,
2156            };
2157            console.col = if delta < 0 {
2158                console.col.saturating_sub(1)
2159            } else {
2160                (console.col + 1).min(n.saturating_sub(1))
2161            };
2162        }
2163    }
2164
2165    /// Runs the typed statement, resolving a warehouse like previews do.
2166    pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
2167        let Some(console) = &self.sql else {
2168            return;
2169        };
2170        if console.running {
2171            return;
2172        }
2173        let query = console.input.trim().to_string();
2174        if query.is_empty() {
2175            return;
2176        }
2177        // Remember the statement (skipping immediate repeats) and reset
2178        // any in-progress history browsing.
2179        if self.sql_history.last() != Some(&query) {
2180            self.sql_history.push(query.clone());
2181            save_history(&self.sql_history);
2182        }
2183        self.hist_idx = None;
2184        self.hist_draft.clear();
2185        let warehouses = self.warehouses();
2186        if warehouses.is_empty() {
2187            self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
2188            return;
2189        }
2190        if let Some((id, name)) = self.preview_warehouse.clone() {
2191            self.start_sql_query(cli, query, id, name);
2192            return;
2193        }
2194        if let [(name, id, _)] = warehouses.as_slice() {
2195            self.preview_warehouse = Some((id.clone(), name.clone()));
2196            self.start_sql_query(cli, query, id.clone(), name.clone());
2197            return;
2198        }
2199        let index = warehouses
2200            .iter()
2201            .position(|(_, _, running)| *running)
2202            .unwrap_or(0);
2203        self.wh_picker = Some(WhPicker {
2204            index,
2205            target: PickTarget::Sql(query),
2206        });
2207    }
2208
2209    fn start_sql_query(
2210        &mut self,
2211        cli: &Arc<DatabricksCli>,
2212        query: String,
2213        id: String,
2214        name: String,
2215    ) {
2216        if let Some(console) = &mut self.sql {
2217            console.running = true;
2218            console.warehouse = name;
2219            console.scroll = 0;
2220            console.last_sql = query.clone();
2221        }
2222        // Published by the task once submitted, so Esc can cancel it.
2223        let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
2224        self.sql_stmt = Some(std::sync::Arc::clone(&handle));
2225        let (tx, rx) = oneshot::channel();
2226        self.sql_rx = Some(rx);
2227        let cli = Arc::clone(cli);
2228        tokio::spawn(async move {
2229            let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
2230            let _ = tx.send(result);
2231        });
2232    }
2233
2234    /// Writes a result set to a timestamped CSV in the working directory
2235    /// and flashes the path.
2236    fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
2237        let stamp = std::time::SystemTime::now()
2238            .duration_since(std::time::UNIX_EPOCH)
2239            .map(|d| d.as_secs())
2240            .unwrap_or(0);
2241        let slug: String = label
2242            .chars()
2243            .map(|c| if c.is_alphanumeric() { c } else { '-' })
2244            .collect::<String>()
2245            .trim_matches('-')
2246            .chars()
2247            .take(40)
2248            .collect();
2249        let name = format!("databricks-{slug}-{stamp}.csv");
2250        let msg = match std::fs::write(&name, data.to_csv()) {
2251            Ok(()) => {
2252                let cwd = std::env::current_dir()
2253                    .map(|d| d.display().to_string())
2254                    .unwrap_or_default();
2255                format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
2256            }
2257            Err(e) => format!("✗ export failed: {e}"),
2258        };
2259        self.flash = Some((msg, Instant::now()));
2260    }
2261
2262    /// Ctrl+S in the console: export the current results.
2263    pub fn sql_export(&mut self) {
2264        if let Some(SqlConsole {
2265            data: Some(Ok(data)),
2266            last_sql,
2267            ..
2268        }) = &self.sql
2269        {
2270            let (label, data) = (last_sql.clone(), data.clone());
2271            self.export_csv(&label, &data);
2272        }
2273    }
2274
2275    /// ←/→ in a preview: page columns in the grid, switch rows in
2276    /// record view.
2277    pub fn preview_h(&mut self, delta: i32) {
2278        let Some(pv) = &mut self.preview else {
2279            return;
2280        };
2281        if pv.record {
2282            let max = match &pv.data {
2283                Some(Ok(t)) => t.rows.len().saturating_sub(1),
2284                _ => 0,
2285            };
2286            pv.scroll = if delta < 0 {
2287                pv.scroll.saturating_sub(1)
2288            } else {
2289                (pv.scroll + 1).min(max)
2290            };
2291            pv.rscroll = 0;
2292        } else {
2293            let cols = pv.visible_cols().len();
2294            pv.col = if delta < 0 {
2295                pv.col.saturating_sub(1)
2296            } else {
2297                (pv.col + 1).min(cols.saturating_sub(1))
2298            };
2299        }
2300    }
2301
2302    /// `v`/enter in a preview: transposed view of the top visible row.
2303    pub fn preview_toggle_record(&mut self) {
2304        if let Some(pv) = &mut self.preview {
2305            if matches!(&pv.data, Some(Ok(t)) if !t.rows.is_empty()) {
2306                pv.record = !pv.record;
2307                pv.rscroll = 0;
2308            }
2309        }
2310    }
2311
2312    pub fn preview_filter_start(&mut self) {
2313        if let Some(pv) = &mut self.preview {
2314            pv.filter.clear();
2315            pv.filter_entry = true;
2316            pv.col = 0;
2317            pv.rscroll = 0;
2318        }
2319    }
2320
2321    pub fn preview_filter_push(&mut self, c: char) {
2322        if let Some(pv) = &mut self.preview {
2323            pv.filter.push(c);
2324            pv.col = 0;
2325            pv.rscroll = 0;
2326        }
2327    }
2328
2329    pub fn preview_filter_pop(&mut self) {
2330        if let Some(pv) = &mut self.preview {
2331            pv.filter.pop();
2332            pv.col = 0;
2333        }
2334    }
2335
2336    pub fn preview_filter_accept(&mut self) {
2337        if let Some(pv) = &mut self.preview {
2338            pv.filter_entry = false;
2339        }
2340    }
2341
2342    pub fn preview_filter_clear(&mut self) {
2343        if let Some(pv) = &mut self.preview {
2344            pv.filter.clear();
2345            pv.filter_entry = false;
2346            pv.col = 0;
2347            pv.rscroll = 0;
2348        }
2349    }
2350
2351    /// `e` in a table preview: export the sampled rows.
2352    pub fn preview_export(&mut self) {
2353        if let Some(Preview {
2354            data: Some(Ok(data)),
2355            name,
2356            ..
2357        }) = &self.preview
2358        {
2359            let (label, data) = (name.clone(), data.clone());
2360            self.export_csv(&label, &data);
2361        }
2362    }
2363
2364    pub fn poll_sql(&mut self) -> bool {
2365        let Some(rx) = &mut self.sql_rx else {
2366            return false;
2367        };
2368        match rx.try_recv() {
2369            Ok(result) => {
2370                // A warehouse that errors shouldn't stay the session
2371                // default — but a user-canceled statement is not its fault.
2372                if let Err(e) = &result {
2373                    if e != "statement canceled" {
2374                        self.preview_warehouse = None;
2375                    }
2376                }
2377                if let Some(console) = &mut self.sql {
2378                    console.running = false;
2379                    console.data = Some(result);
2380                    console.col = 0;
2381                }
2382                self.sql_rx = None;
2383                self.sql_stmt = None;
2384                true
2385            }
2386            Err(oneshot::error::TryRecvError::Empty) => false,
2387            Err(oneshot::error::TryRecvError::Closed) => {
2388                if let Some(console) = &mut self.sql {
2389                    console.running = false;
2390                }
2391                self.sql_rx = None;
2392                true
2393            }
2394        }
2395    }
2396
2397    pub fn poll_cost(&mut self) -> bool {
2398        let Some(rx) = &mut self.cost_rx else {
2399            return false;
2400        };
2401        match rx.try_recv() {
2402            Ok((result, ws)) => {
2403                if result.is_err() {
2404                    self.preview_warehouse = None;
2405                }
2406                if ws.is_some() {
2407                    self.workspace_id = ws;
2408                }
2409                if let Some(cv) = &mut self.cost {
2410                    cv.data = Some(result);
2411                }
2412                self.cost_rx = None;
2413                true
2414            }
2415            Err(oneshot::error::TryRecvError::Empty) => false,
2416            Err(oneshot::error::TryRecvError::Closed) => {
2417                self.cost_rx = None;
2418                true
2419            }
2420        }
2421    }
2422
2423    pub fn wh_picker_next(&mut self) {
2424        let len = self.warehouses().len();
2425        if let Some(p) = &mut self.wh_picker {
2426            p.index = (p.index + 1).min(len.saturating_sub(1));
2427        }
2428    }
2429
2430    pub fn wh_picker_prev(&mut self) {
2431        if let Some(p) = &mut self.wh_picker {
2432            p.index = p.index.saturating_sub(1);
2433        }
2434    }
2435
2436    pub fn wh_picker_cancel(&mut self) {
2437        self.wh_picker = None;
2438    }
2439
2440    /// Confirms the warehouse choice, remembers it, and starts the preview.
2441    pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
2442        let Some(picker) = self.wh_picker.take() else {
2443            return;
2444        };
2445        let warehouses = self.warehouses();
2446        let Some((name, id, _)) = warehouses.get(picker.index) else {
2447            return;
2448        };
2449        self.preview_warehouse = Some((id.clone(), name.clone()));
2450        // An explicit choice is worth remembering across sessions.
2451        let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
2452        self.config
2453            .warehouses
2454            .insert(profile, (id.clone(), name.clone()));
2455        self.config.save();
2456        match picker.target {
2457            PickTarget::Preview(table) => {
2458                self.start_preview_query(cli, table, id.clone(), name.clone())
2459            }
2460            PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
2461            PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
2462            PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
2463        }
2464    }
2465
2466    fn start_preview_query(
2467        &mut self,
2468        cli: &Arc<DatabricksCli>,
2469        full_name: String,
2470        warehouse_id: String,
2471        warehouse_name: String,
2472    ) {
2473        self.preview = Some(Preview {
2474            name: full_name.clone(),
2475            warehouse: warehouse_name,
2476            warehouse_id: warehouse_id.clone(),
2477            data: None,
2478            scroll: 0,
2479            col: 0,
2480            filter: String::new(),
2481            filter_entry: false,
2482            record: false,
2483            rscroll: 0,
2484        });
2485        let (tx, rx) = oneshot::channel();
2486        self.preview_rx = Some(rx);
2487        let cli = Arc::clone(cli);
2488        tokio::spawn(async move {
2489            let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
2490            let _ = tx.send(result);
2491        });
2492    }
2493
2494    pub fn close_preview(&mut self) {
2495        self.preview = None;
2496        self.preview_rx = None;
2497    }
2498
2499    pub fn poll_preview(&mut self) -> bool {
2500        let Some(rx) = &mut self.preview_rx else {
2501            return false;
2502        };
2503        match rx.try_recv() {
2504            Ok(result) => {
2505                // A warehouse that errors shouldn't stay the session default.
2506                if result.is_err() {
2507                    self.preview_warehouse = None;
2508                }
2509                if let Some(pv) = &mut self.preview {
2510                    pv.data = Some(result);
2511                }
2512                self.preview_rx = None;
2513                true
2514            }
2515            Err(oneshot::error::TryRecvError::Empty) => false,
2516            Err(oneshot::error::TryRecvError::Closed) => {
2517                self.preview_rx = None;
2518                true
2519            }
2520        }
2521    }
2522
2523    pub fn preview_scroll(&mut self, delta: i32) {
2524        if let Some(pv) = &mut self.preview {
2525            // Record view: j/k walk the fields, not the rows.
2526            if pv.record {
2527                let max = pv.visible_cols().len().saturating_sub(1) as u16;
2528                pv.rscroll = if delta < 0 {
2529                    pv.rscroll.saturating_sub(delta.unsigned_abs() as u16)
2530                } else {
2531                    pv.rscroll.saturating_add(delta as u16).min(max)
2532                };
2533                return;
2534            }
2535            let max = match &pv.data {
2536                Some(Ok(t)) => t.rows.len().saturating_sub(1),
2537                _ => 0,
2538            };
2539            pv.scroll = if delta < 0 {
2540                pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
2541            } else {
2542                (pv.scroll + delta as usize).min(max)
2543            };
2544        }
2545    }
2546
2547    pub fn poll_uc(&mut self) -> bool {
2548        let Some(rx) = &mut self.uc_rx else {
2549            return false;
2550        };
2551        match rx.try_recv() {
2552            Ok(result) => {
2553                self.shapes[5] = Some(match result {
2554                    Ok(shape) => shape,
2555                    Err(e) => Shape::Text(format!("✗ {e}")),
2556                });
2557                self.updated_at[5] = Some(Instant::now());
2558                self.uc_rx = None;
2559                true
2560            }
2561            Err(oneshot::error::TryRecvError::Empty) => false,
2562            Err(oneshot::error::TryRecvError::Closed) => {
2563                self.uc_rx = None;
2564                true
2565            }
2566        }
2567    }
2568
2569    /// Opens the access view for the selected item: effective UC grants
2570    /// or the workspace object ACL.
2571    pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
2572        if self.focus == Panel::Secrets {
2573            return self.open_secret_acls(cli);
2574        }
2575        let Some(item) = self.selected_item() else {
2576            return;
2577        };
2578        let Some(id) = item.id.clone() else {
2579            return;
2580        };
2581        let (uc, object_type): (bool, &'static str) = match self.focus {
2582            Panel::Catalog => match &item.status {
2583                Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
2584                Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
2585                Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
2586                Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
2587                _ => return,
2588            },
2589            Panel::Clusters => (false, "clusters"),
2590            Panel::Jobs => (false, "jobs"),
2591            Panel::Pipelines => (false, "pipelines"),
2592            Panel::Warehouses => (false, "warehouses"),
2593            Panel::Dashboards => (false, "dashboards"),
2594            // Secrets ACLs are handled by open_secret_acls above.
2595            Panel::Secrets => return,
2596        };
2597        self.detail = Some(Detail {
2598            panel: self.focus,
2599            name: item.name.clone(),
2600            id: id.clone(),
2601            kind: None,
2602            section: "Access",
2603            data: None,
2604            show_raw: false,
2605            scroll: 0,
2606        });
2607        let (tx, rx) = oneshot::channel();
2608        self.detail_rx = Some(rx);
2609        let cli = Arc::clone(cli);
2610        tokio::spawn(async move {
2611            let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
2612            let _ = tx.send(data);
2613        });
2614    }
2615
2616    /// Drills from an open job or pipeline detail into its most recent
2617    /// run/update.
2618    pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
2619        let Some(d) = &self.detail else {
2620            return;
2621        };
2622        let panel = d.panel;
2623        if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
2624            return;
2625        }
2626        let owner_id = d.id.clone();
2627        self.run_view = Some(RunView {
2628            panel,
2629            owner_name: d.name.clone(),
2630            owner_id: owner_id.clone(),
2631            runs: Vec::new(),
2632            idx: 0,
2633            data: None,
2634            show_raw: false,
2635            scroll: 0,
2636            live: false,
2637            output: None,
2638            show_output: false,
2639            show_timeline: false,
2640            fetched_at: Instant::now(),
2641        });
2642        let (tx, rx) = oneshot::channel();
2643        self.run_rx = Some(rx);
2644        let cli = Arc::clone(cli);
2645        tokio::spawn(async move {
2646            let result = async {
2647                let runs = if panel == Panel::Jobs {
2648                    fetchers::runs::list(&cli, &owner_id).await?
2649                } else {
2650                    fetchers::updates::list(&cli, &owner_id).await?
2651                };
2652                let Some((run_id, _, _)) = runs.first().cloned() else {
2653                    return Err("no runs recorded yet".to_string());
2654                };
2655                let (data, live) = if panel == Panel::Jobs {
2656                    fetchers::runs::fetch(&cli, &run_id).await
2657                } else {
2658                    fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2659                };
2660                Ok((runs, data, live))
2661            }
2662            .await;
2663            let _ = tx.send(RunUpdate::Opened(result));
2664        });
2665    }
2666
2667    pub fn close_run(&mut self) {
2668        self.run_view = None;
2669        self.run_rx = None;
2670    }
2671
2672    /// Moves to an older (delta > 0) or newer (delta < 0) run.
2673    pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
2674        if self.run_rx.is_some() {
2675            return;
2676        }
2677        let Some(rv) = &mut self.run_view else {
2678            return;
2679        };
2680        if rv.runs.is_empty() {
2681            return;
2682        }
2683        let new = if delta < 0 {
2684            rv.idx.saturating_sub(delta.unsigned_abs() as usize)
2685        } else {
2686            (rv.idx + delta as usize).min(rv.runs.len() - 1)
2687        };
2688        if new == rv.idx {
2689            return;
2690        }
2691        rv.idx = new;
2692        rv.data = None;
2693        rv.scroll = 0;
2694        rv.show_raw = false;
2695        rv.output = None;
2696        rv.show_output = false;
2697        let run_id = rv.runs[new].0.clone();
2698        self.start_run_fetch(cli, run_id);
2699    }
2700
2701    fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
2702        let Some(rv) = &self.run_view else {
2703            return;
2704        };
2705        let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
2706        let (tx, rx) = oneshot::channel();
2707        self.run_rx = Some(rx);
2708        let cli = Arc::clone(cli);
2709        tokio::spawn(async move {
2710            let (data, live) = if panel == Panel::Jobs {
2711                fetchers::runs::fetch(&cli, &run_id).await
2712            } else {
2713                fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2714            };
2715            let _ = tx.send(RunUpdate::Detail(data, live));
2716        });
2717    }
2718
2719    /// `o` in the run view: toggles the full output/log view, fetching
2720    /// all task outputs on first use.
2721    pub fn run_toggle_output(&mut self, cli: &Arc<DatabricksCli>) {
2722        let Some(rv) = &mut self.run_view else {
2723            return;
2724        };
2725        if rv.panel != Panel::Jobs {
2726            self.flash = Some((
2727                "✗ output view is for job runs — pipeline events are already inline".to_string(),
2728                Instant::now(),
2729            ));
2730            return;
2731        }
2732        if rv.show_output {
2733            rv.show_output = false;
2734            rv.scroll = 0;
2735            return;
2736        }
2737        if rv.output.is_none() && self.run_rx.is_some() {
2738            self.flash = Some((
2739                "⏳ run still loading — try again in a moment".to_string(),
2740                Instant::now(),
2741            ));
2742            return;
2743        }
2744        rv.show_output = true;
2745        rv.scroll = 0;
2746        if rv.output.is_some() {
2747            return;
2748        }
2749        let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() else {
2750            return;
2751        };
2752        let (tx, rx) = oneshot::channel();
2753        self.run_rx = Some(rx);
2754        let cli = Arc::clone(cli);
2755        tokio::spawn(async move {
2756            let text = fetchers::runs::full_output(&cli, &run_id).await;
2757            let _ = tx.send(RunUpdate::Output(text));
2758        });
2759    }
2760
2761    /// `r` in the run view: rerun only the failed tasks of the shown run.
2762    pub fn request_run_repair(&mut self) {
2763        let Some(rv) = &self.run_view else {
2764            return;
2765        };
2766        if rv.panel != Panel::Jobs {
2767            self.flash = Some((
2768                "✗ repair applies to job runs only".to_string(),
2769                Instant::now(),
2770            ));
2771            return;
2772        }
2773        if rv.live {
2774            self.flash = Some((
2775                "✗ run is still executing — cancel it first (s)".to_string(),
2776                Instant::now(),
2777            ));
2778            return;
2779        }
2780        let Some((run_id, status, _)) = rv.runs.get(rv.idx) else {
2781            return;
2782        };
2783        if matches!(status, Status::Success) {
2784            self.flash = Some((
2785                "✗ run succeeded — nothing to repair".to_string(),
2786                Instant::now(),
2787            ));
2788            return;
2789        }
2790        self.confirm = Some(Confirm {
2791            message: format!(
2792                "Repair run {run_id} of “{}” (reruns only the failed tasks)?",
2793                rv.owner_name
2794            ),
2795            args: vec![
2796                "jobs".to_string(),
2797                "repair-run".to_string(),
2798                run_id.clone(),
2799                "--rerun-all-failed-tasks".to_string(),
2800            ],
2801        });
2802    }
2803
2804    /// `t` in the run view: per-task execution timeline of a job run.
2805    pub fn run_toggle_timeline(&mut self) {
2806        let Some(rv) = &mut self.run_view else {
2807            return;
2808        };
2809        if rv.panel != Panel::Jobs {
2810            self.flash = Some((
2811                "✗ timeline is for job runs — pipeline events are already inline".to_string(),
2812                Instant::now(),
2813            ));
2814            return;
2815        }
2816        rv.show_timeline = !rv.show_timeline;
2817        rv.scroll = 0;
2818    }
2819
2820    pub fn run_toggle_raw(&mut self) {
2821        if let Some(rv) = &mut self.run_view {
2822            rv.show_raw = !rv.show_raw;
2823            rv.scroll = 0;
2824        }
2825    }
2826
2827    pub fn run_scroll(&mut self, delta: i32) {
2828        if let Some(rv) = &mut self.run_view {
2829            rv.scroll = if delta < 0 {
2830                rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
2831            } else {
2832                rv.scroll.saturating_add(delta as u16)
2833            };
2834        }
2835    }
2836
2837    /// Applies run fetch results; also re-polls a live run every few
2838    /// seconds so an executing run's tasks update on their own.
2839    pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2840        if let Some(rx) = &mut self.run_rx {
2841            match rx.try_recv() {
2842                Ok(update) => {
2843                    self.run_rx = None;
2844                    if let Some(rv) = &mut self.run_view {
2845                        match update {
2846                            RunUpdate::Opened(Ok((runs, data, live))) => {
2847                                rv.runs = runs;
2848                                rv.idx = 0;
2849                                rv.data = Some(data);
2850                                rv.live = live;
2851                            }
2852                            RunUpdate::Opened(Err(e)) => {
2853                                rv.data = Some(DetailData {
2854                                    summary: Vec::new(),
2855                                    activity: Vec::new(),
2856                                    raw: format!("✗ {e}"),
2857                                });
2858                                rv.live = false;
2859                            }
2860                            RunUpdate::Detail(data, live) => {
2861                                rv.data = Some(data);
2862                                rv.live = live;
2863                            }
2864                            RunUpdate::Output(text) => {
2865                                rv.output = Some(text);
2866                            }
2867                        }
2868                        rv.fetched_at = Instant::now();
2869                    }
2870                    true
2871                }
2872                Err(oneshot::error::TryRecvError::Empty) => false,
2873                Err(oneshot::error::TryRecvError::Closed) => {
2874                    self.run_rx = None;
2875                    true
2876                }
2877            }
2878        } else if let Some(rv) = &self.run_view {
2879            // No auto-refresh while reading logs — it would churn run_rx.
2880            if rv.live
2881                && !rv.show_output
2882                && rv.data.is_some()
2883                && rv.fetched_at.elapsed() >= Duration::from_secs(5)
2884            {
2885                if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
2886                    self.start_run_fetch(cli, run_id);
2887                }
2888            }
2889            false
2890        } else {
2891            false
2892        }
2893    }
2894
2895    pub fn close_detail(&mut self) {
2896        self.detail = None;
2897        self.detail_rx = None;
2898    }
2899
2900    pub fn toggle_raw(&mut self) {
2901        if let Some(d) = &mut self.detail {
2902            d.show_raw = !d.show_raw;
2903            d.scroll = 0;
2904        }
2905    }
2906
2907    /// Applies a finished detail fetch; returns true if the UI should redraw.
2908    pub fn poll_detail(&mut self) -> bool {
2909        let Some(rx) = &mut self.detail_rx else {
2910            return false;
2911        };
2912        match rx.try_recv() {
2913            Ok(data) => {
2914                if let Some(d) = &mut self.detail {
2915                    d.data = Some(data);
2916                }
2917                self.detail_rx = None;
2918                true
2919            }
2920            Err(oneshot::error::TryRecvError::Empty) => false,
2921            Err(oneshot::error::TryRecvError::Closed) => {
2922                self.detail_rx = None;
2923                true
2924            }
2925        }
2926    }
2927
2928    pub fn detail_scroll(&mut self, delta: i32) {
2929        if let Some(d) = &mut self.detail {
2930            let max = match &d.data {
2931                Some(data) if d.show_raw => data.raw.lines().count(),
2932                Some(data) => data.summary.len() + data.activity.len() + 3,
2933                None => 0,
2934            } as u16;
2935            d.scroll = if delta < 0 {
2936                d.scroll.saturating_sub(delta.unsigned_abs() as u16)
2937            } else {
2938                (d.scroll + delta as u16).min(max.saturating_sub(1))
2939            };
2940        }
2941    }
2942
2943    /// Prepares a contextual action for the selected item, pending confirmation:
2944    /// start/stop for clusters, warehouses and pipelines, run-now for jobs.
2945    pub fn request_action(&mut self) {
2946        // Dashboards, Unity Catalog and secrets have no start/stop/run semantics.
2947        if matches!(
2948            self.focus,
2949            Panel::Dashboards | Panel::Catalog | Panel::Secrets
2950        ) {
2951            return;
2952        }
2953        let Some(item) = self.selected_item() else {
2954            return;
2955        };
2956        let Some(id) = item.id.clone() else {
2957            return;
2958        };
2959        let name = item.name.clone();
2960        let active = matches!(
2961            item.status,
2962            Status::Running | Status::Pending | Status::Success
2963        );
2964        let group = self.focus.cli_group();
2965        let (verb, action): (&str, &str) = match self.focus {
2966            Panel::Jobs => ("Run", "run-now"),
2967            Panel::Clusters if active => ("Stop", "delete"),
2968            Panel::Pipelines if active => ("Stop", "stop"),
2969            Panel::Pipelines => ("Start update for", "start-update"),
2970            _ if active => ("Stop", "stop"),
2971            _ => ("Start", "start"),
2972        };
2973        self.confirm = Some(Confirm {
2974            message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
2975            args: vec![group.to_string(), action.to_string(), id],
2976        });
2977    }
2978
2979    /// `s` in the run view: cancel the shown run/update after a confirm.
2980    pub fn request_run_cancel(&mut self) {
2981        let Some(rv) = &self.run_view else {
2982            return;
2983        };
2984        if !rv.live {
2985            self.flash = Some((
2986                "✗ nothing to cancel — this run already finished".to_string(),
2987                Instant::now(),
2988            ));
2989            return;
2990        }
2991        let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
2992            return;
2993        };
2994        let (message, args) = if rv.panel == Panel::Jobs {
2995            (
2996                format!("Cancel run {run_id} of “{}”?", rv.owner_name),
2997                vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
2998            )
2999        } else {
3000            (
3001                format!("Stop “{}” (cancels the active update)?", rv.owner_name),
3002                vec![
3003                    "pipelines".to_string(),
3004                    "stop".to_string(),
3005                    rv.owner_id.clone(),
3006                ],
3007            )
3008        };
3009        self.confirm = Some(Confirm { message, args });
3010    }
3011
3012    /// Cancels the in-flight console statement server-side; the polling
3013    /// task then sees CANCELED and surfaces it in the results pane.
3014    pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
3015        let id = self
3016            .sql_stmt
3017            .as_ref()
3018            .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
3019        let Some(id) = id else {
3020            self.flash = Some((
3021                "✗ statement not submitted yet — try again in a moment".to_string(),
3022                Instant::now(),
3023            ));
3024            return;
3025        };
3026        let cli = Arc::clone(cli);
3027        tokio::spawn(async move {
3028            let path = format!("/api/2.0/sql/statements/{id}/cancel");
3029            let _ = cli.run_action(&["api", "post", &path]).await;
3030        });
3031        self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
3032    }
3033
3034    pub fn cancel_confirm(&mut self) {
3035        self.confirm = None;
3036    }
3037
3038    pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
3039        let Some(c) = self.confirm.take() else {
3040            return;
3041        };
3042        let base = c.message.trim_end_matches('?').to_string();
3043        self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3044
3045        let (tx, rx) = oneshot::channel();
3046        self.action_rx = Some(rx);
3047        let cli = Arc::clone(cli);
3048        tokio::spawn(async move {
3049            let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
3050            let result = match cli.run_action(&args).await {
3051                Ok(()) => Ok(format!("✓ {base} — done")),
3052                Err(e) => Err(format!("✗ {e:#}")),
3053            };
3054            let _ = tx.send(result);
3055        });
3056    }
3057
3058    /// Applies a finished action; refreshes on success. Returns true on change.
3059    pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3060        let Some(rx) = &mut self.action_rx else {
3061            return false;
3062        };
3063        match rx.try_recv() {
3064            Ok(result) => {
3065                let ok = result.is_ok();
3066                self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
3067                self.action_rx = None;
3068                if ok {
3069                    self.start_refresh(cli);
3070                    // A confirmed action from the run view (cancel/repair)
3071                    // changes the shown run — reflect it without a manual nav.
3072                    if self.run_rx.is_none() {
3073                        let current = self
3074                            .run_view
3075                            .as_ref()
3076                            .and_then(|rv| rv.runs.get(rv.idx).cloned());
3077                        if let Some((run_id, _, _)) = current {
3078                            self.start_run_fetch(cli, run_id);
3079                        }
3080                    }
3081                }
3082                true
3083            }
3084            Err(oneshot::error::TryRecvError::Empty) => false,
3085            Err(oneshot::error::TryRecvError::Closed) => {
3086                self.action_rx = None;
3087                true
3088            }
3089        }
3090    }
3091
3092    /// Drops the flash message once it has been visible long enough.
3093    pub fn expire_flash(&mut self) -> bool {
3094        if let Some((_, since)) = &self.flash {
3095            if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
3096                self.flash = None;
3097                return true;
3098            }
3099        }
3100        false
3101    }
3102
3103    /// Opens the selected item (or the open detail view) in the workspace web UI.
3104    pub fn open_in_browser(&self) {
3105        let Some(host) = &self.host else {
3106            return;
3107        };
3108        let (panel, id) = match &self.detail {
3109            Some(d) => (d.panel, Some(d.id.clone())),
3110            None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
3111        };
3112        let Some(id) = id else {
3113            return;
3114        };
3115        let path = match panel {
3116            Panel::Clusters => format!("compute/clusters/{id}"),
3117            Panel::Jobs => format!("jobs/{id}"),
3118            Panel::Pipelines => format!("pipelines/{id}"),
3119            Panel::Warehouses => format!("sql/warehouses/{id}"),
3120            Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
3121            Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
3122            // Secret scopes have no workspace-UI page.
3123            Panel::Secrets => return,
3124        };
3125        let url = format!("{}/{}", host.trim_end_matches('/'), path);
3126        #[cfg(target_os = "macos")]
3127        let opener = "open";
3128        #[cfg(not(target_os = "macos"))]
3129        let opener = "xdg-open";
3130        let _ = std::process::Command::new(opener).arg(url).spawn();
3131    }
3132
3133    /// Counts of (ok, pending, failed, idle) items across all panels.
3134    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
3135        let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
3136        for shape in self.shapes.iter().flatten() {
3137            if let Shape::List(items) = shape {
3138                for item in items {
3139                    match item.status {
3140                        Status::Running | Status::Success => ok += 1,
3141                        Status::Pending => pending += 1,
3142                        Status::Failed => failed += 1,
3143                        Status::Stopped => idle += 1,
3144                        Status::Unknown(_) => {}
3145                    }
3146                }
3147            }
3148        }
3149        (ok, pending, failed, idle)
3150    }
3151
3152    pub fn last_refresh_age(&self) -> Duration {
3153        self.last_refresh.elapsed()
3154    }
3155
3156    pub fn spinner(&self) -> &'static str {
3157        SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
3158    }
3159
3160    pub fn spinner_frame(&self) -> usize {
3161        self.spinner_frame
3162    }
3163
3164    /// True whenever any background work is in flight — the loop uses this
3165    /// to keep spinners ticking, not just during panel refreshes.
3166    pub fn busy(&self) -> bool {
3167        self.loading
3168            || self.detail_rx.is_some()
3169            || self.action_rx.is_some()
3170            || self.preview_rx.is_some()
3171            || self.cost_rx.is_some()
3172            || self.sql_rx.is_some()
3173            || self.run_rx.is_some()
3174    }
3175
3176    pub fn tick_spinner(&mut self) {
3177        self.spinner_frame = self.spinner_frame.wrapping_add(1);
3178    }
3179
3180    pub fn toggle_zoom(&mut self) {
3181        self.zoomed = !self.zoomed;
3182    }
3183
3184    pub fn focus_next(&mut self) {
3185        self.cycle_focus(1);
3186    }
3187
3188    pub fn focus_prev(&mut self) {
3189        self.cycle_focus(-1);
3190    }
3191
3192    /// Cycles focus through the visible panes in display order.
3193    fn cycle_focus(&mut self, delta: i32) {
3194        let visible = self.visible_panes();
3195        if visible.is_empty() {
3196            return;
3197        }
3198        let focus_idx = Panel::ALL
3199            .iter()
3200            .position(|p| p == &self.focus)
3201            .unwrap_or(0);
3202        let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
3203        let n = visible.len() as i32;
3204        let next = ((pos as i32 + delta) % n + n) % n;
3205        self.focus = Panel::ALL[visible[next as usize]];
3206    }
3207
3208    pub fn needs_refresh(&self) -> bool {
3209        !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
3210    }
3211
3212    pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
3213        if self.loading {
3214            return;
3215        }
3216        self.loading = true;
3217        self.error = None;
3218        self.last_refresh = Instant::now();
3219
3220        let (tx, rx) = mpsc::unbounded_channel();
3221        self.pending = Some(rx);
3222        self.in_flight = 8;
3223
3224        // One task per source so each panel updates as soon as its fetch lands,
3225        // instead of waiting for the slowest of the five.
3226        macro_rules! spawn_fetch {
3227            ($update:expr, $fetch:path) => {{
3228                let cli = Arc::clone(cli);
3229                let tx = tx.clone();
3230                tokio::spawn(async move {
3231                    let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
3232                    let _ = tx.send($update(result));
3233                });
3234            }};
3235        }
3236
3237        spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
3238        spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
3239        spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
3240        spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
3241        spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
3242        spawn_fetch!(
3243            |s: Result<Shape, String>| Update::Badge(s.ok()),
3244            fetchers::current_user::fetch
3245        );
3246        {
3247            let cli = Arc::clone(cli);
3248            let tx = tx.clone();
3249            let path = self.uc_path.clone();
3250            tokio::spawn(async move {
3251                let result = fetchers::catalog::fetch(&cli, &path)
3252                    .await
3253                    .map_err(|e| format!("{e:#}"));
3254                let _ = tx.send(Update::Panel(5, result));
3255            });
3256        }
3257        {
3258            let cli = Arc::clone(cli);
3259            let tx = tx.clone();
3260            let scope = self.secret_scope.clone();
3261            tokio::spawn(async move {
3262                let result = fetchers::secrets::fetch(&cli, scope.as_deref())
3263                    .await
3264                    .map_err(|e| format!("{e:#}"));
3265                let _ = tx.send(Update::Panel(6, result));
3266            });
3267        }
3268    }
3269
3270    /// Applies any fetch results that have arrived; returns true if the UI should redraw.
3271    pub fn poll_refresh(&mut self) -> bool {
3272        let Some(rx) = &mut self.pending else {
3273            return false;
3274        };
3275        let mut changed = false;
3276        let mut updated_panes: Vec<usize> = Vec::new();
3277        loop {
3278            match rx.try_recv() {
3279                Ok(Update::Panel(i, result)) => {
3280                    match result {
3281                        Ok(mut shape) => {
3282                            // Active work floats to the top of every pane
3283                            // except the catalog, which stays browsable
3284                            // in its natural (alphabetical) order.
3285                            if i != 5 {
3286                                if let Shape::List(items) = &mut shape {
3287                                    items.sort_by_key(|it| {
3288                                        (it.status.rank(), it.history.is_empty())
3289                                    });
3290                                }
3291                            }
3292                            self.shapes[i] = Some(shape);
3293                            self.updated_at[i] = Some(Instant::now());
3294                            updated_panes.push(i);
3295                        }
3296                        // Keep previous data on failure so panels don't blank
3297                        // out — but surface the error if there's nothing yet.
3298                        Err(e) => {
3299                            if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
3300                                self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
3301                            }
3302                        }
3303                    }
3304                    self.in_flight -= 1;
3305                    changed = true;
3306                }
3307                Ok(Update::Badge(badge)) => {
3308                    if badge.is_some() {
3309                        self.user_badge = badge;
3310                    }
3311                    self.in_flight -= 1;
3312                    changed = true;
3313                }
3314                Err(mpsc::error::TryRecvError::Empty) => break,
3315                Err(mpsc::error::TryRecvError::Disconnected) => {
3316                    self.in_flight = 0;
3317                    break;
3318                }
3319            }
3320        }
3321        for i in updated_panes {
3322            self.alert_new_failures(i);
3323        }
3324        if self.in_flight == 0 {
3325            self.loading = false;
3326            self.pending = None;
3327            changed = true;
3328        }
3329        changed
3330    }
3331}
3332
3333#[cfg(test)]
3334mod tests {
3335    use super::{from_table, token_at_cursor};
3336
3337    #[test]
3338    fn token_bare_word() {
3339        let (start, ctx, prefix) = token_at_cursor("SELECT * FROM ma", 16);
3340        assert_eq!((start, ctx.as_str(), prefix.as_str()), (14, "", "ma"));
3341    }
3342
3343    #[test]
3344    fn token_dotted_path() {
3345        let (start, ctx, prefix) = token_at_cursor("SELECT * FROM main.sales.or", 27);
3346        assert_eq!(
3347            (start, ctx.as_str(), prefix.as_str()),
3348            (25, "main.sales", "or")
3349        );
3350    }
3351
3352    #[test]
3353    fn token_trailing_dot() {
3354        let (start, ctx, prefix) = token_at_cursor("main.", 5);
3355        assert_eq!((start, ctx.as_str(), prefix.as_str()), (5, "main", ""));
3356    }
3357
3358    #[test]
3359    fn token_mid_input() {
3360        // Caret inside the statement, not at the end.
3361        let (start, ctx, prefix) = token_at_cursor("SELECT co FROM t", 9);
3362        assert_eq!((start, ctx.as_str(), prefix.as_str()), (7, "", "co"));
3363    }
3364
3365    #[test]
3366    fn from_table_fully_qualified() {
3367        assert_eq!(
3368            from_table("SELECT x FROM main.sales.orders WHERE x > 1").as_deref(),
3369            Some("main.sales.orders")
3370        );
3371    }
3372
3373    #[test]
3374    fn from_table_rejects_partial_names() {
3375        assert_eq!(from_table("SELECT x FROM orders"), None);
3376        assert_eq!(from_table("SELECT x FROM main.sales."), None);
3377        assert_eq!(from_table("SELECT 1"), None);
3378    }
3379}