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_scroll(&mut self, delta: i32) {
2666 if let Some(console) = &mut self.sql {
2667 if console.record {
2669 let max = match &console.data {
2670 Some(Ok(t)) => t.headers.len().saturating_sub(1) as u16,
2671 _ => 0,
2672 };
2673 console.rscroll = if delta < 0 {
2674 console.rscroll.saturating_sub(delta.unsigned_abs() as u16)
2675 } else {
2676 console.rscroll.saturating_add(delta as u16).min(max)
2677 };
2678 return;
2679 }
2680 let max = match &console.data {
2681 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2682 _ => 0,
2683 };
2684 console.scroll = if delta < 0 {
2685 console.scroll.saturating_sub(delta.unsigned_abs() as usize)
2686 } else {
2687 (console.scroll + delta as usize).min(max)
2688 };
2689 }
2690 }
2691
2692 pub fn sql_cols(&mut self, delta: i32) {
2696 if let Some(console) = &mut self.sql {
2697 if console.record {
2698 let max = match &console.data {
2699 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2700 _ => 0,
2701 };
2702 console.scroll = if delta < 0 {
2703 console.scroll.saturating_sub(1)
2704 } else {
2705 (console.scroll + 1).min(max)
2706 };
2707 console.rscroll = 0;
2708 return;
2709 }
2710 let n = match &console.data {
2711 Some(Ok(t)) => t.headers.len(),
2712 _ => 0,
2713 };
2714 console.col = if delta < 0 {
2715 console.col.saturating_sub(1)
2716 } else {
2717 (console.col + 1).min(n.saturating_sub(1))
2718 };
2719 }
2720 }
2721
2722 pub fn sql_toggle_record(&mut self) {
2725 if let Some(console) = &mut self.sql {
2726 if matches!(&console.data, Some(Ok(t)) if !t.rows.is_empty()) {
2727 console.record = !console.record;
2728 console.rscroll = 0;
2729 }
2730 }
2731 }
2732
2733 pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
2735 let Some(console) = &self.sql else {
2736 return;
2737 };
2738 if console.running {
2739 return;
2740 }
2741 let query = console.input.trim().to_string();
2742 if query.is_empty() {
2743 return;
2744 }
2745 if self.sql_history.last() != Some(&query) {
2748 self.sql_history.push(query.clone());
2749 save_history(&self.sql_history);
2750 }
2751 self.hist_idx = None;
2752 self.hist_draft.clear();
2753 let warehouses = self.warehouses();
2754 if warehouses.is_empty() {
2755 self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
2756 return;
2757 }
2758 if let Some((id, name)) = self.preview_warehouse.clone() {
2759 self.start_sql_query(cli, query, id, name);
2760 return;
2761 }
2762 if let [(name, id, _)] = warehouses.as_slice() {
2763 self.preview_warehouse = Some((id.clone(), name.clone()));
2764 self.start_sql_query(cli, query, id.clone(), name.clone());
2765 return;
2766 }
2767 let index = warehouses
2768 .iter()
2769 .position(|(_, _, running)| *running)
2770 .unwrap_or(0);
2771 self.wh_picker = Some(WhPicker {
2772 index,
2773 target: PickTarget::Sql(query),
2774 });
2775 }
2776
2777 fn start_sql_query(
2778 &mut self,
2779 cli: &Arc<DatabricksCli>,
2780 query: String,
2781 id: String,
2782 name: String,
2783 ) {
2784 if let Some(console) = &mut self.sql {
2785 console.running = true;
2786 console.warehouse = name;
2787 console.scroll = 0;
2788 console.rscroll = 0;
2789 console.record = false;
2791 console.last_sql = query.clone();
2792 }
2793 let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
2795 self.sql_stmt = Some(std::sync::Arc::clone(&handle));
2796 let (tx, rx) = oneshot::channel();
2797 self.sql_rx = Some(rx);
2798 let cli = Arc::clone(cli);
2799 tokio::spawn(async move {
2800 let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
2801 let _ = tx.send(result);
2802 });
2803 }
2804
2805 fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
2808 let stamp = std::time::SystemTime::now()
2809 .duration_since(std::time::UNIX_EPOCH)
2810 .map(|d| d.as_secs())
2811 .unwrap_or(0);
2812 let slug: String = label
2813 .chars()
2814 .map(|c| if c.is_alphanumeric() { c } else { '-' })
2815 .collect::<String>()
2816 .trim_matches('-')
2817 .chars()
2818 .take(40)
2819 .collect();
2820 let name = format!("databricks-{slug}-{stamp}.csv");
2821 let msg = match std::fs::write(&name, data.to_csv()) {
2822 Ok(()) => {
2823 let cwd = std::env::current_dir()
2824 .map(|d| d.display().to_string())
2825 .unwrap_or_default();
2826 format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
2827 }
2828 Err(e) => format!("✗ export failed: {e}"),
2829 };
2830 self.flash = Some((msg, Instant::now()));
2831 }
2832
2833 pub fn sql_export(&mut self) {
2835 if let Some(SqlConsole {
2836 data: Some(Ok(data)),
2837 last_sql,
2838 ..
2839 }) = &self.sql
2840 {
2841 let (label, data) = (last_sql.clone(), data.clone());
2842 self.export_csv(&label, &data);
2843 }
2844 }
2845
2846 pub fn preview_h(&mut self, delta: i32) {
2849 let Some(pv) = &mut self.preview else {
2850 return;
2851 };
2852 if pv.record {
2853 let max = match &pv.data {
2854 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2855 _ => 0,
2856 };
2857 pv.scroll = if delta < 0 {
2858 pv.scroll.saturating_sub(1)
2859 } else {
2860 (pv.scroll + 1).min(max)
2861 };
2862 pv.rscroll = 0;
2863 } else {
2864 let cols = pv.visible_cols().len();
2865 pv.col = if delta < 0 {
2866 pv.col.saturating_sub(1)
2867 } else {
2868 (pv.col + 1).min(cols.saturating_sub(1))
2869 };
2870 }
2871 }
2872
2873 pub fn preview_toggle_record(&mut self) {
2875 if let Some(pv) = &mut self.preview {
2876 if matches!(&pv.data, Some(Ok(t)) if !t.rows.is_empty()) {
2877 pv.record = !pv.record;
2878 pv.rscroll = 0;
2879 }
2880 }
2881 }
2882
2883 pub fn preview_filter_start(&mut self) {
2884 if let Some(pv) = &mut self.preview {
2885 pv.filter.clear();
2886 pv.filter_entry = true;
2887 pv.col = 0;
2888 pv.rscroll = 0;
2889 }
2890 }
2891
2892 pub fn preview_filter_push(&mut self, c: char) {
2893 if let Some(pv) = &mut self.preview {
2894 pv.filter.push(c);
2895 pv.col = 0;
2896 pv.rscroll = 0;
2897 }
2898 }
2899
2900 pub fn preview_filter_pop(&mut self) {
2901 if let Some(pv) = &mut self.preview {
2902 pv.filter.pop();
2903 pv.col = 0;
2904 }
2905 }
2906
2907 pub fn preview_filter_accept(&mut self) {
2908 if let Some(pv) = &mut self.preview {
2909 pv.filter_entry = false;
2910 }
2911 }
2912
2913 pub fn preview_filter_clear(&mut self) {
2914 if let Some(pv) = &mut self.preview {
2915 pv.filter.clear();
2916 pv.filter_entry = false;
2917 pv.col = 0;
2918 pv.rscroll = 0;
2919 }
2920 }
2921
2922 pub fn preview_export(&mut self) {
2924 if let Some(Preview {
2925 data: Some(Ok(data)),
2926 name,
2927 ..
2928 }) = &self.preview
2929 {
2930 let (label, data) = (name.clone(), data.clone());
2931 self.export_csv(&label, &data);
2932 }
2933 }
2934
2935 pub fn poll_sql(&mut self) -> bool {
2936 let Some(rx) = &mut self.sql_rx else {
2937 return false;
2938 };
2939 match rx.try_recv() {
2940 Ok(result) => {
2941 if let Err(e) = &result {
2944 if e != "statement canceled" {
2945 self.preview_warehouse = None;
2946 }
2947 }
2948 if let Some(console) = &mut self.sql {
2949 console.running = false;
2950 console.data = Some(result);
2951 console.col = 0;
2952 }
2953 self.sql_rx = None;
2954 self.sql_stmt = None;
2955 true
2956 }
2957 Err(oneshot::error::TryRecvError::Empty) => false,
2958 Err(oneshot::error::TryRecvError::Closed) => {
2959 if let Some(console) = &mut self.sql {
2960 console.running = false;
2961 }
2962 self.sql_rx = None;
2963 true
2964 }
2965 }
2966 }
2967
2968 pub fn poll_cost(&mut self) -> bool {
2969 let Some(rx) = &mut self.cost_rx else {
2970 return false;
2971 };
2972 match rx.try_recv() {
2973 Ok((result, ws)) => {
2974 if result.is_err() {
2975 self.preview_warehouse = None;
2976 }
2977 if ws.is_some() {
2978 self.workspace_id = ws;
2979 }
2980 if let Some(cv) = &mut self.cost {
2981 cv.data = Some(result);
2982 }
2983 self.cost_rx = None;
2984 true
2985 }
2986 Err(oneshot::error::TryRecvError::Empty) => false,
2987 Err(oneshot::error::TryRecvError::Closed) => {
2988 self.cost_rx = None;
2989 true
2990 }
2991 }
2992 }
2993
2994 pub fn poll_item_cost(&mut self) -> bool {
2995 let Some(rx) = &mut self.item_cost_rx else {
2996 return false;
2997 };
2998 match rx.try_recv() {
2999 Ok((result, ws)) => {
3000 if result.is_err() {
3001 self.preview_warehouse = None;
3002 }
3003 if ws.is_some() {
3004 self.workspace_id = ws;
3005 }
3006 if let Some(cv) = &mut self.item_cost {
3007 cv.data = Some(result);
3008 }
3009 self.item_cost_rx = None;
3010 true
3011 }
3012 Err(oneshot::error::TryRecvError::Empty) => false,
3013 Err(oneshot::error::TryRecvError::Closed) => {
3014 self.item_cost_rx = None;
3015 true
3016 }
3017 }
3018 }
3019
3020 pub fn wh_picker_next(&mut self) {
3021 let len = self.warehouses().len();
3022 if let Some(p) = &mut self.wh_picker {
3023 p.index = (p.index + 1).min(len.saturating_sub(1));
3024 }
3025 }
3026
3027 pub fn wh_picker_prev(&mut self) {
3028 if let Some(p) = &mut self.wh_picker {
3029 p.index = p.index.saturating_sub(1);
3030 }
3031 }
3032
3033 pub fn wh_picker_cancel(&mut self) {
3034 self.wh_picker = None;
3035 }
3036
3037 pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
3039 let Some(picker) = self.wh_picker.take() else {
3040 return;
3041 };
3042 let warehouses = self.warehouses();
3043 let Some((name, id, _)) = warehouses.get(picker.index) else {
3044 return;
3045 };
3046 self.preview_warehouse = Some((id.clone(), name.clone()));
3047 let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
3049 self.config
3050 .warehouses
3051 .insert(profile, (id.clone(), name.clone()));
3052 self.config.save();
3053 match picker.target {
3054 PickTarget::Preview(table) => {
3055 self.start_preview_query(cli, table, id.clone(), name.clone())
3056 }
3057 PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
3058 PickTarget::ItemCost(kind, item_id, item_name) => {
3059 self.start_item_cost_query(cli, id.clone(), name.clone(), kind, item_id, item_name)
3060 }
3061 PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
3062 PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
3063 }
3064 }
3065
3066 fn start_preview_query(
3067 &mut self,
3068 cli: &Arc<DatabricksCli>,
3069 full_name: String,
3070 warehouse_id: String,
3071 warehouse_name: String,
3072 ) {
3073 self.preview = Some(Preview {
3074 name: full_name.clone(),
3075 warehouse: warehouse_name,
3076 warehouse_id: warehouse_id.clone(),
3077 data: None,
3078 scroll: 0,
3079 col: 0,
3080 filter: String::new(),
3081 filter_entry: false,
3082 record: false,
3083 rscroll: 0,
3084 });
3085 let (tx, rx) = oneshot::channel();
3086 self.preview_rx = Some(rx);
3087 let cli = Arc::clone(cli);
3088 tokio::spawn(async move {
3089 let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
3090 let _ = tx.send(result);
3091 });
3092 }
3093
3094 pub fn close_preview(&mut self) {
3095 self.preview = None;
3096 self.preview_rx = None;
3097 }
3098
3099 pub fn poll_preview(&mut self) -> bool {
3100 let Some(rx) = &mut self.preview_rx else {
3101 return false;
3102 };
3103 match rx.try_recv() {
3104 Ok(result) => {
3105 if result.is_err() {
3107 self.preview_warehouse = None;
3108 }
3109 if let Some(pv) = &mut self.preview {
3110 pv.data = Some(result);
3111 }
3112 self.preview_rx = None;
3113 true
3114 }
3115 Err(oneshot::error::TryRecvError::Empty) => false,
3116 Err(oneshot::error::TryRecvError::Closed) => {
3117 self.preview_rx = None;
3118 true
3119 }
3120 }
3121 }
3122
3123 pub fn preview_scroll(&mut self, delta: i32) {
3124 if let Some(pv) = &mut self.preview {
3125 if pv.record {
3127 let max = pv.visible_cols().len().saturating_sub(1) as u16;
3128 pv.rscroll = if delta < 0 {
3129 pv.rscroll.saturating_sub(delta.unsigned_abs() as u16)
3130 } else {
3131 pv.rscroll.saturating_add(delta as u16).min(max)
3132 };
3133 return;
3134 }
3135 let max = match &pv.data {
3136 Some(Ok(t)) => t.rows.len().saturating_sub(1),
3137 _ => 0,
3138 };
3139 pv.scroll = if delta < 0 {
3140 pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
3141 } else {
3142 (pv.scroll + delta as usize).min(max)
3143 };
3144 }
3145 }
3146
3147 pub fn poll_uc(&mut self) -> bool {
3148 let Some(rx) = &mut self.uc_rx else {
3149 return false;
3150 };
3151 match rx.try_recv() {
3152 Ok(result) => {
3153 self.shapes[5] = Some(match result {
3154 Ok(shape) => shape,
3155 Err(e) => Shape::Text(format!("✗ {e}")),
3156 });
3157 self.updated_at[5] = Some(Instant::now());
3158 self.uc_rx = None;
3159 true
3160 }
3161 Err(oneshot::error::TryRecvError::Empty) => false,
3162 Err(oneshot::error::TryRecvError::Closed) => {
3163 self.uc_rx = None;
3164 true
3165 }
3166 }
3167 }
3168
3169 pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
3172 if self.focus == Panel::Secrets {
3173 return self.open_secret_acls(cli);
3174 }
3175 let Some(item) = self.selected_item() else {
3176 return;
3177 };
3178 let Some(id) = item.id.clone() else {
3179 return;
3180 };
3181 let (uc, object_type): (bool, &'static str) = match self.focus {
3182 Panel::Catalog => match &item.status {
3183 Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
3184 Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
3185 Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
3186 Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
3187 _ => return,
3188 },
3189 Panel::Clusters => (false, "clusters"),
3190 Panel::Jobs => (false, "jobs"),
3191 Panel::Pipelines => (false, "pipelines"),
3192 Panel::Warehouses => (false, "warehouses"),
3193 Panel::Dashboards => (false, "dashboards"),
3194 Panel::Secrets => return,
3196 };
3197 self.detail = Some(Detail {
3198 panel: self.focus,
3199 name: item.name.clone(),
3200 id: id.clone(),
3201 kind: None,
3202 section: "Access",
3203 data: None,
3204 show_raw: false,
3205 scroll: 0,
3206 });
3207 let (tx, rx) = oneshot::channel();
3208 self.detail_rx = Some(rx);
3209 let cli = Arc::clone(cli);
3210 tokio::spawn(async move {
3211 let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
3212 let _ = tx.send(data);
3213 });
3214 }
3215
3216 pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
3219 let Some(d) = &self.detail else {
3220 return;
3221 };
3222 let panel = d.panel;
3223 if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
3224 return;
3225 }
3226 let owner_id = d.id.clone();
3227 self.run_view = Some(RunView {
3228 panel,
3229 owner_name: d.name.clone(),
3230 owner_id: owner_id.clone(),
3231 runs: Vec::new(),
3232 idx: 0,
3233 data: None,
3234 show_raw: false,
3235 scroll: 0,
3236 live: false,
3237 output: None,
3238 show_output: false,
3239 show_timeline: false,
3240 show_dag: false,
3241 show_grid: false,
3242 grid: None,
3243 fetched_at: Instant::now(),
3244 });
3245 let (tx, rx) = oneshot::channel();
3246 self.run_rx = Some(rx);
3247 let cli = Arc::clone(cli);
3248 tokio::spawn(async move {
3249 let result = async {
3250 let runs = if panel == Panel::Jobs {
3251 fetchers::runs::list(&cli, &owner_id).await?
3252 } else {
3253 fetchers::updates::list(&cli, &owner_id).await?
3254 };
3255 let Some((run_id, _, _)) = runs.first().cloned() else {
3256 return Err("no runs recorded yet".to_string());
3257 };
3258 let (data, live) = if panel == Panel::Jobs {
3259 fetchers::runs::fetch(&cli, &run_id).await
3260 } else {
3261 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
3262 };
3263 Ok((runs, data, live))
3264 }
3265 .await;
3266 let _ = tx.send(RunUpdate::Opened(result));
3267 });
3268 }
3269
3270 pub fn close_run(&mut self) {
3271 self.run_view = None;
3272 self.run_rx = None;
3273 self.grid_rx = None;
3274 }
3275
3276 pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
3278 if self.run_rx.is_some() {
3279 return;
3280 }
3281 let Some(rv) = &mut self.run_view else {
3282 return;
3283 };
3284 if rv.runs.is_empty() {
3285 return;
3286 }
3287 let new = if delta < 0 {
3288 rv.idx.saturating_sub(delta.unsigned_abs() as usize)
3289 } else {
3290 (rv.idx + delta as usize).min(rv.runs.len() - 1)
3291 };
3292 if new == rv.idx {
3293 return;
3294 }
3295 rv.idx = new;
3296 rv.data = None;
3297 rv.scroll = 0;
3298 rv.show_raw = false;
3299 rv.output = None;
3300 rv.show_output = false;
3301 let run_id = rv.runs[new].0.clone();
3302 self.start_run_fetch(cli, run_id);
3303 }
3304
3305 fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
3306 let Some(rv) = &self.run_view else {
3307 return;
3308 };
3309 let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
3310 let (tx, rx) = oneshot::channel();
3311 self.run_rx = Some(rx);
3312 let cli = Arc::clone(cli);
3313 tokio::spawn(async move {
3314 let (data, live) = if panel == Panel::Jobs {
3315 fetchers::runs::fetch(&cli, &run_id).await
3316 } else {
3317 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
3318 };
3319 let _ = tx.send(RunUpdate::Detail(data, live));
3320 });
3321 }
3322
3323 pub fn run_toggle_output(&mut self, cli: &Arc<DatabricksCli>) {
3326 let Some(rv) = &mut self.run_view else {
3327 return;
3328 };
3329 if rv.panel != Panel::Jobs {
3330 self.flash = Some((
3331 "✗ output view is for job runs — pipeline events are already inline".to_string(),
3332 Instant::now(),
3333 ));
3334 return;
3335 }
3336 if rv.show_output {
3337 rv.show_output = false;
3338 rv.scroll = 0;
3339 return;
3340 }
3341 if rv.output.is_none() && self.run_rx.is_some() {
3342 self.flash = Some((
3343 "⏳ run still loading — try again in a moment".to_string(),
3344 Instant::now(),
3345 ));
3346 return;
3347 }
3348 rv.show_output = true;
3349 rv.scroll = 0;
3350 if rv.output.is_some() {
3351 return;
3352 }
3353 self.start_output_fetch(cli);
3354 }
3355
3356 fn start_output_fetch(&mut self, cli: &Arc<DatabricksCli>) {
3357 let Some(rv) = &self.run_view else {
3358 return;
3359 };
3360 let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() else {
3361 return;
3362 };
3363 let (tx, rx) = oneshot::channel();
3364 self.run_rx = Some(rx);
3365 let cli = Arc::clone(cli);
3366 tokio::spawn(async move {
3367 let (text, live) = fetchers::runs::full_output(&cli, &run_id).await;
3368 let _ = tx.send(RunUpdate::Output(text, live));
3369 });
3370 }
3371
3372 pub fn request_run_repair(&mut self) {
3374 let Some(rv) = &self.run_view else {
3375 return;
3376 };
3377 if rv.panel != Panel::Jobs {
3378 self.flash = Some((
3379 "✗ repair applies to job runs only".to_string(),
3380 Instant::now(),
3381 ));
3382 return;
3383 }
3384 if rv.live {
3385 self.flash = Some((
3386 "✗ run is still executing — cancel it first (s)".to_string(),
3387 Instant::now(),
3388 ));
3389 return;
3390 }
3391 let Some((run_id, status, _)) = rv.runs.get(rv.idx) else {
3392 return;
3393 };
3394 if matches!(status, Status::Success) {
3395 self.flash = Some((
3396 "✗ run succeeded — nothing to repair".to_string(),
3397 Instant::now(),
3398 ));
3399 return;
3400 }
3401 self.confirm = Some(Confirm {
3402 message: format!(
3403 "Repair run {run_id} of “{}” (reruns only the failed tasks)?",
3404 rv.owner_name
3405 ),
3406 args: vec![
3407 "jobs".to_string(),
3408 "repair-run".to_string(),
3409 run_id.clone(),
3410 "--rerun-all-failed-tasks".to_string(),
3411 ],
3412 params: None,
3413 });
3414 }
3415
3416 pub fn run_toggle_timeline(&mut self) {
3418 let Some(rv) = &mut self.run_view else {
3419 return;
3420 };
3421 if rv.panel != Panel::Jobs {
3422 self.flash = Some((
3423 "✗ timeline is for job runs — pipeline events are already inline".to_string(),
3424 Instant::now(),
3425 ));
3426 return;
3427 }
3428 rv.show_timeline = !rv.show_timeline;
3429 rv.show_dag = false;
3430 rv.show_grid = false;
3431 rv.scroll = 0;
3432 }
3433
3434 pub fn run_toggle_dag(&mut self) {
3436 let Some(rv) = &mut self.run_view else {
3437 return;
3438 };
3439 if rv.panel != Panel::Jobs {
3440 self.flash = Some((
3441 "✗ the task DAG is for job runs — pipelines have no task graph here".to_string(),
3442 Instant::now(),
3443 ));
3444 return;
3445 }
3446 rv.show_dag = !rv.show_dag;
3447 rv.show_timeline = false;
3448 rv.show_grid = false;
3449 rv.scroll = 0;
3450 }
3451
3452 pub fn run_toggle_grid(&mut self, cli: &Arc<DatabricksCli>) {
3455 let Some(rv) = &mut self.run_view else {
3456 return;
3457 };
3458 if rv.panel != Panel::Jobs {
3459 self.flash = Some((
3460 "✗ the history grid is for job runs — updates have no task matrix".to_string(),
3461 Instant::now(),
3462 ));
3463 return;
3464 }
3465 rv.show_grid = !rv.show_grid;
3466 rv.show_timeline = false;
3467 rv.show_dag = false;
3468 rv.scroll = 0;
3469 if !rv.show_grid || rv.grid.is_some() || self.grid_rx.is_some() {
3470 return;
3471 }
3472 let job_id = rv.owner_id.clone();
3473 let (tx, rx) = oneshot::channel();
3474 self.grid_rx = Some(rx);
3475 let cli = Arc::clone(cli);
3476 tokio::spawn(async move {
3477 let _ = tx.send(fetchers::runs::grid(&cli, &job_id).await);
3478 });
3479 }
3480
3481 pub fn poll_grid(&mut self) -> bool {
3482 let Some(rx) = &mut self.grid_rx else {
3483 return false;
3484 };
3485 match rx.try_recv() {
3486 Ok(result) => {
3487 self.grid_rx = None;
3488 if let Some(rv) = &mut self.run_view {
3489 rv.grid = Some(result);
3490 }
3491 true
3492 }
3493 Err(oneshot::error::TryRecvError::Empty) => false,
3494 Err(oneshot::error::TryRecvError::Closed) => {
3495 self.grid_rx = None;
3496 true
3497 }
3498 }
3499 }
3500
3501 pub fn run_toggle_raw(&mut self) {
3502 if let Some(rv) = &mut self.run_view {
3503 rv.show_raw = !rv.show_raw;
3504 rv.scroll = 0;
3505 }
3506 }
3507
3508 pub fn run_scroll(&mut self, delta: i32) {
3509 if let Some(rv) = &mut self.run_view {
3510 rv.scroll = if delta < 0 {
3511 rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
3512 } else {
3513 rv.scroll.saturating_add(delta as u16)
3514 };
3515 }
3516 }
3517
3518 pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3521 if let Some(rx) = &mut self.run_rx {
3522 match rx.try_recv() {
3523 Ok(update) => {
3524 self.run_rx = None;
3525 if let Some(rv) = &mut self.run_view {
3526 match update {
3527 RunUpdate::Opened(Ok((runs, data, live))) => {
3528 rv.runs = runs;
3529 rv.idx = 0;
3530 rv.data = Some(data);
3531 rv.live = live;
3532 }
3533 RunUpdate::Opened(Err(e)) => {
3534 rv.data = Some(DetailData {
3535 summary: Vec::new(),
3536 activity: Vec::new(),
3537 raw: format!("✗ {e}"),
3538 });
3539 rv.live = false;
3540 }
3541 RunUpdate::Detail(data, live) => {
3542 rv.data = Some(data);
3543 rv.live = live;
3544 }
3545 RunUpdate::Output(text, live) => {
3546 rv.output = Some(text);
3547 rv.live = live;
3548 }
3549 }
3550 rv.fetched_at = Instant::now();
3551 }
3552 true
3553 }
3554 Err(oneshot::error::TryRecvError::Empty) => false,
3555 Err(oneshot::error::TryRecvError::Closed) => {
3556 self.run_rx = None;
3557 true
3558 }
3559 }
3560 } else if let Some(rv) = &self.run_view {
3561 if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
3562 if rv.show_output {
3563 if rv.output.is_some() {
3566 self.start_output_fetch(cli);
3567 }
3568 } else if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
3569 self.start_run_fetch(cli, run_id);
3570 }
3571 }
3572 false
3573 } else {
3574 false
3575 }
3576 }
3577
3578 pub fn close_detail(&mut self) {
3579 self.detail = None;
3580 self.detail_rx = None;
3581 }
3582
3583 pub fn toggle_raw(&mut self) {
3584 if let Some(d) = &mut self.detail {
3585 d.show_raw = !d.show_raw;
3586 d.scroll = 0;
3587 }
3588 }
3589
3590 pub fn poll_detail(&mut self) -> bool {
3592 let Some(rx) = &mut self.detail_rx else {
3593 return false;
3594 };
3595 match rx.try_recv() {
3596 Ok(data) => {
3597 if let Some(d) = &mut self.detail {
3598 d.data = Some(data);
3599 }
3600 self.detail_rx = None;
3601 true
3602 }
3603 Err(oneshot::error::TryRecvError::Empty) => false,
3604 Err(oneshot::error::TryRecvError::Closed) => {
3605 self.detail_rx = None;
3606 true
3607 }
3608 }
3609 }
3610
3611 pub fn detail_scroll(&mut self, delta: i32) {
3612 if let Some(d) = &mut self.detail {
3613 let max = match &d.data {
3614 Some(data) if d.show_raw => data.raw.lines().count(),
3615 Some(data) => data.summary.len() + data.activity.len() + 3,
3616 None => 0,
3617 } as u16;
3618 d.scroll = if delta < 0 {
3619 d.scroll.saturating_sub(delta.unsigned_abs() as u16)
3620 } else {
3621 (d.scroll + delta as u16).min(max.saturating_sub(1))
3622 };
3623 }
3624 }
3625
3626 pub fn request_action(&mut self) {
3629 if matches!(
3631 self.focus,
3632 Panel::Dashboards | Panel::Catalog | Panel::Secrets
3633 ) {
3634 return;
3635 }
3636 let Some(item) = self.selected_item() else {
3637 return;
3638 };
3639 let Some(id) = item.id.clone() else {
3640 return;
3641 };
3642 let name = item.name.clone();
3643 let active = matches!(
3644 item.status,
3645 Status::Running | Status::Pending | Status::Success
3646 );
3647 let group = self.focus.cli_group();
3648 let (verb, action): (&str, &str) = match self.focus {
3649 Panel::Jobs => ("Run", "run-now"),
3650 Panel::Clusters if active => ("Stop", "delete"),
3651 Panel::Pipelines if active => ("Stop", "stop"),
3652 Panel::Pipelines => ("Start update for", "start-update"),
3653 _ if active => ("Stop", "stop"),
3654 _ => ("Start", "start"),
3655 };
3656 self.confirm = Some(Confirm {
3657 message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
3658 params: (self.focus == Panel::Jobs).then(|| (id.clone(), name.clone())),
3659 args: vec![group.to_string(), action.to_string(), id],
3660 });
3661 }
3662
3663 pub fn request_schedule_toggle(&mut self, cli: &Arc<DatabricksCli>) {
3667 if self.focus != Panel::Jobs {
3668 self.flash = Some((
3669 "✗ schedule pause applies to jobs — focus the Lakeflow pane".to_string(),
3670 Instant::now(),
3671 ));
3672 return;
3673 }
3674 let Some(item) = self.selected_item() else {
3675 return;
3676 };
3677 let Some(id) = item.id.clone() else {
3678 return;
3679 };
3680 let name = item.name.clone();
3681 if self.action_rx.is_some() {
3682 self.flash = Some((
3683 "⏳ another action is still in flight".to_string(),
3684 Instant::now(),
3685 ));
3686 return;
3687 }
3688 self.flash = Some((format!("⏳ toggling schedule of “{name}”…"), Instant::now()));
3689 let (tx, rx) = oneshot::channel();
3690 self.action_rx = Some(rx);
3691 let cli = Arc::clone(cli);
3692 tokio::spawn(async move {
3693 let _ = tx.send(fetchers::jobs::toggle_pause(&cli, &id, &name).await);
3694 });
3695 }
3696
3697 pub fn request_run_cancel(&mut self) {
3699 let Some(rv) = &self.run_view else {
3700 return;
3701 };
3702 if !rv.live {
3703 self.flash = Some((
3704 "✗ nothing to cancel — this run already finished".to_string(),
3705 Instant::now(),
3706 ));
3707 return;
3708 }
3709 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3710 return;
3711 };
3712 let (message, args) = if rv.panel == Panel::Jobs {
3713 (
3714 format!("Cancel run {run_id} of “{}”?", rv.owner_name),
3715 vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
3716 )
3717 } else {
3718 (
3719 format!("Stop “{}” (cancels the active update)?", rv.owner_name),
3720 vec![
3721 "pipelines".to_string(),
3722 "stop".to_string(),
3723 rv.owner_id.clone(),
3724 ],
3725 )
3726 };
3727 self.confirm = Some(Confirm {
3728 message,
3729 args,
3730 params: None,
3731 });
3732 }
3733
3734 pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
3737 let id = self
3738 .sql_stmt
3739 .as_ref()
3740 .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
3741 let Some(id) = id else {
3742 self.flash = Some((
3743 "✗ statement not submitted yet — try again in a moment".to_string(),
3744 Instant::now(),
3745 ));
3746 return;
3747 };
3748 let cli = Arc::clone(cli);
3749 tokio::spawn(async move {
3750 let path = format!("/api/2.0/sql/statements/{id}/cancel");
3751 let _ = cli.run_action(&["api", "post", &path]).await;
3752 });
3753 self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
3754 }
3755
3756 pub fn cancel_confirm(&mut self) {
3757 self.confirm = None;
3758 }
3759
3760 pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
3761 let Some(c) = self.confirm.take() else {
3762 return;
3763 };
3764 let base = c.message.trim_end_matches('?').to_string();
3765 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3766
3767 let (tx, rx) = oneshot::channel();
3768 self.action_rx = Some(rx);
3769 let cli = Arc::clone(cli);
3770 tokio::spawn(async move {
3771 let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
3772 let result = match cli.run_action(&args).await {
3773 Ok(()) => Ok(format!("✓ {base} — done")),
3774 Err(e) => Err(format!("✗ {e:#}")),
3775 };
3776 let _ = tx.send(result);
3777 });
3778 }
3779
3780 pub fn open_param_form(&mut self, cli: &Arc<DatabricksCli>) {
3783 let Some((job_id, name)) = self.confirm.take().and_then(|c| c.params) else {
3784 return;
3785 };
3786 self.param_form = Some(ParamForm {
3787 job_id: job_id.clone(),
3788 job: name,
3789 input: String::new(),
3790 cursor: 0,
3791 kind: fetchers::jobs::ParamKind::Notebook,
3792 loading: true,
3793 });
3794 let (tx, rx) = oneshot::channel();
3795 self.param_rx = Some(rx);
3796 let cli = Arc::clone(cli);
3797 tokio::spawn(async move {
3798 let _ = tx.send(fetchers::jobs::params(&cli, &job_id).await);
3799 });
3800 }
3801
3802 pub fn poll_param(&mut self) -> bool {
3804 let Some(rx) = &mut self.param_rx else {
3805 return false;
3806 };
3807 match rx.try_recv() {
3808 Ok(result) => {
3809 self.param_rx = None;
3810 let Some(form) = &mut self.param_form else {
3811 return true;
3812 };
3813 match result {
3814 Ok((pairs, kind)) => {
3815 form.input = pairs
3816 .iter()
3817 .map(|(k, v)| format!("{k}={v}"))
3818 .collect::<Vec<_>>()
3819 .join(", ");
3820 form.cursor = form.input.chars().count();
3821 form.kind = kind;
3822 form.loading = false;
3823 }
3824 Err(e) => {
3825 self.param_form = None;
3826 self.flash = Some((e, Instant::now()));
3827 }
3828 }
3829 true
3830 }
3831 Err(oneshot::error::TryRecvError::Empty) => false,
3832 Err(oneshot::error::TryRecvError::Closed) => {
3833 self.param_rx = None;
3834 self.param_form = None;
3835 true
3836 }
3837 }
3838 }
3839
3840 pub fn param_push(&mut self, c: char) {
3841 if let Some(form) = self.param_form.as_mut().filter(|f| !f.loading) {
3842 let at = byte_at(&form.input, form.cursor);
3843 form.input.insert(at, c);
3844 form.cursor += 1;
3845 }
3846 }
3847
3848 pub fn param_pop(&mut self) {
3849 if let Some(form) = self.param_form.as_mut().filter(|f| !f.loading) {
3850 if form.cursor > 0 {
3851 form.cursor -= 1;
3852 let at = byte_at(&form.input, form.cursor);
3853 form.input.remove(at);
3854 }
3855 }
3856 }
3857
3858 pub fn param_left(&mut self) {
3859 if let Some(form) = &mut self.param_form {
3860 form.cursor = form.cursor.saturating_sub(1);
3861 }
3862 }
3863
3864 pub fn param_right(&mut self) {
3865 if let Some(form) = &mut self.param_form {
3866 form.cursor = (form.cursor + 1).min(form.input.chars().count());
3867 }
3868 }
3869
3870 pub fn param_home(&mut self) {
3871 if let Some(form) = &mut self.param_form {
3872 form.cursor = 0;
3873 }
3874 }
3875
3876 pub fn param_end(&mut self) {
3877 if let Some(form) = &mut self.param_form {
3878 form.cursor = form.input.chars().count();
3879 }
3880 }
3881
3882 pub fn param_submit(&mut self, cli: &Arc<DatabricksCli>) {
3885 let Some(form) = &self.param_form else {
3886 return;
3887 };
3888 if form.loading {
3889 return;
3890 }
3891 let pairs = match parse_params(&form.input) {
3892 Ok(pairs) => pairs,
3893 Err(e) => {
3894 self.flash = Some((e, Instant::now()));
3895 return;
3896 }
3897 };
3898 let Some(form) = self.param_form.take() else {
3899 return;
3900 };
3901 let Ok(id) = form.job_id.parse::<u64>() else {
3902 self.flash = Some((format!("✗ bad job id: {}", form.job_id), Instant::now()));
3903 return;
3904 };
3905 let (base, args) = if pairs.is_empty() {
3906 (
3907 format!("Run job “{}”", form.job),
3908 vec!["jobs".to_string(), "run-now".to_string(), form.job_id],
3909 )
3910 } else {
3911 let map: serde_json::Map<String, serde_json::Value> = pairs
3912 .into_iter()
3913 .map(|(k, v)| (k, serde_json::Value::String(v)))
3914 .collect();
3915 let payload =
3916 serde_json::json!({"job_id": id, form.kind.payload_key(): map}).to_string();
3917 (
3918 format!("Run job “{}” with parameters", form.job),
3919 vec![
3920 "jobs".to_string(),
3921 "run-now".to_string(),
3922 "--json".to_string(),
3923 payload,
3924 ],
3925 )
3926 };
3927 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3928 let (tx, rx) = oneshot::channel();
3929 self.action_rx = Some(rx);
3930 let cli = Arc::clone(cli);
3931 tokio::spawn(async move {
3932 let args: Vec<&str> = args.iter().map(String::as_str).collect();
3933 let result = match cli.run_action(&args).await {
3934 Ok(()) => Ok(format!("✓ {base} — done")),
3935 Err(e) => Err(format!("✗ {e:#}")),
3936 };
3937 let _ = tx.send(result);
3938 });
3939 }
3940
3941 pub fn close_param_form(&mut self) {
3942 self.param_form = None;
3943 self.param_rx = None;
3944 }
3945
3946 pub fn toggle_watch(&mut self) {
3949 let Some(rv) = &self.run_view else {
3950 return;
3951 };
3952 if rv.panel != Panel::Jobs {
3953 self.flash = Some((
3954 "✗ watching is for job runs — pipeline updates refresh inline".to_string(),
3955 Instant::now(),
3956 ));
3957 return;
3958 }
3959 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3960 return;
3961 };
3962 if let Some(pos) = self.watched.iter().position(|w| w.run_id == *run_id) {
3963 let w = self.watched.remove(pos);
3964 self.flash = Some((
3965 format!("✓ no longer watching run {} of “{}”", w.run_id, w.job),
3966 Instant::now(),
3967 ));
3968 return;
3969 }
3970 if !rv.live {
3971 self.flash = Some((
3972 "✗ this run already finished — nothing to watch".to_string(),
3973 Instant::now(),
3974 ));
3975 return;
3976 }
3977 self.watched.push(Watched {
3978 run_id: run_id.clone(),
3979 job: rv.owner_name.clone(),
3980 });
3981 self.flash = Some((
3982 format!(
3983 "👁 watching run {} of “{}” — bell when it finishes",
3984 run_id, rv.owner_name
3985 ),
3986 Instant::now(),
3987 ));
3988 }
3989
3990 pub fn poll_watch(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3993 if let Some(rx) = &mut self.watch_rx {
3994 return match rx.try_recv() {
3995 Ok(states) => {
3996 self.watch_rx = None;
3997 let mut done: Vec<(String, Status)> = Vec::new();
3998 for (run_id, state) in states {
3999 if let Ok((status, false)) = state {
4002 if let Some(pos) = self.watched.iter().position(|w| w.run_id == run_id)
4003 {
4004 done.push((self.watched.remove(pos).job, status));
4005 }
4006 }
4007 }
4008 if let Some((job, status)) = done.first() {
4009 let extra = if done.len() > 1 {
4010 format!(" (+{} more)", done.len() - 1)
4011 } else {
4012 String::new()
4013 };
4014 self.flash = Some((
4015 if matches!(status, Status::Failed) {
4016 format!("✗ watched run of “{job}” FAILED{extra} — ! to inspect")
4017 } else {
4018 format!("🔔 “{job}” run finished: {}{extra}", status.label())
4019 },
4020 Instant::now(),
4021 ));
4022 print!("\x07");
4023 let _ = std::io::Write::flush(&mut std::io::stdout());
4024 }
4025 !done.is_empty()
4026 }
4027 Err(oneshot::error::TryRecvError::Empty) => false,
4028 Err(oneshot::error::TryRecvError::Closed) => {
4029 self.watch_rx = None;
4030 false
4031 }
4032 };
4033 }
4034 if self.watched.is_empty() || self.watch_at.elapsed() < WATCH_INTERVAL {
4035 return false;
4036 }
4037 self.watch_at = Instant::now();
4038 let ids: Vec<String> = self.watched.iter().map(|w| w.run_id.clone()).collect();
4039 let (tx, rx) = oneshot::channel();
4040 self.watch_rx = Some(rx);
4041 let cli = Arc::clone(cli);
4042 tokio::spawn(async move {
4043 let mut out = Vec::with_capacity(ids.len());
4044 for id in ids {
4045 out.push((id.clone(), fetchers::runs::state(&cli, &id).await));
4046 }
4047 let _ = tx.send(out);
4048 });
4049 false
4050 }
4051
4052 pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
4054 let Some(rx) = &mut self.action_rx else {
4055 return false;
4056 };
4057 match rx.try_recv() {
4058 Ok(result) => {
4059 let ok = result.is_ok();
4060 self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
4061 self.action_rx = None;
4062 if ok {
4063 self.start_refresh(cli);
4064 if self.run_rx.is_none() {
4067 let current = self
4068 .run_view
4069 .as_ref()
4070 .and_then(|rv| rv.runs.get(rv.idx).cloned());
4071 if let Some((run_id, _, _)) = current {
4072 self.start_run_fetch(cli, run_id);
4073 }
4074 }
4075 }
4076 true
4077 }
4078 Err(oneshot::error::TryRecvError::Empty) => false,
4079 Err(oneshot::error::TryRecvError::Closed) => {
4080 self.action_rx = None;
4081 true
4082 }
4083 }
4084 }
4085
4086 pub fn expire_flash(&mut self) -> bool {
4088 if let Some((_, since)) = &self.flash {
4089 if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
4090 self.flash = None;
4091 return true;
4092 }
4093 }
4094 false
4095 }
4096
4097 pub fn open_in_browser(&self) {
4099 let Some(host) = &self.host else {
4100 return;
4101 };
4102 let (panel, id) = match &self.detail {
4103 Some(d) => (d.panel, Some(d.id.clone())),
4104 None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
4105 };
4106 let Some(id) = id else {
4107 return;
4108 };
4109 let path = match panel {
4110 Panel::Clusters => format!("compute/clusters/{id}"),
4111 Panel::Jobs => format!("jobs/{id}"),
4112 Panel::Pipelines => format!("pipelines/{id}"),
4113 Panel::Warehouses => format!("sql/warehouses/{id}"),
4114 Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
4115 Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
4116 Panel::Secrets => return,
4118 };
4119 let url = format!("{}/{}", host.trim_end_matches('/'), path);
4120 #[cfg(target_os = "macos")]
4121 let opener = "open";
4122 #[cfg(not(target_os = "macos"))]
4123 let opener = "xdg-open";
4124 let _ = std::process::Command::new(opener).arg(url).spawn();
4125 }
4126
4127 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
4129 let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
4130 for shape in self.shapes.iter().flatten() {
4131 if let Shape::List(items) = shape {
4132 for item in items {
4133 match item.status {
4134 Status::Running | Status::Success => ok += 1,
4135 Status::Pending => pending += 1,
4136 Status::Failed => failed += 1,
4137 Status::Stopped => idle += 1,
4138 Status::Unknown(_) => {}
4139 }
4140 }
4141 }
4142 }
4143 (ok, pending, failed, idle)
4144 }
4145
4146 pub fn last_refresh_age(&self) -> Duration {
4147 self.last_refresh.elapsed()
4148 }
4149
4150 pub fn spinner(&self) -> &'static str {
4151 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
4152 }
4153
4154 pub fn spinner_frame(&self) -> usize {
4155 self.spinner_frame
4156 }
4157
4158 pub fn busy(&self) -> bool {
4161 self.loading
4162 || self.detail_rx.is_some()
4163 || self.action_rx.is_some()
4164 || self.preview_rx.is_some()
4165 || self.cost_rx.is_some()
4166 || self.item_cost_rx.is_some()
4167 || self.sql_rx.is_some()
4168 || self.run_rx.is_some()
4169 }
4170
4171 pub fn tick_spinner(&mut self) {
4172 self.spinner_frame = self.spinner_frame.wrapping_add(1);
4173 }
4174
4175 pub fn toggle_zoom(&mut self) {
4176 self.zoomed = !self.zoomed;
4177 }
4178
4179 pub fn focus_next(&mut self) {
4180 self.cycle_focus(1);
4181 }
4182
4183 pub fn focus_prev(&mut self) {
4184 self.cycle_focus(-1);
4185 }
4186
4187 fn cycle_focus(&mut self, delta: i32) {
4189 let visible = self.visible_panes();
4190 if visible.is_empty() {
4191 return;
4192 }
4193 let focus_idx = Panel::ALL
4194 .iter()
4195 .position(|p| p == &self.focus)
4196 .unwrap_or(0);
4197 let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
4198 let n = visible.len() as i32;
4199 let next = ((pos as i32 + delta) % n + n) % n;
4200 self.focus = Panel::ALL[visible[next as usize]];
4201 }
4202
4203 pub fn needs_refresh(&self) -> bool {
4204 !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
4205 }
4206
4207 pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
4208 if self.loading {
4209 return;
4210 }
4211 self.loading = true;
4212 self.error = None;
4213 self.last_refresh = Instant::now();
4214
4215 let (tx, rx) = mpsc::unbounded_channel();
4216 self.pending = Some(rx);
4217 self.in_flight = 8;
4218
4219 macro_rules! spawn_fetch {
4222 ($update:expr, $fetch:path) => {{
4223 let cli = Arc::clone(cli);
4224 let tx = tx.clone();
4225 tokio::spawn(async move {
4226 let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
4227 let _ = tx.send($update(result));
4228 });
4229 }};
4230 }
4231
4232 spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
4233 spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
4234 spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
4235 spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
4236 spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
4237 spawn_fetch!(
4238 |s: Result<Shape, String>| Update::Badge(s.ok()),
4239 fetchers::current_user::fetch
4240 );
4241 {
4242 let cli = Arc::clone(cli);
4243 let tx = tx.clone();
4244 let path = self.uc_path.clone();
4245 tokio::spawn(async move {
4246 let result = fetchers::catalog::fetch(&cli, &path)
4247 .await
4248 .map_err(|e| format!("{e:#}"));
4249 let _ = tx.send(Update::Panel(5, result));
4250 });
4251 }
4252 {
4253 let cli = Arc::clone(cli);
4254 let tx = tx.clone();
4255 let scope = self.secret_scope.clone();
4256 tokio::spawn(async move {
4257 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
4258 .await
4259 .map_err(|e| format!("{e:#}"));
4260 let _ = tx.send(Update::Panel(6, result));
4261 });
4262 }
4263 }
4264
4265 pub fn poll_refresh(&mut self) -> bool {
4267 let Some(rx) = &mut self.pending else {
4268 return false;
4269 };
4270 let mut changed = false;
4271 let mut updated_panes: Vec<usize> = Vec::new();
4272 loop {
4273 match rx.try_recv() {
4274 Ok(Update::Panel(i, result)) => {
4275 match result {
4276 Ok(mut shape) => {
4277 if i != 5 {
4281 if let Shape::List(items) = &mut shape {
4282 items.sort_by_key(|it| {
4283 (it.status.rank(), it.history.is_empty())
4284 });
4285 }
4286 }
4287 self.shapes[i] = Some(shape);
4288 self.updated_at[i] = Some(Instant::now());
4289 updated_panes.push(i);
4290 }
4291 Err(e) => {
4294 if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
4295 self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
4296 }
4297 }
4298 }
4299 self.in_flight -= 1;
4300 changed = true;
4301 }
4302 Ok(Update::Badge(badge)) => {
4303 if badge.is_some() {
4304 self.user_badge = badge;
4305 }
4306 self.in_flight -= 1;
4307 changed = true;
4308 }
4309 Err(mpsc::error::TryRecvError::Empty) => break,
4310 Err(mpsc::error::TryRecvError::Disconnected) => {
4311 self.in_flight = 0;
4312 break;
4313 }
4314 }
4315 }
4316 for i in updated_panes {
4317 self.alert_new_failures(i);
4318 }
4319 if self.in_flight == 0 {
4320 self.loading = false;
4321 self.pending = None;
4322 changed = true;
4323 }
4324 changed
4325 }
4326}
4327
4328#[cfg(test)]
4329mod tests {
4330 use super::{from_table, parse_params, token_at_cursor};
4331
4332 #[test]
4333 fn parse_params_pairs_and_errors() {
4334 assert_eq!(parse_params(""), Ok(vec![]));
4335 assert_eq!(
4336 parse_params(" date=2026-07-18, mode=full "),
4337 Ok(vec![
4338 ("date".to_string(), "2026-07-18".to_string()),
4339 ("mode".to_string(), "full".to_string())
4340 ])
4341 );
4342 assert_eq!(
4344 parse_params("expr=a=b, flag="),
4345 Ok(vec![
4346 ("expr".to_string(), "a=b".to_string()),
4347 ("flag".to_string(), String::new())
4348 ])
4349 );
4350 assert!(parse_params("no-equals-here").is_err());
4351 assert!(parse_params("=orphan").is_err());
4352 }
4353
4354 #[test]
4355 fn token_bare_word() {
4356 let (start, ctx, prefix) = token_at_cursor("SELECT * FROM ma", 16);
4357 assert_eq!((start, ctx.as_str(), prefix.as_str()), (14, "", "ma"));
4358 }
4359
4360 #[test]
4361 fn token_dotted_path() {
4362 let (start, ctx, prefix) = token_at_cursor("SELECT * FROM main.sales.or", 27);
4363 assert_eq!(
4364 (start, ctx.as_str(), prefix.as_str()),
4365 (25, "main.sales", "or")
4366 );
4367 }
4368
4369 #[test]
4370 fn token_trailing_dot() {
4371 let (start, ctx, prefix) = token_at_cursor("main.", 5);
4372 assert_eq!((start, ctx.as_str(), prefix.as_str()), (5, "main", ""));
4373 }
4374
4375 #[test]
4376 fn token_mid_input() {
4377 let (start, ctx, prefix) = token_at_cursor("SELECT co FROM t", 9);
4379 assert_eq!((start, ctx.as_str(), prefix.as_str()), (7, "", "co"));
4380 }
4381
4382 #[test]
4383 fn from_table_fully_qualified() {
4384 assert_eq!(
4385 from_table("SELECT x FROM main.sales.orders WHERE x > 1").as_deref(),
4386 Some("main.sales.orders")
4387 );
4388 }
4389
4390 #[test]
4391 fn from_table_rejects_partial_names() {
4392 assert_eq!(from_table("SELECT x FROM orders"), None);
4393 assert_eq!(from_table("SELECT x FROM main.sales."), None);
4394 assert_eq!(from_table("SELECT 1"), None);
4395 }
4396}