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