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