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