Skip to main content

databricks_tui/
app.rs

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