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