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    CatppuccinMacchiato,
14    CatppuccinFrappe,
15    CatppuccinLatte,
16    GruvboxDark,
17    GruvboxLight,
18    Dracula,
19    Nord,
20    TokyoNight,
21    RosePine,
22    Everforest,
23    Kanagawa,
24    SolarizedDark,
25    OneDark,
26}
27
28impl ThemeMode {
29    pub const ALL: &'static [ThemeMode] = &[
30        ThemeMode::Dark,
31        ThemeMode::Light,
32        ThemeMode::CatppuccinMocha,
33        ThemeMode::CatppuccinMacchiato,
34        ThemeMode::CatppuccinFrappe,
35        ThemeMode::CatppuccinLatte,
36        ThemeMode::GruvboxDark,
37        ThemeMode::GruvboxLight,
38        ThemeMode::Dracula,
39        ThemeMode::Nord,
40        ThemeMode::TokyoNight,
41        ThemeMode::RosePine,
42        ThemeMode::Everforest,
43        ThemeMode::Kanagawa,
44        ThemeMode::SolarizedDark,
45        ThemeMode::OneDark,
46    ];
47
48    /// The next theme in the cycle — what `t` steps through.
49    pub fn toggled(self) -> Self {
50        let idx = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
51        Self::ALL[(idx + 1) % Self::ALL.len()]
52    }
53
54    pub fn name(&self) -> &'static str {
55        match self {
56            ThemeMode::Dark => "Dark (terminal colors)",
57            ThemeMode::Light => "Light",
58            ThemeMode::CatppuccinMocha => "Catppuccin Mocha",
59            ThemeMode::CatppuccinMacchiato => "Catppuccin Macchiato",
60            ThemeMode::CatppuccinFrappe => "Catppuccin Frappé",
61            ThemeMode::CatppuccinLatte => "Catppuccin Latte",
62            ThemeMode::GruvboxDark => "Gruvbox Dark",
63            ThemeMode::GruvboxLight => "Gruvbox Light",
64            ThemeMode::Dracula => "Dracula",
65            ThemeMode::Nord => "Nord",
66            ThemeMode::TokyoNight => "Tokyo Night",
67            ThemeMode::RosePine => "Rosé Pine",
68            ThemeMode::Everforest => "Everforest Dark",
69            ThemeMode::Kanagawa => "Kanagawa",
70            ThemeMode::SolarizedDark => "Solarized Dark",
71            ThemeMode::OneDark => "One Dark",
72        }
73    }
74
75    /// Stable id, same kebab-case form the --theme flag accepts.
76    pub fn id(&self) -> &'static str {
77        match self {
78            ThemeMode::Dark => "dark",
79            ThemeMode::Light => "light",
80            ThemeMode::CatppuccinMocha => "catppuccin-mocha",
81            ThemeMode::CatppuccinMacchiato => "catppuccin-macchiato",
82            ThemeMode::CatppuccinFrappe => "catppuccin-frappe",
83            ThemeMode::CatppuccinLatte => "catppuccin-latte",
84            ThemeMode::GruvboxDark => "gruvbox",
85            ThemeMode::GruvboxLight => "gruvbox-light",
86            ThemeMode::Dracula => "dracula",
87            ThemeMode::Nord => "nord",
88            ThemeMode::TokyoNight => "tokyo-night",
89            ThemeMode::RosePine => "rose-pine",
90            ThemeMode::Everforest => "everforest",
91            ThemeMode::Kanagawa => "kanagawa",
92            ThemeMode::SolarizedDark => "solarized-dark",
93            ThemeMode::OneDark => "one-dark",
94        }
95    }
96
97    pub fn from_id(id: &str) -> Option<Self> {
98        Self::ALL.iter().copied().find(|t| t.id() == id)
99    }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub enum Panel {
104    Clusters,
105    Jobs,
106    Pipelines,
107    Warehouses,
108    Dashboards,
109    Catalog,
110    Secrets,
111}
112
113impl Panel {
114    pub const ALL: &'static [Panel] = &[
115        Panel::Clusters,
116        Panel::Jobs,
117        Panel::Pipelines,
118        Panel::Warehouses,
119        Panel::Dashboards,
120        Panel::Catalog,
121        Panel::Secrets,
122    ];
123
124    pub fn title(&self) -> &'static str {
125        match self {
126            Panel::Clusters => "Compute",
127            Panel::Jobs => "Lakeflow Jobs",
128            Panel::Pipelines => "Lakeflow Pipelines",
129            Panel::Warehouses => "SQL Warehouses",
130            Panel::Dashboards => "AI/BI Dashboards",
131            Panel::Catalog => "Unity Catalog",
132            Panel::Secrets => "Secret Scopes",
133        }
134    }
135
136    pub fn icon(&self) -> &'static str {
137        match self {
138            Panel::Clusters => "⌬",
139            Panel::Jobs => "⟳",
140            Panel::Pipelines => "⋙",
141            Panel::Warehouses => "⌁",
142            Panel::Dashboards => "▦",
143            Panel::Catalog => "⧉",
144            Panel::Secrets => "◈",
145        }
146    }
147
148    /// Stable id used in the config file.
149    pub fn id(&self) -> &'static str {
150        match self {
151            Panel::Clusters => "compute",
152            Panel::Jobs => "jobs",
153            Panel::Pipelines => "pipelines",
154            Panel::Warehouses => "warehouses",
155            Panel::Dashboards => "dashboards",
156            Panel::Catalog => "catalog",
157            Panel::Secrets => "secrets",
158        }
159    }
160
161    /// The databricks CLI command group whose `get <id>` returns item details.
162    pub fn cli_group(&self) -> &'static str {
163        match self {
164            Panel::Clusters => "clusters",
165            Panel::Jobs => "jobs",
166            Panel::Pipelines => "pipelines",
167            Panel::Warehouses => "warehouses",
168            Panel::Dashboards => "lakeview",
169            Panel::Catalog => "tables",
170            Panel::Secrets => "secrets",
171        }
172    }
173}
174
175pub struct Detail {
176    pub panel: Panel,
177    pub name: String,
178    pub id: String,
179    /// Item kind for Unity Catalog leaves (TABLE / VIEW / VOLUME).
180    pub kind: Option<String>,
181    /// Heading of the activity section ("Recent activity", "Access", ...).
182    pub section: &'static str,
183    /// None while the fetch is in flight.
184    pub data: Option<DetailData>,
185    /// Toggles between the formatted summary and the raw JSON.
186    pub show_raw: bool,
187    pub scroll: u16,
188}
189
190/// Full-screen sample-data view for a Unity Catalog table or view.
191pub struct Preview {
192    pub name: String,
193    /// Display name and id of the warehouse running the query.
194    pub warehouse: String,
195    pub warehouse_id: String,
196    /// None while the query runs; then rows or an error.
197    pub data: Option<Result<crate::shape::TableData, String>>,
198    /// Top visible row in the grid; the inspected row in record view.
199    pub scroll: usize,
200    /// First visible column, as an index into the filtered column list.
201    pub col: usize,
202    /// Case-insensitive substring filter over column names — the way
203    /// through a 500-column table.
204    pub filter: String,
205    pub filter_entry: bool,
206    /// Transposed view: one row, fields stacked vertically.
207    pub record: bool,
208    /// Field scroll within the record view.
209    pub rscroll: u16,
210}
211
212impl Preview {
213    /// Indices of columns whose name matches the filter (all when empty).
214    pub fn visible_cols(&self) -> Vec<usize> {
215        let Some(Ok(t)) = &self.data else {
216            return Vec::new();
217        };
218        let q = self.filter.to_lowercase();
219        (0..t.headers.len())
220            .filter(|&i| q.is_empty() || t.headers[i].to_lowercase().contains(&q))
221            .collect()
222    }
223}
224
225/// What a confirmed warehouse choice should run.
226enum PickTarget {
227    Preview(String),
228    Cost,
229    Lineage(String),
230    Sql(String),
231}
232
233/// Free-form SQL prompt with results, backed by the preview machinery.
234pub struct SqlConsole {
235    pub input: String,
236    /// Caret position in `input`, counted in characters.
237    pub cursor: usize,
238    /// Display name of the warehouse the last query ran on.
239    pub warehouse: String,
240    pub running: bool,
241    pub data: Option<Result<crate::shape::TableData, String>>,
242    /// The statement that produced `data`.
243    pub last_sql: String,
244    pub scroll: usize,
245    /// First visible result column (shift+←/→ pages wide results).
246    pub col: usize,
247}
248
249/// Tab-completion popup over the SQL prompt, backed by lazily-cached
250/// Unity Catalog names.
251pub struct SqlComplete {
252    /// Candidates for the segment being completed; empty while loading.
253    pub items: Vec<String>,
254    /// The candidate currently inserted into the prompt.
255    pub index: usize,
256    /// Char offset in the input where the completed segment starts —
257    /// the popup anchors under it.
258    pub seg_start: usize,
259    /// The typed prefix, restored on esc.
260    prefix: String,
261    /// Dotted context before the segment ("" for a bare word).
262    context: String,
263    /// True while names are being fetched from the workspace.
264    pub loading: bool,
265}
266
267/// Completed alongside catalog names when a bare word is typed; also
268/// the word list for prompt syntax highlighting.
269pub(crate) const SQL_KEYWORDS: &[&str] = &[
270    "SELECT",
271    "FROM",
272    "WHERE",
273    "GROUP BY",
274    "ORDER BY",
275    "HAVING",
276    "LIMIT",
277    "JOIN",
278    "LEFT JOIN",
279    "INNER JOIN",
280    "FULL OUTER JOIN",
281    "CROSS JOIN",
282    "ON",
283    "AS",
284    "AND",
285    "OR",
286    "NOT",
287    "IN",
288    "IS",
289    "NULL",
290    "DISTINCT",
291    "COUNT",
292    "SUM",
293    "AVG",
294    "MIN",
295    "MAX",
296    "UNION",
297    "UNION ALL",
298    "INSERT INTO",
299    "VALUES",
300    "UPDATE",
301    "SET",
302    "DELETE",
303    "CREATE",
304    "DROP",
305    "ALTER",
306    "SHOW",
307    "DESCRIBE",
308    "EXPLAIN",
309    "WITH",
310    "CASE",
311    "WHEN",
312    "THEN",
313    "ELSE",
314    "END",
315    "BETWEEN",
316    "LIKE",
317    "CAST",
318    "OVER",
319    "PARTITION BY",
320];
321
322/// The dotted identifier ending at the caret: (char offset where its
323/// last segment starts, context path before the last dot, typed prefix).
324fn token_at_cursor(input: &str, cursor: usize) -> (usize, String, String) {
325    let chars: Vec<char> = input.chars().collect();
326    let cursor = cursor.min(chars.len());
327    let mut start = cursor;
328    while start > 0 && (chars[start - 1].is_alphanumeric() || matches!(chars[start - 1], '_' | '.'))
329    {
330        start -= 1;
331    }
332    let token: String = chars[start..cursor].iter().collect();
333    match token.rfind('.') {
334        Some(dot) => {
335            let context = token[..dot].to_string();
336            let prefix = token[dot + 1..].to_string();
337            (start + context.chars().count() + 1, context, prefix)
338        }
339        None => (start, String::new(), token),
340    }
341}
342
343/// The first table referenced by a FROM clause, when fully qualified —
344/// its columns join the candidates for bare words.
345fn from_table(input: &str) -> Option<String> {
346    let pos = input.to_lowercase().find("from ")?;
347    // get(): lowercasing can shift byte offsets in non-ASCII input.
348    let rest = input.get(pos + 5..)?.trim_start();
349    let table: String = rest
350        .chars()
351        .take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '.'))
352        .collect();
353    (table.matches('.').count() == 2 && !table.ends_with('.')).then_some(table)
354}
355
356/// Where console history lives; one statement per line, oldest first.
357fn history_path() -> Option<std::path::PathBuf> {
358    let home = std::env::var_os("HOME")?;
359    Some(
360        std::path::PathBuf::from(home)
361            .join(".config")
362            .join("databricks-tui")
363            .join("history"),
364    )
365}
366
367fn load_history() -> Vec<String> {
368    history_path()
369        .and_then(|p| std::fs::read_to_string(p).ok())
370        .map(|s| {
371            s.lines()
372                .filter(|l| !l.trim().is_empty())
373                .map(str::to_string)
374                .collect()
375        })
376        .unwrap_or_default()
377}
378
379fn save_history(history: &[String]) {
380    let Some(path) = history_path() else {
381        return;
382    };
383    if let Some(dir) = path.parent() {
384        let _ = std::fs::create_dir_all(dir);
385        crate::config::restrict(dir, 0o700);
386    }
387    // Keep the tail; nobody scrolls back 200 queries.
388    let tail: Vec<&str> = history
389        .iter()
390        .rev()
391        .take(200)
392        .rev()
393        .map(String::as_str)
394        .collect();
395    // Queries can hold sensitive literals — owner-only, like shell history.
396    let _ = std::fs::write(&path, tail.join("\n") + "\n");
397    crate::config::restrict(&path, 0o600);
398}
399
400/// True when every char of `needle` appears in `haystack` in order.
401fn subsequence(haystack: &str, needle: &str) -> bool {
402    let mut chars = haystack.chars();
403    needle.chars().all(|n| chars.any(|h| h == n))
404}
405
406/// Byte offset of the `cursor`th character in `input`.
407fn byte_at(input: &str, cursor: usize) -> usize {
408    input
409        .char_indices()
410        .nth(cursor)
411        .map(|(i, _)| i)
412        .unwrap_or(input.len())
413}
414
415/// Parses the run-parameter prompt: `key=value` pairs separated by
416/// commas, whitespace-tolerant. Values may contain `=`; empty values
417/// are allowed; an empty input means "run with the job's defaults".
418fn parse_params(input: &str) -> Result<Vec<(String, String)>, String> {
419    let mut pairs = Vec::new();
420    for seg in input.split(',') {
421        let seg = seg.trim();
422        if seg.is_empty() {
423            continue;
424        }
425        let Some((k, v)) = seg.split_once('=') else {
426            return Err(format!(
427                "✗ “{seg}” isn't key=value — separate parameters with commas"
428            ));
429        };
430        let k = k.trim();
431        if k.is_empty() {
432            return Err(format!("✗ “{seg}” is missing the parameter name"));
433        }
434        pairs.push((k.to_string(), v.trim().to_string()));
435    }
436    Ok(pairs)
437}
438
439/// Overlay for choosing which SQL warehouse runs a query.
440pub struct WhPicker {
441    pub index: usize,
442    target: PickTarget,
443}
444
445/// Full-screen DBU usage view backed by system.billing.usage.
446pub struct CostView {
447    pub warehouse: String,
448    pub data: Option<Result<fetchers::cost::CostData, String>>,
449}
450
451/// Drill-down into a single job run or pipeline update, layered over
452/// the owning detail view.
453pub struct RunView {
454    /// Panel::Jobs (runs) or Panel::Pipelines (updates).
455    pub panel: Panel,
456    pub owner_name: String,
457    /// Job id or pipeline id the runs belong to.
458    owner_id: String,
459    /// Recent runs newest-first: (run_id, status, age).
460    pub runs: Vec<(String, Status, String)>,
461    /// Which of `runs` is shown.
462    pub idx: usize,
463    pub data: Option<DetailData>,
464    pub show_raw: bool,
465    pub scroll: u16,
466    /// True while the shown run is still executing — drives auto-refresh.
467    pub live: bool,
468    /// Full per-task output/logs, fetched on demand via `o`.
469    pub output: Option<String>,
470    pub show_output: bool,
471    /// Gantt view of per-task execution windows; sticky across h/l so
472    /// runs can be compared.
473    pub show_timeline: bool,
474    /// Dependency-tree view of the run's tasks; mutually exclusive with
475    /// the timeline, sticky across h/l like it.
476    pub show_dag: bool,
477    /// History grid: tasks × recent runs, with duration trends. Fetched
478    /// once per run view (the grid is per-job, not per-run).
479    pub show_grid: bool,
480    pub grid: Option<Result<fetchers::runs::GridData, String>>,
481    fetched_at: Instant,
482}
483
484/// (recent runs, detail of the newest, still-executing flag)
485type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
486
487enum RunUpdate {
488    Opened(Result<RunOpened, String>),
489    Detail(DetailData, bool),
490    /// Full task output plus whether the run is still executing.
491    Output(String, bool),
492}
493
494/// One unhealthy resource, pointing back at its pane and item.
495pub struct Problem {
496    /// Index into `Panel::ALL`; None when the problem is a whole
497    /// workspace being unreachable during a cross-workspace scan.
498    pub panel: Option<usize>,
499    pub name: String,
500    pub status: Status,
501    pub note: String,
502    /// Some(profile) when the problem lives in another workspace.
503    pub profile: Option<String>,
504}
505
506/// Overlay collecting everything currently failing: the loaded panes
507/// immediately, plus every other configured workspace as the scan of
508/// their profiles comes back.
509pub struct Problems {
510    pub items: Vec<Problem>,
511    pub index: usize,
512    /// True while other profiles are still being scanned.
513    pub scanning: bool,
514}
515
516/// Overlay listing jobs by their next scheduled execution, soonest first.
517pub struct Upcoming {
518    pub items: Vec<fetchers::upcoming::UpcomingJob>,
519    pub index: usize,
520    pub loading: bool,
521}
522
523/// A pending destructive/mutating action awaiting a y/n keystroke.
524pub struct Confirm {
525    pub message: String,
526    args: Vec<String>,
527    /// Some((job_id, name)) when the action is a job trigger, enabling
528    /// the `p` = edit-parameters escape hatch.
529    pub params: Option<(String, String)>,
530}
531
532/// The run-with-parameters prompt: a single `key=value, …` line,
533/// prefilled with the job's current parameter defaults.
534pub struct ParamForm {
535    pub job_id: String,
536    pub job: String,
537    pub input: String,
538    /// Caret as a char index into `input`.
539    pub cursor: usize,
540    pub kind: fetchers::jobs::ParamKind,
541    /// True until the prefill fetch lands; typing waits for it.
542    pub loading: bool,
543}
544
545/// A run being watched for completion: bell + flash when it finishes.
546pub struct Watched {
547    pub run_id: String,
548    pub job: String,
549}
550
551/// How often the watch list re-checks its runs.
552const WATCH_INTERVAL: Duration = Duration::from_secs(10);
553
554enum Update {
555    Panel(usize, Result<Shape, String>),
556    Badge(Option<Shape>),
557}
558
559const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
560
561pub struct App {
562    pub focus: Panel,
563    pub theme: ThemeMode,
564    pub zoomed: bool,
565    pub shapes: Vec<Option<Shape>>,
566    pub user_badge: Option<Shape>,
567    pub error: Option<String>,
568    pub refresh_interval: Duration,
569    last_refresh: Instant,
570    pub loading: bool,
571    pub detail: Option<Detail>,
572    pub confirm: Option<Confirm>,
573    pub flash: Option<(String, Instant)>,
574    pub selected: [usize; 7],
575    pub host: Option<String>,
576    /// Available profiles from ~/.databrickscfg and the active one.
577    pub profiles: Vec<String>,
578    pub profile: Option<String>,
579    /// When Some, the workspace picker overlay is open at this index.
580    pub picker: Option<usize>,
581    /// When Some, the problems overlay is open.
582    pub problems: Option<Problems>,
583    pub upcoming: Option<Upcoming>,
584    /// Current position in the Unity Catalog tree: [], [catalog] or [catalog, schema].
585    pub uc_path: Vec<String>,
586    uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
587    pub preview: Option<Preview>,
588    preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
589    pub wh_picker: Option<WhPicker>,
590    /// Session-remembered (id, name) of the warehouse used for previews.
591    pub preview_warehouse: Option<(String, String)>,
592    pub cost: Option<CostView>,
593    #[allow(clippy::type_complexity)]
594    cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
595    /// Numeric id of the current workspace, resolved lazily for cost
596    /// scoping and cached for the session.
597    workspace_id: Option<String>,
598    pub sql: Option<SqlConsole>,
599    sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
600    /// Past console statements, oldest first; persisted across sessions.
601    sql_history: Vec<String>,
602    /// Position while cycling history with ↑/↓; None = editing a new line.
603    hist_idx: Option<usize>,
604    /// The unfinished statement stashed when history browsing starts.
605    hist_draft: String,
606    /// Ctrl+R incremental search: (query, nth-newest match shown).
607    pub hist_search: Option<(String, usize)>,
608    pub run_view: Option<RunView>,
609    run_rx: Option<oneshot::Receiver<RunUpdate>>,
610    grid_rx: Option<oneshot::Receiver<Result<fetchers::runs::GridData, String>>>,
611    #[allow(clippy::type_complexity)]
612    upcoming_rx: Option<oneshot::Receiver<Result<Vec<fetchers::upcoming::UpcomingJob>, String>>>,
613    problems_rx: Option<oneshot::Receiver<Vec<fetchers::problems::RemoteProblem>>>,
614    pending: Option<mpsc::UnboundedReceiver<Update>>,
615    detail_rx: Option<oneshot::Receiver<DetailData>>,
616    action_rx: Option<oneshot::Receiver<Result<String, String>>>,
617    host_rx: Option<oneshot::Receiver<Option<String>>>,
618    in_flight: usize,
619    spinner_frame: usize,
620    /// Splash screen deadline; None once dismissed.
621    pub splash_until: Option<Instant>,
622    /// When each pane last received fresh data — drives the title flash.
623    pub updated_at: [Option<Instant>; 7],
624    /// Per-pane search filter; empty string means no filter.
625    pub filters: [String; 7],
626    /// True while the user is typing a filter for the focused pane.
627    pub filter_entry: bool,
628    /// Per-pane favorites-only toggle; session state, not persisted.
629    pub fav_only: [bool; 7],
630    /// Persisted preferences (theme, warehouse per profile).
631    pub config: crate::config::Config,
632    /// (name, is-slow-warning) pairs needing attention per pane at the
633    /// last refresh — None until the pane has loaded once, so the first
634    /// load never alerts.
635    failed_seen: [Option<std::collections::HashSet<(String, bool)>>; 7],
636    /// Ctrl+P fuzzy jump overlay.
637    pub jump: Option<Jump>,
638    /// Canonical pane indices in display order.
639    pub pane_order: Vec<usize>,
640    /// Hidden flag per canonical pane index.
641    pub hidden: [bool; 7],
642    /// When Some, the pane-arrangement overlay is open at this position.
643    pub pane_cfg: Option<usize>,
644    /// True while the `?` help overlay is open.
645    pub help: bool,
646    /// Scroll offset of the help overlay.
647    pub help_scroll: u16,
648    /// Statement id of the in-flight console query, for cancellation.
649    sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
650    /// Tab-completion popup state for the SQL prompt.
651    pub sql_complete: Option<SqlComplete>,
652    /// Unity Catalog names for completion, keyed by dotted path ("" →
653    /// catalogs, "cat" → schemas, …). Filled lazily, kept per workspace.
654    uc_names: std::collections::HashMap<String, Vec<String>>,
655    #[allow(clippy::type_complexity)]
656    uc_names_rx: Option<oneshot::Receiver<(String, Result<Vec<String>, String>)>>,
657    /// Drilled-into secret scope; None = the scopes listing.
658    pub secret_scope: Option<String>,
659    secrets_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
660    /// Create-scope / add-secret input form.
661    pub secret_form: Option<SecretForm>,
662    /// Run-with-parameters prompt (via `p` on a job-run confirm).
663    pub param_form: Option<ParamForm>,
664    #[allow(clippy::type_complexity)]
665    param_rx: Option<
666        oneshot::Receiver<Result<(Vec<(String, String)>, fetchers::jobs::ParamKind), String>>,
667    >,
668    /// Runs watched for completion; they outlive the run view.
669    pub watched: Vec<Watched>,
670    #[allow(clippy::type_complexity)]
671    watch_rx: Option<oneshot::Receiver<Vec<(String, Result<(Status, bool), String>)>>>,
672    /// When the watch list last polled its runs.
673    watch_at: Instant,
674}
675
676/// Ctrl+P overlay: fuzzy-search everything loaded, Enter jumps to it.
677pub struct Jump {
678    pub query: String,
679    pub index: usize,
680}
681
682/// Two-step input: a scope name, or a key then a (masked) value.
683pub struct SecretForm {
684    /// Scope the secret goes into; None = creating a new scope.
685    pub scope: Option<String>,
686    pub key: String,
687    pub value: String,
688    /// 0 = typing the name/key, 1 = typing the value.
689    pub stage: u8,
690}
691
692impl App {
693    pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
694        let mut app = Self {
695            focus: Panel::Clusters,
696            theme,
697            zoomed: false,
698            shapes: vec![None; 7],
699            user_badge: None,
700            error: None,
701            refresh_interval: Duration::from_secs(refresh_secs),
702            last_refresh: Instant::now()
703                .checked_sub(Duration::from_secs(refresh_secs + 1))
704                .unwrap_or(Instant::now()),
705            loading: false,
706            detail: None,
707            confirm: None,
708            flash: None,
709            selected: [0; 7],
710            host: None,
711            profiles: Vec::new(),
712            profile: None,
713            picker: None,
714            problems: None,
715            upcoming: None,
716            uc_path: Vec::new(),
717            uc_rx: None,
718            preview: None,
719            preview_rx: None,
720            wh_picker: None,
721            preview_warehouse: None,
722            cost: None,
723            cost_rx: None,
724            workspace_id: None,
725            sql: None,
726            sql_rx: None,
727            sql_history: load_history(),
728            hist_idx: None,
729            hist_draft: String::new(),
730            hist_search: None,
731            run_view: None,
732            run_rx: None,
733            grid_rx: None,
734            upcoming_rx: None,
735            problems_rx: None,
736            pending: None,
737            detail_rx: None,
738            action_rx: None,
739            host_rx: None,
740            in_flight: 0,
741            spinner_frame: 0,
742            splash_until: Some(Instant::now() + Duration::from_millis(1600)),
743            updated_at: [None; 7],
744            filters: Default::default(),
745            filter_entry: false,
746            fav_only: [false; 7],
747            config: crate::config::Config::load(),
748            failed_seen: Default::default(),
749            jump: None,
750            pane_order: (0..7).collect(),
751            hidden: [false; 7],
752            pane_cfg: None,
753            help: false,
754            help_scroll: 0,
755            sql_stmt: None,
756            sql_complete: None,
757            uc_names: Default::default(),
758            uc_names_rx: None,
759            secret_scope: None,
760            secrets_rx: None,
761            secret_form: None,
762            param_form: None,
763            param_rx: None,
764            watched: Vec::new(),
765            watch_rx: None,
766            watch_at: Instant::now(),
767        };
768        app.load_pane_prefs();
769        app
770    }
771
772    /// Applies pane order/visibility from the config file.
773    fn load_pane_prefs(&mut self) {
774        let idx_of = |id: &str| Panel::ALL.iter().position(|p| p.id() == id);
775        let mut order: Vec<usize> = self
776            .config
777            .pane_order
778            .iter()
779            .filter_map(|id| idx_of(id))
780            .collect();
781        for i in 0..7 {
782            if !order.contains(&i) {
783                order.push(i);
784            }
785        }
786        self.pane_order = order;
787        for id in &self.config.hidden_panes {
788            if let Some(i) = idx_of(id) {
789                self.hidden[i] = true;
790            }
791        }
792        self.ensure_focus_visible();
793    }
794
795    fn persist_panes(&mut self) {
796        self.config.pane_order = self
797            .pane_order
798            .iter()
799            .map(|&i| Panel::ALL[i].id().to_string())
800            .collect();
801        self.config.hidden_panes = (0..7)
802            .filter(|&i| self.hidden[i])
803            .map(|i| Panel::ALL[i].id().to_string())
804            .collect();
805        self.config.save();
806    }
807
808    /// Canonical pane indices currently shown, in display order.
809    pub fn visible_panes(&self) -> Vec<usize> {
810        self.pane_order
811            .iter()
812            .copied()
813            .filter(|&i| !self.hidden[i])
814            .collect()
815    }
816
817    /// Moves focus off a hidden pane onto the first visible one.
818    fn ensure_focus_visible(&mut self) {
819        let visible = self.visible_panes();
820        let focus_idx = Panel::ALL
821            .iter()
822            .position(|p| p == &self.focus)
823            .unwrap_or(0);
824        if !visible.contains(&focus_idx) {
825            if let Some(&first) = visible.first() {
826                self.focus = Panel::ALL[first];
827            }
828        }
829    }
830
831    /// Unhides a pane (used when a jump targets it).
832    fn reveal_pane(&mut self, idx: usize) {
833        if self.hidden[idx] {
834            self.hidden[idx] = false;
835            self.persist_panes();
836        }
837    }
838
839    pub fn open_pane_cfg(&mut self) {
840        self.pane_cfg = Some(0);
841    }
842
843    pub fn pane_cfg_next(&mut self) {
844        if let Some(i) = self.pane_cfg {
845            self.pane_cfg = Some((i + 1).min(6));
846        }
847    }
848
849    pub fn pane_cfg_prev(&mut self) {
850        if let Some(i) = self.pane_cfg {
851            self.pane_cfg = Some(i.saturating_sub(1));
852        }
853    }
854
855    /// Space in the overlay: toggles visibility of the selected pane
856    /// (refusing to hide the last visible one).
857    pub fn pane_cfg_toggle(&mut self) {
858        let Some(pos) = self.pane_cfg else {
859            return;
860        };
861        let idx = self.pane_order[pos];
862        if !self.hidden[idx] && self.visible_panes().len() == 1 {
863            self.flash = Some((
864                "✗ at least one pane has to stay visible".to_string(),
865                Instant::now(),
866            ));
867            return;
868        }
869        self.hidden[idx] = !self.hidden[idx];
870        self.ensure_focus_visible();
871        self.persist_panes();
872    }
873
874    /// J/K in the overlay: moves the selected pane down/up in the order.
875    pub fn pane_cfg_move(&mut self, delta: i32) {
876        let Some(pos) = self.pane_cfg else {
877            return;
878        };
879        let new = if delta < 0 {
880            pos.saturating_sub(1)
881        } else {
882            (pos + 1).min(6)
883        };
884        if new != pos {
885            self.pane_order.swap(pos, new);
886            self.pane_cfg = Some(new);
887            self.persist_panes();
888        }
889    }
890
891    /// Flashes (and rings the bell) when a resource fails — or starts
892    /// running suspiciously long — between one refresh and the next.
893    fn alert_new_failures(&mut self, idx: usize) {
894        // Catalog "error rows" are listing problems, not runtime failures.
895        if idx >= 5 {
896            return;
897        }
898        let Some(Shape::List(items)) = &self.shapes[idx] else {
899            return;
900        };
901        // Keyed (name, is_slow) so a slow-run warning doesn't swallow a
902        // later real failure of the same job, or vice versa.
903        let mut attention: std::collections::HashSet<(String, bool)> =
904            std::collections::HashSet::new();
905        for it in items {
906            let failed = matches!(it.status, Status::Failed)
907                || it
908                    .history
909                    .last()
910                    .is_some_and(|s| matches!(s, Status::Failed));
911            if failed {
912                attention.insert((it.name.clone(), false));
913            }
914            if it.alert.is_some() {
915                attention.insert((it.name.clone(), true));
916            }
917        }
918        if let Some(prev) = &self.failed_seen[idx] {
919            let mut newly: Vec<&(String, bool)> = attention.difference(prev).collect();
920            if !newly.is_empty() {
921                // Failures outrank slow-run warnings when both are new.
922                newly.sort_by_key(|(name, slow)| (*slow, name.clone()));
923                let extra = if newly.len() > 1 {
924                    format!(" (+{} more)", newly.len() - 1)
925                } else {
926                    String::new()
927                };
928                let (name, slow) = newly[0];
929                self.flash = Some((
930                    if *slow {
931                        format!("⚠ {name}{extra} running much longer than usual — ! to inspect")
932                    } else {
933                        format!("✗ {name}{extra} just failed — ! to inspect")
934                    },
935                    Instant::now(),
936                ));
937                // Bell so a backgrounded terminal (or tmux) flags it too.
938                print!("\x07");
939                let _ = std::io::Write::flush(&mut std::io::stdout());
940            }
941        }
942        self.failed_seen[idx] = Some(attention);
943    }
944
945    /// Remembers the current theme across sessions.
946    pub fn persist_theme(&mut self) {
947        self.config.theme = Some(self.theme.id().to_string());
948        self.config.save();
949    }
950
951    /// Restores the remembered warehouse for the active profile.
952    pub fn restore_warehouse_pref(&mut self) {
953        let profile = self.profile.as_deref().unwrap_or("DEFAULT");
954        self.preview_warehouse = self.config.warehouses.get(profile).cloned();
955    }
956
957    pub fn splash_active(&self) -> bool {
958        self.splash_until
959            .map(|t| Instant::now() < t)
960            .unwrap_or(false)
961    }
962
963    pub fn dismiss_splash(&mut self) {
964        self.splash_until = None;
965    }
966
967    /// True while any pane's data just landed — keeps the flash decaying.
968    pub fn any_fresh(&self) -> bool {
969        self.updated_at
970            .iter()
971            .flatten()
972            .any(|t| t.elapsed() < Duration::from_millis(1200))
973    }
974
975    pub fn open_picker(&mut self) {
976        if self.profiles.is_empty() {
977            return;
978        }
979        let current = self
980            .profile
981            .as_deref()
982            .and_then(|p| self.profiles.iter().position(|n| n == p))
983            .unwrap_or(0);
984        self.picker = Some(current);
985    }
986
987    pub fn picker_next(&mut self) {
988        if let Some(i) = self.picker {
989            self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
990        }
991    }
992
993    pub fn picker_prev(&mut self) {
994        if let Some(i) = self.picker {
995            self.picker = Some(i.saturating_sub(1));
996        }
997    }
998
999    /// Confirms the picker selection; returns the new CLI handle to use.
1000    pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
1001        let idx = self.picker.take()?;
1002        let name = self.profiles.get(idx)?.clone();
1003        Some(self.switch_profile(name))
1004    }
1005
1006    /// Switches to `name`, dropping all workspace-specific state, and
1007    /// returns the CLI handle for the new workspace.
1008    fn switch_profile(&mut self, name: String) -> Arc<DatabricksCli> {
1009        let profile_arg = if name == "DEFAULT" {
1010            None
1011        } else {
1012            Some(name.clone())
1013        };
1014        self.profile = Some(name);
1015
1016        // Drop all workspace-specific state; panes go back to loading.
1017        self.shapes = vec![None; 7];
1018        self.user_badge = None;
1019        self.host = None;
1020        self.selected = [0; 7];
1021        self.detail = None;
1022        self.detail_rx = None;
1023        self.confirm = None;
1024        self.problems = None;
1025        self.problems_rx = None;
1026        self.uc_path.clear();
1027        self.uc_rx = None;
1028        self.secret_scope = None;
1029        self.secrets_rx = None;
1030        self.secret_form = None;
1031        self.param_form = None;
1032        self.param_rx = None;
1033        self.watched.clear();
1034        self.watch_rx = None;
1035        self.preview = None;
1036        self.preview_rx = None;
1037        self.wh_picker = None;
1038        self.preview_warehouse = None;
1039        self.cost = None;
1040        self.cost_rx = None;
1041        self.workspace_id = None;
1042        self.sql = None;
1043        self.sql_rx = None;
1044        self.run_view = None;
1045        self.run_rx = None;
1046        self.grid_rx = None;
1047        self.pending = None;
1048        self.in_flight = 0;
1049        self.loading = false;
1050        self.zoomed = false;
1051        self.filters = Default::default();
1052        self.filter_entry = false;
1053        self.fav_only = [false; 7];
1054        self.failed_seen = Default::default();
1055        self.jump = None;
1056        self.sql_stmt = None;
1057        self.sql_complete = None;
1058        self.uc_names.clear();
1059        self.uc_names_rx = None;
1060        self.restore_warehouse_pref();
1061
1062        Arc::new(DatabricksCli::new(profile_arg))
1063    }
1064
1065    pub fn open_jump(&mut self) {
1066        self.jump = Some(Jump {
1067            query: String::new(),
1068            index: 0,
1069        });
1070    }
1071
1072    /// Everything loaded that matches the jump query, best first:
1073    /// (panel index, item name, kind/status label). Substring matches
1074    /// rank above in-order subsequence matches.
1075    pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
1076        let Some(jump) = &self.jump else {
1077            return Vec::new();
1078        };
1079        let q = jump.query.to_lowercase();
1080        let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
1081        for (i, shape) in self.shapes.iter().enumerate() {
1082            let Some(Shape::List(items)) = shape else {
1083                continue;
1084            };
1085            for it in items {
1086                let name = it.name.to_lowercase();
1087                let rank = if q.is_empty() || name.contains(&q) {
1088                    0
1089                } else if subsequence(&name, &q) {
1090                    1
1091                } else {
1092                    continue;
1093                };
1094                scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
1095            }
1096        }
1097        scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
1098        scored
1099            .into_iter()
1100            .take(12)
1101            .map(|(_, i, name, label)| (i, name, label))
1102            .collect()
1103    }
1104
1105    pub fn jump_push(&mut self, c: char) {
1106        if let Some(j) = &mut self.jump {
1107            j.query.push(c);
1108            j.index = 0;
1109        }
1110    }
1111
1112    pub fn jump_pop(&mut self) {
1113        if let Some(j) = &mut self.jump {
1114            j.query.pop();
1115            j.index = 0;
1116        }
1117    }
1118
1119    pub fn jump_next(&mut self) {
1120        let len = self.jump_matches().len();
1121        if let Some(j) = &mut self.jump {
1122            j.index = (j.index + 1).min(len.saturating_sub(1));
1123        }
1124    }
1125
1126    pub fn jump_prev(&mut self) {
1127        if let Some(j) = &mut self.jump {
1128            j.index = j.index.saturating_sub(1);
1129        }
1130    }
1131
1132    /// Jumps focus and selection to the highlighted match.
1133    pub fn jump_go(&mut self) {
1134        let matches = self.jump_matches();
1135        let Some(jump) = self.jump.take() else {
1136            return;
1137        };
1138        let Some((panel_idx, name, _)) = matches.get(jump.index) else {
1139            return;
1140        };
1141        self.reveal_pane(*panel_idx);
1142        self.focus = Panel::ALL[*panel_idx];
1143        self.filters[*panel_idx].clear();
1144        self.fav_only[*panel_idx] = false;
1145        if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
1146            if let Some(pos) = items.iter().position(|i| &i.name == name) {
1147                self.selected[*panel_idx] = pos;
1148            }
1149        }
1150    }
1151
1152    /// Collects everything unhealthy across the loaded panes — items
1153    /// whose status is failed, or whose most recent run failed — then
1154    /// scans every other configured workspace in the background.
1155    pub fn open_problems(&mut self) {
1156        let mut items = Vec::new();
1157        for (i, shape) in self.shapes.iter().enumerate() {
1158            let Some(Shape::List(list)) = shape else {
1159                continue;
1160            };
1161            for it in list {
1162                let failed_now = matches!(it.status, Status::Failed);
1163                let failed_last = it
1164                    .history
1165                    .last()
1166                    .is_some_and(|s| matches!(s, Status::Failed));
1167                if failed_now || failed_last || it.alert.is_some() {
1168                    let note = if failed_now {
1169                        it.detail.clone().unwrap_or_default()
1170                    } else if let Some(alert) = &it.alert {
1171                        alert.clone()
1172                    } else {
1173                        "latest run failed".to_string()
1174                    };
1175                    items.push(Problem {
1176                        panel: Some(i),
1177                        name: it.name.clone(),
1178                        status: it.status.clone(),
1179                        note,
1180                        profile: None,
1181                    });
1182                }
1183            }
1184        }
1185        let current = self
1186            .profile
1187            .clone()
1188            .unwrap_or_else(|| "DEFAULT".to_string());
1189        let others: Vec<String> = self
1190            .profiles
1191            .iter()
1192            .filter(|n| **n != current)
1193            .cloned()
1194            .collect();
1195        let scanning = !others.is_empty();
1196        if scanning {
1197            let (tx, rx) = oneshot::channel();
1198            self.problems_rx = Some(rx);
1199            tokio::spawn(async move {
1200                let _ = tx.send(fetchers::problems::fetch(others, current).await);
1201            });
1202        }
1203        self.problems = Some(Problems {
1204            items,
1205            index: 0,
1206            scanning,
1207        });
1208    }
1209
1210    pub fn close_problems(&mut self) {
1211        self.problems = None;
1212        self.problems_rx = None;
1213    }
1214
1215    pub fn poll_problems(&mut self) -> bool {
1216        let Some(rx) = &mut self.problems_rx else {
1217            return false;
1218        };
1219        match rx.try_recv() {
1220            Ok(remote) => {
1221                self.problems_rx = None;
1222                let Some(pr) = &mut self.problems else {
1223                    return false;
1224                };
1225                pr.scanning = false;
1226                pr.items.extend(remote.into_iter().map(|r| Problem {
1227                    panel: r.panel,
1228                    name: r.name,
1229                    status: r.status,
1230                    note: r.note,
1231                    profile: Some(r.profile),
1232                }));
1233                true
1234            }
1235            Err(oneshot::error::TryRecvError::Empty) => false,
1236            Err(oneshot::error::TryRecvError::Closed) => {
1237                self.problems_rx = None;
1238                if let Some(pr) = &mut self.problems {
1239                    pr.scanning = false;
1240                }
1241                true
1242            }
1243        }
1244    }
1245
1246    pub fn problems_next(&mut self) {
1247        if let Some(pr) = &mut self.problems {
1248            pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
1249        }
1250    }
1251
1252    pub fn problems_prev(&mut self) {
1253        if let Some(pr) = &mut self.problems {
1254            pr.index = pr.index.saturating_sub(1);
1255        }
1256    }
1257
1258    /// Jumps focus and selection to the highlighted problem's pane item.
1259    /// A problem in another workspace switches to that workspace instead;
1260    /// the returned CLI handle must then replace the current one.
1261    pub fn problems_jump(&mut self) -> Option<Arc<DatabricksCli>> {
1262        let Some(pr) = self.problems.take() else {
1263            self.problems_rx = None;
1264            return None;
1265        };
1266        self.problems_rx = None;
1267        let problem = pr.items.get(pr.index)?;
1268        if let Some(profile) = &problem.profile {
1269            let target = match problem.panel {
1270                Some(i) => format!("{} is in {}", problem.name, Panel::ALL[i].title()),
1271                None => "check its auth".to_string(),
1272            };
1273            self.flash = Some((
1274                format!("⌂ switched to {profile} — {target}"),
1275                Instant::now(),
1276            ));
1277            let profile = profile.clone();
1278            return Some(self.switch_profile(profile));
1279        }
1280        let panel = problem.panel?;
1281        self.reveal_pane(panel);
1282        self.focus = Panel::ALL[panel];
1283        // The pane's filter (or favorites-only) could hide the item we're
1284        // jumping to.
1285        self.filters[panel].clear();
1286        self.fav_only[panel] = false;
1287        if let Some(Shape::List(list)) = &self.shapes[panel] {
1288            if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
1289                self.selected[panel] = pos;
1290            }
1291        }
1292        None
1293    }
1294
1295    /// `u`: what runs next — every job with a schedule or trigger,
1296    /// soonest first, fetched fresh so the countdowns are current.
1297    pub fn open_upcoming(&mut self, cli: &Arc<DatabricksCli>) {
1298        self.upcoming = Some(Upcoming {
1299            items: Vec::new(),
1300            index: 0,
1301            loading: true,
1302        });
1303        let (tx, rx) = oneshot::channel();
1304        self.upcoming_rx = Some(rx);
1305        let cli = Arc::clone(cli);
1306        tokio::spawn(async move {
1307            let _ = tx.send(fetchers::upcoming::fetch(&cli).await);
1308        });
1309    }
1310
1311    pub fn close_upcoming(&mut self) {
1312        self.upcoming = None;
1313        self.upcoming_rx = None;
1314    }
1315
1316    pub fn poll_upcoming(&mut self) -> bool {
1317        let Some(rx) = &mut self.upcoming_rx else {
1318            return false;
1319        };
1320        match rx.try_recv() {
1321            Ok(result) => {
1322                self.upcoming_rx = None;
1323                match result {
1324                    Ok(items) => {
1325                        if let Some(u) = &mut self.upcoming {
1326                            u.items = items;
1327                            u.loading = false;
1328                        }
1329                    }
1330                    Err(e) => {
1331                        self.upcoming = None;
1332                        let first = e.lines().next().unwrap_or("failed").to_string();
1333                        self.flash = Some((format!("✗ upcoming: {first}"), Instant::now()));
1334                    }
1335                }
1336                true
1337            }
1338            Err(oneshot::error::TryRecvError::Empty) => false,
1339            Err(oneshot::error::TryRecvError::Closed) => {
1340                self.upcoming_rx = None;
1341                true
1342            }
1343        }
1344    }
1345
1346    pub fn upcoming_next(&mut self) {
1347        if let Some(u) = &mut self.upcoming {
1348            u.index = (u.index + 1).min(u.items.len().saturating_sub(1));
1349        }
1350    }
1351
1352    pub fn upcoming_prev(&mut self) {
1353        if let Some(u) = &mut self.upcoming {
1354            u.index = u.index.saturating_sub(1);
1355        }
1356    }
1357
1358    /// Jumps focus and selection to the highlighted job in the Jobs pane.
1359    pub fn upcoming_jump(&mut self) {
1360        let Some(u) = self.upcoming.take() else {
1361            return;
1362        };
1363        self.upcoming_rx = None;
1364        let Some(item) = u.items.get(u.index) else {
1365            return;
1366        };
1367        let Some(idx) = Panel::ALL.iter().position(|p| *p == Panel::Jobs) else {
1368            return;
1369        };
1370        self.reveal_pane(idx);
1371        self.focus = Panel::Jobs;
1372        self.filters[idx].clear();
1373        if let Some(Shape::List(list)) = &self.shapes[idx] {
1374            if let Some(pos) = list.iter().position(|i| i.name == item.name) {
1375                self.selected[idx] = pos;
1376            }
1377        }
1378    }
1379
1380    /// Resolves the workspace host in the background — `auth describe` can
1381    /// take seconds when it refreshes tokens, so it must not block the loop.
1382    pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
1383        let (tx, rx) = oneshot::channel();
1384        self.host_rx = Some(rx);
1385        let cli = Arc::clone(cli);
1386        tokio::spawn(async move {
1387            let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
1388                json["details"]["host"]
1389                    .as_str()
1390                    .or_else(|| json["host"].as_str())
1391                    .map(str::to_string)
1392            });
1393            let _ = tx.send(host);
1394        });
1395    }
1396
1397    pub fn poll_host(&mut self) {
1398        if let Some(rx) = &mut self.host_rx {
1399            match rx.try_recv() {
1400                Ok(host) => {
1401                    self.host = host;
1402                    self.host_rx = None;
1403                }
1404                Err(oneshot::error::TryRecvError::Empty) => {}
1405                Err(oneshot::error::TryRecvError::Closed) => {
1406                    self.host_rx = None;
1407                }
1408            }
1409        }
1410    }
1411
1412    fn focus_index(&self) -> usize {
1413        Panel::ALL
1414            .iter()
1415            .position(|p| p == &self.focus)
1416            .unwrap_or(0)
1417    }
1418
1419    /// The favorite keys stored for a pane under the active profile, if any.
1420    fn favorite_keys(&self, idx: usize) -> Option<&Vec<String>> {
1421        let profile = self.profile.as_deref().unwrap_or("DEFAULT");
1422        self.config
1423            .favorites
1424            .get(profile)
1425            .and_then(|panes| panes.get(Panel::ALL[idx].id()))
1426    }
1427
1428    /// Whether an item is favorited in its pane.
1429    pub fn is_favorite(&self, idx: usize, item: &crate::shape::ListItem) -> bool {
1430        let key = crate::shape::fav_key(item);
1431        self.favorite_keys(idx)
1432            .is_some_and(|keys| keys.iter().any(|k| k == &key))
1433    }
1434
1435    /// Favorite keys for a pane as a set, for O(1) lookups while rendering.
1436    pub fn pane_fav_set(&self, idx: usize) -> std::collections::HashSet<String> {
1437        self.favorite_keys(idx)
1438            .map(|keys| keys.iter().cloned().collect())
1439            .unwrap_or_default()
1440    }
1441
1442    /// Whether favorites-only is on AND the pane has at least one favorite at
1443    /// the current level — so drilling into the catalog never dead-ends.
1444    pub fn fav_filter_active(&self, idx: usize) -> bool {
1445        self.fav_only[idx]
1446            && matches!(&self.shapes[idx], Some(Shape::List(items))
1447                if items.iter().any(|it| self.is_favorite(idx, it)))
1448    }
1449
1450    /// The predicate deciding whether an item shows in a pane: text filter
1451    /// plus the favorites-only filter when active.
1452    fn passes(&self, idx: usize, item: &crate::shape::ListItem) -> bool {
1453        crate::shape::item_matches(item, &self.filters[idx])
1454            && (!self.fav_filter_active(idx) || self.is_favorite(idx, item))
1455    }
1456
1457    fn list_len(&self, idx: usize) -> usize {
1458        match &self.shapes[idx] {
1459            Some(Shape::List(items)) => items.iter().filter(|it| self.passes(idx, it)).count(),
1460            _ => 0,
1461        }
1462    }
1463
1464    /// Selection index for a panel, clamped to the current list length.
1465    pub fn selection(&self, idx: usize) -> usize {
1466        self.selected[idx].min(self.list_len(idx).saturating_sub(1))
1467    }
1468
1469    pub fn select_next(&mut self) {
1470        let idx = self.focus_index();
1471        let len = self.list_len(idx);
1472        if len > 0 {
1473            self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
1474        }
1475    }
1476
1477    pub fn select_prev(&mut self) {
1478        let idx = self.focus_index();
1479        self.selected[idx] = self.selection(idx).saturating_sub(1);
1480    }
1481
1482    /// The currently highlighted item in the focused panel, respecting
1483    /// the pane's filter — the nth *visible* item, like the UI shows.
1484    fn selected_item(&self) -> Option<&crate::shape::ListItem> {
1485        let idx = self.focus_index();
1486        match &self.shapes[idx] {
1487            Some(Shape::List(items)) => items
1488                .iter()
1489                .filter(|it| self.passes(idx, it))
1490                .nth(self.selection(idx)),
1491            _ => None,
1492        }
1493    }
1494
1495    /// Pins/unpins the selected item in the focused pane; persisted at once.
1496    pub fn toggle_favorite(&mut self) {
1497        let idx = self.focus_index();
1498        let Some((key, name)) = self
1499            .selected_item()
1500            .map(|it| (crate::shape::fav_key(it), it.name.clone()))
1501        else {
1502            return;
1503        };
1504        let pane_id = Panel::ALL[idx].id().to_string();
1505        let profile = self.profile.as_deref().unwrap_or("DEFAULT").to_string();
1506        let list = self
1507            .config
1508            .favorites
1509            .entry(profile)
1510            .or_default()
1511            .entry(pane_id)
1512            .or_default();
1513        let pinned = match list.iter().position(|k| k == &key) {
1514            Some(pos) => {
1515                list.remove(pos);
1516                false
1517            }
1518            None => {
1519                list.push(key);
1520                true
1521            }
1522        };
1523        self.config.save();
1524        let msg = if pinned {
1525            format!("★ pinned {name}")
1526        } else {
1527            format!("☆ unpinned {name}")
1528        };
1529        self.flash = Some((msg, Instant::now()));
1530    }
1531
1532    /// Toggles favorites-only for the focused pane.
1533    pub fn toggle_fav_only(&mut self) {
1534        let idx = self.focus_index();
1535        self.fav_only[idx] = !self.fav_only[idx];
1536        self.selected[idx] = 0;
1537        let has_fav = self.favorite_keys(idx).is_some_and(|k| !k.is_empty());
1538        let msg = if !self.fav_only[idx] {
1539            "showing all".to_string()
1540        } else if has_fav {
1541            "★ favorites only".to_string()
1542        } else {
1543            "★ favorites only — none pinned yet (press f)".to_string()
1544        };
1545        self.flash = Some((msg, Instant::now()));
1546    }
1547
1548    /// Opens filter entry for the focused pane, starting from scratch.
1549    pub fn filter_start(&mut self) {
1550        let idx = self.focus_index();
1551        self.filters[idx].clear();
1552        self.selected[idx] = 0;
1553        self.filter_entry = true;
1554    }
1555
1556    pub fn filter_push(&mut self, c: char) {
1557        let idx = self.focus_index();
1558        self.filters[idx].push(c);
1559        self.selected[idx] = 0;
1560    }
1561
1562    pub fn filter_pop(&mut self) {
1563        let idx = self.focus_index();
1564        self.filters[idx].pop();
1565        self.selected[idx] = 0;
1566    }
1567
1568    /// Keeps the filter applied and returns keys to normal navigation.
1569    pub fn filter_accept(&mut self) {
1570        self.filter_entry = false;
1571    }
1572
1573    pub fn filter_clear(&mut self) {
1574        let idx = self.focus_index();
1575        self.filters[idx].clear();
1576        self.selected[idx] = 0;
1577        self.filter_entry = false;
1578    }
1579
1580    /// The focused pane's filter, if any.
1581    pub fn active_filter(&self) -> &str {
1582        &self.filters[self.focus_index()]
1583    }
1584
1585    pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
1586        let Some(item) = self.selected_item() else {
1587            return;
1588        };
1589        let Some(id) = item.id.clone() else {
1590            return;
1591        };
1592        let kind = match &item.status {
1593            Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
1594            _ => None,
1595        };
1596        let section = match self.focus {
1597            Panel::Dashboards => "Contents",
1598            Panel::Catalog => "Columns",
1599            Panel::Warehouses => "Recent queries",
1600            _ => "Recent activity",
1601        };
1602        self.detail = Some(Detail {
1603            panel: self.focus,
1604            name: item.name.clone(),
1605            id: id.clone(),
1606            kind,
1607            section,
1608            data: None,
1609            show_raw: false,
1610            scroll: 0,
1611        });
1612
1613        let (tx, rx) = oneshot::channel();
1614        self.detail_rx = Some(rx);
1615        let cli = Arc::clone(cli);
1616        let kind = self.detail.as_ref().unwrap().kind.clone();
1617        // Files in volumes get a content peek instead of an API `get`.
1618        if kind.as_deref() == Some("FILE") {
1619            if let Some(d) = &mut self.detail {
1620                d.section = "File head";
1621            }
1622            tokio::spawn(async move {
1623                let data = fetchers::catalog::file_peek(&cli, &id).await;
1624                let _ = tx.send(data);
1625            });
1626            return;
1627        }
1628        let group = match &kind {
1629            Some(k) if k == "VOLUME" => "volumes",
1630            _ => self.focus.cli_group(),
1631        };
1632        // Tables get extra facts from DESCRIBE DETAIL when a warehouse
1633        // is already remembered — free depth, no picker interruption.
1634        let warehouse = match &kind {
1635            Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
1636            _ => None,
1637        };
1638        tokio::spawn(async move {
1639            let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
1640            let _ = tx.send(data);
1641        });
1642    }
1643
1644    /// Descends one level in the Unity Catalog tree. Returns false when the
1645    /// selection is a leaf (caller should open the detail view instead).
1646    pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1647        if self.focus != Panel::Catalog {
1648            return false;
1649        }
1650        let Some(item) = self.selected_item() else {
1651            return self.uc_path.is_empty(); // empty root pane: swallow the key
1652        };
1653        // Below the schema level only volumes and their directories are
1654        // containers; tables/views fall through to the detail view.
1655        if self.uc_path.len() >= 2 {
1656            let drillable =
1657                matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
1658            if !drillable {
1659                return false;
1660            }
1661        }
1662        self.uc_path.push(item.name.clone());
1663        self.refresh_catalog(cli);
1664        true
1665    }
1666
1667    /// Ascends one level; returns false if already at the catalog root.
1668    pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1669        if self.focus != Panel::Catalog || self.uc_path.is_empty() {
1670            return false;
1671        }
1672        self.uc_path.pop();
1673        self.refresh_catalog(cli);
1674        true
1675    }
1676
1677    fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
1678        self.shapes[5] = None;
1679        self.selected[5] = 0;
1680        // A filter typed at one level would silently hide the next.
1681        self.filters[5].clear();
1682        let (tx, rx) = oneshot::channel();
1683        self.uc_rx = Some(rx);
1684        let cli = Arc::clone(cli);
1685        let path = self.uc_path.clone();
1686        tokio::spawn(async move {
1687            let result = fetchers::catalog::fetch(&cli, &path)
1688                .await
1689                .map_err(|e| format!("{e:#}"));
1690            let _ = tx.send(result);
1691        });
1692    }
1693
1694    /// Enter in the Secrets pane: descend from a scope into its keys.
1695    /// On a key it does nothing — secret values are never displayed —
1696    /// but still returns true so Enter never opens a (bogus) detail view.
1697    pub fn secrets_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1698        if self.focus != Panel::Secrets {
1699            return false;
1700        }
1701        // Already inside a scope: the selection is a key, nothing to open.
1702        if self.secret_scope.is_some() {
1703            return true;
1704        }
1705        let Some(item) = self.selected_item() else {
1706            return true; // empty pane: swallow the key
1707        };
1708        self.secret_scope = Some(item.name.clone());
1709        self.refresh_secrets(cli);
1710        true
1711    }
1712
1713    /// Backspace inside a scope: back to the scopes listing.
1714    pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1715        if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1716            return false;
1717        }
1718        self.secret_scope = None;
1719        self.refresh_secrets(cli);
1720        true
1721    }
1722
1723    fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1724        let idx = 6;
1725        self.shapes[idx] = None;
1726        self.selected[idx] = 0;
1727        self.filters[idx].clear();
1728        let (tx, rx) = oneshot::channel();
1729        self.secrets_rx = Some(rx);
1730        let cli = Arc::clone(cli);
1731        let scope = self.secret_scope.clone();
1732        tokio::spawn(async move {
1733            let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1734                .await
1735                .map_err(|e| format!("{e:#}"));
1736            let _ = tx.send(result);
1737        });
1738    }
1739
1740    pub fn poll_secrets(&mut self) -> bool {
1741        let Some(rx) = &mut self.secrets_rx else {
1742            return false;
1743        };
1744        match rx.try_recv() {
1745            Ok(result) => {
1746                self.shapes[6] = Some(match result {
1747                    Ok(shape) => shape,
1748                    Err(e) => Shape::Text(format!("✗ {e}")),
1749                });
1750                self.updated_at[6] = Some(Instant::now());
1751                self.secrets_rx = None;
1752                true
1753            }
1754            Err(oneshot::error::TryRecvError::Empty) => false,
1755            Err(oneshot::error::TryRecvError::Closed) => {
1756                self.secrets_rx = None;
1757                true
1758            }
1759        }
1760    }
1761
1762    /// `a` in the secrets pane: create a scope (top level) or add a
1763    /// secret (inside a scope).
1764    pub fn open_secret_form(&mut self) {
1765        if self.focus != Panel::Secrets {
1766            return;
1767        }
1768        self.secret_form = Some(SecretForm {
1769            scope: self.secret_scope.clone(),
1770            key: String::new(),
1771            value: String::new(),
1772            stage: 0,
1773        });
1774    }
1775
1776    pub fn secret_form_push(&mut self, c: char) {
1777        if let Some(form) = &mut self.secret_form {
1778            if form.stage == 0 {
1779                form.key.push(c);
1780            } else {
1781                form.value.push(c);
1782            }
1783        }
1784    }
1785
1786    pub fn secret_form_pop(&mut self) {
1787        if let Some(form) = &mut self.secret_form {
1788            if form.stage == 0 {
1789                form.key.pop();
1790            } else {
1791                form.value.pop();
1792            }
1793        }
1794    }
1795
1796    /// Enter in the form: advance to the value stage, or submit.
1797    pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1798        let Some(form) = &mut self.secret_form else {
1799            return;
1800        };
1801        if form.key.trim().is_empty() {
1802            return;
1803        }
1804        match (form.scope.clone(), form.stage) {
1805            // New scope: single field.
1806            (None, _) => {
1807                let name = form.key.trim().to_string();
1808                self.secret_form = None;
1809                self.run_secret_action(
1810                    cli,
1811                    format!("Create scope “{name}”"),
1812                    vec!["secrets".into(), "create-scope".into(), name],
1813                );
1814            }
1815            // Secret: key first, then value.
1816            (Some(_), 0) => form.stage = 1,
1817            (Some(scope), _) => {
1818                let key = form.key.trim().to_string();
1819                let value = form.value.clone();
1820                self.secret_form = None;
1821                self.run_secret_action(
1822                    cli,
1823                    format!("Put secret “{key}” in “{scope}”"),
1824                    vec![
1825                        "secrets".into(),
1826                        "put-secret".into(),
1827                        scope,
1828                        key,
1829                        "--string-value".into(),
1830                        value,
1831                    ],
1832                );
1833            }
1834        }
1835    }
1836
1837    /// Runs a secrets mutation right away (the form itself was the
1838    /// deliberate step); the usual action poll refreshes the panes.
1839    fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1840        self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1841        let (tx, rx) = oneshot::channel();
1842        self.action_rx = Some(rx);
1843        let cli = Arc::clone(cli);
1844        tokio::spawn(async move {
1845            let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1846            let result = match cli.run_action(&arg_refs).await {
1847                Ok(()) => Ok(format!("✓ {label} — done")),
1848                Err(e) => Err(format!("✗ {e:#}")),
1849            };
1850            let _ = tx.send(result);
1851        });
1852    }
1853
1854    /// `x` in the secrets pane: delete the selected scope or key.
1855    pub fn request_secret_delete(&mut self) {
1856        if self.focus != Panel::Secrets {
1857            return;
1858        }
1859        let Some(item) = self.selected_item() else {
1860            return;
1861        };
1862        let name = item.name.clone();
1863        let (message, args) = match &self.secret_scope {
1864            None => (
1865                format!("Delete scope “{name}” and all its secrets?"),
1866                vec!["secrets".to_string(), "delete-scope".to_string(), name],
1867            ),
1868            Some(scope) => (
1869                format!("Delete secret “{name}” from “{scope}”?"),
1870                vec![
1871                    "secrets".to_string(),
1872                    "delete-secret".to_string(),
1873                    scope.clone(),
1874                    name,
1875                ],
1876            ),
1877        };
1878        self.confirm = Some(Confirm {
1879            message,
1880            args,
1881            params: None,
1882        });
1883    }
1884
1885    /// `g` in the secrets pane: the scope's ACLs.
1886    fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1887        let scope = match &self.secret_scope {
1888            Some(s) => Some(s.clone()),
1889            None => self.selected_item().map(|i| i.name.clone()),
1890        };
1891        let Some(scope) = scope else {
1892            return;
1893        };
1894        self.detail = Some(Detail {
1895            panel: Panel::Secrets,
1896            name: scope.clone(),
1897            id: scope.clone(),
1898            kind: None,
1899            section: "Access",
1900            data: None,
1901            show_raw: false,
1902            scroll: 0,
1903        });
1904        let (tx, rx) = oneshot::channel();
1905        self.detail_rx = Some(rx);
1906        let cli = Arc::clone(cli);
1907        tokio::spawn(async move {
1908            let acl_args = ["secrets", "list-acls", &scope];
1909            let data = match cli.run(&acl_args).await {
1910                Ok(json) => {
1911                    let raw =
1912                        serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1913                    // The CLI unwraps to a bare array; REST wraps in "items".
1914                    let acls = json
1915                        .as_array()
1916                        .cloned()
1917                        .or_else(|| json["items"].as_array().cloned())
1918                        .unwrap_or_default();
1919                    let activity: Vec<(Status, String)> = acls
1920                        .iter()
1921                        .map(|a| {
1922                            let principal = a["principal"].as_str().unwrap_or("?");
1923                            let perm = a["permission"].as_str().unwrap_or("?");
1924                            let status = if perm == "MANAGE" {
1925                                Status::Success
1926                            } else {
1927                                Status::Unknown(String::new())
1928                            };
1929                            (status, format!("{principal}  ·  {perm}"))
1930                        })
1931                        .collect();
1932                    DetailData {
1933                        summary: vec![("Scope".to_string(), scope.clone())],
1934                        activity,
1935                        raw,
1936                    }
1937                }
1938                Err(e) => DetailData {
1939                    summary: Vec::new(),
1940                    activity: Vec::new(),
1941                    raw: format!("{e:#}"),
1942                },
1943            };
1944            let _ = tx.send(data);
1945        });
1946    }
1947
1948    /// Looks up a resource's display name by id in the loaded panes —
1949    /// lets the cost view show "nightly-etl" instead of a job id.
1950    pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1951        let idx = match kind {
1952            "cluster" => 0,
1953            "job" => 1,
1954            "warehouse" => 3,
1955            _ => return None,
1956        };
1957        match &self.shapes[idx] {
1958            Some(Shape::List(items)) => items
1959                .iter()
1960                .find(|i| i.id.as_deref() == Some(id))
1961                .map(|i| i.name.clone()),
1962            _ => None,
1963        }
1964    }
1965
1966    /// All known warehouses as (name, id, running).
1967    pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1968        let Some(Shape::List(items)) = &self.shapes[3] else {
1969            return Vec::new();
1970        };
1971        items
1972            .iter()
1973            .filter_map(|i| {
1974                let id = i.id.clone()?;
1975                Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1976            })
1977            .collect()
1978    }
1979
1980    /// Runs a sample-data query for the selected table or view. With
1981    /// `force_pick` (or several warehouses and no remembered choice) a
1982    /// warehouse picker opens first.
1983    pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
1984        if self.focus != Panel::Catalog {
1985            return;
1986        }
1987        let Some(item) = self.selected_item() else {
1988            return;
1989        };
1990        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1991            return;
1992        }
1993        let Some(full_name) = item.id.clone() else {
1994            return;
1995        };
1996        let warehouses = self.warehouses();
1997        if warehouses.is_empty() {
1998            self.flash = Some((
1999                "✗ no SQL warehouse available for previews".to_string(),
2000                Instant::now(),
2001            ));
2002            return;
2003        }
2004        if !force_pick {
2005            if let Some((id, name)) = self.preview_warehouse.clone() {
2006                self.start_preview_query(cli, full_name, id, name);
2007                return;
2008            }
2009            if let [(name, id, _)] = warehouses.as_slice() {
2010                self.preview_warehouse = Some((id.clone(), name.clone()));
2011                self.start_preview_query(cli, full_name, id.clone(), name.clone());
2012                return;
2013            }
2014        }
2015        // Default the cursor to the remembered choice, else a running warehouse.
2016        let index = self
2017            .preview_warehouse
2018            .as_ref()
2019            .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
2020            .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
2021            .unwrap_or(0);
2022        self.wh_picker = Some(WhPicker {
2023            index,
2024            target: PickTarget::Preview(full_name),
2025        });
2026    }
2027
2028    /// Opens the DBU usage view, resolving a warehouse like previews do.
2029    pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
2030        let warehouses = self.warehouses();
2031        if warehouses.is_empty() {
2032            self.flash = Some((
2033                "✗ no SQL warehouse available to query system tables".to_string(),
2034                Instant::now(),
2035            ));
2036            return;
2037        }
2038        if let Some((id, name)) = self.preview_warehouse.clone() {
2039            self.start_cost_query(cli, id, name);
2040            return;
2041        }
2042        if let [(name, id, _)] = warehouses.as_slice() {
2043            self.preview_warehouse = Some((id.clone(), name.clone()));
2044            self.start_cost_query(cli, id.clone(), name.clone());
2045            return;
2046        }
2047        let index = warehouses
2048            .iter()
2049            .position(|(_, _, running)| *running)
2050            .unwrap_or(0);
2051        self.wh_picker = Some(WhPicker {
2052            index,
2053            target: PickTarget::Cost,
2054        });
2055    }
2056
2057    fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
2058        self.cost = Some(CostView {
2059            warehouse: name,
2060            data: None,
2061        });
2062        let (tx, rx) = oneshot::channel();
2063        self.cost_rx = Some(rx);
2064        let cli = Arc::clone(cli);
2065        let host = self.host.clone();
2066        let cached_ws = self.workspace_id.clone();
2067        tokio::spawn(async move {
2068            // Scope usage to this workspace; resolved once, then cached.
2069            let ws = match (cached_ws, host) {
2070                (Some(w), _) => Some(w),
2071                (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
2072                (None, None) => None,
2073            };
2074            let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
2075            let _ = tx.send((result, ws));
2076        });
2077    }
2078
2079    /// Opens the lineage view for the selected table/view; needs a
2080    /// warehouse since lineage lives in system tables.
2081    pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
2082        if self.focus != Panel::Catalog {
2083            return;
2084        }
2085        let Some(item) = self.selected_item() else {
2086            return;
2087        };
2088        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
2089            return;
2090        }
2091        let Some(full_name) = item.id.clone() else {
2092            return;
2093        };
2094        let warehouses = self.warehouses();
2095        if warehouses.is_empty() {
2096            self.flash = Some((
2097                "✗ no SQL warehouse available to query lineage".to_string(),
2098                Instant::now(),
2099            ));
2100            return;
2101        }
2102        if let Some((id, _)) = self.preview_warehouse.clone() {
2103            self.start_lineage_query(cli, full_name, id);
2104            return;
2105        }
2106        if let [(name, id, _)] = warehouses.as_slice() {
2107            self.preview_warehouse = Some((id.clone(), name.clone()));
2108            let id = id.clone();
2109            self.start_lineage_query(cli, full_name, id);
2110            return;
2111        }
2112        let index = warehouses
2113            .iter()
2114            .position(|(_, _, running)| *running)
2115            .unwrap_or(0);
2116        self.wh_picker = Some(WhPicker {
2117            index,
2118            target: PickTarget::Lineage(full_name),
2119        });
2120    }
2121
2122    fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
2123        self.detail = Some(Detail {
2124            panel: Panel::Catalog,
2125            name: full_name.clone(),
2126            id: full_name.clone(),
2127            kind: None,
2128            section: "Lineage",
2129            data: None,
2130            show_raw: false,
2131            scroll: 0,
2132        });
2133        let (tx, rx) = oneshot::channel();
2134        self.detail_rx = Some(rx);
2135        let cli = Arc::clone(cli);
2136        tokio::spawn(async move {
2137            let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
2138            let _ = tx.send(data);
2139        });
2140    }
2141
2142    pub fn close_cost(&mut self) {
2143        self.cost = None;
2144        self.cost_rx = None;
2145    }
2146
2147    /// The fully-qualified name of the selected catalog-pane table/view.
2148    fn selected_table_fqn(&self) -> Option<String> {
2149        if self.focus != Panel::Catalog {
2150            return None;
2151        }
2152        let item = self.selected_item()?;
2153        if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
2154            return None;
2155        }
2156        item.id.clone()
2157    }
2158
2159    /// Opens the SQL console. With a table/view selected in the catalog
2160    /// pane, the prompt starts as an editable query against it.
2161    pub fn open_sql(&mut self) {
2162        if self.sql.is_none() {
2163            let input = self
2164                .selected_table_fqn()
2165                .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
2166                .unwrap_or_default();
2167            self.sql = Some(SqlConsole {
2168                cursor: input.chars().count(),
2169                input,
2170                warehouse: String::new(),
2171                running: false,
2172                data: None,
2173                last_sql: String::new(),
2174                scroll: 0,
2175                col: 0,
2176            });
2177        }
2178    }
2179
2180    pub fn close_sql(&mut self) {
2181        self.sql = None;
2182        self.sql_rx = None;
2183        self.hist_idx = None;
2184        self.hist_draft.clear();
2185        self.hist_search = None;
2186        self.sql_complete = None;
2187    }
2188
2189    /// Tab in the SQL prompt: complete catalog / schema / table / column
2190    /// names from the workspace, fetching each level once per session.
2191    /// A repeat press cycles to the next candidate.
2192    pub fn sql_tab(&mut self, cli: &Arc<DatabricksCli>) {
2193        match &self.sql_complete {
2194            Some(c) if c.loading => return,
2195            Some(_) => {
2196                self.sql_complete_next(1);
2197                return;
2198            }
2199            None => {}
2200        }
2201        let Some(console) = &self.sql else {
2202            return;
2203        };
2204        let (seg_start, context, prefix) = token_at_cursor(&console.input, console.cursor);
2205        // Which cache entry is missing: column names of the FROM table
2206        // matter most for a bare word, then the level being dotted into.
2207        let missing = if context.is_empty() {
2208            from_table(&console.input)
2209                .filter(|fqn| !self.uc_names.contains_key(fqn))
2210                .or_else(|| (!self.uc_names.contains_key("")).then(String::new))
2211        } else {
2212            (!self.uc_names.contains_key(&context)).then(|| context.clone())
2213        };
2214        let loading = missing.is_some();
2215        self.sql_complete = Some(SqlComplete {
2216            items: Vec::new(),
2217            index: 0,
2218            seg_start,
2219            prefix,
2220            context,
2221            loading,
2222        });
2223        match missing {
2224            Some(path) => self.fetch_uc_names(cli, path),
2225            None => self.sql_complete_fill(),
2226        }
2227    }
2228
2229    /// Builds the candidate list from the cache and inserts the first
2230    /// match; single matches complete silently, no popup.
2231    fn sql_complete_fill(&mut self) {
2232        let Some(console) = &self.sql else {
2233            self.sql_complete = None;
2234            return;
2235        };
2236        let input = console.input.clone();
2237        let Some(comp) = &self.sql_complete else {
2238            return;
2239        };
2240        let q = comp.prefix.to_lowercase();
2241        let mut items: Vec<String> = Vec::new();
2242        if comp.context.is_empty() {
2243            if let Some(cols) = from_table(&input).and_then(|fqn| self.uc_names.get(&fqn)) {
2244                items.extend(
2245                    cols.iter()
2246                        .filter(|n| n.to_lowercase().starts_with(&q))
2247                        .cloned(),
2248                );
2249            }
2250            if let Some(cats) = self.uc_names.get("") {
2251                items.extend(
2252                    cats.iter()
2253                        .filter(|n| n.to_lowercase().starts_with(&q))
2254                        .cloned(),
2255                );
2256            }
2257            if !q.is_empty() {
2258                items.extend(
2259                    SQL_KEYWORDS
2260                        .iter()
2261                        .filter(|k| k.to_lowercase().starts_with(&q))
2262                        .map(|k| k.to_string()),
2263                );
2264            }
2265        } else if let Some(names) = self.uc_names.get(&comp.context) {
2266            items.extend(
2267                names
2268                    .iter()
2269                    .filter(|n| n.to_lowercase().starts_with(&q))
2270                    .cloned(),
2271            );
2272        }
2273        items.dedup();
2274        if items.is_empty() {
2275            self.sql_complete = None;
2276            self.flash = Some(("no completions".to_string(), Instant::now()));
2277            return;
2278        }
2279        let single = items.len() == 1;
2280        if let Some(comp) = &mut self.sql_complete {
2281            comp.items = items;
2282            comp.index = 0;
2283        }
2284        self.sql_complete_apply();
2285        if single {
2286            self.sql_complete = None;
2287        }
2288    }
2289
2290    /// Replaces the completed segment with the current candidate.
2291    fn sql_complete_apply(&mut self) {
2292        let Some(comp) = &self.sql_complete else {
2293            return;
2294        };
2295        let candidate = comp.items[comp.index].clone();
2296        let plain = candidate
2297            .chars()
2298            .all(|c| c.is_alphanumeric() || matches!(c, '_' | ' '));
2299        let text = if plain {
2300            candidate
2301        } else {
2302            format!("`{candidate}`")
2303        };
2304        self.sql_replace_segment(comp.seg_start, &text);
2305    }
2306
2307    /// Overwrites seg_start..caret with `text`, caret landing at its end.
2308    fn sql_replace_segment(&mut self, seg_start: usize, text: &str) {
2309        if let Some(console) = &mut self.sql {
2310            let from = byte_at(&console.input, seg_start);
2311            let to = byte_at(&console.input, console.cursor);
2312            console.input.replace_range(from..to, text);
2313            console.cursor = seg_start + text.chars().count();
2314        }
2315    }
2316
2317    /// Tab / shift+tab with the popup open: cycle candidates.
2318    pub fn sql_complete_next(&mut self, delta: i32) {
2319        let Some(comp) = &mut self.sql_complete else {
2320            return;
2321        };
2322        if comp.items.is_empty() {
2323            return;
2324        }
2325        let n = comp.items.len() as i32;
2326        comp.index = (comp.index as i32 + delta).rem_euclid(n) as usize;
2327        self.sql_complete_apply();
2328    }
2329
2330    /// Esc: restore the typed prefix and close the popup.
2331    pub fn sql_complete_cancel(&mut self) {
2332        if let Some(comp) = &self.sql_complete {
2333            let (seg_start, prefix) = (comp.seg_start, comp.prefix.clone());
2334            self.sql_replace_segment(seg_start, &prefix);
2335        }
2336        self.sql_complete = None;
2337    }
2338
2339    /// Keeps the inserted candidate and closes the popup.
2340    pub fn sql_complete_accept(&mut self) {
2341        self.sql_complete = None;
2342    }
2343
2344    fn fetch_uc_names(&mut self, cli: &Arc<DatabricksCli>, path: String) {
2345        let (tx, rx) = oneshot::channel();
2346        self.uc_names_rx = Some(rx);
2347        let cli = Arc::clone(cli);
2348        tokio::spawn(async move {
2349            let names = fetchers::catalog::names(&cli, &path)
2350                .await
2351                .map_err(|e| format!("{e:#}"));
2352            let _ = tx.send((path, names));
2353        });
2354    }
2355
2356    /// Caches fetched completion names and fills the waiting popup.
2357    pub fn poll_uc_names(&mut self) -> bool {
2358        let Some(rx) = &mut self.uc_names_rx else {
2359            return false;
2360        };
2361        match rx.try_recv() {
2362            Ok((path, result)) => {
2363                self.uc_names_rx = None;
2364                match result {
2365                    Ok(names) => {
2366                        self.uc_names.insert(path, names);
2367                        if self.sql_complete.as_ref().is_some_and(|c| c.loading) {
2368                            if let Some(c) = &mut self.sql_complete {
2369                                c.loading = false;
2370                            }
2371                            self.sql_complete_fill();
2372                        }
2373                    }
2374                    Err(e) => {
2375                        self.sql_complete = None;
2376                        let first = e.lines().next().unwrap_or("fetch failed").to_string();
2377                        self.flash = Some((format!("✗ completions: {first}"), Instant::now()));
2378                    }
2379                }
2380                true
2381            }
2382            Err(oneshot::error::TryRecvError::Empty) => false,
2383            Err(oneshot::error::TryRecvError::Closed) => {
2384                self.uc_names_rx = None;
2385                self.sql_complete = None;
2386                true
2387            }
2388        }
2389    }
2390
2391    /// The statement currently in the prompt.
2392    pub fn sql_input(&self) -> Option<String> {
2393        self.sql.as_ref().map(|c| c.input.clone())
2394    }
2395
2396    /// Replaces the prompt contents (after an $EDITOR round-trip).
2397    pub fn sql_set_input(&mut self, s: &str) {
2398        if let Some(console) = &mut self.sql {
2399            console.input = s.to_string();
2400            console.cursor = console.input.chars().count();
2401        }
2402    }
2403
2404    /// The history entry the active Ctrl+R search currently matches.
2405    pub fn hist_search_current(&self) -> Option<&String> {
2406        let (query, nth) = self.hist_search.as_ref()?;
2407        self.sql_history
2408            .iter()
2409            .rev()
2410            .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
2411            .nth(*nth)
2412    }
2413
2414    pub fn hist_search_start(&mut self) {
2415        if self.sql.is_some() {
2416            self.hist_search = Some((String::new(), 0));
2417        }
2418    }
2419
2420    pub fn hist_search_push(&mut self, c: char) {
2421        if let Some((query, nth)) = &mut self.hist_search {
2422            query.push(c);
2423            *nth = 0;
2424        }
2425    }
2426
2427    pub fn hist_search_pop(&mut self) {
2428        if let Some((query, nth)) = &mut self.hist_search {
2429            query.pop();
2430            *nth = 0;
2431        }
2432    }
2433
2434    /// Ctrl+R again: step to the next older match.
2435    pub fn hist_search_older(&mut self) {
2436        let Some((query, nth)) = &self.hist_search else {
2437            return;
2438        };
2439        let q = query.to_lowercase();
2440        let matches = self
2441            .sql_history
2442            .iter()
2443            .filter(|h| h.to_lowercase().contains(&q))
2444            .count();
2445        if nth + 1 < matches {
2446            if let Some((_, n)) = &mut self.hist_search {
2447                *n += 1;
2448            }
2449        }
2450    }
2451
2452    pub fn hist_search_accept(&mut self) {
2453        if let Some(stmt) = self.hist_search_current().cloned() {
2454            self.sql_set_input(&stmt);
2455        }
2456        self.hist_search = None;
2457    }
2458
2459    pub fn hist_search_cancel(&mut self) {
2460        self.hist_search = None;
2461    }
2462
2463    pub fn sql_push(&mut self, c: char) {
2464        if let Some(console) = &mut self.sql {
2465            let at = byte_at(&console.input, console.cursor);
2466            console.input.insert(at, c);
2467            console.cursor += 1;
2468        }
2469    }
2470
2471    /// Backspace: deletes the character before the caret.
2472    pub fn sql_pop(&mut self) {
2473        if let Some(console) = &mut self.sql {
2474            if console.cursor > 0 {
2475                let at = byte_at(&console.input, console.cursor - 1);
2476                console.input.remove(at);
2477                console.cursor -= 1;
2478            }
2479        }
2480    }
2481
2482    /// Delete: removes the character under the caret.
2483    pub fn sql_delete(&mut self) {
2484        if let Some(console) = &mut self.sql {
2485            if console.cursor < console.input.chars().count() {
2486                let at = byte_at(&console.input, console.cursor);
2487                console.input.remove(at);
2488            }
2489        }
2490    }
2491
2492    pub fn sql_left(&mut self) {
2493        if let Some(console) = &mut self.sql {
2494            console.cursor = console.cursor.saturating_sub(1);
2495        }
2496    }
2497
2498    pub fn sql_right(&mut self) {
2499        if let Some(console) = &mut self.sql {
2500            console.cursor = (console.cursor + 1).min(console.input.chars().count());
2501        }
2502    }
2503
2504    /// ↑ at the prompt: step back through history, stashing the draft.
2505    pub fn sql_hist_prev(&mut self) {
2506        let Some(console) = &mut self.sql else {
2507            return;
2508        };
2509        if self.sql_history.is_empty() {
2510            return;
2511        }
2512        let idx = match self.hist_idx {
2513            None => {
2514                self.hist_draft = console.input.clone();
2515                self.sql_history.len() - 1
2516            }
2517            Some(i) => i.saturating_sub(1),
2518        };
2519        self.hist_idx = Some(idx);
2520        console.input = self.sql_history[idx].clone();
2521        console.cursor = console.input.chars().count();
2522    }
2523
2524    /// ↓ at the prompt: step forward, back to the stashed draft at the end.
2525    pub fn sql_hist_next(&mut self) {
2526        let Some(console) = &mut self.sql else {
2527            return;
2528        };
2529        let Some(idx) = self.hist_idx else {
2530            return;
2531        };
2532        if idx + 1 < self.sql_history.len() {
2533            self.hist_idx = Some(idx + 1);
2534            console.input = self.sql_history[idx + 1].clone();
2535        } else {
2536            self.hist_idx = None;
2537            console.input = self.hist_draft.clone();
2538        }
2539        console.cursor = console.input.chars().count();
2540    }
2541
2542    pub fn sql_home(&mut self) {
2543        if let Some(console) = &mut self.sql {
2544            console.cursor = 0;
2545        }
2546    }
2547
2548    pub fn sql_end(&mut self) {
2549        if let Some(console) = &mut self.sql {
2550            console.cursor = console.input.chars().count();
2551        }
2552    }
2553
2554    pub fn sql_scroll(&mut self, delta: i32) {
2555        if let Some(console) = &mut self.sql {
2556            let max = match &console.data {
2557                Some(Ok(t)) => t.rows.len().saturating_sub(1),
2558                _ => 0,
2559            };
2560            console.scroll = if delta < 0 {
2561                console.scroll.saturating_sub(delta.unsigned_abs() as usize)
2562            } else {
2563                (console.scroll + delta as usize).min(max)
2564            };
2565        }
2566    }
2567
2568    /// Shift+←/→ in the console: page result columns.
2569    pub fn sql_cols(&mut self, delta: i32) {
2570        if let Some(console) = &mut self.sql {
2571            let n = match &console.data {
2572                Some(Ok(t)) => t.headers.len(),
2573                _ => 0,
2574            };
2575            console.col = if delta < 0 {
2576                console.col.saturating_sub(1)
2577            } else {
2578                (console.col + 1).min(n.saturating_sub(1))
2579            };
2580        }
2581    }
2582
2583    /// Runs the typed statement, resolving a warehouse like previews do.
2584    pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
2585        let Some(console) = &self.sql else {
2586            return;
2587        };
2588        if console.running {
2589            return;
2590        }
2591        let query = console.input.trim().to_string();
2592        if query.is_empty() {
2593            return;
2594        }
2595        // Remember the statement (skipping immediate repeats) and reset
2596        // any in-progress history browsing.
2597        if self.sql_history.last() != Some(&query) {
2598            self.sql_history.push(query.clone());
2599            save_history(&self.sql_history);
2600        }
2601        self.hist_idx = None;
2602        self.hist_draft.clear();
2603        let warehouses = self.warehouses();
2604        if warehouses.is_empty() {
2605            self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
2606            return;
2607        }
2608        if let Some((id, name)) = self.preview_warehouse.clone() {
2609            self.start_sql_query(cli, query, id, name);
2610            return;
2611        }
2612        if let [(name, id, _)] = warehouses.as_slice() {
2613            self.preview_warehouse = Some((id.clone(), name.clone()));
2614            self.start_sql_query(cli, query, id.clone(), name.clone());
2615            return;
2616        }
2617        let index = warehouses
2618            .iter()
2619            .position(|(_, _, running)| *running)
2620            .unwrap_or(0);
2621        self.wh_picker = Some(WhPicker {
2622            index,
2623            target: PickTarget::Sql(query),
2624        });
2625    }
2626
2627    fn start_sql_query(
2628        &mut self,
2629        cli: &Arc<DatabricksCli>,
2630        query: String,
2631        id: String,
2632        name: String,
2633    ) {
2634        if let Some(console) = &mut self.sql {
2635            console.running = true;
2636            console.warehouse = name;
2637            console.scroll = 0;
2638            console.last_sql = query.clone();
2639        }
2640        // Published by the task once submitted, so Esc can cancel it.
2641        let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
2642        self.sql_stmt = Some(std::sync::Arc::clone(&handle));
2643        let (tx, rx) = oneshot::channel();
2644        self.sql_rx = Some(rx);
2645        let cli = Arc::clone(cli);
2646        tokio::spawn(async move {
2647            let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
2648            let _ = tx.send(result);
2649        });
2650    }
2651
2652    /// Writes a result set to a timestamped CSV in the working directory
2653    /// and flashes the path.
2654    fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
2655        let stamp = std::time::SystemTime::now()
2656            .duration_since(std::time::UNIX_EPOCH)
2657            .map(|d| d.as_secs())
2658            .unwrap_or(0);
2659        let slug: String = label
2660            .chars()
2661            .map(|c| if c.is_alphanumeric() { c } else { '-' })
2662            .collect::<String>()
2663            .trim_matches('-')
2664            .chars()
2665            .take(40)
2666            .collect();
2667        let name = format!("databricks-{slug}-{stamp}.csv");
2668        let msg = match std::fs::write(&name, data.to_csv()) {
2669            Ok(()) => {
2670                let cwd = std::env::current_dir()
2671                    .map(|d| d.display().to_string())
2672                    .unwrap_or_default();
2673                format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
2674            }
2675            Err(e) => format!("✗ export failed: {e}"),
2676        };
2677        self.flash = Some((msg, Instant::now()));
2678    }
2679
2680    /// Ctrl+S in the console: export the current results.
2681    pub fn sql_export(&mut self) {
2682        if let Some(SqlConsole {
2683            data: Some(Ok(data)),
2684            last_sql,
2685            ..
2686        }) = &self.sql
2687        {
2688            let (label, data) = (last_sql.clone(), data.clone());
2689            self.export_csv(&label, &data);
2690        }
2691    }
2692
2693    /// ←/→ in a preview: page columns in the grid, switch rows in
2694    /// record view.
2695    pub fn preview_h(&mut self, delta: i32) {
2696        let Some(pv) = &mut self.preview else {
2697            return;
2698        };
2699        if pv.record {
2700            let max = match &pv.data {
2701                Some(Ok(t)) => t.rows.len().saturating_sub(1),
2702                _ => 0,
2703            };
2704            pv.scroll = if delta < 0 {
2705                pv.scroll.saturating_sub(1)
2706            } else {
2707                (pv.scroll + 1).min(max)
2708            };
2709            pv.rscroll = 0;
2710        } else {
2711            let cols = pv.visible_cols().len();
2712            pv.col = if delta < 0 {
2713                pv.col.saturating_sub(1)
2714            } else {
2715                (pv.col + 1).min(cols.saturating_sub(1))
2716            };
2717        }
2718    }
2719
2720    /// `v`/enter in a preview: transposed view of the top visible row.
2721    pub fn preview_toggle_record(&mut self) {
2722        if let Some(pv) = &mut self.preview {
2723            if matches!(&pv.data, Some(Ok(t)) if !t.rows.is_empty()) {
2724                pv.record = !pv.record;
2725                pv.rscroll = 0;
2726            }
2727        }
2728    }
2729
2730    pub fn preview_filter_start(&mut self) {
2731        if let Some(pv) = &mut self.preview {
2732            pv.filter.clear();
2733            pv.filter_entry = true;
2734            pv.col = 0;
2735            pv.rscroll = 0;
2736        }
2737    }
2738
2739    pub fn preview_filter_push(&mut self, c: char) {
2740        if let Some(pv) = &mut self.preview {
2741            pv.filter.push(c);
2742            pv.col = 0;
2743            pv.rscroll = 0;
2744        }
2745    }
2746
2747    pub fn preview_filter_pop(&mut self) {
2748        if let Some(pv) = &mut self.preview {
2749            pv.filter.pop();
2750            pv.col = 0;
2751        }
2752    }
2753
2754    pub fn preview_filter_accept(&mut self) {
2755        if let Some(pv) = &mut self.preview {
2756            pv.filter_entry = false;
2757        }
2758    }
2759
2760    pub fn preview_filter_clear(&mut self) {
2761        if let Some(pv) = &mut self.preview {
2762            pv.filter.clear();
2763            pv.filter_entry = false;
2764            pv.col = 0;
2765            pv.rscroll = 0;
2766        }
2767    }
2768
2769    /// `e` in a table preview: export the sampled rows.
2770    pub fn preview_export(&mut self) {
2771        if let Some(Preview {
2772            data: Some(Ok(data)),
2773            name,
2774            ..
2775        }) = &self.preview
2776        {
2777            let (label, data) = (name.clone(), data.clone());
2778            self.export_csv(&label, &data);
2779        }
2780    }
2781
2782    pub fn poll_sql(&mut self) -> bool {
2783        let Some(rx) = &mut self.sql_rx else {
2784            return false;
2785        };
2786        match rx.try_recv() {
2787            Ok(result) => {
2788                // A warehouse that errors shouldn't stay the session
2789                // default — but a user-canceled statement is not its fault.
2790                if let Err(e) = &result {
2791                    if e != "statement canceled" {
2792                        self.preview_warehouse = None;
2793                    }
2794                }
2795                if let Some(console) = &mut self.sql {
2796                    console.running = false;
2797                    console.data = Some(result);
2798                    console.col = 0;
2799                }
2800                self.sql_rx = None;
2801                self.sql_stmt = None;
2802                true
2803            }
2804            Err(oneshot::error::TryRecvError::Empty) => false,
2805            Err(oneshot::error::TryRecvError::Closed) => {
2806                if let Some(console) = &mut self.sql {
2807                    console.running = false;
2808                }
2809                self.sql_rx = None;
2810                true
2811            }
2812        }
2813    }
2814
2815    pub fn poll_cost(&mut self) -> bool {
2816        let Some(rx) = &mut self.cost_rx else {
2817            return false;
2818        };
2819        match rx.try_recv() {
2820            Ok((result, ws)) => {
2821                if result.is_err() {
2822                    self.preview_warehouse = None;
2823                }
2824                if ws.is_some() {
2825                    self.workspace_id = ws;
2826                }
2827                if let Some(cv) = &mut self.cost {
2828                    cv.data = Some(result);
2829                }
2830                self.cost_rx = None;
2831                true
2832            }
2833            Err(oneshot::error::TryRecvError::Empty) => false,
2834            Err(oneshot::error::TryRecvError::Closed) => {
2835                self.cost_rx = None;
2836                true
2837            }
2838        }
2839    }
2840
2841    pub fn wh_picker_next(&mut self) {
2842        let len = self.warehouses().len();
2843        if let Some(p) = &mut self.wh_picker {
2844            p.index = (p.index + 1).min(len.saturating_sub(1));
2845        }
2846    }
2847
2848    pub fn wh_picker_prev(&mut self) {
2849        if let Some(p) = &mut self.wh_picker {
2850            p.index = p.index.saturating_sub(1);
2851        }
2852    }
2853
2854    pub fn wh_picker_cancel(&mut self) {
2855        self.wh_picker = None;
2856    }
2857
2858    /// Confirms the warehouse choice, remembers it, and starts the preview.
2859    pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
2860        let Some(picker) = self.wh_picker.take() else {
2861            return;
2862        };
2863        let warehouses = self.warehouses();
2864        let Some((name, id, _)) = warehouses.get(picker.index) else {
2865            return;
2866        };
2867        self.preview_warehouse = Some((id.clone(), name.clone()));
2868        // An explicit choice is worth remembering across sessions.
2869        let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
2870        self.config
2871            .warehouses
2872            .insert(profile, (id.clone(), name.clone()));
2873        self.config.save();
2874        match picker.target {
2875            PickTarget::Preview(table) => {
2876                self.start_preview_query(cli, table, id.clone(), name.clone())
2877            }
2878            PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
2879            PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
2880            PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
2881        }
2882    }
2883
2884    fn start_preview_query(
2885        &mut self,
2886        cli: &Arc<DatabricksCli>,
2887        full_name: String,
2888        warehouse_id: String,
2889        warehouse_name: String,
2890    ) {
2891        self.preview = Some(Preview {
2892            name: full_name.clone(),
2893            warehouse: warehouse_name,
2894            warehouse_id: warehouse_id.clone(),
2895            data: None,
2896            scroll: 0,
2897            col: 0,
2898            filter: String::new(),
2899            filter_entry: false,
2900            record: false,
2901            rscroll: 0,
2902        });
2903        let (tx, rx) = oneshot::channel();
2904        self.preview_rx = Some(rx);
2905        let cli = Arc::clone(cli);
2906        tokio::spawn(async move {
2907            let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
2908            let _ = tx.send(result);
2909        });
2910    }
2911
2912    pub fn close_preview(&mut self) {
2913        self.preview = None;
2914        self.preview_rx = None;
2915    }
2916
2917    pub fn poll_preview(&mut self) -> bool {
2918        let Some(rx) = &mut self.preview_rx else {
2919            return false;
2920        };
2921        match rx.try_recv() {
2922            Ok(result) => {
2923                // A warehouse that errors shouldn't stay the session default.
2924                if result.is_err() {
2925                    self.preview_warehouse = None;
2926                }
2927                if let Some(pv) = &mut self.preview {
2928                    pv.data = Some(result);
2929                }
2930                self.preview_rx = None;
2931                true
2932            }
2933            Err(oneshot::error::TryRecvError::Empty) => false,
2934            Err(oneshot::error::TryRecvError::Closed) => {
2935                self.preview_rx = None;
2936                true
2937            }
2938        }
2939    }
2940
2941    pub fn preview_scroll(&mut self, delta: i32) {
2942        if let Some(pv) = &mut self.preview {
2943            // Record view: j/k walk the fields, not the rows.
2944            if pv.record {
2945                let max = pv.visible_cols().len().saturating_sub(1) as u16;
2946                pv.rscroll = if delta < 0 {
2947                    pv.rscroll.saturating_sub(delta.unsigned_abs() as u16)
2948                } else {
2949                    pv.rscroll.saturating_add(delta as u16).min(max)
2950                };
2951                return;
2952            }
2953            let max = match &pv.data {
2954                Some(Ok(t)) => t.rows.len().saturating_sub(1),
2955                _ => 0,
2956            };
2957            pv.scroll = if delta < 0 {
2958                pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
2959            } else {
2960                (pv.scroll + delta as usize).min(max)
2961            };
2962        }
2963    }
2964
2965    pub fn poll_uc(&mut self) -> bool {
2966        let Some(rx) = &mut self.uc_rx else {
2967            return false;
2968        };
2969        match rx.try_recv() {
2970            Ok(result) => {
2971                self.shapes[5] = Some(match result {
2972                    Ok(shape) => shape,
2973                    Err(e) => Shape::Text(format!("✗ {e}")),
2974                });
2975                self.updated_at[5] = Some(Instant::now());
2976                self.uc_rx = None;
2977                true
2978            }
2979            Err(oneshot::error::TryRecvError::Empty) => false,
2980            Err(oneshot::error::TryRecvError::Closed) => {
2981                self.uc_rx = None;
2982                true
2983            }
2984        }
2985    }
2986
2987    /// Opens the access view for the selected item: effective UC grants
2988    /// or the workspace object ACL.
2989    pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
2990        if self.focus == Panel::Secrets {
2991            return self.open_secret_acls(cli);
2992        }
2993        let Some(item) = self.selected_item() else {
2994            return;
2995        };
2996        let Some(id) = item.id.clone() else {
2997            return;
2998        };
2999        let (uc, object_type): (bool, &'static str) = match self.focus {
3000            Panel::Catalog => match &item.status {
3001                Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
3002                Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
3003                Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
3004                Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
3005                _ => return,
3006            },
3007            Panel::Clusters => (false, "clusters"),
3008            Panel::Jobs => (false, "jobs"),
3009            Panel::Pipelines => (false, "pipelines"),
3010            Panel::Warehouses => (false, "warehouses"),
3011            Panel::Dashboards => (false, "dashboards"),
3012            // Secrets ACLs are handled by open_secret_acls above.
3013            Panel::Secrets => return,
3014        };
3015        self.detail = Some(Detail {
3016            panel: self.focus,
3017            name: item.name.clone(),
3018            id: id.clone(),
3019            kind: None,
3020            section: "Access",
3021            data: None,
3022            show_raw: false,
3023            scroll: 0,
3024        });
3025        let (tx, rx) = oneshot::channel();
3026        self.detail_rx = Some(rx);
3027        let cli = Arc::clone(cli);
3028        tokio::spawn(async move {
3029            let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
3030            let _ = tx.send(data);
3031        });
3032    }
3033
3034    /// Drills from an open job or pipeline detail into its most recent
3035    /// run/update.
3036    pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
3037        let Some(d) = &self.detail else {
3038            return;
3039        };
3040        let panel = d.panel;
3041        if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
3042            return;
3043        }
3044        let owner_id = d.id.clone();
3045        self.run_view = Some(RunView {
3046            panel,
3047            owner_name: d.name.clone(),
3048            owner_id: owner_id.clone(),
3049            runs: Vec::new(),
3050            idx: 0,
3051            data: None,
3052            show_raw: false,
3053            scroll: 0,
3054            live: false,
3055            output: None,
3056            show_output: false,
3057            show_timeline: false,
3058            show_dag: false,
3059            show_grid: false,
3060            grid: None,
3061            fetched_at: Instant::now(),
3062        });
3063        let (tx, rx) = oneshot::channel();
3064        self.run_rx = Some(rx);
3065        let cli = Arc::clone(cli);
3066        tokio::spawn(async move {
3067            let result = async {
3068                let runs = if panel == Panel::Jobs {
3069                    fetchers::runs::list(&cli, &owner_id).await?
3070                } else {
3071                    fetchers::updates::list(&cli, &owner_id).await?
3072                };
3073                let Some((run_id, _, _)) = runs.first().cloned() else {
3074                    return Err("no runs recorded yet".to_string());
3075                };
3076                let (data, live) = if panel == Panel::Jobs {
3077                    fetchers::runs::fetch(&cli, &run_id).await
3078                } else {
3079                    fetchers::updates::fetch(&cli, &owner_id, &run_id).await
3080                };
3081                Ok((runs, data, live))
3082            }
3083            .await;
3084            let _ = tx.send(RunUpdate::Opened(result));
3085        });
3086    }
3087
3088    pub fn close_run(&mut self) {
3089        self.run_view = None;
3090        self.run_rx = None;
3091        self.grid_rx = None;
3092    }
3093
3094    /// Moves to an older (delta > 0) or newer (delta < 0) run.
3095    pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
3096        if self.run_rx.is_some() {
3097            return;
3098        }
3099        let Some(rv) = &mut self.run_view else {
3100            return;
3101        };
3102        if rv.runs.is_empty() {
3103            return;
3104        }
3105        let new = if delta < 0 {
3106            rv.idx.saturating_sub(delta.unsigned_abs() as usize)
3107        } else {
3108            (rv.idx + delta as usize).min(rv.runs.len() - 1)
3109        };
3110        if new == rv.idx {
3111            return;
3112        }
3113        rv.idx = new;
3114        rv.data = None;
3115        rv.scroll = 0;
3116        rv.show_raw = false;
3117        rv.output = None;
3118        rv.show_output = false;
3119        let run_id = rv.runs[new].0.clone();
3120        self.start_run_fetch(cli, run_id);
3121    }
3122
3123    fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
3124        let Some(rv) = &self.run_view else {
3125            return;
3126        };
3127        let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
3128        let (tx, rx) = oneshot::channel();
3129        self.run_rx = Some(rx);
3130        let cli = Arc::clone(cli);
3131        tokio::spawn(async move {
3132            let (data, live) = if panel == Panel::Jobs {
3133                fetchers::runs::fetch(&cli, &run_id).await
3134            } else {
3135                fetchers::updates::fetch(&cli, &owner_id, &run_id).await
3136            };
3137            let _ = tx.send(RunUpdate::Detail(data, live));
3138        });
3139    }
3140
3141    /// `o` in the run view: toggles the full output/log view, fetching
3142    /// all task outputs on first use.
3143    pub fn run_toggle_output(&mut self, cli: &Arc<DatabricksCli>) {
3144        let Some(rv) = &mut self.run_view else {
3145            return;
3146        };
3147        if rv.panel != Panel::Jobs {
3148            self.flash = Some((
3149                "✗ output view is for job runs — pipeline events are already inline".to_string(),
3150                Instant::now(),
3151            ));
3152            return;
3153        }
3154        if rv.show_output {
3155            rv.show_output = false;
3156            rv.scroll = 0;
3157            return;
3158        }
3159        if rv.output.is_none() && self.run_rx.is_some() {
3160            self.flash = Some((
3161                "⏳ run still loading — try again in a moment".to_string(),
3162                Instant::now(),
3163            ));
3164            return;
3165        }
3166        rv.show_output = true;
3167        rv.scroll = 0;
3168        if rv.output.is_some() {
3169            return;
3170        }
3171        self.start_output_fetch(cli);
3172    }
3173
3174    fn start_output_fetch(&mut self, cli: &Arc<DatabricksCli>) {
3175        let Some(rv) = &self.run_view else {
3176            return;
3177        };
3178        let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() else {
3179            return;
3180        };
3181        let (tx, rx) = oneshot::channel();
3182        self.run_rx = Some(rx);
3183        let cli = Arc::clone(cli);
3184        tokio::spawn(async move {
3185            let (text, live) = fetchers::runs::full_output(&cli, &run_id).await;
3186            let _ = tx.send(RunUpdate::Output(text, live));
3187        });
3188    }
3189
3190    /// `r` in the run view: rerun only the failed tasks of the shown run.
3191    pub fn request_run_repair(&mut self) {
3192        let Some(rv) = &self.run_view else {
3193            return;
3194        };
3195        if rv.panel != Panel::Jobs {
3196            self.flash = Some((
3197                "✗ repair applies to job runs only".to_string(),
3198                Instant::now(),
3199            ));
3200            return;
3201        }
3202        if rv.live {
3203            self.flash = Some((
3204                "✗ run is still executing — cancel it first (s)".to_string(),
3205                Instant::now(),
3206            ));
3207            return;
3208        }
3209        let Some((run_id, status, _)) = rv.runs.get(rv.idx) else {
3210            return;
3211        };
3212        if matches!(status, Status::Success) {
3213            self.flash = Some((
3214                "✗ run succeeded — nothing to repair".to_string(),
3215                Instant::now(),
3216            ));
3217            return;
3218        }
3219        self.confirm = Some(Confirm {
3220            message: format!(
3221                "Repair run {run_id} of “{}” (reruns only the failed tasks)?",
3222                rv.owner_name
3223            ),
3224            args: vec![
3225                "jobs".to_string(),
3226                "repair-run".to_string(),
3227                run_id.clone(),
3228                "--rerun-all-failed-tasks".to_string(),
3229            ],
3230            params: None,
3231        });
3232    }
3233
3234    /// `t` in the run view: per-task execution timeline of a job run.
3235    pub fn run_toggle_timeline(&mut self) {
3236        let Some(rv) = &mut self.run_view else {
3237            return;
3238        };
3239        if rv.panel != Panel::Jobs {
3240            self.flash = Some((
3241                "✗ timeline is for job runs — pipeline events are already inline".to_string(),
3242                Instant::now(),
3243            ));
3244            return;
3245        }
3246        rv.show_timeline = !rv.show_timeline;
3247        rv.show_dag = false;
3248        rv.show_grid = false;
3249        rv.scroll = 0;
3250    }
3251
3252    /// `d` in the run view: dependency tree of the run's tasks.
3253    pub fn run_toggle_dag(&mut self) {
3254        let Some(rv) = &mut self.run_view else {
3255            return;
3256        };
3257        if rv.panel != Panel::Jobs {
3258            self.flash = Some((
3259                "✗ the task DAG is for job runs — pipelines have no task graph here".to_string(),
3260                Instant::now(),
3261            ));
3262            return;
3263        }
3264        rv.show_dag = !rv.show_dag;
3265        rv.show_timeline = false;
3266        rv.show_grid = false;
3267        rv.scroll = 0;
3268    }
3269
3270    /// `g` in the run view: history grid — every task's state across the
3271    /// job's recent runs, with duration trends.
3272    pub fn run_toggle_grid(&mut self, cli: &Arc<DatabricksCli>) {
3273        let Some(rv) = &mut self.run_view else {
3274            return;
3275        };
3276        if rv.panel != Panel::Jobs {
3277            self.flash = Some((
3278                "✗ the history grid is for job runs — updates have no task matrix".to_string(),
3279                Instant::now(),
3280            ));
3281            return;
3282        }
3283        rv.show_grid = !rv.show_grid;
3284        rv.show_timeline = false;
3285        rv.show_dag = false;
3286        rv.scroll = 0;
3287        if !rv.show_grid || rv.grid.is_some() || self.grid_rx.is_some() {
3288            return;
3289        }
3290        let job_id = rv.owner_id.clone();
3291        let (tx, rx) = oneshot::channel();
3292        self.grid_rx = Some(rx);
3293        let cli = Arc::clone(cli);
3294        tokio::spawn(async move {
3295            let _ = tx.send(fetchers::runs::grid(&cli, &job_id).await);
3296        });
3297    }
3298
3299    pub fn poll_grid(&mut self) -> bool {
3300        let Some(rx) = &mut self.grid_rx else {
3301            return false;
3302        };
3303        match rx.try_recv() {
3304            Ok(result) => {
3305                self.grid_rx = None;
3306                if let Some(rv) = &mut self.run_view {
3307                    rv.grid = Some(result);
3308                }
3309                true
3310            }
3311            Err(oneshot::error::TryRecvError::Empty) => false,
3312            Err(oneshot::error::TryRecvError::Closed) => {
3313                self.grid_rx = None;
3314                true
3315            }
3316        }
3317    }
3318
3319    pub fn run_toggle_raw(&mut self) {
3320        if let Some(rv) = &mut self.run_view {
3321            rv.show_raw = !rv.show_raw;
3322            rv.scroll = 0;
3323        }
3324    }
3325
3326    pub fn run_scroll(&mut self, delta: i32) {
3327        if let Some(rv) = &mut self.run_view {
3328            rv.scroll = if delta < 0 {
3329                rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
3330            } else {
3331                rv.scroll.saturating_add(delta as u16)
3332            };
3333        }
3334    }
3335
3336    /// Applies run fetch results; also re-polls a live run every few
3337    /// seconds so an executing run's tasks update on their own.
3338    pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3339        if let Some(rx) = &mut self.run_rx {
3340            match rx.try_recv() {
3341                Ok(update) => {
3342                    self.run_rx = None;
3343                    if let Some(rv) = &mut self.run_view {
3344                        match update {
3345                            RunUpdate::Opened(Ok((runs, data, live))) => {
3346                                rv.runs = runs;
3347                                rv.idx = 0;
3348                                rv.data = Some(data);
3349                                rv.live = live;
3350                            }
3351                            RunUpdate::Opened(Err(e)) => {
3352                                rv.data = Some(DetailData {
3353                                    summary: Vec::new(),
3354                                    activity: Vec::new(),
3355                                    raw: format!("✗ {e}"),
3356                                });
3357                                rv.live = false;
3358                            }
3359                            RunUpdate::Detail(data, live) => {
3360                                rv.data = Some(data);
3361                                rv.live = live;
3362                            }
3363                            RunUpdate::Output(text, live) => {
3364                                rv.output = Some(text);
3365                                rv.live = live;
3366                            }
3367                        }
3368                        rv.fetched_at = Instant::now();
3369                    }
3370                    true
3371                }
3372                Err(oneshot::error::TryRecvError::Empty) => false,
3373                Err(oneshot::error::TryRecvError::Closed) => {
3374                    self.run_rx = None;
3375                    true
3376                }
3377            }
3378        } else if let Some(rv) = &self.run_view {
3379            if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
3380                if rv.show_output {
3381                    // Live tail: keep re-fetching output so task results
3382                    // stream in as they finish.
3383                    if rv.output.is_some() {
3384                        self.start_output_fetch(cli);
3385                    }
3386                } else if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
3387                    self.start_run_fetch(cli, run_id);
3388                }
3389            }
3390            false
3391        } else {
3392            false
3393        }
3394    }
3395
3396    pub fn close_detail(&mut self) {
3397        self.detail = None;
3398        self.detail_rx = None;
3399    }
3400
3401    pub fn toggle_raw(&mut self) {
3402        if let Some(d) = &mut self.detail {
3403            d.show_raw = !d.show_raw;
3404            d.scroll = 0;
3405        }
3406    }
3407
3408    /// Applies a finished detail fetch; returns true if the UI should redraw.
3409    pub fn poll_detail(&mut self) -> bool {
3410        let Some(rx) = &mut self.detail_rx else {
3411            return false;
3412        };
3413        match rx.try_recv() {
3414            Ok(data) => {
3415                if let Some(d) = &mut self.detail {
3416                    d.data = Some(data);
3417                }
3418                self.detail_rx = None;
3419                true
3420            }
3421            Err(oneshot::error::TryRecvError::Empty) => false,
3422            Err(oneshot::error::TryRecvError::Closed) => {
3423                self.detail_rx = None;
3424                true
3425            }
3426        }
3427    }
3428
3429    pub fn detail_scroll(&mut self, delta: i32) {
3430        if let Some(d) = &mut self.detail {
3431            let max = match &d.data {
3432                Some(data) if d.show_raw => data.raw.lines().count(),
3433                Some(data) => data.summary.len() + data.activity.len() + 3,
3434                None => 0,
3435            } as u16;
3436            d.scroll = if delta < 0 {
3437                d.scroll.saturating_sub(delta.unsigned_abs() as u16)
3438            } else {
3439                (d.scroll + delta as u16).min(max.saturating_sub(1))
3440            };
3441        }
3442    }
3443
3444    /// Prepares a contextual action for the selected item, pending confirmation:
3445    /// start/stop for clusters, warehouses and pipelines, run-now for jobs.
3446    pub fn request_action(&mut self) {
3447        // Dashboards, Unity Catalog and secrets have no start/stop/run semantics.
3448        if matches!(
3449            self.focus,
3450            Panel::Dashboards | Panel::Catalog | Panel::Secrets
3451        ) {
3452            return;
3453        }
3454        let Some(item) = self.selected_item() else {
3455            return;
3456        };
3457        let Some(id) = item.id.clone() else {
3458            return;
3459        };
3460        let name = item.name.clone();
3461        let active = matches!(
3462            item.status,
3463            Status::Running | Status::Pending | Status::Success
3464        );
3465        let group = self.focus.cli_group();
3466        let (verb, action): (&str, &str) = match self.focus {
3467            Panel::Jobs => ("Run", "run-now"),
3468            Panel::Clusters if active => ("Stop", "delete"),
3469            Panel::Pipelines if active => ("Stop", "stop"),
3470            Panel::Pipelines => ("Start update for", "start-update"),
3471            _ if active => ("Stop", "stop"),
3472            _ => ("Start", "start"),
3473        };
3474        self.confirm = Some(Confirm {
3475            message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
3476            params: (self.focus == Panel::Jobs).then(|| (id.clone(), name.clone())),
3477            args: vec![group.to_string(), action.to_string(), id],
3478        });
3479    }
3480
3481    /// `S` on the jobs pane: pause or resume the selected job's schedule,
3482    /// trigger or continuous mode. No confirm — it's a symmetric toggle,
3483    /// undone by pressing S again.
3484    pub fn request_schedule_toggle(&mut self, cli: &Arc<DatabricksCli>) {
3485        if self.focus != Panel::Jobs {
3486            self.flash = Some((
3487                "✗ schedule pause applies to jobs — focus the Lakeflow pane".to_string(),
3488                Instant::now(),
3489            ));
3490            return;
3491        }
3492        let Some(item) = self.selected_item() else {
3493            return;
3494        };
3495        let Some(id) = item.id.clone() else {
3496            return;
3497        };
3498        let name = item.name.clone();
3499        if self.action_rx.is_some() {
3500            self.flash = Some((
3501                "⏳ another action is still in flight".to_string(),
3502                Instant::now(),
3503            ));
3504            return;
3505        }
3506        self.flash = Some((format!("⏳ toggling schedule of “{name}”…"), Instant::now()));
3507        let (tx, rx) = oneshot::channel();
3508        self.action_rx = Some(rx);
3509        let cli = Arc::clone(cli);
3510        tokio::spawn(async move {
3511            let _ = tx.send(fetchers::jobs::toggle_pause(&cli, &id, &name).await);
3512        });
3513    }
3514
3515    /// `s` in the run view: cancel the shown run/update after a confirm.
3516    pub fn request_run_cancel(&mut self) {
3517        let Some(rv) = &self.run_view else {
3518            return;
3519        };
3520        if !rv.live {
3521            self.flash = Some((
3522                "✗ nothing to cancel — this run already finished".to_string(),
3523                Instant::now(),
3524            ));
3525            return;
3526        }
3527        let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3528            return;
3529        };
3530        let (message, args) = if rv.panel == Panel::Jobs {
3531            (
3532                format!("Cancel run {run_id} of “{}”?", rv.owner_name),
3533                vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
3534            )
3535        } else {
3536            (
3537                format!("Stop “{}” (cancels the active update)?", rv.owner_name),
3538                vec![
3539                    "pipelines".to_string(),
3540                    "stop".to_string(),
3541                    rv.owner_id.clone(),
3542                ],
3543            )
3544        };
3545        self.confirm = Some(Confirm {
3546            message,
3547            args,
3548            params: None,
3549        });
3550    }
3551
3552    /// Cancels the in-flight console statement server-side; the polling
3553    /// task then sees CANCELED and surfaces it in the results pane.
3554    pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
3555        let id = self
3556            .sql_stmt
3557            .as_ref()
3558            .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
3559        let Some(id) = id else {
3560            self.flash = Some((
3561                "✗ statement not submitted yet — try again in a moment".to_string(),
3562                Instant::now(),
3563            ));
3564            return;
3565        };
3566        let cli = Arc::clone(cli);
3567        tokio::spawn(async move {
3568            let path = format!("/api/2.0/sql/statements/{id}/cancel");
3569            let _ = cli.run_action(&["api", "post", &path]).await;
3570        });
3571        self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
3572    }
3573
3574    pub fn cancel_confirm(&mut self) {
3575        self.confirm = None;
3576    }
3577
3578    pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
3579        let Some(c) = self.confirm.take() else {
3580            return;
3581        };
3582        let base = c.message.trim_end_matches('?').to_string();
3583        self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3584
3585        let (tx, rx) = oneshot::channel();
3586        self.action_rx = Some(rx);
3587        let cli = Arc::clone(cli);
3588        tokio::spawn(async move {
3589            let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
3590            let result = match cli.run_action(&args).await {
3591                Ok(()) => Ok(format!("✓ {base} — done")),
3592                Err(e) => Err(format!("✗ {e:#}")),
3593            };
3594            let _ = tx.send(result);
3595        });
3596    }
3597
3598    /// `p` on a job-run confirm: swaps the plain trigger for the
3599    /// run-with-parameters prompt, prefilled with the job's defaults.
3600    pub fn open_param_form(&mut self, cli: &Arc<DatabricksCli>) {
3601        let Some((job_id, name)) = self.confirm.take().and_then(|c| c.params) else {
3602            return;
3603        };
3604        self.param_form = Some(ParamForm {
3605            job_id: job_id.clone(),
3606            job: name,
3607            input: String::new(),
3608            cursor: 0,
3609            kind: fetchers::jobs::ParamKind::Notebook,
3610            loading: true,
3611        });
3612        let (tx, rx) = oneshot::channel();
3613        self.param_rx = Some(rx);
3614        let cli = Arc::clone(cli);
3615        tokio::spawn(async move {
3616            let _ = tx.send(fetchers::jobs::params(&cli, &job_id).await);
3617        });
3618    }
3619
3620    /// Delivers the parameter prefill into the open form.
3621    pub fn poll_param(&mut self) -> bool {
3622        let Some(rx) = &mut self.param_rx else {
3623            return false;
3624        };
3625        match rx.try_recv() {
3626            Ok(result) => {
3627                self.param_rx = None;
3628                let Some(form) = &mut self.param_form else {
3629                    return true;
3630                };
3631                match result {
3632                    Ok((pairs, kind)) => {
3633                        form.input = pairs
3634                            .iter()
3635                            .map(|(k, v)| format!("{k}={v}"))
3636                            .collect::<Vec<_>>()
3637                            .join(", ");
3638                        form.cursor = form.input.chars().count();
3639                        form.kind = kind;
3640                        form.loading = false;
3641                    }
3642                    Err(e) => {
3643                        self.param_form = None;
3644                        self.flash = Some((e, Instant::now()));
3645                    }
3646                }
3647                true
3648            }
3649            Err(oneshot::error::TryRecvError::Empty) => false,
3650            Err(oneshot::error::TryRecvError::Closed) => {
3651                self.param_rx = None;
3652                self.param_form = None;
3653                true
3654            }
3655        }
3656    }
3657
3658    pub fn param_push(&mut self, c: char) {
3659        if let Some(form) = self.param_form.as_mut().filter(|f| !f.loading) {
3660            let at = byte_at(&form.input, form.cursor);
3661            form.input.insert(at, c);
3662            form.cursor += 1;
3663        }
3664    }
3665
3666    pub fn param_pop(&mut self) {
3667        if let Some(form) = self.param_form.as_mut().filter(|f| !f.loading) {
3668            if form.cursor > 0 {
3669                form.cursor -= 1;
3670                let at = byte_at(&form.input, form.cursor);
3671                form.input.remove(at);
3672            }
3673        }
3674    }
3675
3676    pub fn param_left(&mut self) {
3677        if let Some(form) = &mut self.param_form {
3678            form.cursor = form.cursor.saturating_sub(1);
3679        }
3680    }
3681
3682    pub fn param_right(&mut self) {
3683        if let Some(form) = &mut self.param_form {
3684            form.cursor = (form.cursor + 1).min(form.input.chars().count());
3685        }
3686    }
3687
3688    pub fn param_home(&mut self) {
3689        if let Some(form) = &mut self.param_form {
3690            form.cursor = 0;
3691        }
3692    }
3693
3694    pub fn param_end(&mut self) {
3695        if let Some(form) = &mut self.param_form {
3696            form.cursor = form.input.chars().count();
3697        }
3698    }
3699
3700    /// Enter on the parameter prompt: triggers the run with the edited
3701    /// overrides via `run-now --json`; an empty prompt runs as-is.
3702    pub fn param_submit(&mut self, cli: &Arc<DatabricksCli>) {
3703        let Some(form) = &self.param_form else {
3704            return;
3705        };
3706        if form.loading {
3707            return;
3708        }
3709        let pairs = match parse_params(&form.input) {
3710            Ok(pairs) => pairs,
3711            Err(e) => {
3712                self.flash = Some((e, Instant::now()));
3713                return;
3714            }
3715        };
3716        let Some(form) = self.param_form.take() else {
3717            return;
3718        };
3719        let Ok(id) = form.job_id.parse::<u64>() else {
3720            self.flash = Some((format!("✗ bad job id: {}", form.job_id), Instant::now()));
3721            return;
3722        };
3723        let (base, args) = if pairs.is_empty() {
3724            (
3725                format!("Run job “{}”", form.job),
3726                vec!["jobs".to_string(), "run-now".to_string(), form.job_id],
3727            )
3728        } else {
3729            let map: serde_json::Map<String, serde_json::Value> = pairs
3730                .into_iter()
3731                .map(|(k, v)| (k, serde_json::Value::String(v)))
3732                .collect();
3733            let payload =
3734                serde_json::json!({"job_id": id, form.kind.payload_key(): map}).to_string();
3735            (
3736                format!("Run job “{}” with parameters", form.job),
3737                vec![
3738                    "jobs".to_string(),
3739                    "run-now".to_string(),
3740                    "--json".to_string(),
3741                    payload,
3742                ],
3743            )
3744        };
3745        self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3746        let (tx, rx) = oneshot::channel();
3747        self.action_rx = Some(rx);
3748        let cli = Arc::clone(cli);
3749        tokio::spawn(async move {
3750            let args: Vec<&str> = args.iter().map(String::as_str).collect();
3751            let result = match cli.run_action(&args).await {
3752                Ok(()) => Ok(format!("✓ {base} — done")),
3753                Err(e) => Err(format!("✗ {e:#}")),
3754            };
3755            let _ = tx.send(result);
3756        });
3757    }
3758
3759    pub fn close_param_form(&mut self) {
3760        self.param_form = None;
3761        self.param_rx = None;
3762    }
3763
3764    /// `W` in the run view: watch the shown run — bell + flash when it
3765    /// finishes, however long that takes and wherever you are by then.
3766    pub fn toggle_watch(&mut self) {
3767        let Some(rv) = &self.run_view else {
3768            return;
3769        };
3770        if rv.panel != Panel::Jobs {
3771            self.flash = Some((
3772                "✗ watching is for job runs — pipeline updates refresh inline".to_string(),
3773                Instant::now(),
3774            ));
3775            return;
3776        }
3777        let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3778            return;
3779        };
3780        if let Some(pos) = self.watched.iter().position(|w| w.run_id == *run_id) {
3781            let w = self.watched.remove(pos);
3782            self.flash = Some((
3783                format!("✓ no longer watching run {} of “{}”", w.run_id, w.job),
3784                Instant::now(),
3785            ));
3786            return;
3787        }
3788        if !rv.live {
3789            self.flash = Some((
3790                "✗ this run already finished — nothing to watch".to_string(),
3791                Instant::now(),
3792            ));
3793            return;
3794        }
3795        self.watched.push(Watched {
3796            run_id: run_id.clone(),
3797            job: rv.owner_name.clone(),
3798        });
3799        self.flash = Some((
3800            format!(
3801                "👁 watching run {} of “{}” — bell when it finishes",
3802                run_id, rv.owner_name
3803            ),
3804            Instant::now(),
3805        ));
3806    }
3807
3808    /// Re-checks watched runs every few seconds; a finished one rings
3809    /// the bell, flashes its result and leaves the list.
3810    pub fn poll_watch(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3811        if let Some(rx) = &mut self.watch_rx {
3812            return match rx.try_recv() {
3813                Ok(states) => {
3814                    self.watch_rx = None;
3815                    let mut done: Vec<(String, Status)> = Vec::new();
3816                    for (run_id, state) in states {
3817                        // Errors are left in place: most are transient,
3818                        // and the next poll retries anyway.
3819                        if let Ok((status, false)) = state {
3820                            if let Some(pos) = self.watched.iter().position(|w| w.run_id == run_id)
3821                            {
3822                                done.push((self.watched.remove(pos).job, status));
3823                            }
3824                        }
3825                    }
3826                    if let Some((job, status)) = done.first() {
3827                        let extra = if done.len() > 1 {
3828                            format!(" (+{} more)", done.len() - 1)
3829                        } else {
3830                            String::new()
3831                        };
3832                        self.flash = Some((
3833                            if matches!(status, Status::Failed) {
3834                                format!("✗ watched run of “{job}” FAILED{extra} — ! to inspect")
3835                            } else {
3836                                format!("🔔 “{job}” run finished: {}{extra}", status.label())
3837                            },
3838                            Instant::now(),
3839                        ));
3840                        print!("\x07");
3841                        let _ = std::io::Write::flush(&mut std::io::stdout());
3842                    }
3843                    !done.is_empty()
3844                }
3845                Err(oneshot::error::TryRecvError::Empty) => false,
3846                Err(oneshot::error::TryRecvError::Closed) => {
3847                    self.watch_rx = None;
3848                    false
3849                }
3850            };
3851        }
3852        if self.watched.is_empty() || self.watch_at.elapsed() < WATCH_INTERVAL {
3853            return false;
3854        }
3855        self.watch_at = Instant::now();
3856        let ids: Vec<String> = self.watched.iter().map(|w| w.run_id.clone()).collect();
3857        let (tx, rx) = oneshot::channel();
3858        self.watch_rx = Some(rx);
3859        let cli = Arc::clone(cli);
3860        tokio::spawn(async move {
3861            let mut out = Vec::with_capacity(ids.len());
3862            for id in ids {
3863                out.push((id.clone(), fetchers::runs::state(&cli, &id).await));
3864            }
3865            let _ = tx.send(out);
3866        });
3867        false
3868    }
3869
3870    /// Applies a finished action; refreshes on success. Returns true on change.
3871    pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3872        let Some(rx) = &mut self.action_rx else {
3873            return false;
3874        };
3875        match rx.try_recv() {
3876            Ok(result) => {
3877                let ok = result.is_ok();
3878                self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
3879                self.action_rx = None;
3880                if ok {
3881                    self.start_refresh(cli);
3882                    // A confirmed action from the run view (cancel/repair)
3883                    // changes the shown run — reflect it without a manual nav.
3884                    if self.run_rx.is_none() {
3885                        let current = self
3886                            .run_view
3887                            .as_ref()
3888                            .and_then(|rv| rv.runs.get(rv.idx).cloned());
3889                        if let Some((run_id, _, _)) = current {
3890                            self.start_run_fetch(cli, run_id);
3891                        }
3892                    }
3893                }
3894                true
3895            }
3896            Err(oneshot::error::TryRecvError::Empty) => false,
3897            Err(oneshot::error::TryRecvError::Closed) => {
3898                self.action_rx = None;
3899                true
3900            }
3901        }
3902    }
3903
3904    /// Drops the flash message once it has been visible long enough.
3905    pub fn expire_flash(&mut self) -> bool {
3906        if let Some((_, since)) = &self.flash {
3907            if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
3908                self.flash = None;
3909                return true;
3910            }
3911        }
3912        false
3913    }
3914
3915    /// Opens the selected item (or the open detail view) in the workspace web UI.
3916    pub fn open_in_browser(&self) {
3917        let Some(host) = &self.host else {
3918            return;
3919        };
3920        let (panel, id) = match &self.detail {
3921            Some(d) => (d.panel, Some(d.id.clone())),
3922            None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
3923        };
3924        let Some(id) = id else {
3925            return;
3926        };
3927        let path = match panel {
3928            Panel::Clusters => format!("compute/clusters/{id}"),
3929            Panel::Jobs => format!("jobs/{id}"),
3930            Panel::Pipelines => format!("pipelines/{id}"),
3931            Panel::Warehouses => format!("sql/warehouses/{id}"),
3932            Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
3933            Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
3934            // Secret scopes have no workspace-UI page.
3935            Panel::Secrets => return,
3936        };
3937        let url = format!("{}/{}", host.trim_end_matches('/'), path);
3938        #[cfg(target_os = "macos")]
3939        let opener = "open";
3940        #[cfg(not(target_os = "macos"))]
3941        let opener = "xdg-open";
3942        let _ = std::process::Command::new(opener).arg(url).spawn();
3943    }
3944
3945    /// Counts of (ok, pending, failed, idle) items across all panels.
3946    pub fn status_counts(&self) -> (usize, usize, usize, usize) {
3947        let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
3948        for shape in self.shapes.iter().flatten() {
3949            if let Shape::List(items) = shape {
3950                for item in items {
3951                    match item.status {
3952                        Status::Running | Status::Success => ok += 1,
3953                        Status::Pending => pending += 1,
3954                        Status::Failed => failed += 1,
3955                        Status::Stopped => idle += 1,
3956                        Status::Unknown(_) => {}
3957                    }
3958                }
3959            }
3960        }
3961        (ok, pending, failed, idle)
3962    }
3963
3964    pub fn last_refresh_age(&self) -> Duration {
3965        self.last_refresh.elapsed()
3966    }
3967
3968    pub fn spinner(&self) -> &'static str {
3969        SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
3970    }
3971
3972    pub fn spinner_frame(&self) -> usize {
3973        self.spinner_frame
3974    }
3975
3976    /// True whenever any background work is in flight — the loop uses this
3977    /// to keep spinners ticking, not just during panel refreshes.
3978    pub fn busy(&self) -> bool {
3979        self.loading
3980            || self.detail_rx.is_some()
3981            || self.action_rx.is_some()
3982            || self.preview_rx.is_some()
3983            || self.cost_rx.is_some()
3984            || self.sql_rx.is_some()
3985            || self.run_rx.is_some()
3986    }
3987
3988    pub fn tick_spinner(&mut self) {
3989        self.spinner_frame = self.spinner_frame.wrapping_add(1);
3990    }
3991
3992    pub fn toggle_zoom(&mut self) {
3993        self.zoomed = !self.zoomed;
3994    }
3995
3996    pub fn focus_next(&mut self) {
3997        self.cycle_focus(1);
3998    }
3999
4000    pub fn focus_prev(&mut self) {
4001        self.cycle_focus(-1);
4002    }
4003
4004    /// Cycles focus through the visible panes in display order.
4005    fn cycle_focus(&mut self, delta: i32) {
4006        let visible = self.visible_panes();
4007        if visible.is_empty() {
4008            return;
4009        }
4010        let focus_idx = Panel::ALL
4011            .iter()
4012            .position(|p| p == &self.focus)
4013            .unwrap_or(0);
4014        let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
4015        let n = visible.len() as i32;
4016        let next = ((pos as i32 + delta) % n + n) % n;
4017        self.focus = Panel::ALL[visible[next as usize]];
4018    }
4019
4020    pub fn needs_refresh(&self) -> bool {
4021        !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
4022    }
4023
4024    pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
4025        if self.loading {
4026            return;
4027        }
4028        self.loading = true;
4029        self.error = None;
4030        self.last_refresh = Instant::now();
4031
4032        let (tx, rx) = mpsc::unbounded_channel();
4033        self.pending = Some(rx);
4034        self.in_flight = 8;
4035
4036        // One task per source so each panel updates as soon as its fetch lands,
4037        // instead of waiting for the slowest of the five.
4038        macro_rules! spawn_fetch {
4039            ($update:expr, $fetch:path) => {{
4040                let cli = Arc::clone(cli);
4041                let tx = tx.clone();
4042                tokio::spawn(async move {
4043                    let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
4044                    let _ = tx.send($update(result));
4045                });
4046            }};
4047        }
4048
4049        spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
4050        spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
4051        spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
4052        spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
4053        spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
4054        spawn_fetch!(
4055            |s: Result<Shape, String>| Update::Badge(s.ok()),
4056            fetchers::current_user::fetch
4057        );
4058        {
4059            let cli = Arc::clone(cli);
4060            let tx = tx.clone();
4061            let path = self.uc_path.clone();
4062            tokio::spawn(async move {
4063                let result = fetchers::catalog::fetch(&cli, &path)
4064                    .await
4065                    .map_err(|e| format!("{e:#}"));
4066                let _ = tx.send(Update::Panel(5, result));
4067            });
4068        }
4069        {
4070            let cli = Arc::clone(cli);
4071            let tx = tx.clone();
4072            let scope = self.secret_scope.clone();
4073            tokio::spawn(async move {
4074                let result = fetchers::secrets::fetch(&cli, scope.as_deref())
4075                    .await
4076                    .map_err(|e| format!("{e:#}"));
4077                let _ = tx.send(Update::Panel(6, result));
4078            });
4079        }
4080    }
4081
4082    /// Applies any fetch results that have arrived; returns true if the UI should redraw.
4083    pub fn poll_refresh(&mut self) -> bool {
4084        let Some(rx) = &mut self.pending else {
4085            return false;
4086        };
4087        let mut changed = false;
4088        let mut updated_panes: Vec<usize> = Vec::new();
4089        loop {
4090            match rx.try_recv() {
4091                Ok(Update::Panel(i, result)) => {
4092                    match result {
4093                        Ok(mut shape) => {
4094                            // Active work floats to the top of every pane
4095                            // except the catalog, which stays browsable
4096                            // in its natural (alphabetical) order.
4097                            if i != 5 {
4098                                if let Shape::List(items) = &mut shape {
4099                                    items.sort_by_key(|it| {
4100                                        (it.status.rank(), it.history.is_empty())
4101                                    });
4102                                }
4103                            }
4104                            self.shapes[i] = Some(shape);
4105                            self.updated_at[i] = Some(Instant::now());
4106                            updated_panes.push(i);
4107                        }
4108                        // Keep previous data on failure so panels don't blank
4109                        // out — but surface the error if there's nothing yet.
4110                        Err(e) => {
4111                            if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
4112                                self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
4113                            }
4114                        }
4115                    }
4116                    self.in_flight -= 1;
4117                    changed = true;
4118                }
4119                Ok(Update::Badge(badge)) => {
4120                    if badge.is_some() {
4121                        self.user_badge = badge;
4122                    }
4123                    self.in_flight -= 1;
4124                    changed = true;
4125                }
4126                Err(mpsc::error::TryRecvError::Empty) => break,
4127                Err(mpsc::error::TryRecvError::Disconnected) => {
4128                    self.in_flight = 0;
4129                    break;
4130                }
4131            }
4132        }
4133        for i in updated_panes {
4134            self.alert_new_failures(i);
4135        }
4136        if self.in_flight == 0 {
4137            self.loading = false;
4138            self.pending = None;
4139            changed = true;
4140        }
4141        changed
4142    }
4143}
4144
4145#[cfg(test)]
4146mod tests {
4147    use super::{from_table, parse_params, token_at_cursor};
4148
4149    #[test]
4150    fn parse_params_pairs_and_errors() {
4151        assert_eq!(parse_params(""), Ok(vec![]));
4152        assert_eq!(
4153            parse_params(" date=2026-07-18, mode=full "),
4154            Ok(vec![
4155                ("date".to_string(), "2026-07-18".to_string()),
4156                ("mode".to_string(), "full".to_string())
4157            ])
4158        );
4159        // Values keep embedded '='; empty values are fine.
4160        assert_eq!(
4161            parse_params("expr=a=b, flag="),
4162            Ok(vec![
4163                ("expr".to_string(), "a=b".to_string()),
4164                ("flag".to_string(), String::new())
4165            ])
4166        );
4167        assert!(parse_params("no-equals-here").is_err());
4168        assert!(parse_params("=orphan").is_err());
4169    }
4170
4171    #[test]
4172    fn token_bare_word() {
4173        let (start, ctx, prefix) = token_at_cursor("SELECT * FROM ma", 16);
4174        assert_eq!((start, ctx.as_str(), prefix.as_str()), (14, "", "ma"));
4175    }
4176
4177    #[test]
4178    fn token_dotted_path() {
4179        let (start, ctx, prefix) = token_at_cursor("SELECT * FROM main.sales.or", 27);
4180        assert_eq!(
4181            (start, ctx.as_str(), prefix.as_str()),
4182            (25, "main.sales", "or")
4183        );
4184    }
4185
4186    #[test]
4187    fn token_trailing_dot() {
4188        let (start, ctx, prefix) = token_at_cursor("main.", 5);
4189        assert_eq!((start, ctx.as_str(), prefix.as_str()), (5, "main", ""));
4190    }
4191
4192    #[test]
4193    fn token_mid_input() {
4194        // Caret inside the statement, not at the end.
4195        let (start, ctx, prefix) = token_at_cursor("SELECT co FROM t", 9);
4196        assert_eq!((start, ctx.as_str(), prefix.as_str()), (7, "", "co"));
4197    }
4198
4199    #[test]
4200    fn from_table_fully_qualified() {
4201        assert_eq!(
4202            from_table("SELECT x FROM main.sales.orders WHERE x > 1").as_deref(),
4203            Some("main.sales.orders")
4204        );
4205    }
4206
4207    #[test]
4208    fn from_table_rejects_partial_names() {
4209        assert_eq!(from_table("SELECT x FROM orders"), None);
4210        assert_eq!(from_table("SELECT x FROM main.sales."), None);
4211        assert_eq!(from_table("SELECT 1"), None);
4212    }
4213}