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