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