1use crate::cli::DatabricksCli;
2use crate::fetchers;
3use crate::shape::{DetailData, Shape, Status};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6use tokio::sync::{mpsc, oneshot};
7
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum ThemeMode {
10 Dark,
11 Light,
12 CatppuccinMocha,
13 CatppuccinLatte,
14 GruvboxDark,
15 Dracula,
16 Nord,
17 TokyoNight,
18}
19
20impl ThemeMode {
21 pub const ALL: &'static [ThemeMode] = &[
22 ThemeMode::Dark,
23 ThemeMode::Light,
24 ThemeMode::CatppuccinMocha,
25 ThemeMode::CatppuccinLatte,
26 ThemeMode::GruvboxDark,
27 ThemeMode::Dracula,
28 ThemeMode::Nord,
29 ThemeMode::TokyoNight,
30 ];
31
32 pub fn toggled(self) -> Self {
34 let idx = Self::ALL.iter().position(|t| *t == self).unwrap_or(0);
35 Self::ALL[(idx + 1) % Self::ALL.len()]
36 }
37
38 pub fn name(&self) -> &'static str {
39 match self {
40 ThemeMode::Dark => "Dark (terminal colors)",
41 ThemeMode::Light => "Light",
42 ThemeMode::CatppuccinMocha => "Catppuccin Mocha",
43 ThemeMode::CatppuccinLatte => "Catppuccin Latte",
44 ThemeMode::GruvboxDark => "Gruvbox Dark",
45 ThemeMode::Dracula => "Dracula",
46 ThemeMode::Nord => "Nord",
47 ThemeMode::TokyoNight => "Tokyo Night",
48 }
49 }
50
51 pub fn id(&self) -> &'static str {
53 match self {
54 ThemeMode::Dark => "dark",
55 ThemeMode::Light => "light",
56 ThemeMode::CatppuccinMocha => "catppuccin-mocha",
57 ThemeMode::CatppuccinLatte => "catppuccin-latte",
58 ThemeMode::GruvboxDark => "gruvbox",
59 ThemeMode::Dracula => "dracula",
60 ThemeMode::Nord => "nord",
61 ThemeMode::TokyoNight => "tokyo-night",
62 }
63 }
64
65 pub fn from_id(id: &str) -> Option<Self> {
66 Self::ALL.iter().copied().find(|t| t.id() == id)
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub enum Panel {
72 Clusters,
73 Jobs,
74 Pipelines,
75 Warehouses,
76 Dashboards,
77 Catalog,
78 Secrets,
79}
80
81impl Panel {
82 pub const ALL: &'static [Panel] = &[
83 Panel::Clusters,
84 Panel::Jobs,
85 Panel::Pipelines,
86 Panel::Warehouses,
87 Panel::Dashboards,
88 Panel::Catalog,
89 Panel::Secrets,
90 ];
91
92 pub fn title(&self) -> &'static str {
93 match self {
94 Panel::Clusters => "Compute",
95 Panel::Jobs => "Lakeflow Jobs",
96 Panel::Pipelines => "Lakeflow Pipelines",
97 Panel::Warehouses => "SQL Warehouses",
98 Panel::Dashboards => "AI/BI Dashboards",
99 Panel::Catalog => "Unity Catalog",
100 Panel::Secrets => "Secret Scopes",
101 }
102 }
103
104 pub fn icon(&self) -> &'static str {
105 match self {
106 Panel::Clusters => "⌬",
107 Panel::Jobs => "⟳",
108 Panel::Pipelines => "⋙",
109 Panel::Warehouses => "⌁",
110 Panel::Dashboards => "▦",
111 Panel::Catalog => "⧉",
112 Panel::Secrets => "◈",
113 }
114 }
115
116 pub fn id(&self) -> &'static str {
118 match self {
119 Panel::Clusters => "compute",
120 Panel::Jobs => "jobs",
121 Panel::Pipelines => "pipelines",
122 Panel::Warehouses => "warehouses",
123 Panel::Dashboards => "dashboards",
124 Panel::Catalog => "catalog",
125 Panel::Secrets => "secrets",
126 }
127 }
128
129 pub fn cli_group(&self) -> &'static str {
131 match self {
132 Panel::Clusters => "clusters",
133 Panel::Jobs => "jobs",
134 Panel::Pipelines => "pipelines",
135 Panel::Warehouses => "warehouses",
136 Panel::Dashboards => "lakeview",
137 Panel::Catalog => "tables",
138 Panel::Secrets => "secrets",
139 }
140 }
141}
142
143pub struct Detail {
144 pub panel: Panel,
145 pub name: String,
146 pub id: String,
147 pub kind: Option<String>,
149 pub section: &'static str,
151 pub data: Option<DetailData>,
153 pub show_raw: bool,
155 pub scroll: u16,
156}
157
158pub struct Preview {
160 pub name: String,
161 pub warehouse: String,
163 pub warehouse_id: String,
164 pub data: Option<Result<crate::shape::TableData, String>>,
166 pub scroll: usize,
168 pub col: usize,
170 pub filter: String,
173 pub filter_entry: bool,
174 pub record: bool,
176 pub rscroll: u16,
178}
179
180impl Preview {
181 pub fn visible_cols(&self) -> Vec<usize> {
183 let Some(Ok(t)) = &self.data else {
184 return Vec::new();
185 };
186 let q = self.filter.to_lowercase();
187 (0..t.headers.len())
188 .filter(|&i| q.is_empty() || t.headers[i].to_lowercase().contains(&q))
189 .collect()
190 }
191}
192
193enum PickTarget {
195 Preview(String),
196 Cost,
197 Lineage(String),
198 Sql(String),
199}
200
201pub struct SqlConsole {
203 pub input: String,
204 pub cursor: usize,
206 pub warehouse: String,
208 pub running: bool,
209 pub data: Option<Result<crate::shape::TableData, String>>,
210 pub last_sql: String,
212 pub scroll: usize,
213 pub col: usize,
215}
216
217pub struct SqlComplete {
220 pub items: Vec<String>,
222 pub index: usize,
224 pub seg_start: usize,
227 prefix: String,
229 context: String,
231 pub loading: bool,
233}
234
235pub(crate) const SQL_KEYWORDS: &[&str] = &[
238 "SELECT",
239 "FROM",
240 "WHERE",
241 "GROUP BY",
242 "ORDER BY",
243 "HAVING",
244 "LIMIT",
245 "JOIN",
246 "LEFT JOIN",
247 "INNER JOIN",
248 "FULL OUTER JOIN",
249 "CROSS JOIN",
250 "ON",
251 "AS",
252 "AND",
253 "OR",
254 "NOT",
255 "IN",
256 "IS",
257 "NULL",
258 "DISTINCT",
259 "COUNT",
260 "SUM",
261 "AVG",
262 "MIN",
263 "MAX",
264 "UNION",
265 "UNION ALL",
266 "INSERT INTO",
267 "VALUES",
268 "UPDATE",
269 "SET",
270 "DELETE",
271 "CREATE",
272 "DROP",
273 "ALTER",
274 "SHOW",
275 "DESCRIBE",
276 "EXPLAIN",
277 "WITH",
278 "CASE",
279 "WHEN",
280 "THEN",
281 "ELSE",
282 "END",
283 "BETWEEN",
284 "LIKE",
285 "CAST",
286 "OVER",
287 "PARTITION BY",
288];
289
290fn token_at_cursor(input: &str, cursor: usize) -> (usize, String, String) {
293 let chars: Vec<char> = input.chars().collect();
294 let cursor = cursor.min(chars.len());
295 let mut start = cursor;
296 while start > 0 && (chars[start - 1].is_alphanumeric() || matches!(chars[start - 1], '_' | '.'))
297 {
298 start -= 1;
299 }
300 let token: String = chars[start..cursor].iter().collect();
301 match token.rfind('.') {
302 Some(dot) => {
303 let context = token[..dot].to_string();
304 let prefix = token[dot + 1..].to_string();
305 (start + context.chars().count() + 1, context, prefix)
306 }
307 None => (start, String::new(), token),
308 }
309}
310
311fn from_table(input: &str) -> Option<String> {
314 let pos = input.to_lowercase().find("from ")?;
315 let rest = input.get(pos + 5..)?.trim_start();
317 let table: String = rest
318 .chars()
319 .take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '.'))
320 .collect();
321 (table.matches('.').count() == 2 && !table.ends_with('.')).then_some(table)
322}
323
324fn history_path() -> Option<std::path::PathBuf> {
326 let home = std::env::var_os("HOME")?;
327 Some(
328 std::path::PathBuf::from(home)
329 .join(".config")
330 .join("databricks-tui")
331 .join("history"),
332 )
333}
334
335fn load_history() -> Vec<String> {
336 history_path()
337 .and_then(|p| std::fs::read_to_string(p).ok())
338 .map(|s| {
339 s.lines()
340 .filter(|l| !l.trim().is_empty())
341 .map(str::to_string)
342 .collect()
343 })
344 .unwrap_or_default()
345}
346
347fn save_history(history: &[String]) {
348 let Some(path) = history_path() else {
349 return;
350 };
351 if let Some(dir) = path.parent() {
352 let _ = std::fs::create_dir_all(dir);
353 crate::config::restrict(dir, 0o700);
354 }
355 let tail: Vec<&str> = history
357 .iter()
358 .rev()
359 .take(200)
360 .rev()
361 .map(String::as_str)
362 .collect();
363 let _ = std::fs::write(&path, tail.join("\n") + "\n");
365 crate::config::restrict(&path, 0o600);
366}
367
368fn subsequence(haystack: &str, needle: &str) -> bool {
370 let mut chars = haystack.chars();
371 needle.chars().all(|n| chars.any(|h| h == n))
372}
373
374fn byte_at(input: &str, cursor: usize) -> usize {
376 input
377 .char_indices()
378 .nth(cursor)
379 .map(|(i, _)| i)
380 .unwrap_or(input.len())
381}
382
383pub struct WhPicker {
385 pub index: usize,
386 target: PickTarget,
387}
388
389pub struct CostView {
391 pub warehouse: String,
392 pub data: Option<Result<fetchers::cost::CostData, String>>,
393}
394
395pub struct RunView {
398 pub panel: Panel,
400 pub owner_name: String,
401 owner_id: String,
403 pub runs: Vec<(String, Status, String)>,
405 pub idx: usize,
407 pub data: Option<DetailData>,
408 pub show_raw: bool,
409 pub scroll: u16,
410 pub live: bool,
412 pub output: Option<String>,
414 pub show_output: bool,
415 pub show_timeline: bool,
418 pub show_dag: bool,
421 fetched_at: Instant,
422}
423
424type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
426
427enum RunUpdate {
428 Opened(Result<RunOpened, String>),
429 Detail(DetailData, bool),
430 Output(String, bool),
432}
433
434pub struct Problem {
436 pub panel: Option<usize>,
439 pub name: String,
440 pub status: Status,
441 pub note: String,
442 pub profile: Option<String>,
444}
445
446pub struct Problems {
450 pub items: Vec<Problem>,
451 pub index: usize,
452 pub scanning: bool,
454}
455
456pub struct Upcoming {
458 pub items: Vec<fetchers::upcoming::UpcomingJob>,
459 pub index: usize,
460 pub loading: bool,
461}
462
463pub struct Confirm {
465 pub message: String,
466 args: Vec<String>,
467}
468
469enum Update {
470 Panel(usize, Result<Shape, String>),
471 Badge(Option<Shape>),
472}
473
474const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
475
476pub struct App {
477 pub focus: Panel,
478 pub theme: ThemeMode,
479 pub zoomed: bool,
480 pub shapes: Vec<Option<Shape>>,
481 pub user_badge: Option<Shape>,
482 pub error: Option<String>,
483 pub refresh_interval: Duration,
484 last_refresh: Instant,
485 pub loading: bool,
486 pub detail: Option<Detail>,
487 pub confirm: Option<Confirm>,
488 pub flash: Option<(String, Instant)>,
489 pub selected: [usize; 7],
490 pub host: Option<String>,
491 pub profiles: Vec<String>,
493 pub profile: Option<String>,
494 pub picker: Option<usize>,
496 pub problems: Option<Problems>,
498 pub upcoming: Option<Upcoming>,
499 pub uc_path: Vec<String>,
501 uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
502 pub preview: Option<Preview>,
503 preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
504 pub wh_picker: Option<WhPicker>,
505 pub preview_warehouse: Option<(String, String)>,
507 pub cost: Option<CostView>,
508 #[allow(clippy::type_complexity)]
509 cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
510 workspace_id: Option<String>,
513 pub sql: Option<SqlConsole>,
514 sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
515 sql_history: Vec<String>,
517 hist_idx: Option<usize>,
519 hist_draft: String,
521 pub hist_search: Option<(String, usize)>,
523 pub run_view: Option<RunView>,
524 run_rx: Option<oneshot::Receiver<RunUpdate>>,
525 #[allow(clippy::type_complexity)]
526 upcoming_rx: Option<oneshot::Receiver<Result<Vec<fetchers::upcoming::UpcomingJob>, String>>>,
527 problems_rx: Option<oneshot::Receiver<Vec<fetchers::problems::RemoteProblem>>>,
528 pending: Option<mpsc::UnboundedReceiver<Update>>,
529 detail_rx: Option<oneshot::Receiver<DetailData>>,
530 action_rx: Option<oneshot::Receiver<Result<String, String>>>,
531 host_rx: Option<oneshot::Receiver<Option<String>>>,
532 in_flight: usize,
533 spinner_frame: usize,
534 pub splash_until: Option<Instant>,
536 pub updated_at: [Option<Instant>; 7],
538 pub filters: [String; 7],
540 pub filter_entry: bool,
542 pub config: crate::config::Config,
544 failed_seen: [Option<std::collections::HashSet<String>>; 7],
547 pub jump: Option<Jump>,
549 pub pane_order: Vec<usize>,
551 pub hidden: [bool; 7],
553 pub pane_cfg: Option<usize>,
555 pub help: bool,
557 pub help_scroll: u16,
559 sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
561 pub sql_complete: Option<SqlComplete>,
563 uc_names: std::collections::HashMap<String, Vec<String>>,
566 #[allow(clippy::type_complexity)]
567 uc_names_rx: Option<oneshot::Receiver<(String, Result<Vec<String>, String>)>>,
568 pub secret_scope: Option<String>,
570 secrets_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
571 pub secret_form: Option<SecretForm>,
573}
574
575pub struct Jump {
577 pub query: String,
578 pub index: usize,
579}
580
581pub struct SecretForm {
583 pub scope: Option<String>,
585 pub key: String,
586 pub value: String,
587 pub stage: u8,
589}
590
591impl App {
592 pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
593 let mut app = Self {
594 focus: Panel::Clusters,
595 theme,
596 zoomed: false,
597 shapes: vec![None; 7],
598 user_badge: None,
599 error: None,
600 refresh_interval: Duration::from_secs(refresh_secs),
601 last_refresh: Instant::now()
602 .checked_sub(Duration::from_secs(refresh_secs + 1))
603 .unwrap_or(Instant::now()),
604 loading: false,
605 detail: None,
606 confirm: None,
607 flash: None,
608 selected: [0; 7],
609 host: None,
610 profiles: Vec::new(),
611 profile: None,
612 picker: None,
613 problems: None,
614 upcoming: None,
615 uc_path: Vec::new(),
616 uc_rx: None,
617 preview: None,
618 preview_rx: None,
619 wh_picker: None,
620 preview_warehouse: None,
621 cost: None,
622 cost_rx: None,
623 workspace_id: None,
624 sql: None,
625 sql_rx: None,
626 sql_history: load_history(),
627 hist_idx: None,
628 hist_draft: String::new(),
629 hist_search: None,
630 run_view: None,
631 run_rx: None,
632 upcoming_rx: None,
633 problems_rx: None,
634 pending: None,
635 detail_rx: None,
636 action_rx: None,
637 host_rx: None,
638 in_flight: 0,
639 spinner_frame: 0,
640 splash_until: Some(Instant::now() + Duration::from_millis(1600)),
641 updated_at: [None; 7],
642 filters: Default::default(),
643 filter_entry: false,
644 config: crate::config::Config::load(),
645 failed_seen: Default::default(),
646 jump: None,
647 pane_order: (0..7).collect(),
648 hidden: [false; 7],
649 pane_cfg: None,
650 help: false,
651 help_scroll: 0,
652 sql_stmt: None,
653 sql_complete: None,
654 uc_names: Default::default(),
655 uc_names_rx: None,
656 secret_scope: None,
657 secrets_rx: None,
658 secret_form: None,
659 };
660 app.load_pane_prefs();
661 app
662 }
663
664 fn load_pane_prefs(&mut self) {
666 let idx_of = |id: &str| Panel::ALL.iter().position(|p| p.id() == id);
667 let mut order: Vec<usize> = self
668 .config
669 .pane_order
670 .iter()
671 .filter_map(|id| idx_of(id))
672 .collect();
673 for i in 0..7 {
674 if !order.contains(&i) {
675 order.push(i);
676 }
677 }
678 self.pane_order = order;
679 for id in &self.config.hidden_panes {
680 if let Some(i) = idx_of(id) {
681 self.hidden[i] = true;
682 }
683 }
684 self.ensure_focus_visible();
685 }
686
687 fn persist_panes(&mut self) {
688 self.config.pane_order = self
689 .pane_order
690 .iter()
691 .map(|&i| Panel::ALL[i].id().to_string())
692 .collect();
693 self.config.hidden_panes = (0..7)
694 .filter(|&i| self.hidden[i])
695 .map(|i| Panel::ALL[i].id().to_string())
696 .collect();
697 self.config.save();
698 }
699
700 pub fn visible_panes(&self) -> Vec<usize> {
702 self.pane_order
703 .iter()
704 .copied()
705 .filter(|&i| !self.hidden[i])
706 .collect()
707 }
708
709 fn ensure_focus_visible(&mut self) {
711 let visible = self.visible_panes();
712 let focus_idx = Panel::ALL
713 .iter()
714 .position(|p| p == &self.focus)
715 .unwrap_or(0);
716 if !visible.contains(&focus_idx) {
717 if let Some(&first) = visible.first() {
718 self.focus = Panel::ALL[first];
719 }
720 }
721 }
722
723 fn reveal_pane(&mut self, idx: usize) {
725 if self.hidden[idx] {
726 self.hidden[idx] = false;
727 self.persist_panes();
728 }
729 }
730
731 pub fn open_pane_cfg(&mut self) {
732 self.pane_cfg = Some(0);
733 }
734
735 pub fn pane_cfg_next(&mut self) {
736 if let Some(i) = self.pane_cfg {
737 self.pane_cfg = Some((i + 1).min(6));
738 }
739 }
740
741 pub fn pane_cfg_prev(&mut self) {
742 if let Some(i) = self.pane_cfg {
743 self.pane_cfg = Some(i.saturating_sub(1));
744 }
745 }
746
747 pub fn pane_cfg_toggle(&mut self) {
750 let Some(pos) = self.pane_cfg else {
751 return;
752 };
753 let idx = self.pane_order[pos];
754 if !self.hidden[idx] && self.visible_panes().len() == 1 {
755 self.flash = Some((
756 "✗ at least one pane has to stay visible".to_string(),
757 Instant::now(),
758 ));
759 return;
760 }
761 self.hidden[idx] = !self.hidden[idx];
762 self.ensure_focus_visible();
763 self.persist_panes();
764 }
765
766 pub fn pane_cfg_move(&mut self, delta: i32) {
768 let Some(pos) = self.pane_cfg else {
769 return;
770 };
771 let new = if delta < 0 {
772 pos.saturating_sub(1)
773 } else {
774 (pos + 1).min(6)
775 };
776 if new != pos {
777 self.pane_order.swap(pos, new);
778 self.pane_cfg = Some(new);
779 self.persist_panes();
780 }
781 }
782
783 fn alert_new_failures(&mut self, idx: usize) {
786 if idx >= 5 {
788 return;
789 }
790 let Some(Shape::List(items)) = &self.shapes[idx] else {
791 return;
792 };
793 let failed: std::collections::HashSet<String> = items
794 .iter()
795 .filter(|it| {
796 matches!(it.status, Status::Failed)
797 || it
798 .history
799 .last()
800 .is_some_and(|s| matches!(s, Status::Failed))
801 })
802 .map(|it| it.name.clone())
803 .collect();
804 if let Some(prev) = &self.failed_seen[idx] {
805 let mut newly: Vec<&String> = failed.difference(prev).collect();
806 if !newly.is_empty() {
807 newly.sort();
808 let extra = if newly.len() > 1 {
809 format!(" (+{} more)", newly.len() - 1)
810 } else {
811 String::new()
812 };
813 self.flash = Some((
814 format!("✗ {}{extra} just failed — ! to inspect", newly[0]),
815 Instant::now(),
816 ));
817 print!("\x07");
819 let _ = std::io::Write::flush(&mut std::io::stdout());
820 }
821 }
822 self.failed_seen[idx] = Some(failed);
823 }
824
825 pub fn persist_theme(&mut self) {
827 self.config.theme = Some(self.theme.id().to_string());
828 self.config.save();
829 }
830
831 pub fn restore_warehouse_pref(&mut self) {
833 let profile = self.profile.as_deref().unwrap_or("DEFAULT");
834 self.preview_warehouse = self.config.warehouses.get(profile).cloned();
835 }
836
837 pub fn splash_active(&self) -> bool {
838 self.splash_until
839 .map(|t| Instant::now() < t)
840 .unwrap_or(false)
841 }
842
843 pub fn dismiss_splash(&mut self) {
844 self.splash_until = None;
845 }
846
847 pub fn any_fresh(&self) -> bool {
849 self.updated_at
850 .iter()
851 .flatten()
852 .any(|t| t.elapsed() < Duration::from_millis(1200))
853 }
854
855 pub fn open_picker(&mut self) {
856 if self.profiles.is_empty() {
857 return;
858 }
859 let current = self
860 .profile
861 .as_deref()
862 .and_then(|p| self.profiles.iter().position(|n| n == p))
863 .unwrap_or(0);
864 self.picker = Some(current);
865 }
866
867 pub fn picker_next(&mut self) {
868 if let Some(i) = self.picker {
869 self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
870 }
871 }
872
873 pub fn picker_prev(&mut self) {
874 if let Some(i) = self.picker {
875 self.picker = Some(i.saturating_sub(1));
876 }
877 }
878
879 pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
881 let idx = self.picker.take()?;
882 let name = self.profiles.get(idx)?.clone();
883 Some(self.switch_profile(name))
884 }
885
886 fn switch_profile(&mut self, name: String) -> Arc<DatabricksCli> {
889 let profile_arg = if name == "DEFAULT" {
890 None
891 } else {
892 Some(name.clone())
893 };
894 self.profile = Some(name);
895
896 self.shapes = vec![None; 7];
898 self.user_badge = None;
899 self.host = None;
900 self.selected = [0; 7];
901 self.detail = None;
902 self.detail_rx = None;
903 self.confirm = None;
904 self.problems = None;
905 self.problems_rx = None;
906 self.uc_path.clear();
907 self.uc_rx = None;
908 self.secret_scope = None;
909 self.secrets_rx = None;
910 self.secret_form = None;
911 self.preview = None;
912 self.preview_rx = None;
913 self.wh_picker = None;
914 self.preview_warehouse = None;
915 self.cost = None;
916 self.cost_rx = None;
917 self.workspace_id = None;
918 self.sql = None;
919 self.sql_rx = None;
920 self.run_view = None;
921 self.run_rx = None;
922 self.pending = None;
923 self.in_flight = 0;
924 self.loading = false;
925 self.zoomed = false;
926 self.filters = Default::default();
927 self.filter_entry = false;
928 self.failed_seen = Default::default();
929 self.jump = None;
930 self.sql_stmt = None;
931 self.sql_complete = None;
932 self.uc_names.clear();
933 self.uc_names_rx = None;
934 self.restore_warehouse_pref();
935
936 Arc::new(DatabricksCli::new(profile_arg))
937 }
938
939 pub fn open_jump(&mut self) {
940 self.jump = Some(Jump {
941 query: String::new(),
942 index: 0,
943 });
944 }
945
946 pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
950 let Some(jump) = &self.jump else {
951 return Vec::new();
952 };
953 let q = jump.query.to_lowercase();
954 let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
955 for (i, shape) in self.shapes.iter().enumerate() {
956 let Some(Shape::List(items)) = shape else {
957 continue;
958 };
959 for it in items {
960 let name = it.name.to_lowercase();
961 let rank = if q.is_empty() || name.contains(&q) {
962 0
963 } else if subsequence(&name, &q) {
964 1
965 } else {
966 continue;
967 };
968 scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
969 }
970 }
971 scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
972 scored
973 .into_iter()
974 .take(12)
975 .map(|(_, i, name, label)| (i, name, label))
976 .collect()
977 }
978
979 pub fn jump_push(&mut self, c: char) {
980 if let Some(j) = &mut self.jump {
981 j.query.push(c);
982 j.index = 0;
983 }
984 }
985
986 pub fn jump_pop(&mut self) {
987 if let Some(j) = &mut self.jump {
988 j.query.pop();
989 j.index = 0;
990 }
991 }
992
993 pub fn jump_next(&mut self) {
994 let len = self.jump_matches().len();
995 if let Some(j) = &mut self.jump {
996 j.index = (j.index + 1).min(len.saturating_sub(1));
997 }
998 }
999
1000 pub fn jump_prev(&mut self) {
1001 if let Some(j) = &mut self.jump {
1002 j.index = j.index.saturating_sub(1);
1003 }
1004 }
1005
1006 pub fn jump_go(&mut self) {
1008 let matches = self.jump_matches();
1009 let Some(jump) = self.jump.take() else {
1010 return;
1011 };
1012 let Some((panel_idx, name, _)) = matches.get(jump.index) else {
1013 return;
1014 };
1015 self.reveal_pane(*panel_idx);
1016 self.focus = Panel::ALL[*panel_idx];
1017 self.filters[*panel_idx].clear();
1018 if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
1019 if let Some(pos) = items.iter().position(|i| &i.name == name) {
1020 self.selected[*panel_idx] = pos;
1021 }
1022 }
1023 }
1024
1025 pub fn open_problems(&mut self) {
1029 let mut items = Vec::new();
1030 for (i, shape) in self.shapes.iter().enumerate() {
1031 let Some(Shape::List(list)) = shape else {
1032 continue;
1033 };
1034 for it in list {
1035 let failed_now = matches!(it.status, Status::Failed);
1036 let failed_last = it
1037 .history
1038 .last()
1039 .is_some_and(|s| matches!(s, Status::Failed));
1040 if failed_now || failed_last {
1041 let note = if failed_now {
1042 it.detail.clone().unwrap_or_default()
1043 } else {
1044 "latest run failed".to_string()
1045 };
1046 items.push(Problem {
1047 panel: Some(i),
1048 name: it.name.clone(),
1049 status: it.status.clone(),
1050 note,
1051 profile: None,
1052 });
1053 }
1054 }
1055 }
1056 let current = self
1057 .profile
1058 .clone()
1059 .unwrap_or_else(|| "DEFAULT".to_string());
1060 let others: Vec<String> = self
1061 .profiles
1062 .iter()
1063 .filter(|n| **n != current)
1064 .cloned()
1065 .collect();
1066 let scanning = !others.is_empty();
1067 if scanning {
1068 let (tx, rx) = oneshot::channel();
1069 self.problems_rx = Some(rx);
1070 tokio::spawn(async move {
1071 let _ = tx.send(fetchers::problems::fetch(others, current).await);
1072 });
1073 }
1074 self.problems = Some(Problems {
1075 items,
1076 index: 0,
1077 scanning,
1078 });
1079 }
1080
1081 pub fn close_problems(&mut self) {
1082 self.problems = None;
1083 self.problems_rx = None;
1084 }
1085
1086 pub fn poll_problems(&mut self) -> bool {
1087 let Some(rx) = &mut self.problems_rx else {
1088 return false;
1089 };
1090 match rx.try_recv() {
1091 Ok(remote) => {
1092 self.problems_rx = None;
1093 let Some(pr) = &mut self.problems else {
1094 return false;
1095 };
1096 pr.scanning = false;
1097 pr.items.extend(remote.into_iter().map(|r| Problem {
1098 panel: r.panel,
1099 name: r.name,
1100 status: r.status,
1101 note: r.note,
1102 profile: Some(r.profile),
1103 }));
1104 true
1105 }
1106 Err(oneshot::error::TryRecvError::Empty) => false,
1107 Err(oneshot::error::TryRecvError::Closed) => {
1108 self.problems_rx = None;
1109 if let Some(pr) = &mut self.problems {
1110 pr.scanning = false;
1111 }
1112 true
1113 }
1114 }
1115 }
1116
1117 pub fn problems_next(&mut self) {
1118 if let Some(pr) = &mut self.problems {
1119 pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
1120 }
1121 }
1122
1123 pub fn problems_prev(&mut self) {
1124 if let Some(pr) = &mut self.problems {
1125 pr.index = pr.index.saturating_sub(1);
1126 }
1127 }
1128
1129 pub fn problems_jump(&mut self) -> Option<Arc<DatabricksCli>> {
1133 let Some(pr) = self.problems.take() else {
1134 self.problems_rx = None;
1135 return None;
1136 };
1137 self.problems_rx = None;
1138 let problem = pr.items.get(pr.index)?;
1139 if let Some(profile) = &problem.profile {
1140 let target = match problem.panel {
1141 Some(i) => format!("{} is in {}", problem.name, Panel::ALL[i].title()),
1142 None => "check its auth".to_string(),
1143 };
1144 self.flash = Some((
1145 format!("⌂ switched to {profile} — {target}"),
1146 Instant::now(),
1147 ));
1148 let profile = profile.clone();
1149 return Some(self.switch_profile(profile));
1150 }
1151 let panel = problem.panel?;
1152 self.reveal_pane(panel);
1153 self.focus = Panel::ALL[panel];
1154 self.filters[panel].clear();
1156 if let Some(Shape::List(list)) = &self.shapes[panel] {
1157 if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
1158 self.selected[panel] = pos;
1159 }
1160 }
1161 None
1162 }
1163
1164 pub fn open_upcoming(&mut self, cli: &Arc<DatabricksCli>) {
1167 self.upcoming = Some(Upcoming {
1168 items: Vec::new(),
1169 index: 0,
1170 loading: true,
1171 });
1172 let (tx, rx) = oneshot::channel();
1173 self.upcoming_rx = Some(rx);
1174 let cli = Arc::clone(cli);
1175 tokio::spawn(async move {
1176 let _ = tx.send(fetchers::upcoming::fetch(&cli).await);
1177 });
1178 }
1179
1180 pub fn close_upcoming(&mut self) {
1181 self.upcoming = None;
1182 self.upcoming_rx = None;
1183 }
1184
1185 pub fn poll_upcoming(&mut self) -> bool {
1186 let Some(rx) = &mut self.upcoming_rx else {
1187 return false;
1188 };
1189 match rx.try_recv() {
1190 Ok(result) => {
1191 self.upcoming_rx = None;
1192 match result {
1193 Ok(items) => {
1194 if let Some(u) = &mut self.upcoming {
1195 u.items = items;
1196 u.loading = false;
1197 }
1198 }
1199 Err(e) => {
1200 self.upcoming = None;
1201 let first = e.lines().next().unwrap_or("failed").to_string();
1202 self.flash = Some((format!("✗ upcoming: {first}"), Instant::now()));
1203 }
1204 }
1205 true
1206 }
1207 Err(oneshot::error::TryRecvError::Empty) => false,
1208 Err(oneshot::error::TryRecvError::Closed) => {
1209 self.upcoming_rx = None;
1210 true
1211 }
1212 }
1213 }
1214
1215 pub fn upcoming_next(&mut self) {
1216 if let Some(u) = &mut self.upcoming {
1217 u.index = (u.index + 1).min(u.items.len().saturating_sub(1));
1218 }
1219 }
1220
1221 pub fn upcoming_prev(&mut self) {
1222 if let Some(u) = &mut self.upcoming {
1223 u.index = u.index.saturating_sub(1);
1224 }
1225 }
1226
1227 pub fn upcoming_jump(&mut self) {
1229 let Some(u) = self.upcoming.take() else {
1230 return;
1231 };
1232 self.upcoming_rx = None;
1233 let Some(item) = u.items.get(u.index) else {
1234 return;
1235 };
1236 let Some(idx) = Panel::ALL.iter().position(|p| *p == Panel::Jobs) else {
1237 return;
1238 };
1239 self.reveal_pane(idx);
1240 self.focus = Panel::Jobs;
1241 self.filters[idx].clear();
1242 if let Some(Shape::List(list)) = &self.shapes[idx] {
1243 if let Some(pos) = list.iter().position(|i| i.name == item.name) {
1244 self.selected[idx] = pos;
1245 }
1246 }
1247 }
1248
1249 pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
1252 let (tx, rx) = oneshot::channel();
1253 self.host_rx = Some(rx);
1254 let cli = Arc::clone(cli);
1255 tokio::spawn(async move {
1256 let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
1257 json["details"]["host"]
1258 .as_str()
1259 .or_else(|| json["host"].as_str())
1260 .map(str::to_string)
1261 });
1262 let _ = tx.send(host);
1263 });
1264 }
1265
1266 pub fn poll_host(&mut self) {
1267 if let Some(rx) = &mut self.host_rx {
1268 match rx.try_recv() {
1269 Ok(host) => {
1270 self.host = host;
1271 self.host_rx = None;
1272 }
1273 Err(oneshot::error::TryRecvError::Empty) => {}
1274 Err(oneshot::error::TryRecvError::Closed) => {
1275 self.host_rx = None;
1276 }
1277 }
1278 }
1279 }
1280
1281 fn focus_index(&self) -> usize {
1282 Panel::ALL
1283 .iter()
1284 .position(|p| p == &self.focus)
1285 .unwrap_or(0)
1286 }
1287
1288 fn list_len(&self, idx: usize) -> usize {
1289 match &self.shapes[idx] {
1290 Some(Shape::List(items)) => items
1291 .iter()
1292 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
1293 .count(),
1294 _ => 0,
1295 }
1296 }
1297
1298 pub fn selection(&self, idx: usize) -> usize {
1300 self.selected[idx].min(self.list_len(idx).saturating_sub(1))
1301 }
1302
1303 pub fn select_next(&mut self) {
1304 let idx = self.focus_index();
1305 let len = self.list_len(idx);
1306 if len > 0 {
1307 self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
1308 }
1309 }
1310
1311 pub fn select_prev(&mut self) {
1312 let idx = self.focus_index();
1313 self.selected[idx] = self.selection(idx).saturating_sub(1);
1314 }
1315
1316 fn selected_item(&self) -> Option<&crate::shape::ListItem> {
1319 let idx = self.focus_index();
1320 match &self.shapes[idx] {
1321 Some(Shape::List(items)) => items
1322 .iter()
1323 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
1324 .nth(self.selection(idx)),
1325 _ => None,
1326 }
1327 }
1328
1329 pub fn filter_start(&mut self) {
1331 let idx = self.focus_index();
1332 self.filters[idx].clear();
1333 self.selected[idx] = 0;
1334 self.filter_entry = true;
1335 }
1336
1337 pub fn filter_push(&mut self, c: char) {
1338 let idx = self.focus_index();
1339 self.filters[idx].push(c);
1340 self.selected[idx] = 0;
1341 }
1342
1343 pub fn filter_pop(&mut self) {
1344 let idx = self.focus_index();
1345 self.filters[idx].pop();
1346 self.selected[idx] = 0;
1347 }
1348
1349 pub fn filter_accept(&mut self) {
1351 self.filter_entry = false;
1352 }
1353
1354 pub fn filter_clear(&mut self) {
1355 let idx = self.focus_index();
1356 self.filters[idx].clear();
1357 self.selected[idx] = 0;
1358 self.filter_entry = false;
1359 }
1360
1361 pub fn active_filter(&self) -> &str {
1363 &self.filters[self.focus_index()]
1364 }
1365
1366 pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
1367 let Some(item) = self.selected_item() else {
1368 return;
1369 };
1370 let Some(id) = item.id.clone() else {
1371 return;
1372 };
1373 let kind = match &item.status {
1374 Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
1375 _ => None,
1376 };
1377 let section = match self.focus {
1378 Panel::Dashboards => "Contents",
1379 Panel::Catalog => "Columns",
1380 Panel::Warehouses => "Recent queries",
1381 _ => "Recent activity",
1382 };
1383 self.detail = Some(Detail {
1384 panel: self.focus,
1385 name: item.name.clone(),
1386 id: id.clone(),
1387 kind,
1388 section,
1389 data: None,
1390 show_raw: false,
1391 scroll: 0,
1392 });
1393
1394 let (tx, rx) = oneshot::channel();
1395 self.detail_rx = Some(rx);
1396 let cli = Arc::clone(cli);
1397 let kind = self.detail.as_ref().unwrap().kind.clone();
1398 if kind.as_deref() == Some("FILE") {
1400 if let Some(d) = &mut self.detail {
1401 d.section = "File head";
1402 }
1403 tokio::spawn(async move {
1404 let data = fetchers::catalog::file_peek(&cli, &id).await;
1405 let _ = tx.send(data);
1406 });
1407 return;
1408 }
1409 let group = match &kind {
1410 Some(k) if k == "VOLUME" => "volumes",
1411 _ => self.focus.cli_group(),
1412 };
1413 let warehouse = match &kind {
1416 Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
1417 _ => None,
1418 };
1419 tokio::spawn(async move {
1420 let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
1421 let _ = tx.send(data);
1422 });
1423 }
1424
1425 pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1428 if self.focus != Panel::Catalog {
1429 return false;
1430 }
1431 let Some(item) = self.selected_item() else {
1432 return self.uc_path.is_empty(); };
1434 if self.uc_path.len() >= 2 {
1437 let drillable =
1438 matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
1439 if !drillable {
1440 return false;
1441 }
1442 }
1443 self.uc_path.push(item.name.clone());
1444 self.refresh_catalog(cli);
1445 true
1446 }
1447
1448 pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1450 if self.focus != Panel::Catalog || self.uc_path.is_empty() {
1451 return false;
1452 }
1453 self.uc_path.pop();
1454 self.refresh_catalog(cli);
1455 true
1456 }
1457
1458 fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
1459 self.shapes[5] = None;
1460 self.selected[5] = 0;
1461 self.filters[5].clear();
1463 let (tx, rx) = oneshot::channel();
1464 self.uc_rx = Some(rx);
1465 let cli = Arc::clone(cli);
1466 let path = self.uc_path.clone();
1467 tokio::spawn(async move {
1468 let result = fetchers::catalog::fetch(&cli, &path)
1469 .await
1470 .map_err(|e| format!("{e:#}"));
1471 let _ = tx.send(result);
1472 });
1473 }
1474
1475 pub fn secrets_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1479 if self.focus != Panel::Secrets {
1480 return false;
1481 }
1482 if self.secret_scope.is_some() {
1484 return true;
1485 }
1486 let Some(item) = self.selected_item() else {
1487 return true; };
1489 self.secret_scope = Some(item.name.clone());
1490 self.refresh_secrets(cli);
1491 true
1492 }
1493
1494 pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1496 if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1497 return false;
1498 }
1499 self.secret_scope = None;
1500 self.refresh_secrets(cli);
1501 true
1502 }
1503
1504 fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1505 let idx = 6;
1506 self.shapes[idx] = None;
1507 self.selected[idx] = 0;
1508 self.filters[idx].clear();
1509 let (tx, rx) = oneshot::channel();
1510 self.secrets_rx = Some(rx);
1511 let cli = Arc::clone(cli);
1512 let scope = self.secret_scope.clone();
1513 tokio::spawn(async move {
1514 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1515 .await
1516 .map_err(|e| format!("{e:#}"));
1517 let _ = tx.send(result);
1518 });
1519 }
1520
1521 pub fn poll_secrets(&mut self) -> bool {
1522 let Some(rx) = &mut self.secrets_rx else {
1523 return false;
1524 };
1525 match rx.try_recv() {
1526 Ok(result) => {
1527 self.shapes[6] = Some(match result {
1528 Ok(shape) => shape,
1529 Err(e) => Shape::Text(format!("✗ {e}")),
1530 });
1531 self.updated_at[6] = Some(Instant::now());
1532 self.secrets_rx = None;
1533 true
1534 }
1535 Err(oneshot::error::TryRecvError::Empty) => false,
1536 Err(oneshot::error::TryRecvError::Closed) => {
1537 self.secrets_rx = None;
1538 true
1539 }
1540 }
1541 }
1542
1543 pub fn open_secret_form(&mut self) {
1546 if self.focus != Panel::Secrets {
1547 return;
1548 }
1549 self.secret_form = Some(SecretForm {
1550 scope: self.secret_scope.clone(),
1551 key: String::new(),
1552 value: String::new(),
1553 stage: 0,
1554 });
1555 }
1556
1557 pub fn secret_form_push(&mut self, c: char) {
1558 if let Some(form) = &mut self.secret_form {
1559 if form.stage == 0 {
1560 form.key.push(c);
1561 } else {
1562 form.value.push(c);
1563 }
1564 }
1565 }
1566
1567 pub fn secret_form_pop(&mut self) {
1568 if let Some(form) = &mut self.secret_form {
1569 if form.stage == 0 {
1570 form.key.pop();
1571 } else {
1572 form.value.pop();
1573 }
1574 }
1575 }
1576
1577 pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1579 let Some(form) = &mut self.secret_form else {
1580 return;
1581 };
1582 if form.key.trim().is_empty() {
1583 return;
1584 }
1585 match (form.scope.clone(), form.stage) {
1586 (None, _) => {
1588 let name = form.key.trim().to_string();
1589 self.secret_form = None;
1590 self.run_secret_action(
1591 cli,
1592 format!("Create scope “{name}”"),
1593 vec!["secrets".into(), "create-scope".into(), name],
1594 );
1595 }
1596 (Some(_), 0) => form.stage = 1,
1598 (Some(scope), _) => {
1599 let key = form.key.trim().to_string();
1600 let value = form.value.clone();
1601 self.secret_form = None;
1602 self.run_secret_action(
1603 cli,
1604 format!("Put secret “{key}” in “{scope}”"),
1605 vec![
1606 "secrets".into(),
1607 "put-secret".into(),
1608 scope,
1609 key,
1610 "--string-value".into(),
1611 value,
1612 ],
1613 );
1614 }
1615 }
1616 }
1617
1618 fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1621 self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1622 let (tx, rx) = oneshot::channel();
1623 self.action_rx = Some(rx);
1624 let cli = Arc::clone(cli);
1625 tokio::spawn(async move {
1626 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1627 let result = match cli.run_action(&arg_refs).await {
1628 Ok(()) => Ok(format!("✓ {label} — done")),
1629 Err(e) => Err(format!("✗ {e:#}")),
1630 };
1631 let _ = tx.send(result);
1632 });
1633 }
1634
1635 pub fn request_secret_delete(&mut self) {
1637 if self.focus != Panel::Secrets {
1638 return;
1639 }
1640 let Some(item) = self.selected_item() else {
1641 return;
1642 };
1643 let name = item.name.clone();
1644 let (message, args) = match &self.secret_scope {
1645 None => (
1646 format!("Delete scope “{name}” and all its secrets?"),
1647 vec!["secrets".to_string(), "delete-scope".to_string(), name],
1648 ),
1649 Some(scope) => (
1650 format!("Delete secret “{name}” from “{scope}”?"),
1651 vec![
1652 "secrets".to_string(),
1653 "delete-secret".to_string(),
1654 scope.clone(),
1655 name,
1656 ],
1657 ),
1658 };
1659 self.confirm = Some(Confirm { message, args });
1660 }
1661
1662 fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1664 let scope = match &self.secret_scope {
1665 Some(s) => Some(s.clone()),
1666 None => self.selected_item().map(|i| i.name.clone()),
1667 };
1668 let Some(scope) = scope else {
1669 return;
1670 };
1671 self.detail = Some(Detail {
1672 panel: Panel::Secrets,
1673 name: scope.clone(),
1674 id: scope.clone(),
1675 kind: None,
1676 section: "Access",
1677 data: None,
1678 show_raw: false,
1679 scroll: 0,
1680 });
1681 let (tx, rx) = oneshot::channel();
1682 self.detail_rx = Some(rx);
1683 let cli = Arc::clone(cli);
1684 tokio::spawn(async move {
1685 let acl_args = ["secrets", "list-acls", &scope];
1686 let data = match cli.run(&acl_args).await {
1687 Ok(json) => {
1688 let raw =
1689 serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1690 let acls = json
1692 .as_array()
1693 .cloned()
1694 .or_else(|| json["items"].as_array().cloned())
1695 .unwrap_or_default();
1696 let activity: Vec<(Status, String)> = acls
1697 .iter()
1698 .map(|a| {
1699 let principal = a["principal"].as_str().unwrap_or("?");
1700 let perm = a["permission"].as_str().unwrap_or("?");
1701 let status = if perm == "MANAGE" {
1702 Status::Success
1703 } else {
1704 Status::Unknown(String::new())
1705 };
1706 (status, format!("{principal} · {perm}"))
1707 })
1708 .collect();
1709 DetailData {
1710 summary: vec![("Scope".to_string(), scope.clone())],
1711 activity,
1712 raw,
1713 }
1714 }
1715 Err(e) => DetailData {
1716 summary: Vec::new(),
1717 activity: Vec::new(),
1718 raw: format!("{e:#}"),
1719 },
1720 };
1721 let _ = tx.send(data);
1722 });
1723 }
1724
1725 pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1728 let idx = match kind {
1729 "cluster" => 0,
1730 "job" => 1,
1731 "warehouse" => 3,
1732 _ => return None,
1733 };
1734 match &self.shapes[idx] {
1735 Some(Shape::List(items)) => items
1736 .iter()
1737 .find(|i| i.id.as_deref() == Some(id))
1738 .map(|i| i.name.clone()),
1739 _ => None,
1740 }
1741 }
1742
1743 pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1745 let Some(Shape::List(items)) = &self.shapes[3] else {
1746 return Vec::new();
1747 };
1748 items
1749 .iter()
1750 .filter_map(|i| {
1751 let id = i.id.clone()?;
1752 Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1753 })
1754 .collect()
1755 }
1756
1757 pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
1761 if self.focus != Panel::Catalog {
1762 return;
1763 }
1764 let Some(item) = self.selected_item() else {
1765 return;
1766 };
1767 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1768 return;
1769 }
1770 let Some(full_name) = item.id.clone() else {
1771 return;
1772 };
1773 let warehouses = self.warehouses();
1774 if warehouses.is_empty() {
1775 self.flash = Some((
1776 "✗ no SQL warehouse available for previews".to_string(),
1777 Instant::now(),
1778 ));
1779 return;
1780 }
1781 if !force_pick {
1782 if let Some((id, name)) = self.preview_warehouse.clone() {
1783 self.start_preview_query(cli, full_name, id, name);
1784 return;
1785 }
1786 if let [(name, id, _)] = warehouses.as_slice() {
1787 self.preview_warehouse = Some((id.clone(), name.clone()));
1788 self.start_preview_query(cli, full_name, id.clone(), name.clone());
1789 return;
1790 }
1791 }
1792 let index = self
1794 .preview_warehouse
1795 .as_ref()
1796 .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1797 .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1798 .unwrap_or(0);
1799 self.wh_picker = Some(WhPicker {
1800 index,
1801 target: PickTarget::Preview(full_name),
1802 });
1803 }
1804
1805 pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1807 let warehouses = self.warehouses();
1808 if warehouses.is_empty() {
1809 self.flash = Some((
1810 "✗ no SQL warehouse available to query system tables".to_string(),
1811 Instant::now(),
1812 ));
1813 return;
1814 }
1815 if let Some((id, name)) = self.preview_warehouse.clone() {
1816 self.start_cost_query(cli, id, name);
1817 return;
1818 }
1819 if let [(name, id, _)] = warehouses.as_slice() {
1820 self.preview_warehouse = Some((id.clone(), name.clone()));
1821 self.start_cost_query(cli, id.clone(), name.clone());
1822 return;
1823 }
1824 let index = warehouses
1825 .iter()
1826 .position(|(_, _, running)| *running)
1827 .unwrap_or(0);
1828 self.wh_picker = Some(WhPicker {
1829 index,
1830 target: PickTarget::Cost,
1831 });
1832 }
1833
1834 fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1835 self.cost = Some(CostView {
1836 warehouse: name,
1837 data: None,
1838 });
1839 let (tx, rx) = oneshot::channel();
1840 self.cost_rx = Some(rx);
1841 let cli = Arc::clone(cli);
1842 let host = self.host.clone();
1843 let cached_ws = self.workspace_id.clone();
1844 tokio::spawn(async move {
1845 let ws = match (cached_ws, host) {
1847 (Some(w), _) => Some(w),
1848 (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1849 (None, None) => None,
1850 };
1851 let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1852 let _ = tx.send((result, ws));
1853 });
1854 }
1855
1856 pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1859 if self.focus != Panel::Catalog {
1860 return;
1861 }
1862 let Some(item) = self.selected_item() else {
1863 return;
1864 };
1865 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1866 return;
1867 }
1868 let Some(full_name) = item.id.clone() else {
1869 return;
1870 };
1871 let warehouses = self.warehouses();
1872 if warehouses.is_empty() {
1873 self.flash = Some((
1874 "✗ no SQL warehouse available to query lineage".to_string(),
1875 Instant::now(),
1876 ));
1877 return;
1878 }
1879 if let Some((id, _)) = self.preview_warehouse.clone() {
1880 self.start_lineage_query(cli, full_name, id);
1881 return;
1882 }
1883 if let [(name, id, _)] = warehouses.as_slice() {
1884 self.preview_warehouse = Some((id.clone(), name.clone()));
1885 let id = id.clone();
1886 self.start_lineage_query(cli, full_name, id);
1887 return;
1888 }
1889 let index = warehouses
1890 .iter()
1891 .position(|(_, _, running)| *running)
1892 .unwrap_or(0);
1893 self.wh_picker = Some(WhPicker {
1894 index,
1895 target: PickTarget::Lineage(full_name),
1896 });
1897 }
1898
1899 fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1900 self.detail = Some(Detail {
1901 panel: Panel::Catalog,
1902 name: full_name.clone(),
1903 id: full_name.clone(),
1904 kind: None,
1905 section: "Lineage",
1906 data: None,
1907 show_raw: false,
1908 scroll: 0,
1909 });
1910 let (tx, rx) = oneshot::channel();
1911 self.detail_rx = Some(rx);
1912 let cli = Arc::clone(cli);
1913 tokio::spawn(async move {
1914 let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1915 let _ = tx.send(data);
1916 });
1917 }
1918
1919 pub fn close_cost(&mut self) {
1920 self.cost = None;
1921 self.cost_rx = None;
1922 }
1923
1924 fn selected_table_fqn(&self) -> Option<String> {
1926 if self.focus != Panel::Catalog {
1927 return None;
1928 }
1929 let item = self.selected_item()?;
1930 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1931 return None;
1932 }
1933 item.id.clone()
1934 }
1935
1936 pub fn open_sql(&mut self) {
1939 if self.sql.is_none() {
1940 let input = self
1941 .selected_table_fqn()
1942 .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1943 .unwrap_or_default();
1944 self.sql = Some(SqlConsole {
1945 cursor: input.chars().count(),
1946 input,
1947 warehouse: String::new(),
1948 running: false,
1949 data: None,
1950 last_sql: String::new(),
1951 scroll: 0,
1952 col: 0,
1953 });
1954 }
1955 }
1956
1957 pub fn close_sql(&mut self) {
1958 self.sql = None;
1959 self.sql_rx = None;
1960 self.hist_idx = None;
1961 self.hist_draft.clear();
1962 self.hist_search = None;
1963 self.sql_complete = None;
1964 }
1965
1966 pub fn sql_tab(&mut self, cli: &Arc<DatabricksCli>) {
1970 match &self.sql_complete {
1971 Some(c) if c.loading => return,
1972 Some(_) => {
1973 self.sql_complete_next(1);
1974 return;
1975 }
1976 None => {}
1977 }
1978 let Some(console) = &self.sql else {
1979 return;
1980 };
1981 let (seg_start, context, prefix) = token_at_cursor(&console.input, console.cursor);
1982 let missing = if context.is_empty() {
1985 from_table(&console.input)
1986 .filter(|fqn| !self.uc_names.contains_key(fqn))
1987 .or_else(|| (!self.uc_names.contains_key("")).then(String::new))
1988 } else {
1989 (!self.uc_names.contains_key(&context)).then(|| context.clone())
1990 };
1991 let loading = missing.is_some();
1992 self.sql_complete = Some(SqlComplete {
1993 items: Vec::new(),
1994 index: 0,
1995 seg_start,
1996 prefix,
1997 context,
1998 loading,
1999 });
2000 match missing {
2001 Some(path) => self.fetch_uc_names(cli, path),
2002 None => self.sql_complete_fill(),
2003 }
2004 }
2005
2006 fn sql_complete_fill(&mut self) {
2009 let Some(console) = &self.sql else {
2010 self.sql_complete = None;
2011 return;
2012 };
2013 let input = console.input.clone();
2014 let Some(comp) = &self.sql_complete else {
2015 return;
2016 };
2017 let q = comp.prefix.to_lowercase();
2018 let mut items: Vec<String> = Vec::new();
2019 if comp.context.is_empty() {
2020 if let Some(cols) = from_table(&input).and_then(|fqn| self.uc_names.get(&fqn)) {
2021 items.extend(
2022 cols.iter()
2023 .filter(|n| n.to_lowercase().starts_with(&q))
2024 .cloned(),
2025 );
2026 }
2027 if let Some(cats) = self.uc_names.get("") {
2028 items.extend(
2029 cats.iter()
2030 .filter(|n| n.to_lowercase().starts_with(&q))
2031 .cloned(),
2032 );
2033 }
2034 if !q.is_empty() {
2035 items.extend(
2036 SQL_KEYWORDS
2037 .iter()
2038 .filter(|k| k.to_lowercase().starts_with(&q))
2039 .map(|k| k.to_string()),
2040 );
2041 }
2042 } else if let Some(names) = self.uc_names.get(&comp.context) {
2043 items.extend(
2044 names
2045 .iter()
2046 .filter(|n| n.to_lowercase().starts_with(&q))
2047 .cloned(),
2048 );
2049 }
2050 items.dedup();
2051 if items.is_empty() {
2052 self.sql_complete = None;
2053 self.flash = Some(("no completions".to_string(), Instant::now()));
2054 return;
2055 }
2056 let single = items.len() == 1;
2057 if let Some(comp) = &mut self.sql_complete {
2058 comp.items = items;
2059 comp.index = 0;
2060 }
2061 self.sql_complete_apply();
2062 if single {
2063 self.sql_complete = None;
2064 }
2065 }
2066
2067 fn sql_complete_apply(&mut self) {
2069 let Some(comp) = &self.sql_complete else {
2070 return;
2071 };
2072 let candidate = comp.items[comp.index].clone();
2073 let plain = candidate
2074 .chars()
2075 .all(|c| c.is_alphanumeric() || matches!(c, '_' | ' '));
2076 let text = if plain {
2077 candidate
2078 } else {
2079 format!("`{candidate}`")
2080 };
2081 self.sql_replace_segment(comp.seg_start, &text);
2082 }
2083
2084 fn sql_replace_segment(&mut self, seg_start: usize, text: &str) {
2086 if let Some(console) = &mut self.sql {
2087 let from = byte_at(&console.input, seg_start);
2088 let to = byte_at(&console.input, console.cursor);
2089 console.input.replace_range(from..to, text);
2090 console.cursor = seg_start + text.chars().count();
2091 }
2092 }
2093
2094 pub fn sql_complete_next(&mut self, delta: i32) {
2096 let Some(comp) = &mut self.sql_complete else {
2097 return;
2098 };
2099 if comp.items.is_empty() {
2100 return;
2101 }
2102 let n = comp.items.len() as i32;
2103 comp.index = (comp.index as i32 + delta).rem_euclid(n) as usize;
2104 self.sql_complete_apply();
2105 }
2106
2107 pub fn sql_complete_cancel(&mut self) {
2109 if let Some(comp) = &self.sql_complete {
2110 let (seg_start, prefix) = (comp.seg_start, comp.prefix.clone());
2111 self.sql_replace_segment(seg_start, &prefix);
2112 }
2113 self.sql_complete = None;
2114 }
2115
2116 pub fn sql_complete_accept(&mut self) {
2118 self.sql_complete = None;
2119 }
2120
2121 fn fetch_uc_names(&mut self, cli: &Arc<DatabricksCli>, path: String) {
2122 let (tx, rx) = oneshot::channel();
2123 self.uc_names_rx = Some(rx);
2124 let cli = Arc::clone(cli);
2125 tokio::spawn(async move {
2126 let names = fetchers::catalog::names(&cli, &path)
2127 .await
2128 .map_err(|e| format!("{e:#}"));
2129 let _ = tx.send((path, names));
2130 });
2131 }
2132
2133 pub fn poll_uc_names(&mut self) -> bool {
2135 let Some(rx) = &mut self.uc_names_rx else {
2136 return false;
2137 };
2138 match rx.try_recv() {
2139 Ok((path, result)) => {
2140 self.uc_names_rx = None;
2141 match result {
2142 Ok(names) => {
2143 self.uc_names.insert(path, names);
2144 if self.sql_complete.as_ref().is_some_and(|c| c.loading) {
2145 if let Some(c) = &mut self.sql_complete {
2146 c.loading = false;
2147 }
2148 self.sql_complete_fill();
2149 }
2150 }
2151 Err(e) => {
2152 self.sql_complete = None;
2153 let first = e.lines().next().unwrap_or("fetch failed").to_string();
2154 self.flash = Some((format!("✗ completions: {first}"), Instant::now()));
2155 }
2156 }
2157 true
2158 }
2159 Err(oneshot::error::TryRecvError::Empty) => false,
2160 Err(oneshot::error::TryRecvError::Closed) => {
2161 self.uc_names_rx = None;
2162 self.sql_complete = None;
2163 true
2164 }
2165 }
2166 }
2167
2168 pub fn sql_input(&self) -> Option<String> {
2170 self.sql.as_ref().map(|c| c.input.clone())
2171 }
2172
2173 pub fn sql_set_input(&mut self, s: &str) {
2175 if let Some(console) = &mut self.sql {
2176 console.input = s.to_string();
2177 console.cursor = console.input.chars().count();
2178 }
2179 }
2180
2181 pub fn hist_search_current(&self) -> Option<&String> {
2183 let (query, nth) = self.hist_search.as_ref()?;
2184 self.sql_history
2185 .iter()
2186 .rev()
2187 .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
2188 .nth(*nth)
2189 }
2190
2191 pub fn hist_search_start(&mut self) {
2192 if self.sql.is_some() {
2193 self.hist_search = Some((String::new(), 0));
2194 }
2195 }
2196
2197 pub fn hist_search_push(&mut self, c: char) {
2198 if let Some((query, nth)) = &mut self.hist_search {
2199 query.push(c);
2200 *nth = 0;
2201 }
2202 }
2203
2204 pub fn hist_search_pop(&mut self) {
2205 if let Some((query, nth)) = &mut self.hist_search {
2206 query.pop();
2207 *nth = 0;
2208 }
2209 }
2210
2211 pub fn hist_search_older(&mut self) {
2213 let Some((query, nth)) = &self.hist_search else {
2214 return;
2215 };
2216 let q = query.to_lowercase();
2217 let matches = self
2218 .sql_history
2219 .iter()
2220 .filter(|h| h.to_lowercase().contains(&q))
2221 .count();
2222 if nth + 1 < matches {
2223 if let Some((_, n)) = &mut self.hist_search {
2224 *n += 1;
2225 }
2226 }
2227 }
2228
2229 pub fn hist_search_accept(&mut self) {
2230 if let Some(stmt) = self.hist_search_current().cloned() {
2231 self.sql_set_input(&stmt);
2232 }
2233 self.hist_search = None;
2234 }
2235
2236 pub fn hist_search_cancel(&mut self) {
2237 self.hist_search = None;
2238 }
2239
2240 pub fn sql_push(&mut self, c: char) {
2241 if let Some(console) = &mut self.sql {
2242 let at = byte_at(&console.input, console.cursor);
2243 console.input.insert(at, c);
2244 console.cursor += 1;
2245 }
2246 }
2247
2248 pub fn sql_pop(&mut self) {
2250 if let Some(console) = &mut self.sql {
2251 if console.cursor > 0 {
2252 let at = byte_at(&console.input, console.cursor - 1);
2253 console.input.remove(at);
2254 console.cursor -= 1;
2255 }
2256 }
2257 }
2258
2259 pub fn sql_delete(&mut self) {
2261 if let Some(console) = &mut self.sql {
2262 if console.cursor < console.input.chars().count() {
2263 let at = byte_at(&console.input, console.cursor);
2264 console.input.remove(at);
2265 }
2266 }
2267 }
2268
2269 pub fn sql_left(&mut self) {
2270 if let Some(console) = &mut self.sql {
2271 console.cursor = console.cursor.saturating_sub(1);
2272 }
2273 }
2274
2275 pub fn sql_right(&mut self) {
2276 if let Some(console) = &mut self.sql {
2277 console.cursor = (console.cursor + 1).min(console.input.chars().count());
2278 }
2279 }
2280
2281 pub fn sql_hist_prev(&mut self) {
2283 let Some(console) = &mut self.sql else {
2284 return;
2285 };
2286 if self.sql_history.is_empty() {
2287 return;
2288 }
2289 let idx = match self.hist_idx {
2290 None => {
2291 self.hist_draft = console.input.clone();
2292 self.sql_history.len() - 1
2293 }
2294 Some(i) => i.saturating_sub(1),
2295 };
2296 self.hist_idx = Some(idx);
2297 console.input = self.sql_history[idx].clone();
2298 console.cursor = console.input.chars().count();
2299 }
2300
2301 pub fn sql_hist_next(&mut self) {
2303 let Some(console) = &mut self.sql else {
2304 return;
2305 };
2306 let Some(idx) = self.hist_idx else {
2307 return;
2308 };
2309 if idx + 1 < self.sql_history.len() {
2310 self.hist_idx = Some(idx + 1);
2311 console.input = self.sql_history[idx + 1].clone();
2312 } else {
2313 self.hist_idx = None;
2314 console.input = self.hist_draft.clone();
2315 }
2316 console.cursor = console.input.chars().count();
2317 }
2318
2319 pub fn sql_home(&mut self) {
2320 if let Some(console) = &mut self.sql {
2321 console.cursor = 0;
2322 }
2323 }
2324
2325 pub fn sql_end(&mut self) {
2326 if let Some(console) = &mut self.sql {
2327 console.cursor = console.input.chars().count();
2328 }
2329 }
2330
2331 pub fn sql_scroll(&mut self, delta: i32) {
2332 if let Some(console) = &mut self.sql {
2333 let max = match &console.data {
2334 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2335 _ => 0,
2336 };
2337 console.scroll = if delta < 0 {
2338 console.scroll.saturating_sub(delta.unsigned_abs() as usize)
2339 } else {
2340 (console.scroll + delta as usize).min(max)
2341 };
2342 }
2343 }
2344
2345 pub fn sql_cols(&mut self, delta: i32) {
2347 if let Some(console) = &mut self.sql {
2348 let n = match &console.data {
2349 Some(Ok(t)) => t.headers.len(),
2350 _ => 0,
2351 };
2352 console.col = if delta < 0 {
2353 console.col.saturating_sub(1)
2354 } else {
2355 (console.col + 1).min(n.saturating_sub(1))
2356 };
2357 }
2358 }
2359
2360 pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
2362 let Some(console) = &self.sql else {
2363 return;
2364 };
2365 if console.running {
2366 return;
2367 }
2368 let query = console.input.trim().to_string();
2369 if query.is_empty() {
2370 return;
2371 }
2372 if self.sql_history.last() != Some(&query) {
2375 self.sql_history.push(query.clone());
2376 save_history(&self.sql_history);
2377 }
2378 self.hist_idx = None;
2379 self.hist_draft.clear();
2380 let warehouses = self.warehouses();
2381 if warehouses.is_empty() {
2382 self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
2383 return;
2384 }
2385 if let Some((id, name)) = self.preview_warehouse.clone() {
2386 self.start_sql_query(cli, query, id, name);
2387 return;
2388 }
2389 if let [(name, id, _)] = warehouses.as_slice() {
2390 self.preview_warehouse = Some((id.clone(), name.clone()));
2391 self.start_sql_query(cli, query, id.clone(), name.clone());
2392 return;
2393 }
2394 let index = warehouses
2395 .iter()
2396 .position(|(_, _, running)| *running)
2397 .unwrap_or(0);
2398 self.wh_picker = Some(WhPicker {
2399 index,
2400 target: PickTarget::Sql(query),
2401 });
2402 }
2403
2404 fn start_sql_query(
2405 &mut self,
2406 cli: &Arc<DatabricksCli>,
2407 query: String,
2408 id: String,
2409 name: String,
2410 ) {
2411 if let Some(console) = &mut self.sql {
2412 console.running = true;
2413 console.warehouse = name;
2414 console.scroll = 0;
2415 console.last_sql = query.clone();
2416 }
2417 let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
2419 self.sql_stmt = Some(std::sync::Arc::clone(&handle));
2420 let (tx, rx) = oneshot::channel();
2421 self.sql_rx = Some(rx);
2422 let cli = Arc::clone(cli);
2423 tokio::spawn(async move {
2424 let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
2425 let _ = tx.send(result);
2426 });
2427 }
2428
2429 fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
2432 let stamp = std::time::SystemTime::now()
2433 .duration_since(std::time::UNIX_EPOCH)
2434 .map(|d| d.as_secs())
2435 .unwrap_or(0);
2436 let slug: String = label
2437 .chars()
2438 .map(|c| if c.is_alphanumeric() { c } else { '-' })
2439 .collect::<String>()
2440 .trim_matches('-')
2441 .chars()
2442 .take(40)
2443 .collect();
2444 let name = format!("databricks-{slug}-{stamp}.csv");
2445 let msg = match std::fs::write(&name, data.to_csv()) {
2446 Ok(()) => {
2447 let cwd = std::env::current_dir()
2448 .map(|d| d.display().to_string())
2449 .unwrap_or_default();
2450 format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
2451 }
2452 Err(e) => format!("✗ export failed: {e}"),
2453 };
2454 self.flash = Some((msg, Instant::now()));
2455 }
2456
2457 pub fn sql_export(&mut self) {
2459 if let Some(SqlConsole {
2460 data: Some(Ok(data)),
2461 last_sql,
2462 ..
2463 }) = &self.sql
2464 {
2465 let (label, data) = (last_sql.clone(), data.clone());
2466 self.export_csv(&label, &data);
2467 }
2468 }
2469
2470 pub fn preview_h(&mut self, delta: i32) {
2473 let Some(pv) = &mut self.preview else {
2474 return;
2475 };
2476 if pv.record {
2477 let max = match &pv.data {
2478 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2479 _ => 0,
2480 };
2481 pv.scroll = if delta < 0 {
2482 pv.scroll.saturating_sub(1)
2483 } else {
2484 (pv.scroll + 1).min(max)
2485 };
2486 pv.rscroll = 0;
2487 } else {
2488 let cols = pv.visible_cols().len();
2489 pv.col = if delta < 0 {
2490 pv.col.saturating_sub(1)
2491 } else {
2492 (pv.col + 1).min(cols.saturating_sub(1))
2493 };
2494 }
2495 }
2496
2497 pub fn preview_toggle_record(&mut self) {
2499 if let Some(pv) = &mut self.preview {
2500 if matches!(&pv.data, Some(Ok(t)) if !t.rows.is_empty()) {
2501 pv.record = !pv.record;
2502 pv.rscroll = 0;
2503 }
2504 }
2505 }
2506
2507 pub fn preview_filter_start(&mut self) {
2508 if let Some(pv) = &mut self.preview {
2509 pv.filter.clear();
2510 pv.filter_entry = true;
2511 pv.col = 0;
2512 pv.rscroll = 0;
2513 }
2514 }
2515
2516 pub fn preview_filter_push(&mut self, c: char) {
2517 if let Some(pv) = &mut self.preview {
2518 pv.filter.push(c);
2519 pv.col = 0;
2520 pv.rscroll = 0;
2521 }
2522 }
2523
2524 pub fn preview_filter_pop(&mut self) {
2525 if let Some(pv) = &mut self.preview {
2526 pv.filter.pop();
2527 pv.col = 0;
2528 }
2529 }
2530
2531 pub fn preview_filter_accept(&mut self) {
2532 if let Some(pv) = &mut self.preview {
2533 pv.filter_entry = false;
2534 }
2535 }
2536
2537 pub fn preview_filter_clear(&mut self) {
2538 if let Some(pv) = &mut self.preview {
2539 pv.filter.clear();
2540 pv.filter_entry = false;
2541 pv.col = 0;
2542 pv.rscroll = 0;
2543 }
2544 }
2545
2546 pub fn preview_export(&mut self) {
2548 if let Some(Preview {
2549 data: Some(Ok(data)),
2550 name,
2551 ..
2552 }) = &self.preview
2553 {
2554 let (label, data) = (name.clone(), data.clone());
2555 self.export_csv(&label, &data);
2556 }
2557 }
2558
2559 pub fn poll_sql(&mut self) -> bool {
2560 let Some(rx) = &mut self.sql_rx else {
2561 return false;
2562 };
2563 match rx.try_recv() {
2564 Ok(result) => {
2565 if let Err(e) = &result {
2568 if e != "statement canceled" {
2569 self.preview_warehouse = None;
2570 }
2571 }
2572 if let Some(console) = &mut self.sql {
2573 console.running = false;
2574 console.data = Some(result);
2575 console.col = 0;
2576 }
2577 self.sql_rx = None;
2578 self.sql_stmt = None;
2579 true
2580 }
2581 Err(oneshot::error::TryRecvError::Empty) => false,
2582 Err(oneshot::error::TryRecvError::Closed) => {
2583 if let Some(console) = &mut self.sql {
2584 console.running = false;
2585 }
2586 self.sql_rx = None;
2587 true
2588 }
2589 }
2590 }
2591
2592 pub fn poll_cost(&mut self) -> bool {
2593 let Some(rx) = &mut self.cost_rx else {
2594 return false;
2595 };
2596 match rx.try_recv() {
2597 Ok((result, ws)) => {
2598 if result.is_err() {
2599 self.preview_warehouse = None;
2600 }
2601 if ws.is_some() {
2602 self.workspace_id = ws;
2603 }
2604 if let Some(cv) = &mut self.cost {
2605 cv.data = Some(result);
2606 }
2607 self.cost_rx = None;
2608 true
2609 }
2610 Err(oneshot::error::TryRecvError::Empty) => false,
2611 Err(oneshot::error::TryRecvError::Closed) => {
2612 self.cost_rx = None;
2613 true
2614 }
2615 }
2616 }
2617
2618 pub fn wh_picker_next(&mut self) {
2619 let len = self.warehouses().len();
2620 if let Some(p) = &mut self.wh_picker {
2621 p.index = (p.index + 1).min(len.saturating_sub(1));
2622 }
2623 }
2624
2625 pub fn wh_picker_prev(&mut self) {
2626 if let Some(p) = &mut self.wh_picker {
2627 p.index = p.index.saturating_sub(1);
2628 }
2629 }
2630
2631 pub fn wh_picker_cancel(&mut self) {
2632 self.wh_picker = None;
2633 }
2634
2635 pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
2637 let Some(picker) = self.wh_picker.take() else {
2638 return;
2639 };
2640 let warehouses = self.warehouses();
2641 let Some((name, id, _)) = warehouses.get(picker.index) else {
2642 return;
2643 };
2644 self.preview_warehouse = Some((id.clone(), name.clone()));
2645 let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
2647 self.config
2648 .warehouses
2649 .insert(profile, (id.clone(), name.clone()));
2650 self.config.save();
2651 match picker.target {
2652 PickTarget::Preview(table) => {
2653 self.start_preview_query(cli, table, id.clone(), name.clone())
2654 }
2655 PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
2656 PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
2657 PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
2658 }
2659 }
2660
2661 fn start_preview_query(
2662 &mut self,
2663 cli: &Arc<DatabricksCli>,
2664 full_name: String,
2665 warehouse_id: String,
2666 warehouse_name: String,
2667 ) {
2668 self.preview = Some(Preview {
2669 name: full_name.clone(),
2670 warehouse: warehouse_name,
2671 warehouse_id: warehouse_id.clone(),
2672 data: None,
2673 scroll: 0,
2674 col: 0,
2675 filter: String::new(),
2676 filter_entry: false,
2677 record: false,
2678 rscroll: 0,
2679 });
2680 let (tx, rx) = oneshot::channel();
2681 self.preview_rx = Some(rx);
2682 let cli = Arc::clone(cli);
2683 tokio::spawn(async move {
2684 let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
2685 let _ = tx.send(result);
2686 });
2687 }
2688
2689 pub fn close_preview(&mut self) {
2690 self.preview = None;
2691 self.preview_rx = None;
2692 }
2693
2694 pub fn poll_preview(&mut self) -> bool {
2695 let Some(rx) = &mut self.preview_rx else {
2696 return false;
2697 };
2698 match rx.try_recv() {
2699 Ok(result) => {
2700 if result.is_err() {
2702 self.preview_warehouse = None;
2703 }
2704 if let Some(pv) = &mut self.preview {
2705 pv.data = Some(result);
2706 }
2707 self.preview_rx = None;
2708 true
2709 }
2710 Err(oneshot::error::TryRecvError::Empty) => false,
2711 Err(oneshot::error::TryRecvError::Closed) => {
2712 self.preview_rx = None;
2713 true
2714 }
2715 }
2716 }
2717
2718 pub fn preview_scroll(&mut self, delta: i32) {
2719 if let Some(pv) = &mut self.preview {
2720 if pv.record {
2722 let max = pv.visible_cols().len().saturating_sub(1) as u16;
2723 pv.rscroll = if delta < 0 {
2724 pv.rscroll.saturating_sub(delta.unsigned_abs() as u16)
2725 } else {
2726 pv.rscroll.saturating_add(delta as u16).min(max)
2727 };
2728 return;
2729 }
2730 let max = match &pv.data {
2731 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2732 _ => 0,
2733 };
2734 pv.scroll = if delta < 0 {
2735 pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
2736 } else {
2737 (pv.scroll + delta as usize).min(max)
2738 };
2739 }
2740 }
2741
2742 pub fn poll_uc(&mut self) -> bool {
2743 let Some(rx) = &mut self.uc_rx else {
2744 return false;
2745 };
2746 match rx.try_recv() {
2747 Ok(result) => {
2748 self.shapes[5] = Some(match result {
2749 Ok(shape) => shape,
2750 Err(e) => Shape::Text(format!("✗ {e}")),
2751 });
2752 self.updated_at[5] = Some(Instant::now());
2753 self.uc_rx = None;
2754 true
2755 }
2756 Err(oneshot::error::TryRecvError::Empty) => false,
2757 Err(oneshot::error::TryRecvError::Closed) => {
2758 self.uc_rx = None;
2759 true
2760 }
2761 }
2762 }
2763
2764 pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
2767 if self.focus == Panel::Secrets {
2768 return self.open_secret_acls(cli);
2769 }
2770 let Some(item) = self.selected_item() else {
2771 return;
2772 };
2773 let Some(id) = item.id.clone() else {
2774 return;
2775 };
2776 let (uc, object_type): (bool, &'static str) = match self.focus {
2777 Panel::Catalog => match &item.status {
2778 Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
2779 Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
2780 Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
2781 Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
2782 _ => return,
2783 },
2784 Panel::Clusters => (false, "clusters"),
2785 Panel::Jobs => (false, "jobs"),
2786 Panel::Pipelines => (false, "pipelines"),
2787 Panel::Warehouses => (false, "warehouses"),
2788 Panel::Dashboards => (false, "dashboards"),
2789 Panel::Secrets => return,
2791 };
2792 self.detail = Some(Detail {
2793 panel: self.focus,
2794 name: item.name.clone(),
2795 id: id.clone(),
2796 kind: None,
2797 section: "Access",
2798 data: None,
2799 show_raw: false,
2800 scroll: 0,
2801 });
2802 let (tx, rx) = oneshot::channel();
2803 self.detail_rx = Some(rx);
2804 let cli = Arc::clone(cli);
2805 tokio::spawn(async move {
2806 let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
2807 let _ = tx.send(data);
2808 });
2809 }
2810
2811 pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
2814 let Some(d) = &self.detail else {
2815 return;
2816 };
2817 let panel = d.panel;
2818 if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
2819 return;
2820 }
2821 let owner_id = d.id.clone();
2822 self.run_view = Some(RunView {
2823 panel,
2824 owner_name: d.name.clone(),
2825 owner_id: owner_id.clone(),
2826 runs: Vec::new(),
2827 idx: 0,
2828 data: None,
2829 show_raw: false,
2830 scroll: 0,
2831 live: false,
2832 output: None,
2833 show_output: false,
2834 show_timeline: false,
2835 show_dag: false,
2836 fetched_at: Instant::now(),
2837 });
2838 let (tx, rx) = oneshot::channel();
2839 self.run_rx = Some(rx);
2840 let cli = Arc::clone(cli);
2841 tokio::spawn(async move {
2842 let result = async {
2843 let runs = if panel == Panel::Jobs {
2844 fetchers::runs::list(&cli, &owner_id).await?
2845 } else {
2846 fetchers::updates::list(&cli, &owner_id).await?
2847 };
2848 let Some((run_id, _, _)) = runs.first().cloned() else {
2849 return Err("no runs recorded yet".to_string());
2850 };
2851 let (data, live) = if panel == Panel::Jobs {
2852 fetchers::runs::fetch(&cli, &run_id).await
2853 } else {
2854 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2855 };
2856 Ok((runs, data, live))
2857 }
2858 .await;
2859 let _ = tx.send(RunUpdate::Opened(result));
2860 });
2861 }
2862
2863 pub fn close_run(&mut self) {
2864 self.run_view = None;
2865 self.run_rx = None;
2866 }
2867
2868 pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
2870 if self.run_rx.is_some() {
2871 return;
2872 }
2873 let Some(rv) = &mut self.run_view else {
2874 return;
2875 };
2876 if rv.runs.is_empty() {
2877 return;
2878 }
2879 let new = if delta < 0 {
2880 rv.idx.saturating_sub(delta.unsigned_abs() as usize)
2881 } else {
2882 (rv.idx + delta as usize).min(rv.runs.len() - 1)
2883 };
2884 if new == rv.idx {
2885 return;
2886 }
2887 rv.idx = new;
2888 rv.data = None;
2889 rv.scroll = 0;
2890 rv.show_raw = false;
2891 rv.output = None;
2892 rv.show_output = false;
2893 let run_id = rv.runs[new].0.clone();
2894 self.start_run_fetch(cli, run_id);
2895 }
2896
2897 fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
2898 let Some(rv) = &self.run_view else {
2899 return;
2900 };
2901 let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
2902 let (tx, rx) = oneshot::channel();
2903 self.run_rx = Some(rx);
2904 let cli = Arc::clone(cli);
2905 tokio::spawn(async move {
2906 let (data, live) = if panel == Panel::Jobs {
2907 fetchers::runs::fetch(&cli, &run_id).await
2908 } else {
2909 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2910 };
2911 let _ = tx.send(RunUpdate::Detail(data, live));
2912 });
2913 }
2914
2915 pub fn run_toggle_output(&mut self, cli: &Arc<DatabricksCli>) {
2918 let Some(rv) = &mut self.run_view else {
2919 return;
2920 };
2921 if rv.panel != Panel::Jobs {
2922 self.flash = Some((
2923 "✗ output view is for job runs — pipeline events are already inline".to_string(),
2924 Instant::now(),
2925 ));
2926 return;
2927 }
2928 if rv.show_output {
2929 rv.show_output = false;
2930 rv.scroll = 0;
2931 return;
2932 }
2933 if rv.output.is_none() && self.run_rx.is_some() {
2934 self.flash = Some((
2935 "⏳ run still loading — try again in a moment".to_string(),
2936 Instant::now(),
2937 ));
2938 return;
2939 }
2940 rv.show_output = true;
2941 rv.scroll = 0;
2942 if rv.output.is_some() {
2943 return;
2944 }
2945 self.start_output_fetch(cli);
2946 }
2947
2948 fn start_output_fetch(&mut self, cli: &Arc<DatabricksCli>) {
2949 let Some(rv) = &self.run_view else {
2950 return;
2951 };
2952 let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() else {
2953 return;
2954 };
2955 let (tx, rx) = oneshot::channel();
2956 self.run_rx = Some(rx);
2957 let cli = Arc::clone(cli);
2958 tokio::spawn(async move {
2959 let (text, live) = fetchers::runs::full_output(&cli, &run_id).await;
2960 let _ = tx.send(RunUpdate::Output(text, live));
2961 });
2962 }
2963
2964 pub fn request_run_repair(&mut self) {
2966 let Some(rv) = &self.run_view else {
2967 return;
2968 };
2969 if rv.panel != Panel::Jobs {
2970 self.flash = Some((
2971 "✗ repair applies to job runs only".to_string(),
2972 Instant::now(),
2973 ));
2974 return;
2975 }
2976 if rv.live {
2977 self.flash = Some((
2978 "✗ run is still executing — cancel it first (s)".to_string(),
2979 Instant::now(),
2980 ));
2981 return;
2982 }
2983 let Some((run_id, status, _)) = rv.runs.get(rv.idx) else {
2984 return;
2985 };
2986 if matches!(status, Status::Success) {
2987 self.flash = Some((
2988 "✗ run succeeded — nothing to repair".to_string(),
2989 Instant::now(),
2990 ));
2991 return;
2992 }
2993 self.confirm = Some(Confirm {
2994 message: format!(
2995 "Repair run {run_id} of “{}” (reruns only the failed tasks)?",
2996 rv.owner_name
2997 ),
2998 args: vec![
2999 "jobs".to_string(),
3000 "repair-run".to_string(),
3001 run_id.clone(),
3002 "--rerun-all-failed-tasks".to_string(),
3003 ],
3004 });
3005 }
3006
3007 pub fn run_toggle_timeline(&mut self) {
3009 let Some(rv) = &mut self.run_view else {
3010 return;
3011 };
3012 if rv.panel != Panel::Jobs {
3013 self.flash = Some((
3014 "✗ timeline is for job runs — pipeline events are already inline".to_string(),
3015 Instant::now(),
3016 ));
3017 return;
3018 }
3019 rv.show_timeline = !rv.show_timeline;
3020 rv.show_dag = false;
3021 rv.scroll = 0;
3022 }
3023
3024 pub fn run_toggle_dag(&mut self) {
3026 let Some(rv) = &mut self.run_view else {
3027 return;
3028 };
3029 if rv.panel != Panel::Jobs {
3030 self.flash = Some((
3031 "✗ the task DAG is for job runs — pipelines have no task graph here".to_string(),
3032 Instant::now(),
3033 ));
3034 return;
3035 }
3036 rv.show_dag = !rv.show_dag;
3037 rv.show_timeline = false;
3038 rv.scroll = 0;
3039 }
3040
3041 pub fn run_toggle_raw(&mut self) {
3042 if let Some(rv) = &mut self.run_view {
3043 rv.show_raw = !rv.show_raw;
3044 rv.scroll = 0;
3045 }
3046 }
3047
3048 pub fn run_scroll(&mut self, delta: i32) {
3049 if let Some(rv) = &mut self.run_view {
3050 rv.scroll = if delta < 0 {
3051 rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
3052 } else {
3053 rv.scroll.saturating_add(delta as u16)
3054 };
3055 }
3056 }
3057
3058 pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3061 if let Some(rx) = &mut self.run_rx {
3062 match rx.try_recv() {
3063 Ok(update) => {
3064 self.run_rx = None;
3065 if let Some(rv) = &mut self.run_view {
3066 match update {
3067 RunUpdate::Opened(Ok((runs, data, live))) => {
3068 rv.runs = runs;
3069 rv.idx = 0;
3070 rv.data = Some(data);
3071 rv.live = live;
3072 }
3073 RunUpdate::Opened(Err(e)) => {
3074 rv.data = Some(DetailData {
3075 summary: Vec::new(),
3076 activity: Vec::new(),
3077 raw: format!("✗ {e}"),
3078 });
3079 rv.live = false;
3080 }
3081 RunUpdate::Detail(data, live) => {
3082 rv.data = Some(data);
3083 rv.live = live;
3084 }
3085 RunUpdate::Output(text, live) => {
3086 rv.output = Some(text);
3087 rv.live = live;
3088 }
3089 }
3090 rv.fetched_at = Instant::now();
3091 }
3092 true
3093 }
3094 Err(oneshot::error::TryRecvError::Empty) => false,
3095 Err(oneshot::error::TryRecvError::Closed) => {
3096 self.run_rx = None;
3097 true
3098 }
3099 }
3100 } else if let Some(rv) = &self.run_view {
3101 if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
3102 if rv.show_output {
3103 if rv.output.is_some() {
3106 self.start_output_fetch(cli);
3107 }
3108 } else if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
3109 self.start_run_fetch(cli, run_id);
3110 }
3111 }
3112 false
3113 } else {
3114 false
3115 }
3116 }
3117
3118 pub fn close_detail(&mut self) {
3119 self.detail = None;
3120 self.detail_rx = None;
3121 }
3122
3123 pub fn toggle_raw(&mut self) {
3124 if let Some(d) = &mut self.detail {
3125 d.show_raw = !d.show_raw;
3126 d.scroll = 0;
3127 }
3128 }
3129
3130 pub fn poll_detail(&mut self) -> bool {
3132 let Some(rx) = &mut self.detail_rx else {
3133 return false;
3134 };
3135 match rx.try_recv() {
3136 Ok(data) => {
3137 if let Some(d) = &mut self.detail {
3138 d.data = Some(data);
3139 }
3140 self.detail_rx = None;
3141 true
3142 }
3143 Err(oneshot::error::TryRecvError::Empty) => false,
3144 Err(oneshot::error::TryRecvError::Closed) => {
3145 self.detail_rx = None;
3146 true
3147 }
3148 }
3149 }
3150
3151 pub fn detail_scroll(&mut self, delta: i32) {
3152 if let Some(d) = &mut self.detail {
3153 let max = match &d.data {
3154 Some(data) if d.show_raw => data.raw.lines().count(),
3155 Some(data) => data.summary.len() + data.activity.len() + 3,
3156 None => 0,
3157 } as u16;
3158 d.scroll = if delta < 0 {
3159 d.scroll.saturating_sub(delta.unsigned_abs() as u16)
3160 } else {
3161 (d.scroll + delta as u16).min(max.saturating_sub(1))
3162 };
3163 }
3164 }
3165
3166 pub fn request_action(&mut self) {
3169 if matches!(
3171 self.focus,
3172 Panel::Dashboards | Panel::Catalog | Panel::Secrets
3173 ) {
3174 return;
3175 }
3176 let Some(item) = self.selected_item() else {
3177 return;
3178 };
3179 let Some(id) = item.id.clone() else {
3180 return;
3181 };
3182 let name = item.name.clone();
3183 let active = matches!(
3184 item.status,
3185 Status::Running | Status::Pending | Status::Success
3186 );
3187 let group = self.focus.cli_group();
3188 let (verb, action): (&str, &str) = match self.focus {
3189 Panel::Jobs => ("Run", "run-now"),
3190 Panel::Clusters if active => ("Stop", "delete"),
3191 Panel::Pipelines if active => ("Stop", "stop"),
3192 Panel::Pipelines => ("Start update for", "start-update"),
3193 _ if active => ("Stop", "stop"),
3194 _ => ("Start", "start"),
3195 };
3196 self.confirm = Some(Confirm {
3197 message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
3198 args: vec![group.to_string(), action.to_string(), id],
3199 });
3200 }
3201
3202 pub fn request_run_cancel(&mut self) {
3204 let Some(rv) = &self.run_view else {
3205 return;
3206 };
3207 if !rv.live {
3208 self.flash = Some((
3209 "✗ nothing to cancel — this run already finished".to_string(),
3210 Instant::now(),
3211 ));
3212 return;
3213 }
3214 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3215 return;
3216 };
3217 let (message, args) = if rv.panel == Panel::Jobs {
3218 (
3219 format!("Cancel run {run_id} of “{}”?", rv.owner_name),
3220 vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
3221 )
3222 } else {
3223 (
3224 format!("Stop “{}” (cancels the active update)?", rv.owner_name),
3225 vec![
3226 "pipelines".to_string(),
3227 "stop".to_string(),
3228 rv.owner_id.clone(),
3229 ],
3230 )
3231 };
3232 self.confirm = Some(Confirm { message, args });
3233 }
3234
3235 pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
3238 let id = self
3239 .sql_stmt
3240 .as_ref()
3241 .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
3242 let Some(id) = id else {
3243 self.flash = Some((
3244 "✗ statement not submitted yet — try again in a moment".to_string(),
3245 Instant::now(),
3246 ));
3247 return;
3248 };
3249 let cli = Arc::clone(cli);
3250 tokio::spawn(async move {
3251 let path = format!("/api/2.0/sql/statements/{id}/cancel");
3252 let _ = cli.run_action(&["api", "post", &path]).await;
3253 });
3254 self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
3255 }
3256
3257 pub fn cancel_confirm(&mut self) {
3258 self.confirm = None;
3259 }
3260
3261 pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
3262 let Some(c) = self.confirm.take() else {
3263 return;
3264 };
3265 let base = c.message.trim_end_matches('?').to_string();
3266 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3267
3268 let (tx, rx) = oneshot::channel();
3269 self.action_rx = Some(rx);
3270 let cli = Arc::clone(cli);
3271 tokio::spawn(async move {
3272 let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
3273 let result = match cli.run_action(&args).await {
3274 Ok(()) => Ok(format!("✓ {base} — done")),
3275 Err(e) => Err(format!("✗ {e:#}")),
3276 };
3277 let _ = tx.send(result);
3278 });
3279 }
3280
3281 pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3283 let Some(rx) = &mut self.action_rx else {
3284 return false;
3285 };
3286 match rx.try_recv() {
3287 Ok(result) => {
3288 let ok = result.is_ok();
3289 self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
3290 self.action_rx = None;
3291 if ok {
3292 self.start_refresh(cli);
3293 if self.run_rx.is_none() {
3296 let current = self
3297 .run_view
3298 .as_ref()
3299 .and_then(|rv| rv.runs.get(rv.idx).cloned());
3300 if let Some((run_id, _, _)) = current {
3301 self.start_run_fetch(cli, run_id);
3302 }
3303 }
3304 }
3305 true
3306 }
3307 Err(oneshot::error::TryRecvError::Empty) => false,
3308 Err(oneshot::error::TryRecvError::Closed) => {
3309 self.action_rx = None;
3310 true
3311 }
3312 }
3313 }
3314
3315 pub fn expire_flash(&mut self) -> bool {
3317 if let Some((_, since)) = &self.flash {
3318 if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
3319 self.flash = None;
3320 return true;
3321 }
3322 }
3323 false
3324 }
3325
3326 pub fn open_in_browser(&self) {
3328 let Some(host) = &self.host else {
3329 return;
3330 };
3331 let (panel, id) = match &self.detail {
3332 Some(d) => (d.panel, Some(d.id.clone())),
3333 None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
3334 };
3335 let Some(id) = id else {
3336 return;
3337 };
3338 let path = match panel {
3339 Panel::Clusters => format!("compute/clusters/{id}"),
3340 Panel::Jobs => format!("jobs/{id}"),
3341 Panel::Pipelines => format!("pipelines/{id}"),
3342 Panel::Warehouses => format!("sql/warehouses/{id}"),
3343 Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
3344 Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
3345 Panel::Secrets => return,
3347 };
3348 let url = format!("{}/{}", host.trim_end_matches('/'), path);
3349 #[cfg(target_os = "macos")]
3350 let opener = "open";
3351 #[cfg(not(target_os = "macos"))]
3352 let opener = "xdg-open";
3353 let _ = std::process::Command::new(opener).arg(url).spawn();
3354 }
3355
3356 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
3358 let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
3359 for shape in self.shapes.iter().flatten() {
3360 if let Shape::List(items) = shape {
3361 for item in items {
3362 match item.status {
3363 Status::Running | Status::Success => ok += 1,
3364 Status::Pending => pending += 1,
3365 Status::Failed => failed += 1,
3366 Status::Stopped => idle += 1,
3367 Status::Unknown(_) => {}
3368 }
3369 }
3370 }
3371 }
3372 (ok, pending, failed, idle)
3373 }
3374
3375 pub fn last_refresh_age(&self) -> Duration {
3376 self.last_refresh.elapsed()
3377 }
3378
3379 pub fn spinner(&self) -> &'static str {
3380 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
3381 }
3382
3383 pub fn spinner_frame(&self) -> usize {
3384 self.spinner_frame
3385 }
3386
3387 pub fn busy(&self) -> bool {
3390 self.loading
3391 || self.detail_rx.is_some()
3392 || self.action_rx.is_some()
3393 || self.preview_rx.is_some()
3394 || self.cost_rx.is_some()
3395 || self.sql_rx.is_some()
3396 || self.run_rx.is_some()
3397 }
3398
3399 pub fn tick_spinner(&mut self) {
3400 self.spinner_frame = self.spinner_frame.wrapping_add(1);
3401 }
3402
3403 pub fn toggle_zoom(&mut self) {
3404 self.zoomed = !self.zoomed;
3405 }
3406
3407 pub fn focus_next(&mut self) {
3408 self.cycle_focus(1);
3409 }
3410
3411 pub fn focus_prev(&mut self) {
3412 self.cycle_focus(-1);
3413 }
3414
3415 fn cycle_focus(&mut self, delta: i32) {
3417 let visible = self.visible_panes();
3418 if visible.is_empty() {
3419 return;
3420 }
3421 let focus_idx = Panel::ALL
3422 .iter()
3423 .position(|p| p == &self.focus)
3424 .unwrap_or(0);
3425 let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
3426 let n = visible.len() as i32;
3427 let next = ((pos as i32 + delta) % n + n) % n;
3428 self.focus = Panel::ALL[visible[next as usize]];
3429 }
3430
3431 pub fn needs_refresh(&self) -> bool {
3432 !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
3433 }
3434
3435 pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
3436 if self.loading {
3437 return;
3438 }
3439 self.loading = true;
3440 self.error = None;
3441 self.last_refresh = Instant::now();
3442
3443 let (tx, rx) = mpsc::unbounded_channel();
3444 self.pending = Some(rx);
3445 self.in_flight = 8;
3446
3447 macro_rules! spawn_fetch {
3450 ($update:expr, $fetch:path) => {{
3451 let cli = Arc::clone(cli);
3452 let tx = tx.clone();
3453 tokio::spawn(async move {
3454 let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
3455 let _ = tx.send($update(result));
3456 });
3457 }};
3458 }
3459
3460 spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
3461 spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
3462 spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
3463 spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
3464 spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
3465 spawn_fetch!(
3466 |s: Result<Shape, String>| Update::Badge(s.ok()),
3467 fetchers::current_user::fetch
3468 );
3469 {
3470 let cli = Arc::clone(cli);
3471 let tx = tx.clone();
3472 let path = self.uc_path.clone();
3473 tokio::spawn(async move {
3474 let result = fetchers::catalog::fetch(&cli, &path)
3475 .await
3476 .map_err(|e| format!("{e:#}"));
3477 let _ = tx.send(Update::Panel(5, result));
3478 });
3479 }
3480 {
3481 let cli = Arc::clone(cli);
3482 let tx = tx.clone();
3483 let scope = self.secret_scope.clone();
3484 tokio::spawn(async move {
3485 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
3486 .await
3487 .map_err(|e| format!("{e:#}"));
3488 let _ = tx.send(Update::Panel(6, result));
3489 });
3490 }
3491 }
3492
3493 pub fn poll_refresh(&mut self) -> bool {
3495 let Some(rx) = &mut self.pending else {
3496 return false;
3497 };
3498 let mut changed = false;
3499 let mut updated_panes: Vec<usize> = Vec::new();
3500 loop {
3501 match rx.try_recv() {
3502 Ok(Update::Panel(i, result)) => {
3503 match result {
3504 Ok(mut shape) => {
3505 if i != 5 {
3509 if let Shape::List(items) = &mut shape {
3510 items.sort_by_key(|it| {
3511 (it.status.rank(), it.history.is_empty())
3512 });
3513 }
3514 }
3515 self.shapes[i] = Some(shape);
3516 self.updated_at[i] = Some(Instant::now());
3517 updated_panes.push(i);
3518 }
3519 Err(e) => {
3522 if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
3523 self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
3524 }
3525 }
3526 }
3527 self.in_flight -= 1;
3528 changed = true;
3529 }
3530 Ok(Update::Badge(badge)) => {
3531 if badge.is_some() {
3532 self.user_badge = badge;
3533 }
3534 self.in_flight -= 1;
3535 changed = true;
3536 }
3537 Err(mpsc::error::TryRecvError::Empty) => break,
3538 Err(mpsc::error::TryRecvError::Disconnected) => {
3539 self.in_flight = 0;
3540 break;
3541 }
3542 }
3543 }
3544 for i in updated_panes {
3545 self.alert_new_failures(i);
3546 }
3547 if self.in_flight == 0 {
3548 self.loading = false;
3549 self.pending = None;
3550 changed = true;
3551 }
3552 changed
3553 }
3554}
3555
3556#[cfg(test)]
3557mod tests {
3558 use super::{from_table, token_at_cursor};
3559
3560 #[test]
3561 fn token_bare_word() {
3562 let (start, ctx, prefix) = token_at_cursor("SELECT * FROM ma", 16);
3563 assert_eq!((start, ctx.as_str(), prefix.as_str()), (14, "", "ma"));
3564 }
3565
3566 #[test]
3567 fn token_dotted_path() {
3568 let (start, ctx, prefix) = token_at_cursor("SELECT * FROM main.sales.or", 27);
3569 assert_eq!(
3570 (start, ctx.as_str(), prefix.as_str()),
3571 (25, "main.sales", "or")
3572 );
3573 }
3574
3575 #[test]
3576 fn token_trailing_dot() {
3577 let (start, ctx, prefix) = token_at_cursor("main.", 5);
3578 assert_eq!((start, ctx.as_str(), prefix.as_str()), (5, "main", ""));
3579 }
3580
3581 #[test]
3582 fn token_mid_input() {
3583 let (start, ctx, prefix) = token_at_cursor("SELECT co FROM t", 9);
3585 assert_eq!((start, ctx.as_str(), prefix.as_str()), (7, "", "co"));
3586 }
3587
3588 #[test]
3589 fn from_table_fully_qualified() {
3590 assert_eq!(
3591 from_table("SELECT x FROM main.sales.orders WHERE x > 1").as_deref(),
3592 Some("main.sales.orders")
3593 );
3594 }
3595
3596 #[test]
3597 fn from_table_rejects_partial_names() {
3598 assert_eq!(from_table("SELECT x FROM orders"), None);
3599 assert_eq!(from_table("SELECT x FROM main.sales."), None);
3600 assert_eq!(from_table("SELECT 1"), None);
3601 }
3602}