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