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