Skip to main content

databricks_tui/
app.rs

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