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