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: usize,
437 pub name: String,
438 pub status: Status,
439 pub note: String,
440}
441
442pub struct Problems {
444 pub items: Vec<Problem>,
445 pub index: usize,
446}
447
448pub struct Upcoming {
450 pub items: Vec<fetchers::upcoming::UpcomingJob>,
451 pub index: usize,
452 pub loading: bool,
453}
454
455pub struct Confirm {
457 pub message: String,
458 args: Vec<String>,
459}
460
461enum Update {
462 Panel(usize, Result<Shape, String>),
463 Badge(Option<Shape>),
464}
465
466const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
467
468pub struct App {
469 pub focus: Panel,
470 pub theme: ThemeMode,
471 pub zoomed: bool,
472 pub shapes: Vec<Option<Shape>>,
473 pub user_badge: Option<Shape>,
474 pub error: Option<String>,
475 pub refresh_interval: Duration,
476 last_refresh: Instant,
477 pub loading: bool,
478 pub detail: Option<Detail>,
479 pub confirm: Option<Confirm>,
480 pub flash: Option<(String, Instant)>,
481 pub selected: [usize; 7],
482 pub host: Option<String>,
483 pub profiles: Vec<String>,
485 pub profile: Option<String>,
486 pub picker: Option<usize>,
488 pub problems: Option<Problems>,
490 pub upcoming: Option<Upcoming>,
491 pub uc_path: Vec<String>,
493 uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
494 pub preview: Option<Preview>,
495 preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
496 pub wh_picker: Option<WhPicker>,
497 pub preview_warehouse: Option<(String, String)>,
499 pub cost: Option<CostView>,
500 #[allow(clippy::type_complexity)]
501 cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
502 workspace_id: Option<String>,
505 pub sql: Option<SqlConsole>,
506 sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
507 sql_history: Vec<String>,
509 hist_idx: Option<usize>,
511 hist_draft: String,
513 pub hist_search: Option<(String, usize)>,
515 pub run_view: Option<RunView>,
516 run_rx: Option<oneshot::Receiver<RunUpdate>>,
517 #[allow(clippy::type_complexity)]
518 upcoming_rx: Option<oneshot::Receiver<Result<Vec<fetchers::upcoming::UpcomingJob>, String>>>,
519 pending: Option<mpsc::UnboundedReceiver<Update>>,
520 detail_rx: Option<oneshot::Receiver<DetailData>>,
521 action_rx: Option<oneshot::Receiver<Result<String, String>>>,
522 host_rx: Option<oneshot::Receiver<Option<String>>>,
523 in_flight: usize,
524 spinner_frame: usize,
525 pub splash_until: Option<Instant>,
527 pub updated_at: [Option<Instant>; 7],
529 pub filters: [String; 7],
531 pub filter_entry: bool,
533 pub config: crate::config::Config,
535 failed_seen: [Option<std::collections::HashSet<String>>; 7],
538 pub jump: Option<Jump>,
540 pub pane_order: Vec<usize>,
542 pub hidden: [bool; 7],
544 pub pane_cfg: Option<usize>,
546 pub help: bool,
548 pub help_scroll: u16,
550 sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
552 pub sql_complete: Option<SqlComplete>,
554 uc_names: std::collections::HashMap<String, Vec<String>>,
557 #[allow(clippy::type_complexity)]
558 uc_names_rx: Option<oneshot::Receiver<(String, Result<Vec<String>, String>)>>,
559 pub secret_scope: Option<String>,
561 secrets_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
562 pub secret_form: Option<SecretForm>,
564}
565
566pub struct Jump {
568 pub query: String,
569 pub index: usize,
570}
571
572pub struct SecretForm {
574 pub scope: Option<String>,
576 pub key: String,
577 pub value: String,
578 pub stage: u8,
580}
581
582impl App {
583 pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
584 let mut app = Self {
585 focus: Panel::Clusters,
586 theme,
587 zoomed: false,
588 shapes: vec![None; 7],
589 user_badge: None,
590 error: None,
591 refresh_interval: Duration::from_secs(refresh_secs),
592 last_refresh: Instant::now()
593 .checked_sub(Duration::from_secs(refresh_secs + 1))
594 .unwrap_or(Instant::now()),
595 loading: false,
596 detail: None,
597 confirm: None,
598 flash: None,
599 selected: [0; 7],
600 host: None,
601 profiles: Vec::new(),
602 profile: None,
603 picker: None,
604 problems: None,
605 upcoming: None,
606 uc_path: Vec::new(),
607 uc_rx: None,
608 preview: None,
609 preview_rx: None,
610 wh_picker: None,
611 preview_warehouse: None,
612 cost: None,
613 cost_rx: None,
614 workspace_id: None,
615 sql: None,
616 sql_rx: None,
617 sql_history: load_history(),
618 hist_idx: None,
619 hist_draft: String::new(),
620 hist_search: None,
621 run_view: None,
622 run_rx: None,
623 upcoming_rx: None,
624 pending: None,
625 detail_rx: None,
626 action_rx: None,
627 host_rx: None,
628 in_flight: 0,
629 spinner_frame: 0,
630 splash_until: Some(Instant::now() + Duration::from_millis(1600)),
631 updated_at: [None; 7],
632 filters: Default::default(),
633 filter_entry: false,
634 config: crate::config::Config::load(),
635 failed_seen: Default::default(),
636 jump: None,
637 pane_order: (0..7).collect(),
638 hidden: [false; 7],
639 pane_cfg: None,
640 help: false,
641 help_scroll: 0,
642 sql_stmt: None,
643 sql_complete: None,
644 uc_names: Default::default(),
645 uc_names_rx: None,
646 secret_scope: None,
647 secrets_rx: None,
648 secret_form: None,
649 };
650 app.load_pane_prefs();
651 app
652 }
653
654 fn load_pane_prefs(&mut self) {
656 let idx_of = |id: &str| Panel::ALL.iter().position(|p| p.id() == id);
657 let mut order: Vec<usize> = self
658 .config
659 .pane_order
660 .iter()
661 .filter_map(|id| idx_of(id))
662 .collect();
663 for i in 0..7 {
664 if !order.contains(&i) {
665 order.push(i);
666 }
667 }
668 self.pane_order = order;
669 for id in &self.config.hidden_panes {
670 if let Some(i) = idx_of(id) {
671 self.hidden[i] = true;
672 }
673 }
674 self.ensure_focus_visible();
675 }
676
677 fn persist_panes(&mut self) {
678 self.config.pane_order = self
679 .pane_order
680 .iter()
681 .map(|&i| Panel::ALL[i].id().to_string())
682 .collect();
683 self.config.hidden_panes = (0..7)
684 .filter(|&i| self.hidden[i])
685 .map(|i| Panel::ALL[i].id().to_string())
686 .collect();
687 self.config.save();
688 }
689
690 pub fn visible_panes(&self) -> Vec<usize> {
692 self.pane_order
693 .iter()
694 .copied()
695 .filter(|&i| !self.hidden[i])
696 .collect()
697 }
698
699 fn ensure_focus_visible(&mut self) {
701 let visible = self.visible_panes();
702 let focus_idx = Panel::ALL
703 .iter()
704 .position(|p| p == &self.focus)
705 .unwrap_or(0);
706 if !visible.contains(&focus_idx) {
707 if let Some(&first) = visible.first() {
708 self.focus = Panel::ALL[first];
709 }
710 }
711 }
712
713 fn reveal_pane(&mut self, idx: usize) {
715 if self.hidden[idx] {
716 self.hidden[idx] = false;
717 self.persist_panes();
718 }
719 }
720
721 pub fn open_pane_cfg(&mut self) {
722 self.pane_cfg = Some(0);
723 }
724
725 pub fn pane_cfg_next(&mut self) {
726 if let Some(i) = self.pane_cfg {
727 self.pane_cfg = Some((i + 1).min(6));
728 }
729 }
730
731 pub fn pane_cfg_prev(&mut self) {
732 if let Some(i) = self.pane_cfg {
733 self.pane_cfg = Some(i.saturating_sub(1));
734 }
735 }
736
737 pub fn pane_cfg_toggle(&mut self) {
740 let Some(pos) = self.pane_cfg else {
741 return;
742 };
743 let idx = self.pane_order[pos];
744 if !self.hidden[idx] && self.visible_panes().len() == 1 {
745 self.flash = Some((
746 "✗ at least one pane has to stay visible".to_string(),
747 Instant::now(),
748 ));
749 return;
750 }
751 self.hidden[idx] = !self.hidden[idx];
752 self.ensure_focus_visible();
753 self.persist_panes();
754 }
755
756 pub fn pane_cfg_move(&mut self, delta: i32) {
758 let Some(pos) = self.pane_cfg else {
759 return;
760 };
761 let new = if delta < 0 {
762 pos.saturating_sub(1)
763 } else {
764 (pos + 1).min(6)
765 };
766 if new != pos {
767 self.pane_order.swap(pos, new);
768 self.pane_cfg = Some(new);
769 self.persist_panes();
770 }
771 }
772
773 fn alert_new_failures(&mut self, idx: usize) {
776 if idx >= 5 {
778 return;
779 }
780 let Some(Shape::List(items)) = &self.shapes[idx] else {
781 return;
782 };
783 let failed: std::collections::HashSet<String> = items
784 .iter()
785 .filter(|it| {
786 matches!(it.status, Status::Failed)
787 || it
788 .history
789 .last()
790 .is_some_and(|s| matches!(s, Status::Failed))
791 })
792 .map(|it| it.name.clone())
793 .collect();
794 if let Some(prev) = &self.failed_seen[idx] {
795 let mut newly: Vec<&String> = failed.difference(prev).collect();
796 if !newly.is_empty() {
797 newly.sort();
798 let extra = if newly.len() > 1 {
799 format!(" (+{} more)", newly.len() - 1)
800 } else {
801 String::new()
802 };
803 self.flash = Some((
804 format!("✗ {}{extra} just failed — ! to inspect", newly[0]),
805 Instant::now(),
806 ));
807 print!("\x07");
809 let _ = std::io::Write::flush(&mut std::io::stdout());
810 }
811 }
812 self.failed_seen[idx] = Some(failed);
813 }
814
815 pub fn persist_theme(&mut self) {
817 self.config.theme = Some(self.theme.id().to_string());
818 self.config.save();
819 }
820
821 pub fn restore_warehouse_pref(&mut self) {
823 let profile = self.profile.as_deref().unwrap_or("DEFAULT");
824 self.preview_warehouse = self.config.warehouses.get(profile).cloned();
825 }
826
827 pub fn splash_active(&self) -> bool {
828 self.splash_until
829 .map(|t| Instant::now() < t)
830 .unwrap_or(false)
831 }
832
833 pub fn dismiss_splash(&mut self) {
834 self.splash_until = None;
835 }
836
837 pub fn any_fresh(&self) -> bool {
839 self.updated_at
840 .iter()
841 .flatten()
842 .any(|t| t.elapsed() < Duration::from_millis(1200))
843 }
844
845 pub fn open_picker(&mut self) {
846 if self.profiles.is_empty() {
847 return;
848 }
849 let current = self
850 .profile
851 .as_deref()
852 .and_then(|p| self.profiles.iter().position(|n| n == p))
853 .unwrap_or(0);
854 self.picker = Some(current);
855 }
856
857 pub fn picker_next(&mut self) {
858 if let Some(i) = self.picker {
859 self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
860 }
861 }
862
863 pub fn picker_prev(&mut self) {
864 if let Some(i) = self.picker {
865 self.picker = Some(i.saturating_sub(1));
866 }
867 }
868
869 pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
871 let idx = self.picker.take()?;
872 let name = self.profiles.get(idx)?.clone();
873 let profile_arg = if name == "DEFAULT" {
874 None
875 } else {
876 Some(name.clone())
877 };
878 self.profile = Some(name);
879
880 self.shapes = vec![None; 7];
882 self.user_badge = None;
883 self.host = None;
884 self.selected = [0; 7];
885 self.detail = None;
886 self.detail_rx = None;
887 self.confirm = None;
888 self.problems = None;
889 self.uc_path.clear();
890 self.uc_rx = None;
891 self.secret_scope = None;
892 self.secrets_rx = None;
893 self.secret_form = None;
894 self.preview = None;
895 self.preview_rx = None;
896 self.wh_picker = None;
897 self.preview_warehouse = None;
898 self.cost = None;
899 self.cost_rx = None;
900 self.workspace_id = None;
901 self.sql = None;
902 self.sql_rx = None;
903 self.run_view = None;
904 self.run_rx = None;
905 self.pending = None;
906 self.in_flight = 0;
907 self.loading = false;
908 self.zoomed = false;
909 self.filters = Default::default();
910 self.filter_entry = false;
911 self.failed_seen = Default::default();
912 self.jump = None;
913 self.sql_stmt = None;
914 self.sql_complete = None;
915 self.uc_names.clear();
916 self.uc_names_rx = None;
917 self.restore_warehouse_pref();
918
919 Some(Arc::new(DatabricksCli::new(profile_arg)))
920 }
921
922 pub fn open_jump(&mut self) {
923 self.jump = Some(Jump {
924 query: String::new(),
925 index: 0,
926 });
927 }
928
929 pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
933 let Some(jump) = &self.jump else {
934 return Vec::new();
935 };
936 let q = jump.query.to_lowercase();
937 let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
938 for (i, shape) in self.shapes.iter().enumerate() {
939 let Some(Shape::List(items)) = shape else {
940 continue;
941 };
942 for it in items {
943 let name = it.name.to_lowercase();
944 let rank = if q.is_empty() || name.contains(&q) {
945 0
946 } else if subsequence(&name, &q) {
947 1
948 } else {
949 continue;
950 };
951 scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
952 }
953 }
954 scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
955 scored
956 .into_iter()
957 .take(12)
958 .map(|(_, i, name, label)| (i, name, label))
959 .collect()
960 }
961
962 pub fn jump_push(&mut self, c: char) {
963 if let Some(j) = &mut self.jump {
964 j.query.push(c);
965 j.index = 0;
966 }
967 }
968
969 pub fn jump_pop(&mut self) {
970 if let Some(j) = &mut self.jump {
971 j.query.pop();
972 j.index = 0;
973 }
974 }
975
976 pub fn jump_next(&mut self) {
977 let len = self.jump_matches().len();
978 if let Some(j) = &mut self.jump {
979 j.index = (j.index + 1).min(len.saturating_sub(1));
980 }
981 }
982
983 pub fn jump_prev(&mut self) {
984 if let Some(j) = &mut self.jump {
985 j.index = j.index.saturating_sub(1);
986 }
987 }
988
989 pub fn jump_go(&mut self) {
991 let matches = self.jump_matches();
992 let Some(jump) = self.jump.take() else {
993 return;
994 };
995 let Some((panel_idx, name, _)) = matches.get(jump.index) else {
996 return;
997 };
998 self.reveal_pane(*panel_idx);
999 self.focus = Panel::ALL[*panel_idx];
1000 self.filters[*panel_idx].clear();
1001 if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
1002 if let Some(pos) = items.iter().position(|i| &i.name == name) {
1003 self.selected[*panel_idx] = pos;
1004 }
1005 }
1006 }
1007
1008 pub fn open_problems(&mut self) {
1011 let mut items = Vec::new();
1012 for (i, shape) in self.shapes.iter().enumerate() {
1013 let Some(Shape::List(list)) = shape else {
1014 continue;
1015 };
1016 for it in list {
1017 let failed_now = matches!(it.status, Status::Failed);
1018 let failed_last = it
1019 .history
1020 .last()
1021 .is_some_and(|s| matches!(s, Status::Failed));
1022 if failed_now || failed_last {
1023 let note = if failed_now {
1024 it.detail.clone().unwrap_or_default()
1025 } else {
1026 "latest run failed".to_string()
1027 };
1028 items.push(Problem {
1029 panel: i,
1030 name: it.name.clone(),
1031 status: it.status.clone(),
1032 note,
1033 });
1034 }
1035 }
1036 }
1037 self.problems = Some(Problems { items, index: 0 });
1038 }
1039
1040 pub fn problems_next(&mut self) {
1041 if let Some(pr) = &mut self.problems {
1042 pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
1043 }
1044 }
1045
1046 pub fn problems_prev(&mut self) {
1047 if let Some(pr) = &mut self.problems {
1048 pr.index = pr.index.saturating_sub(1);
1049 }
1050 }
1051
1052 pub fn problems_jump(&mut self) {
1054 let Some(pr) = self.problems.take() else {
1055 return;
1056 };
1057 let Some(problem) = pr.items.get(pr.index) else {
1058 return;
1059 };
1060 self.reveal_pane(problem.panel);
1061 self.focus = Panel::ALL[problem.panel];
1062 self.filters[problem.panel].clear();
1064 if let Some(Shape::List(list)) = &self.shapes[problem.panel] {
1065 if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
1066 self.selected[problem.panel] = pos;
1067 }
1068 }
1069 }
1070
1071 pub fn open_upcoming(&mut self, cli: &Arc<DatabricksCli>) {
1074 self.upcoming = Some(Upcoming {
1075 items: Vec::new(),
1076 index: 0,
1077 loading: true,
1078 });
1079 let (tx, rx) = oneshot::channel();
1080 self.upcoming_rx = Some(rx);
1081 let cli = Arc::clone(cli);
1082 tokio::spawn(async move {
1083 let _ = tx.send(fetchers::upcoming::fetch(&cli).await);
1084 });
1085 }
1086
1087 pub fn close_upcoming(&mut self) {
1088 self.upcoming = None;
1089 self.upcoming_rx = None;
1090 }
1091
1092 pub fn poll_upcoming(&mut self) -> bool {
1093 let Some(rx) = &mut self.upcoming_rx else {
1094 return false;
1095 };
1096 match rx.try_recv() {
1097 Ok(result) => {
1098 self.upcoming_rx = None;
1099 match result {
1100 Ok(items) => {
1101 if let Some(u) = &mut self.upcoming {
1102 u.items = items;
1103 u.loading = false;
1104 }
1105 }
1106 Err(e) => {
1107 self.upcoming = None;
1108 let first = e.lines().next().unwrap_or("failed").to_string();
1109 self.flash = Some((format!("✗ upcoming: {first}"), Instant::now()));
1110 }
1111 }
1112 true
1113 }
1114 Err(oneshot::error::TryRecvError::Empty) => false,
1115 Err(oneshot::error::TryRecvError::Closed) => {
1116 self.upcoming_rx = None;
1117 true
1118 }
1119 }
1120 }
1121
1122 pub fn upcoming_next(&mut self) {
1123 if let Some(u) = &mut self.upcoming {
1124 u.index = (u.index + 1).min(u.items.len().saturating_sub(1));
1125 }
1126 }
1127
1128 pub fn upcoming_prev(&mut self) {
1129 if let Some(u) = &mut self.upcoming {
1130 u.index = u.index.saturating_sub(1);
1131 }
1132 }
1133
1134 pub fn upcoming_jump(&mut self) {
1136 let Some(u) = self.upcoming.take() else {
1137 return;
1138 };
1139 self.upcoming_rx = None;
1140 let Some(item) = u.items.get(u.index) else {
1141 return;
1142 };
1143 let Some(idx) = Panel::ALL.iter().position(|p| *p == Panel::Jobs) else {
1144 return;
1145 };
1146 self.reveal_pane(idx);
1147 self.focus = Panel::Jobs;
1148 self.filters[idx].clear();
1149 if let Some(Shape::List(list)) = &self.shapes[idx] {
1150 if let Some(pos) = list.iter().position(|i| i.name == item.name) {
1151 self.selected[idx] = pos;
1152 }
1153 }
1154 }
1155
1156 pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
1159 let (tx, rx) = oneshot::channel();
1160 self.host_rx = Some(rx);
1161 let cli = Arc::clone(cli);
1162 tokio::spawn(async move {
1163 let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
1164 json["details"]["host"]
1165 .as_str()
1166 .or_else(|| json["host"].as_str())
1167 .map(str::to_string)
1168 });
1169 let _ = tx.send(host);
1170 });
1171 }
1172
1173 pub fn poll_host(&mut self) {
1174 if let Some(rx) = &mut self.host_rx {
1175 match rx.try_recv() {
1176 Ok(host) => {
1177 self.host = host;
1178 self.host_rx = None;
1179 }
1180 Err(oneshot::error::TryRecvError::Empty) => {}
1181 Err(oneshot::error::TryRecvError::Closed) => {
1182 self.host_rx = None;
1183 }
1184 }
1185 }
1186 }
1187
1188 fn focus_index(&self) -> usize {
1189 Panel::ALL
1190 .iter()
1191 .position(|p| p == &self.focus)
1192 .unwrap_or(0)
1193 }
1194
1195 fn list_len(&self, idx: usize) -> usize {
1196 match &self.shapes[idx] {
1197 Some(Shape::List(items)) => items
1198 .iter()
1199 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
1200 .count(),
1201 _ => 0,
1202 }
1203 }
1204
1205 pub fn selection(&self, idx: usize) -> usize {
1207 self.selected[idx].min(self.list_len(idx).saturating_sub(1))
1208 }
1209
1210 pub fn select_next(&mut self) {
1211 let idx = self.focus_index();
1212 let len = self.list_len(idx);
1213 if len > 0 {
1214 self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
1215 }
1216 }
1217
1218 pub fn select_prev(&mut self) {
1219 let idx = self.focus_index();
1220 self.selected[idx] = self.selection(idx).saturating_sub(1);
1221 }
1222
1223 fn selected_item(&self) -> Option<&crate::shape::ListItem> {
1226 let idx = self.focus_index();
1227 match &self.shapes[idx] {
1228 Some(Shape::List(items)) => items
1229 .iter()
1230 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
1231 .nth(self.selection(idx)),
1232 _ => None,
1233 }
1234 }
1235
1236 pub fn filter_start(&mut self) {
1238 let idx = self.focus_index();
1239 self.filters[idx].clear();
1240 self.selected[idx] = 0;
1241 self.filter_entry = true;
1242 }
1243
1244 pub fn filter_push(&mut self, c: char) {
1245 let idx = self.focus_index();
1246 self.filters[idx].push(c);
1247 self.selected[idx] = 0;
1248 }
1249
1250 pub fn filter_pop(&mut self) {
1251 let idx = self.focus_index();
1252 self.filters[idx].pop();
1253 self.selected[idx] = 0;
1254 }
1255
1256 pub fn filter_accept(&mut self) {
1258 self.filter_entry = false;
1259 }
1260
1261 pub fn filter_clear(&mut self) {
1262 let idx = self.focus_index();
1263 self.filters[idx].clear();
1264 self.selected[idx] = 0;
1265 self.filter_entry = false;
1266 }
1267
1268 pub fn active_filter(&self) -> &str {
1270 &self.filters[self.focus_index()]
1271 }
1272
1273 pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
1274 let Some(item) = self.selected_item() else {
1275 return;
1276 };
1277 let Some(id) = item.id.clone() else {
1278 return;
1279 };
1280 let kind = match &item.status {
1281 Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
1282 _ => None,
1283 };
1284 let section = match self.focus {
1285 Panel::Dashboards => "Contents",
1286 Panel::Catalog => "Columns",
1287 Panel::Warehouses => "Recent queries",
1288 _ => "Recent activity",
1289 };
1290 self.detail = Some(Detail {
1291 panel: self.focus,
1292 name: item.name.clone(),
1293 id: id.clone(),
1294 kind,
1295 section,
1296 data: None,
1297 show_raw: false,
1298 scroll: 0,
1299 });
1300
1301 let (tx, rx) = oneshot::channel();
1302 self.detail_rx = Some(rx);
1303 let cli = Arc::clone(cli);
1304 let kind = self.detail.as_ref().unwrap().kind.clone();
1305 if kind.as_deref() == Some("FILE") {
1307 if let Some(d) = &mut self.detail {
1308 d.section = "File head";
1309 }
1310 tokio::spawn(async move {
1311 let data = fetchers::catalog::file_peek(&cli, &id).await;
1312 let _ = tx.send(data);
1313 });
1314 return;
1315 }
1316 let group = match &kind {
1317 Some(k) if k == "VOLUME" => "volumes",
1318 _ => self.focus.cli_group(),
1319 };
1320 let warehouse = match &kind {
1323 Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
1324 _ => None,
1325 };
1326 tokio::spawn(async move {
1327 let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
1328 let _ = tx.send(data);
1329 });
1330 }
1331
1332 pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1335 if self.focus != Panel::Catalog {
1336 return false;
1337 }
1338 let Some(item) = self.selected_item() else {
1339 return self.uc_path.is_empty(); };
1341 if self.uc_path.len() >= 2 {
1344 let drillable =
1345 matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
1346 if !drillable {
1347 return false;
1348 }
1349 }
1350 self.uc_path.push(item.name.clone());
1351 self.refresh_catalog(cli);
1352 true
1353 }
1354
1355 pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1357 if self.focus != Panel::Catalog || self.uc_path.is_empty() {
1358 return false;
1359 }
1360 self.uc_path.pop();
1361 self.refresh_catalog(cli);
1362 true
1363 }
1364
1365 fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
1366 self.shapes[5] = None;
1367 self.selected[5] = 0;
1368 self.filters[5].clear();
1370 let (tx, rx) = oneshot::channel();
1371 self.uc_rx = Some(rx);
1372 let cli = Arc::clone(cli);
1373 let path = self.uc_path.clone();
1374 tokio::spawn(async move {
1375 let result = fetchers::catalog::fetch(&cli, &path)
1376 .await
1377 .map_err(|e| format!("{e:#}"));
1378 let _ = tx.send(result);
1379 });
1380 }
1381
1382 pub fn secrets_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1386 if self.focus != Panel::Secrets {
1387 return false;
1388 }
1389 if self.secret_scope.is_some() {
1391 return true;
1392 }
1393 let Some(item) = self.selected_item() else {
1394 return true; };
1396 self.secret_scope = Some(item.name.clone());
1397 self.refresh_secrets(cli);
1398 true
1399 }
1400
1401 pub fn secrets_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1403 if self.focus != Panel::Secrets || self.secret_scope.is_none() {
1404 return false;
1405 }
1406 self.secret_scope = None;
1407 self.refresh_secrets(cli);
1408 true
1409 }
1410
1411 fn refresh_secrets(&mut self, cli: &Arc<DatabricksCli>) {
1412 let idx = 6;
1413 self.shapes[idx] = None;
1414 self.selected[idx] = 0;
1415 self.filters[idx].clear();
1416 let (tx, rx) = oneshot::channel();
1417 self.secrets_rx = Some(rx);
1418 let cli = Arc::clone(cli);
1419 let scope = self.secret_scope.clone();
1420 tokio::spawn(async move {
1421 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
1422 .await
1423 .map_err(|e| format!("{e:#}"));
1424 let _ = tx.send(result);
1425 });
1426 }
1427
1428 pub fn poll_secrets(&mut self) -> bool {
1429 let Some(rx) = &mut self.secrets_rx else {
1430 return false;
1431 };
1432 match rx.try_recv() {
1433 Ok(result) => {
1434 self.shapes[6] = Some(match result {
1435 Ok(shape) => shape,
1436 Err(e) => Shape::Text(format!("✗ {e}")),
1437 });
1438 self.updated_at[6] = Some(Instant::now());
1439 self.secrets_rx = None;
1440 true
1441 }
1442 Err(oneshot::error::TryRecvError::Empty) => false,
1443 Err(oneshot::error::TryRecvError::Closed) => {
1444 self.secrets_rx = None;
1445 true
1446 }
1447 }
1448 }
1449
1450 pub fn open_secret_form(&mut self) {
1453 if self.focus != Panel::Secrets {
1454 return;
1455 }
1456 self.secret_form = Some(SecretForm {
1457 scope: self.secret_scope.clone(),
1458 key: String::new(),
1459 value: String::new(),
1460 stage: 0,
1461 });
1462 }
1463
1464 pub fn secret_form_push(&mut self, c: char) {
1465 if let Some(form) = &mut self.secret_form {
1466 if form.stage == 0 {
1467 form.key.push(c);
1468 } else {
1469 form.value.push(c);
1470 }
1471 }
1472 }
1473
1474 pub fn secret_form_pop(&mut self) {
1475 if let Some(form) = &mut self.secret_form {
1476 if form.stage == 0 {
1477 form.key.pop();
1478 } else {
1479 form.value.pop();
1480 }
1481 }
1482 }
1483
1484 pub fn secret_form_submit(&mut self, cli: &Arc<DatabricksCli>) {
1486 let Some(form) = &mut self.secret_form else {
1487 return;
1488 };
1489 if form.key.trim().is_empty() {
1490 return;
1491 }
1492 match (form.scope.clone(), form.stage) {
1493 (None, _) => {
1495 let name = form.key.trim().to_string();
1496 self.secret_form = None;
1497 self.run_secret_action(
1498 cli,
1499 format!("Create scope “{name}”"),
1500 vec!["secrets".into(), "create-scope".into(), name],
1501 );
1502 }
1503 (Some(_), 0) => form.stage = 1,
1505 (Some(scope), _) => {
1506 let key = form.key.trim().to_string();
1507 let value = form.value.clone();
1508 self.secret_form = None;
1509 self.run_secret_action(
1510 cli,
1511 format!("Put secret “{key}” in “{scope}”"),
1512 vec![
1513 "secrets".into(),
1514 "put-secret".into(),
1515 scope,
1516 key,
1517 "--string-value".into(),
1518 value,
1519 ],
1520 );
1521 }
1522 }
1523 }
1524
1525 fn run_secret_action(&mut self, cli: &Arc<DatabricksCli>, label: String, args: Vec<String>) {
1528 self.flash = Some((format!("⏳ {label}…"), Instant::now()));
1529 let (tx, rx) = oneshot::channel();
1530 self.action_rx = Some(rx);
1531 let cli = Arc::clone(cli);
1532 tokio::spawn(async move {
1533 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1534 let result = match cli.run_action(&arg_refs).await {
1535 Ok(()) => Ok(format!("✓ {label} — done")),
1536 Err(e) => Err(format!("✗ {e:#}")),
1537 };
1538 let _ = tx.send(result);
1539 });
1540 }
1541
1542 pub fn request_secret_delete(&mut self) {
1544 if self.focus != Panel::Secrets {
1545 return;
1546 }
1547 let Some(item) = self.selected_item() else {
1548 return;
1549 };
1550 let name = item.name.clone();
1551 let (message, args) = match &self.secret_scope {
1552 None => (
1553 format!("Delete scope “{name}” and all its secrets?"),
1554 vec!["secrets".to_string(), "delete-scope".to_string(), name],
1555 ),
1556 Some(scope) => (
1557 format!("Delete secret “{name}” from “{scope}”?"),
1558 vec![
1559 "secrets".to_string(),
1560 "delete-secret".to_string(),
1561 scope.clone(),
1562 name,
1563 ],
1564 ),
1565 };
1566 self.confirm = Some(Confirm { message, args });
1567 }
1568
1569 fn open_secret_acls(&mut self, cli: &Arc<DatabricksCli>) {
1571 let scope = match &self.secret_scope {
1572 Some(s) => Some(s.clone()),
1573 None => self.selected_item().map(|i| i.name.clone()),
1574 };
1575 let Some(scope) = scope else {
1576 return;
1577 };
1578 self.detail = Some(Detail {
1579 panel: Panel::Secrets,
1580 name: scope.clone(),
1581 id: scope.clone(),
1582 kind: None,
1583 section: "Access",
1584 data: None,
1585 show_raw: false,
1586 scroll: 0,
1587 });
1588 let (tx, rx) = oneshot::channel();
1589 self.detail_rx = Some(rx);
1590 let cli = Arc::clone(cli);
1591 tokio::spawn(async move {
1592 let acl_args = ["secrets", "list-acls", &scope];
1593 let data = match cli.run(&acl_args).await {
1594 Ok(json) => {
1595 let raw =
1596 serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
1597 let acls = json
1599 .as_array()
1600 .cloned()
1601 .or_else(|| json["items"].as_array().cloned())
1602 .unwrap_or_default();
1603 let activity: Vec<(Status, String)> = acls
1604 .iter()
1605 .map(|a| {
1606 let principal = a["principal"].as_str().unwrap_or("?");
1607 let perm = a["permission"].as_str().unwrap_or("?");
1608 let status = if perm == "MANAGE" {
1609 Status::Success
1610 } else {
1611 Status::Unknown(String::new())
1612 };
1613 (status, format!("{principal} · {perm}"))
1614 })
1615 .collect();
1616 DetailData {
1617 summary: vec![("Scope".to_string(), scope.clone())],
1618 activity,
1619 raw,
1620 }
1621 }
1622 Err(e) => DetailData {
1623 summary: Vec::new(),
1624 activity: Vec::new(),
1625 raw: format!("{e:#}"),
1626 },
1627 };
1628 let _ = tx.send(data);
1629 });
1630 }
1631
1632 pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1635 let idx = match kind {
1636 "cluster" => 0,
1637 "job" => 1,
1638 "warehouse" => 3,
1639 _ => return None,
1640 };
1641 match &self.shapes[idx] {
1642 Some(Shape::List(items)) => items
1643 .iter()
1644 .find(|i| i.id.as_deref() == Some(id))
1645 .map(|i| i.name.clone()),
1646 _ => None,
1647 }
1648 }
1649
1650 pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1652 let Some(Shape::List(items)) = &self.shapes[3] else {
1653 return Vec::new();
1654 };
1655 items
1656 .iter()
1657 .filter_map(|i| {
1658 let id = i.id.clone()?;
1659 Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1660 })
1661 .collect()
1662 }
1663
1664 pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
1668 if self.focus != Panel::Catalog {
1669 return;
1670 }
1671 let Some(item) = self.selected_item() else {
1672 return;
1673 };
1674 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1675 return;
1676 }
1677 let Some(full_name) = item.id.clone() else {
1678 return;
1679 };
1680 let warehouses = self.warehouses();
1681 if warehouses.is_empty() {
1682 self.flash = Some((
1683 "✗ no SQL warehouse available for previews".to_string(),
1684 Instant::now(),
1685 ));
1686 return;
1687 }
1688 if !force_pick {
1689 if let Some((id, name)) = self.preview_warehouse.clone() {
1690 self.start_preview_query(cli, full_name, id, name);
1691 return;
1692 }
1693 if let [(name, id, _)] = warehouses.as_slice() {
1694 self.preview_warehouse = Some((id.clone(), name.clone()));
1695 self.start_preview_query(cli, full_name, id.clone(), name.clone());
1696 return;
1697 }
1698 }
1699 let index = self
1701 .preview_warehouse
1702 .as_ref()
1703 .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1704 .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1705 .unwrap_or(0);
1706 self.wh_picker = Some(WhPicker {
1707 index,
1708 target: PickTarget::Preview(full_name),
1709 });
1710 }
1711
1712 pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1714 let warehouses = self.warehouses();
1715 if warehouses.is_empty() {
1716 self.flash = Some((
1717 "✗ no SQL warehouse available to query system tables".to_string(),
1718 Instant::now(),
1719 ));
1720 return;
1721 }
1722 if let Some((id, name)) = self.preview_warehouse.clone() {
1723 self.start_cost_query(cli, id, name);
1724 return;
1725 }
1726 if let [(name, id, _)] = warehouses.as_slice() {
1727 self.preview_warehouse = Some((id.clone(), name.clone()));
1728 self.start_cost_query(cli, id.clone(), name.clone());
1729 return;
1730 }
1731 let index = warehouses
1732 .iter()
1733 .position(|(_, _, running)| *running)
1734 .unwrap_or(0);
1735 self.wh_picker = Some(WhPicker {
1736 index,
1737 target: PickTarget::Cost,
1738 });
1739 }
1740
1741 fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1742 self.cost = Some(CostView {
1743 warehouse: name,
1744 data: None,
1745 });
1746 let (tx, rx) = oneshot::channel();
1747 self.cost_rx = Some(rx);
1748 let cli = Arc::clone(cli);
1749 let host = self.host.clone();
1750 let cached_ws = self.workspace_id.clone();
1751 tokio::spawn(async move {
1752 let ws = match (cached_ws, host) {
1754 (Some(w), _) => Some(w),
1755 (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1756 (None, None) => None,
1757 };
1758 let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1759 let _ = tx.send((result, ws));
1760 });
1761 }
1762
1763 pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1766 if self.focus != Panel::Catalog {
1767 return;
1768 }
1769 let Some(item) = self.selected_item() else {
1770 return;
1771 };
1772 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1773 return;
1774 }
1775 let Some(full_name) = item.id.clone() else {
1776 return;
1777 };
1778 let warehouses = self.warehouses();
1779 if warehouses.is_empty() {
1780 self.flash = Some((
1781 "✗ no SQL warehouse available to query lineage".to_string(),
1782 Instant::now(),
1783 ));
1784 return;
1785 }
1786 if let Some((id, _)) = self.preview_warehouse.clone() {
1787 self.start_lineage_query(cli, full_name, id);
1788 return;
1789 }
1790 if let [(name, id, _)] = warehouses.as_slice() {
1791 self.preview_warehouse = Some((id.clone(), name.clone()));
1792 let id = id.clone();
1793 self.start_lineage_query(cli, full_name, id);
1794 return;
1795 }
1796 let index = warehouses
1797 .iter()
1798 .position(|(_, _, running)| *running)
1799 .unwrap_or(0);
1800 self.wh_picker = Some(WhPicker {
1801 index,
1802 target: PickTarget::Lineage(full_name),
1803 });
1804 }
1805
1806 fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1807 self.detail = Some(Detail {
1808 panel: Panel::Catalog,
1809 name: full_name.clone(),
1810 id: full_name.clone(),
1811 kind: None,
1812 section: "Lineage",
1813 data: None,
1814 show_raw: false,
1815 scroll: 0,
1816 });
1817 let (tx, rx) = oneshot::channel();
1818 self.detail_rx = Some(rx);
1819 let cli = Arc::clone(cli);
1820 tokio::spawn(async move {
1821 let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1822 let _ = tx.send(data);
1823 });
1824 }
1825
1826 pub fn close_cost(&mut self) {
1827 self.cost = None;
1828 self.cost_rx = None;
1829 }
1830
1831 fn selected_table_fqn(&self) -> Option<String> {
1833 if self.focus != Panel::Catalog {
1834 return None;
1835 }
1836 let item = self.selected_item()?;
1837 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1838 return None;
1839 }
1840 item.id.clone()
1841 }
1842
1843 pub fn open_sql(&mut self) {
1846 if self.sql.is_none() {
1847 let input = self
1848 .selected_table_fqn()
1849 .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1850 .unwrap_or_default();
1851 self.sql = Some(SqlConsole {
1852 cursor: input.chars().count(),
1853 input,
1854 warehouse: String::new(),
1855 running: false,
1856 data: None,
1857 last_sql: String::new(),
1858 scroll: 0,
1859 col: 0,
1860 });
1861 }
1862 }
1863
1864 pub fn close_sql(&mut self) {
1865 self.sql = None;
1866 self.sql_rx = None;
1867 self.hist_idx = None;
1868 self.hist_draft.clear();
1869 self.hist_search = None;
1870 self.sql_complete = None;
1871 }
1872
1873 pub fn sql_tab(&mut self, cli: &Arc<DatabricksCli>) {
1877 match &self.sql_complete {
1878 Some(c) if c.loading => return,
1879 Some(_) => {
1880 self.sql_complete_next(1);
1881 return;
1882 }
1883 None => {}
1884 }
1885 let Some(console) = &self.sql else {
1886 return;
1887 };
1888 let (seg_start, context, prefix) = token_at_cursor(&console.input, console.cursor);
1889 let missing = if context.is_empty() {
1892 from_table(&console.input)
1893 .filter(|fqn| !self.uc_names.contains_key(fqn))
1894 .or_else(|| (!self.uc_names.contains_key("")).then(String::new))
1895 } else {
1896 (!self.uc_names.contains_key(&context)).then(|| context.clone())
1897 };
1898 let loading = missing.is_some();
1899 self.sql_complete = Some(SqlComplete {
1900 items: Vec::new(),
1901 index: 0,
1902 seg_start,
1903 prefix,
1904 context,
1905 loading,
1906 });
1907 match missing {
1908 Some(path) => self.fetch_uc_names(cli, path),
1909 None => self.sql_complete_fill(),
1910 }
1911 }
1912
1913 fn sql_complete_fill(&mut self) {
1916 let Some(console) = &self.sql else {
1917 self.sql_complete = None;
1918 return;
1919 };
1920 let input = console.input.clone();
1921 let Some(comp) = &self.sql_complete else {
1922 return;
1923 };
1924 let q = comp.prefix.to_lowercase();
1925 let mut items: Vec<String> = Vec::new();
1926 if comp.context.is_empty() {
1927 if let Some(cols) = from_table(&input).and_then(|fqn| self.uc_names.get(&fqn)) {
1928 items.extend(
1929 cols.iter()
1930 .filter(|n| n.to_lowercase().starts_with(&q))
1931 .cloned(),
1932 );
1933 }
1934 if let Some(cats) = self.uc_names.get("") {
1935 items.extend(
1936 cats.iter()
1937 .filter(|n| n.to_lowercase().starts_with(&q))
1938 .cloned(),
1939 );
1940 }
1941 if !q.is_empty() {
1942 items.extend(
1943 SQL_KEYWORDS
1944 .iter()
1945 .filter(|k| k.to_lowercase().starts_with(&q))
1946 .map(|k| k.to_string()),
1947 );
1948 }
1949 } else if let Some(names) = self.uc_names.get(&comp.context) {
1950 items.extend(
1951 names
1952 .iter()
1953 .filter(|n| n.to_lowercase().starts_with(&q))
1954 .cloned(),
1955 );
1956 }
1957 items.dedup();
1958 if items.is_empty() {
1959 self.sql_complete = None;
1960 self.flash = Some(("no completions".to_string(), Instant::now()));
1961 return;
1962 }
1963 let single = items.len() == 1;
1964 if let Some(comp) = &mut self.sql_complete {
1965 comp.items = items;
1966 comp.index = 0;
1967 }
1968 self.sql_complete_apply();
1969 if single {
1970 self.sql_complete = None;
1971 }
1972 }
1973
1974 fn sql_complete_apply(&mut self) {
1976 let Some(comp) = &self.sql_complete else {
1977 return;
1978 };
1979 let candidate = comp.items[comp.index].clone();
1980 let plain = candidate
1981 .chars()
1982 .all(|c| c.is_alphanumeric() || matches!(c, '_' | ' '));
1983 let text = if plain {
1984 candidate
1985 } else {
1986 format!("`{candidate}`")
1987 };
1988 self.sql_replace_segment(comp.seg_start, &text);
1989 }
1990
1991 fn sql_replace_segment(&mut self, seg_start: usize, text: &str) {
1993 if let Some(console) = &mut self.sql {
1994 let from = byte_at(&console.input, seg_start);
1995 let to = byte_at(&console.input, console.cursor);
1996 console.input.replace_range(from..to, text);
1997 console.cursor = seg_start + text.chars().count();
1998 }
1999 }
2000
2001 pub fn sql_complete_next(&mut self, delta: i32) {
2003 let Some(comp) = &mut self.sql_complete else {
2004 return;
2005 };
2006 if comp.items.is_empty() {
2007 return;
2008 }
2009 let n = comp.items.len() as i32;
2010 comp.index = (comp.index as i32 + delta).rem_euclid(n) as usize;
2011 self.sql_complete_apply();
2012 }
2013
2014 pub fn sql_complete_cancel(&mut self) {
2016 if let Some(comp) = &self.sql_complete {
2017 let (seg_start, prefix) = (comp.seg_start, comp.prefix.clone());
2018 self.sql_replace_segment(seg_start, &prefix);
2019 }
2020 self.sql_complete = None;
2021 }
2022
2023 pub fn sql_complete_accept(&mut self) {
2025 self.sql_complete = None;
2026 }
2027
2028 fn fetch_uc_names(&mut self, cli: &Arc<DatabricksCli>, path: String) {
2029 let (tx, rx) = oneshot::channel();
2030 self.uc_names_rx = Some(rx);
2031 let cli = Arc::clone(cli);
2032 tokio::spawn(async move {
2033 let names = fetchers::catalog::names(&cli, &path)
2034 .await
2035 .map_err(|e| format!("{e:#}"));
2036 let _ = tx.send((path, names));
2037 });
2038 }
2039
2040 pub fn poll_uc_names(&mut self) -> bool {
2042 let Some(rx) = &mut self.uc_names_rx else {
2043 return false;
2044 };
2045 match rx.try_recv() {
2046 Ok((path, result)) => {
2047 self.uc_names_rx = None;
2048 match result {
2049 Ok(names) => {
2050 self.uc_names.insert(path, names);
2051 if self.sql_complete.as_ref().is_some_and(|c| c.loading) {
2052 if let Some(c) = &mut self.sql_complete {
2053 c.loading = false;
2054 }
2055 self.sql_complete_fill();
2056 }
2057 }
2058 Err(e) => {
2059 self.sql_complete = None;
2060 let first = e.lines().next().unwrap_or("fetch failed").to_string();
2061 self.flash = Some((format!("✗ completions: {first}"), Instant::now()));
2062 }
2063 }
2064 true
2065 }
2066 Err(oneshot::error::TryRecvError::Empty) => false,
2067 Err(oneshot::error::TryRecvError::Closed) => {
2068 self.uc_names_rx = None;
2069 self.sql_complete = None;
2070 true
2071 }
2072 }
2073 }
2074
2075 pub fn sql_input(&self) -> Option<String> {
2077 self.sql.as_ref().map(|c| c.input.clone())
2078 }
2079
2080 pub fn sql_set_input(&mut self, s: &str) {
2082 if let Some(console) = &mut self.sql {
2083 console.input = s.to_string();
2084 console.cursor = console.input.chars().count();
2085 }
2086 }
2087
2088 pub fn hist_search_current(&self) -> Option<&String> {
2090 let (query, nth) = self.hist_search.as_ref()?;
2091 self.sql_history
2092 .iter()
2093 .rev()
2094 .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
2095 .nth(*nth)
2096 }
2097
2098 pub fn hist_search_start(&mut self) {
2099 if self.sql.is_some() {
2100 self.hist_search = Some((String::new(), 0));
2101 }
2102 }
2103
2104 pub fn hist_search_push(&mut self, c: char) {
2105 if let Some((query, nth)) = &mut self.hist_search {
2106 query.push(c);
2107 *nth = 0;
2108 }
2109 }
2110
2111 pub fn hist_search_pop(&mut self) {
2112 if let Some((query, nth)) = &mut self.hist_search {
2113 query.pop();
2114 *nth = 0;
2115 }
2116 }
2117
2118 pub fn hist_search_older(&mut self) {
2120 let Some((query, nth)) = &self.hist_search else {
2121 return;
2122 };
2123 let q = query.to_lowercase();
2124 let matches = self
2125 .sql_history
2126 .iter()
2127 .filter(|h| h.to_lowercase().contains(&q))
2128 .count();
2129 if nth + 1 < matches {
2130 if let Some((_, n)) = &mut self.hist_search {
2131 *n += 1;
2132 }
2133 }
2134 }
2135
2136 pub fn hist_search_accept(&mut self) {
2137 if let Some(stmt) = self.hist_search_current().cloned() {
2138 self.sql_set_input(&stmt);
2139 }
2140 self.hist_search = None;
2141 }
2142
2143 pub fn hist_search_cancel(&mut self) {
2144 self.hist_search = None;
2145 }
2146
2147 pub fn sql_push(&mut self, c: char) {
2148 if let Some(console) = &mut self.sql {
2149 let at = byte_at(&console.input, console.cursor);
2150 console.input.insert(at, c);
2151 console.cursor += 1;
2152 }
2153 }
2154
2155 pub fn sql_pop(&mut self) {
2157 if let Some(console) = &mut self.sql {
2158 if console.cursor > 0 {
2159 let at = byte_at(&console.input, console.cursor - 1);
2160 console.input.remove(at);
2161 console.cursor -= 1;
2162 }
2163 }
2164 }
2165
2166 pub fn sql_delete(&mut self) {
2168 if let Some(console) = &mut self.sql {
2169 if console.cursor < console.input.chars().count() {
2170 let at = byte_at(&console.input, console.cursor);
2171 console.input.remove(at);
2172 }
2173 }
2174 }
2175
2176 pub fn sql_left(&mut self) {
2177 if let Some(console) = &mut self.sql {
2178 console.cursor = console.cursor.saturating_sub(1);
2179 }
2180 }
2181
2182 pub fn sql_right(&mut self) {
2183 if let Some(console) = &mut self.sql {
2184 console.cursor = (console.cursor + 1).min(console.input.chars().count());
2185 }
2186 }
2187
2188 pub fn sql_hist_prev(&mut self) {
2190 let Some(console) = &mut self.sql else {
2191 return;
2192 };
2193 if self.sql_history.is_empty() {
2194 return;
2195 }
2196 let idx = match self.hist_idx {
2197 None => {
2198 self.hist_draft = console.input.clone();
2199 self.sql_history.len() - 1
2200 }
2201 Some(i) => i.saturating_sub(1),
2202 };
2203 self.hist_idx = Some(idx);
2204 console.input = self.sql_history[idx].clone();
2205 console.cursor = console.input.chars().count();
2206 }
2207
2208 pub fn sql_hist_next(&mut self) {
2210 let Some(console) = &mut self.sql else {
2211 return;
2212 };
2213 let Some(idx) = self.hist_idx else {
2214 return;
2215 };
2216 if idx + 1 < self.sql_history.len() {
2217 self.hist_idx = Some(idx + 1);
2218 console.input = self.sql_history[idx + 1].clone();
2219 } else {
2220 self.hist_idx = None;
2221 console.input = self.hist_draft.clone();
2222 }
2223 console.cursor = console.input.chars().count();
2224 }
2225
2226 pub fn sql_home(&mut self) {
2227 if let Some(console) = &mut self.sql {
2228 console.cursor = 0;
2229 }
2230 }
2231
2232 pub fn sql_end(&mut self) {
2233 if let Some(console) = &mut self.sql {
2234 console.cursor = console.input.chars().count();
2235 }
2236 }
2237
2238 pub fn sql_scroll(&mut self, delta: i32) {
2239 if let Some(console) = &mut self.sql {
2240 let max = match &console.data {
2241 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2242 _ => 0,
2243 };
2244 console.scroll = if delta < 0 {
2245 console.scroll.saturating_sub(delta.unsigned_abs() as usize)
2246 } else {
2247 (console.scroll + delta as usize).min(max)
2248 };
2249 }
2250 }
2251
2252 pub fn sql_cols(&mut self, delta: i32) {
2254 if let Some(console) = &mut self.sql {
2255 let n = match &console.data {
2256 Some(Ok(t)) => t.headers.len(),
2257 _ => 0,
2258 };
2259 console.col = if delta < 0 {
2260 console.col.saturating_sub(1)
2261 } else {
2262 (console.col + 1).min(n.saturating_sub(1))
2263 };
2264 }
2265 }
2266
2267 pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
2269 let Some(console) = &self.sql else {
2270 return;
2271 };
2272 if console.running {
2273 return;
2274 }
2275 let query = console.input.trim().to_string();
2276 if query.is_empty() {
2277 return;
2278 }
2279 if self.sql_history.last() != Some(&query) {
2282 self.sql_history.push(query.clone());
2283 save_history(&self.sql_history);
2284 }
2285 self.hist_idx = None;
2286 self.hist_draft.clear();
2287 let warehouses = self.warehouses();
2288 if warehouses.is_empty() {
2289 self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
2290 return;
2291 }
2292 if let Some((id, name)) = self.preview_warehouse.clone() {
2293 self.start_sql_query(cli, query, id, name);
2294 return;
2295 }
2296 if let [(name, id, _)] = warehouses.as_slice() {
2297 self.preview_warehouse = Some((id.clone(), name.clone()));
2298 self.start_sql_query(cli, query, id.clone(), name.clone());
2299 return;
2300 }
2301 let index = warehouses
2302 .iter()
2303 .position(|(_, _, running)| *running)
2304 .unwrap_or(0);
2305 self.wh_picker = Some(WhPicker {
2306 index,
2307 target: PickTarget::Sql(query),
2308 });
2309 }
2310
2311 fn start_sql_query(
2312 &mut self,
2313 cli: &Arc<DatabricksCli>,
2314 query: String,
2315 id: String,
2316 name: String,
2317 ) {
2318 if let Some(console) = &mut self.sql {
2319 console.running = true;
2320 console.warehouse = name;
2321 console.scroll = 0;
2322 console.last_sql = query.clone();
2323 }
2324 let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
2326 self.sql_stmt = Some(std::sync::Arc::clone(&handle));
2327 let (tx, rx) = oneshot::channel();
2328 self.sql_rx = Some(rx);
2329 let cli = Arc::clone(cli);
2330 tokio::spawn(async move {
2331 let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
2332 let _ = tx.send(result);
2333 });
2334 }
2335
2336 fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
2339 let stamp = std::time::SystemTime::now()
2340 .duration_since(std::time::UNIX_EPOCH)
2341 .map(|d| d.as_secs())
2342 .unwrap_or(0);
2343 let slug: String = label
2344 .chars()
2345 .map(|c| if c.is_alphanumeric() { c } else { '-' })
2346 .collect::<String>()
2347 .trim_matches('-')
2348 .chars()
2349 .take(40)
2350 .collect();
2351 let name = format!("databricks-{slug}-{stamp}.csv");
2352 let msg = match std::fs::write(&name, data.to_csv()) {
2353 Ok(()) => {
2354 let cwd = std::env::current_dir()
2355 .map(|d| d.display().to_string())
2356 .unwrap_or_default();
2357 format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
2358 }
2359 Err(e) => format!("✗ export failed: {e}"),
2360 };
2361 self.flash = Some((msg, Instant::now()));
2362 }
2363
2364 pub fn sql_export(&mut self) {
2366 if let Some(SqlConsole {
2367 data: Some(Ok(data)),
2368 last_sql,
2369 ..
2370 }) = &self.sql
2371 {
2372 let (label, data) = (last_sql.clone(), data.clone());
2373 self.export_csv(&label, &data);
2374 }
2375 }
2376
2377 pub fn preview_h(&mut self, delta: i32) {
2380 let Some(pv) = &mut self.preview else {
2381 return;
2382 };
2383 if pv.record {
2384 let max = match &pv.data {
2385 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2386 _ => 0,
2387 };
2388 pv.scroll = if delta < 0 {
2389 pv.scroll.saturating_sub(1)
2390 } else {
2391 (pv.scroll + 1).min(max)
2392 };
2393 pv.rscroll = 0;
2394 } else {
2395 let cols = pv.visible_cols().len();
2396 pv.col = if delta < 0 {
2397 pv.col.saturating_sub(1)
2398 } else {
2399 (pv.col + 1).min(cols.saturating_sub(1))
2400 };
2401 }
2402 }
2403
2404 pub fn preview_toggle_record(&mut self) {
2406 if let Some(pv) = &mut self.preview {
2407 if matches!(&pv.data, Some(Ok(t)) if !t.rows.is_empty()) {
2408 pv.record = !pv.record;
2409 pv.rscroll = 0;
2410 }
2411 }
2412 }
2413
2414 pub fn preview_filter_start(&mut self) {
2415 if let Some(pv) = &mut self.preview {
2416 pv.filter.clear();
2417 pv.filter_entry = true;
2418 pv.col = 0;
2419 pv.rscroll = 0;
2420 }
2421 }
2422
2423 pub fn preview_filter_push(&mut self, c: char) {
2424 if let Some(pv) = &mut self.preview {
2425 pv.filter.push(c);
2426 pv.col = 0;
2427 pv.rscroll = 0;
2428 }
2429 }
2430
2431 pub fn preview_filter_pop(&mut self) {
2432 if let Some(pv) = &mut self.preview {
2433 pv.filter.pop();
2434 pv.col = 0;
2435 }
2436 }
2437
2438 pub fn preview_filter_accept(&mut self) {
2439 if let Some(pv) = &mut self.preview {
2440 pv.filter_entry = false;
2441 }
2442 }
2443
2444 pub fn preview_filter_clear(&mut self) {
2445 if let Some(pv) = &mut self.preview {
2446 pv.filter.clear();
2447 pv.filter_entry = false;
2448 pv.col = 0;
2449 pv.rscroll = 0;
2450 }
2451 }
2452
2453 pub fn preview_export(&mut self) {
2455 if let Some(Preview {
2456 data: Some(Ok(data)),
2457 name,
2458 ..
2459 }) = &self.preview
2460 {
2461 let (label, data) = (name.clone(), data.clone());
2462 self.export_csv(&label, &data);
2463 }
2464 }
2465
2466 pub fn poll_sql(&mut self) -> bool {
2467 let Some(rx) = &mut self.sql_rx else {
2468 return false;
2469 };
2470 match rx.try_recv() {
2471 Ok(result) => {
2472 if let Err(e) = &result {
2475 if e != "statement canceled" {
2476 self.preview_warehouse = None;
2477 }
2478 }
2479 if let Some(console) = &mut self.sql {
2480 console.running = false;
2481 console.data = Some(result);
2482 console.col = 0;
2483 }
2484 self.sql_rx = None;
2485 self.sql_stmt = None;
2486 true
2487 }
2488 Err(oneshot::error::TryRecvError::Empty) => false,
2489 Err(oneshot::error::TryRecvError::Closed) => {
2490 if let Some(console) = &mut self.sql {
2491 console.running = false;
2492 }
2493 self.sql_rx = None;
2494 true
2495 }
2496 }
2497 }
2498
2499 pub fn poll_cost(&mut self) -> bool {
2500 let Some(rx) = &mut self.cost_rx else {
2501 return false;
2502 };
2503 match rx.try_recv() {
2504 Ok((result, ws)) => {
2505 if result.is_err() {
2506 self.preview_warehouse = None;
2507 }
2508 if ws.is_some() {
2509 self.workspace_id = ws;
2510 }
2511 if let Some(cv) = &mut self.cost {
2512 cv.data = Some(result);
2513 }
2514 self.cost_rx = None;
2515 true
2516 }
2517 Err(oneshot::error::TryRecvError::Empty) => false,
2518 Err(oneshot::error::TryRecvError::Closed) => {
2519 self.cost_rx = None;
2520 true
2521 }
2522 }
2523 }
2524
2525 pub fn wh_picker_next(&mut self) {
2526 let len = self.warehouses().len();
2527 if let Some(p) = &mut self.wh_picker {
2528 p.index = (p.index + 1).min(len.saturating_sub(1));
2529 }
2530 }
2531
2532 pub fn wh_picker_prev(&mut self) {
2533 if let Some(p) = &mut self.wh_picker {
2534 p.index = p.index.saturating_sub(1);
2535 }
2536 }
2537
2538 pub fn wh_picker_cancel(&mut self) {
2539 self.wh_picker = None;
2540 }
2541
2542 pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
2544 let Some(picker) = self.wh_picker.take() else {
2545 return;
2546 };
2547 let warehouses = self.warehouses();
2548 let Some((name, id, _)) = warehouses.get(picker.index) else {
2549 return;
2550 };
2551 self.preview_warehouse = Some((id.clone(), name.clone()));
2552 let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
2554 self.config
2555 .warehouses
2556 .insert(profile, (id.clone(), name.clone()));
2557 self.config.save();
2558 match picker.target {
2559 PickTarget::Preview(table) => {
2560 self.start_preview_query(cli, table, id.clone(), name.clone())
2561 }
2562 PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
2563 PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
2564 PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
2565 }
2566 }
2567
2568 fn start_preview_query(
2569 &mut self,
2570 cli: &Arc<DatabricksCli>,
2571 full_name: String,
2572 warehouse_id: String,
2573 warehouse_name: String,
2574 ) {
2575 self.preview = Some(Preview {
2576 name: full_name.clone(),
2577 warehouse: warehouse_name,
2578 warehouse_id: warehouse_id.clone(),
2579 data: None,
2580 scroll: 0,
2581 col: 0,
2582 filter: String::new(),
2583 filter_entry: false,
2584 record: false,
2585 rscroll: 0,
2586 });
2587 let (tx, rx) = oneshot::channel();
2588 self.preview_rx = Some(rx);
2589 let cli = Arc::clone(cli);
2590 tokio::spawn(async move {
2591 let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
2592 let _ = tx.send(result);
2593 });
2594 }
2595
2596 pub fn close_preview(&mut self) {
2597 self.preview = None;
2598 self.preview_rx = None;
2599 }
2600
2601 pub fn poll_preview(&mut self) -> bool {
2602 let Some(rx) = &mut self.preview_rx else {
2603 return false;
2604 };
2605 match rx.try_recv() {
2606 Ok(result) => {
2607 if result.is_err() {
2609 self.preview_warehouse = None;
2610 }
2611 if let Some(pv) = &mut self.preview {
2612 pv.data = Some(result);
2613 }
2614 self.preview_rx = None;
2615 true
2616 }
2617 Err(oneshot::error::TryRecvError::Empty) => false,
2618 Err(oneshot::error::TryRecvError::Closed) => {
2619 self.preview_rx = None;
2620 true
2621 }
2622 }
2623 }
2624
2625 pub fn preview_scroll(&mut self, delta: i32) {
2626 if let Some(pv) = &mut self.preview {
2627 if pv.record {
2629 let max = pv.visible_cols().len().saturating_sub(1) as u16;
2630 pv.rscroll = if delta < 0 {
2631 pv.rscroll.saturating_sub(delta.unsigned_abs() as u16)
2632 } else {
2633 pv.rscroll.saturating_add(delta as u16).min(max)
2634 };
2635 return;
2636 }
2637 let max = match &pv.data {
2638 Some(Ok(t)) => t.rows.len().saturating_sub(1),
2639 _ => 0,
2640 };
2641 pv.scroll = if delta < 0 {
2642 pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
2643 } else {
2644 (pv.scroll + delta as usize).min(max)
2645 };
2646 }
2647 }
2648
2649 pub fn poll_uc(&mut self) -> bool {
2650 let Some(rx) = &mut self.uc_rx else {
2651 return false;
2652 };
2653 match rx.try_recv() {
2654 Ok(result) => {
2655 self.shapes[5] = Some(match result {
2656 Ok(shape) => shape,
2657 Err(e) => Shape::Text(format!("✗ {e}")),
2658 });
2659 self.updated_at[5] = Some(Instant::now());
2660 self.uc_rx = None;
2661 true
2662 }
2663 Err(oneshot::error::TryRecvError::Empty) => false,
2664 Err(oneshot::error::TryRecvError::Closed) => {
2665 self.uc_rx = None;
2666 true
2667 }
2668 }
2669 }
2670
2671 pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
2674 if self.focus == Panel::Secrets {
2675 return self.open_secret_acls(cli);
2676 }
2677 let Some(item) = self.selected_item() else {
2678 return;
2679 };
2680 let Some(id) = item.id.clone() else {
2681 return;
2682 };
2683 let (uc, object_type): (bool, &'static str) = match self.focus {
2684 Panel::Catalog => match &item.status {
2685 Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
2686 Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
2687 Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
2688 Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
2689 _ => return,
2690 },
2691 Panel::Clusters => (false, "clusters"),
2692 Panel::Jobs => (false, "jobs"),
2693 Panel::Pipelines => (false, "pipelines"),
2694 Panel::Warehouses => (false, "warehouses"),
2695 Panel::Dashboards => (false, "dashboards"),
2696 Panel::Secrets => return,
2698 };
2699 self.detail = Some(Detail {
2700 panel: self.focus,
2701 name: item.name.clone(),
2702 id: id.clone(),
2703 kind: None,
2704 section: "Access",
2705 data: None,
2706 show_raw: false,
2707 scroll: 0,
2708 });
2709 let (tx, rx) = oneshot::channel();
2710 self.detail_rx = Some(rx);
2711 let cli = Arc::clone(cli);
2712 tokio::spawn(async move {
2713 let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
2714 let _ = tx.send(data);
2715 });
2716 }
2717
2718 pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
2721 let Some(d) = &self.detail else {
2722 return;
2723 };
2724 let panel = d.panel;
2725 if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
2726 return;
2727 }
2728 let owner_id = d.id.clone();
2729 self.run_view = Some(RunView {
2730 panel,
2731 owner_name: d.name.clone(),
2732 owner_id: owner_id.clone(),
2733 runs: Vec::new(),
2734 idx: 0,
2735 data: None,
2736 show_raw: false,
2737 scroll: 0,
2738 live: false,
2739 output: None,
2740 show_output: false,
2741 show_timeline: false,
2742 show_dag: false,
2743 fetched_at: Instant::now(),
2744 });
2745 let (tx, rx) = oneshot::channel();
2746 self.run_rx = Some(rx);
2747 let cli = Arc::clone(cli);
2748 tokio::spawn(async move {
2749 let result = async {
2750 let runs = if panel == Panel::Jobs {
2751 fetchers::runs::list(&cli, &owner_id).await?
2752 } else {
2753 fetchers::updates::list(&cli, &owner_id).await?
2754 };
2755 let Some((run_id, _, _)) = runs.first().cloned() else {
2756 return Err("no runs recorded yet".to_string());
2757 };
2758 let (data, live) = if panel == Panel::Jobs {
2759 fetchers::runs::fetch(&cli, &run_id).await
2760 } else {
2761 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2762 };
2763 Ok((runs, data, live))
2764 }
2765 .await;
2766 let _ = tx.send(RunUpdate::Opened(result));
2767 });
2768 }
2769
2770 pub fn close_run(&mut self) {
2771 self.run_view = None;
2772 self.run_rx = None;
2773 }
2774
2775 pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
2777 if self.run_rx.is_some() {
2778 return;
2779 }
2780 let Some(rv) = &mut self.run_view else {
2781 return;
2782 };
2783 if rv.runs.is_empty() {
2784 return;
2785 }
2786 let new = if delta < 0 {
2787 rv.idx.saturating_sub(delta.unsigned_abs() as usize)
2788 } else {
2789 (rv.idx + delta as usize).min(rv.runs.len() - 1)
2790 };
2791 if new == rv.idx {
2792 return;
2793 }
2794 rv.idx = new;
2795 rv.data = None;
2796 rv.scroll = 0;
2797 rv.show_raw = false;
2798 rv.output = None;
2799 rv.show_output = false;
2800 let run_id = rv.runs[new].0.clone();
2801 self.start_run_fetch(cli, run_id);
2802 }
2803
2804 fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
2805 let Some(rv) = &self.run_view else {
2806 return;
2807 };
2808 let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
2809 let (tx, rx) = oneshot::channel();
2810 self.run_rx = Some(rx);
2811 let cli = Arc::clone(cli);
2812 tokio::spawn(async move {
2813 let (data, live) = if panel == Panel::Jobs {
2814 fetchers::runs::fetch(&cli, &run_id).await
2815 } else {
2816 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
2817 };
2818 let _ = tx.send(RunUpdate::Detail(data, live));
2819 });
2820 }
2821
2822 pub fn run_toggle_output(&mut self, cli: &Arc<DatabricksCli>) {
2825 let Some(rv) = &mut self.run_view else {
2826 return;
2827 };
2828 if rv.panel != Panel::Jobs {
2829 self.flash = Some((
2830 "✗ output view is for job runs — pipeline events are already inline".to_string(),
2831 Instant::now(),
2832 ));
2833 return;
2834 }
2835 if rv.show_output {
2836 rv.show_output = false;
2837 rv.scroll = 0;
2838 return;
2839 }
2840 if rv.output.is_none() && self.run_rx.is_some() {
2841 self.flash = Some((
2842 "⏳ run still loading — try again in a moment".to_string(),
2843 Instant::now(),
2844 ));
2845 return;
2846 }
2847 rv.show_output = true;
2848 rv.scroll = 0;
2849 if rv.output.is_some() {
2850 return;
2851 }
2852 self.start_output_fetch(cli);
2853 }
2854
2855 fn start_output_fetch(&mut self, cli: &Arc<DatabricksCli>) {
2856 let Some(rv) = &self.run_view else {
2857 return;
2858 };
2859 let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() else {
2860 return;
2861 };
2862 let (tx, rx) = oneshot::channel();
2863 self.run_rx = Some(rx);
2864 let cli = Arc::clone(cli);
2865 tokio::spawn(async move {
2866 let (text, live) = fetchers::runs::full_output(&cli, &run_id).await;
2867 let _ = tx.send(RunUpdate::Output(text, live));
2868 });
2869 }
2870
2871 pub fn request_run_repair(&mut self) {
2873 let Some(rv) = &self.run_view else {
2874 return;
2875 };
2876 if rv.panel != Panel::Jobs {
2877 self.flash = Some((
2878 "✗ repair applies to job runs only".to_string(),
2879 Instant::now(),
2880 ));
2881 return;
2882 }
2883 if rv.live {
2884 self.flash = Some((
2885 "✗ run is still executing — cancel it first (s)".to_string(),
2886 Instant::now(),
2887 ));
2888 return;
2889 }
2890 let Some((run_id, status, _)) = rv.runs.get(rv.idx) else {
2891 return;
2892 };
2893 if matches!(status, Status::Success) {
2894 self.flash = Some((
2895 "✗ run succeeded — nothing to repair".to_string(),
2896 Instant::now(),
2897 ));
2898 return;
2899 }
2900 self.confirm = Some(Confirm {
2901 message: format!(
2902 "Repair run {run_id} of “{}” (reruns only the failed tasks)?",
2903 rv.owner_name
2904 ),
2905 args: vec![
2906 "jobs".to_string(),
2907 "repair-run".to_string(),
2908 run_id.clone(),
2909 "--rerun-all-failed-tasks".to_string(),
2910 ],
2911 });
2912 }
2913
2914 pub fn run_toggle_timeline(&mut self) {
2916 let Some(rv) = &mut self.run_view else {
2917 return;
2918 };
2919 if rv.panel != Panel::Jobs {
2920 self.flash = Some((
2921 "✗ timeline is for job runs — pipeline events are already inline".to_string(),
2922 Instant::now(),
2923 ));
2924 return;
2925 }
2926 rv.show_timeline = !rv.show_timeline;
2927 rv.show_dag = false;
2928 rv.scroll = 0;
2929 }
2930
2931 pub fn run_toggle_dag(&mut self) {
2933 let Some(rv) = &mut self.run_view else {
2934 return;
2935 };
2936 if rv.panel != Panel::Jobs {
2937 self.flash = Some((
2938 "✗ the task DAG is for job runs — pipelines have no task graph here".to_string(),
2939 Instant::now(),
2940 ));
2941 return;
2942 }
2943 rv.show_dag = !rv.show_dag;
2944 rv.show_timeline = false;
2945 rv.scroll = 0;
2946 }
2947
2948 pub fn run_toggle_raw(&mut self) {
2949 if let Some(rv) = &mut self.run_view {
2950 rv.show_raw = !rv.show_raw;
2951 rv.scroll = 0;
2952 }
2953 }
2954
2955 pub fn run_scroll(&mut self, delta: i32) {
2956 if let Some(rv) = &mut self.run_view {
2957 rv.scroll = if delta < 0 {
2958 rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
2959 } else {
2960 rv.scroll.saturating_add(delta as u16)
2961 };
2962 }
2963 }
2964
2965 pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2968 if let Some(rx) = &mut self.run_rx {
2969 match rx.try_recv() {
2970 Ok(update) => {
2971 self.run_rx = None;
2972 if let Some(rv) = &mut self.run_view {
2973 match update {
2974 RunUpdate::Opened(Ok((runs, data, live))) => {
2975 rv.runs = runs;
2976 rv.idx = 0;
2977 rv.data = Some(data);
2978 rv.live = live;
2979 }
2980 RunUpdate::Opened(Err(e)) => {
2981 rv.data = Some(DetailData {
2982 summary: Vec::new(),
2983 activity: Vec::new(),
2984 raw: format!("✗ {e}"),
2985 });
2986 rv.live = false;
2987 }
2988 RunUpdate::Detail(data, live) => {
2989 rv.data = Some(data);
2990 rv.live = live;
2991 }
2992 RunUpdate::Output(text, live) => {
2993 rv.output = Some(text);
2994 rv.live = live;
2995 }
2996 }
2997 rv.fetched_at = Instant::now();
2998 }
2999 true
3000 }
3001 Err(oneshot::error::TryRecvError::Empty) => false,
3002 Err(oneshot::error::TryRecvError::Closed) => {
3003 self.run_rx = None;
3004 true
3005 }
3006 }
3007 } else if let Some(rv) = &self.run_view {
3008 if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
3009 if rv.show_output {
3010 if rv.output.is_some() {
3013 self.start_output_fetch(cli);
3014 }
3015 } else if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
3016 self.start_run_fetch(cli, run_id);
3017 }
3018 }
3019 false
3020 } else {
3021 false
3022 }
3023 }
3024
3025 pub fn close_detail(&mut self) {
3026 self.detail = None;
3027 self.detail_rx = None;
3028 }
3029
3030 pub fn toggle_raw(&mut self) {
3031 if let Some(d) = &mut self.detail {
3032 d.show_raw = !d.show_raw;
3033 d.scroll = 0;
3034 }
3035 }
3036
3037 pub fn poll_detail(&mut self) -> bool {
3039 let Some(rx) = &mut self.detail_rx else {
3040 return false;
3041 };
3042 match rx.try_recv() {
3043 Ok(data) => {
3044 if let Some(d) = &mut self.detail {
3045 d.data = Some(data);
3046 }
3047 self.detail_rx = None;
3048 true
3049 }
3050 Err(oneshot::error::TryRecvError::Empty) => false,
3051 Err(oneshot::error::TryRecvError::Closed) => {
3052 self.detail_rx = None;
3053 true
3054 }
3055 }
3056 }
3057
3058 pub fn detail_scroll(&mut self, delta: i32) {
3059 if let Some(d) = &mut self.detail {
3060 let max = match &d.data {
3061 Some(data) if d.show_raw => data.raw.lines().count(),
3062 Some(data) => data.summary.len() + data.activity.len() + 3,
3063 None => 0,
3064 } as u16;
3065 d.scroll = if delta < 0 {
3066 d.scroll.saturating_sub(delta.unsigned_abs() as u16)
3067 } else {
3068 (d.scroll + delta as u16).min(max.saturating_sub(1))
3069 };
3070 }
3071 }
3072
3073 pub fn request_action(&mut self) {
3076 if matches!(
3078 self.focus,
3079 Panel::Dashboards | Panel::Catalog | Panel::Secrets
3080 ) {
3081 return;
3082 }
3083 let Some(item) = self.selected_item() else {
3084 return;
3085 };
3086 let Some(id) = item.id.clone() else {
3087 return;
3088 };
3089 let name = item.name.clone();
3090 let active = matches!(
3091 item.status,
3092 Status::Running | Status::Pending | Status::Success
3093 );
3094 let group = self.focus.cli_group();
3095 let (verb, action): (&str, &str) = match self.focus {
3096 Panel::Jobs => ("Run", "run-now"),
3097 Panel::Clusters if active => ("Stop", "delete"),
3098 Panel::Pipelines if active => ("Stop", "stop"),
3099 Panel::Pipelines => ("Start update for", "start-update"),
3100 _ if active => ("Stop", "stop"),
3101 _ => ("Start", "start"),
3102 };
3103 self.confirm = Some(Confirm {
3104 message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
3105 args: vec![group.to_string(), action.to_string(), id],
3106 });
3107 }
3108
3109 pub fn request_run_cancel(&mut self) {
3111 let Some(rv) = &self.run_view else {
3112 return;
3113 };
3114 if !rv.live {
3115 self.flash = Some((
3116 "✗ nothing to cancel — this run already finished".to_string(),
3117 Instant::now(),
3118 ));
3119 return;
3120 }
3121 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
3122 return;
3123 };
3124 let (message, args) = if rv.panel == Panel::Jobs {
3125 (
3126 format!("Cancel run {run_id} of “{}”?", rv.owner_name),
3127 vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
3128 )
3129 } else {
3130 (
3131 format!("Stop “{}” (cancels the active update)?", rv.owner_name),
3132 vec![
3133 "pipelines".to_string(),
3134 "stop".to_string(),
3135 rv.owner_id.clone(),
3136 ],
3137 )
3138 };
3139 self.confirm = Some(Confirm { message, args });
3140 }
3141
3142 pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
3145 let id = self
3146 .sql_stmt
3147 .as_ref()
3148 .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
3149 let Some(id) = id else {
3150 self.flash = Some((
3151 "✗ statement not submitted yet — try again in a moment".to_string(),
3152 Instant::now(),
3153 ));
3154 return;
3155 };
3156 let cli = Arc::clone(cli);
3157 tokio::spawn(async move {
3158 let path = format!("/api/2.0/sql/statements/{id}/cancel");
3159 let _ = cli.run_action(&["api", "post", &path]).await;
3160 });
3161 self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
3162 }
3163
3164 pub fn cancel_confirm(&mut self) {
3165 self.confirm = None;
3166 }
3167
3168 pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
3169 let Some(c) = self.confirm.take() else {
3170 return;
3171 };
3172 let base = c.message.trim_end_matches('?').to_string();
3173 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
3174
3175 let (tx, rx) = oneshot::channel();
3176 self.action_rx = Some(rx);
3177 let cli = Arc::clone(cli);
3178 tokio::spawn(async move {
3179 let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
3180 let result = match cli.run_action(&args).await {
3181 Ok(()) => Ok(format!("✓ {base} — done")),
3182 Err(e) => Err(format!("✗ {e:#}")),
3183 };
3184 let _ = tx.send(result);
3185 });
3186 }
3187
3188 pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
3190 let Some(rx) = &mut self.action_rx else {
3191 return false;
3192 };
3193 match rx.try_recv() {
3194 Ok(result) => {
3195 let ok = result.is_ok();
3196 self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
3197 self.action_rx = None;
3198 if ok {
3199 self.start_refresh(cli);
3200 if self.run_rx.is_none() {
3203 let current = self
3204 .run_view
3205 .as_ref()
3206 .and_then(|rv| rv.runs.get(rv.idx).cloned());
3207 if let Some((run_id, _, _)) = current {
3208 self.start_run_fetch(cli, run_id);
3209 }
3210 }
3211 }
3212 true
3213 }
3214 Err(oneshot::error::TryRecvError::Empty) => false,
3215 Err(oneshot::error::TryRecvError::Closed) => {
3216 self.action_rx = None;
3217 true
3218 }
3219 }
3220 }
3221
3222 pub fn expire_flash(&mut self) -> bool {
3224 if let Some((_, since)) = &self.flash {
3225 if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
3226 self.flash = None;
3227 return true;
3228 }
3229 }
3230 false
3231 }
3232
3233 pub fn open_in_browser(&self) {
3235 let Some(host) = &self.host else {
3236 return;
3237 };
3238 let (panel, id) = match &self.detail {
3239 Some(d) => (d.panel, Some(d.id.clone())),
3240 None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
3241 };
3242 let Some(id) = id else {
3243 return;
3244 };
3245 let path = match panel {
3246 Panel::Clusters => format!("compute/clusters/{id}"),
3247 Panel::Jobs => format!("jobs/{id}"),
3248 Panel::Pipelines => format!("pipelines/{id}"),
3249 Panel::Warehouses => format!("sql/warehouses/{id}"),
3250 Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
3251 Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
3252 Panel::Secrets => return,
3254 };
3255 let url = format!("{}/{}", host.trim_end_matches('/'), path);
3256 #[cfg(target_os = "macos")]
3257 let opener = "open";
3258 #[cfg(not(target_os = "macos"))]
3259 let opener = "xdg-open";
3260 let _ = std::process::Command::new(opener).arg(url).spawn();
3261 }
3262
3263 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
3265 let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
3266 for shape in self.shapes.iter().flatten() {
3267 if let Shape::List(items) = shape {
3268 for item in items {
3269 match item.status {
3270 Status::Running | Status::Success => ok += 1,
3271 Status::Pending => pending += 1,
3272 Status::Failed => failed += 1,
3273 Status::Stopped => idle += 1,
3274 Status::Unknown(_) => {}
3275 }
3276 }
3277 }
3278 }
3279 (ok, pending, failed, idle)
3280 }
3281
3282 pub fn last_refresh_age(&self) -> Duration {
3283 self.last_refresh.elapsed()
3284 }
3285
3286 pub fn spinner(&self) -> &'static str {
3287 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
3288 }
3289
3290 pub fn spinner_frame(&self) -> usize {
3291 self.spinner_frame
3292 }
3293
3294 pub fn busy(&self) -> bool {
3297 self.loading
3298 || self.detail_rx.is_some()
3299 || self.action_rx.is_some()
3300 || self.preview_rx.is_some()
3301 || self.cost_rx.is_some()
3302 || self.sql_rx.is_some()
3303 || self.run_rx.is_some()
3304 }
3305
3306 pub fn tick_spinner(&mut self) {
3307 self.spinner_frame = self.spinner_frame.wrapping_add(1);
3308 }
3309
3310 pub fn toggle_zoom(&mut self) {
3311 self.zoomed = !self.zoomed;
3312 }
3313
3314 pub fn focus_next(&mut self) {
3315 self.cycle_focus(1);
3316 }
3317
3318 pub fn focus_prev(&mut self) {
3319 self.cycle_focus(-1);
3320 }
3321
3322 fn cycle_focus(&mut self, delta: i32) {
3324 let visible = self.visible_panes();
3325 if visible.is_empty() {
3326 return;
3327 }
3328 let focus_idx = Panel::ALL
3329 .iter()
3330 .position(|p| p == &self.focus)
3331 .unwrap_or(0);
3332 let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
3333 let n = visible.len() as i32;
3334 let next = ((pos as i32 + delta) % n + n) % n;
3335 self.focus = Panel::ALL[visible[next as usize]];
3336 }
3337
3338 pub fn needs_refresh(&self) -> bool {
3339 !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
3340 }
3341
3342 pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
3343 if self.loading {
3344 return;
3345 }
3346 self.loading = true;
3347 self.error = None;
3348 self.last_refresh = Instant::now();
3349
3350 let (tx, rx) = mpsc::unbounded_channel();
3351 self.pending = Some(rx);
3352 self.in_flight = 8;
3353
3354 macro_rules! spawn_fetch {
3357 ($update:expr, $fetch:path) => {{
3358 let cli = Arc::clone(cli);
3359 let tx = tx.clone();
3360 tokio::spawn(async move {
3361 let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
3362 let _ = tx.send($update(result));
3363 });
3364 }};
3365 }
3366
3367 spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
3368 spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
3369 spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
3370 spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
3371 spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
3372 spawn_fetch!(
3373 |s: Result<Shape, String>| Update::Badge(s.ok()),
3374 fetchers::current_user::fetch
3375 );
3376 {
3377 let cli = Arc::clone(cli);
3378 let tx = tx.clone();
3379 let path = self.uc_path.clone();
3380 tokio::spawn(async move {
3381 let result = fetchers::catalog::fetch(&cli, &path)
3382 .await
3383 .map_err(|e| format!("{e:#}"));
3384 let _ = tx.send(Update::Panel(5, result));
3385 });
3386 }
3387 {
3388 let cli = Arc::clone(cli);
3389 let tx = tx.clone();
3390 let scope = self.secret_scope.clone();
3391 tokio::spawn(async move {
3392 let result = fetchers::secrets::fetch(&cli, scope.as_deref())
3393 .await
3394 .map_err(|e| format!("{e:#}"));
3395 let _ = tx.send(Update::Panel(6, result));
3396 });
3397 }
3398 }
3399
3400 pub fn poll_refresh(&mut self) -> bool {
3402 let Some(rx) = &mut self.pending else {
3403 return false;
3404 };
3405 let mut changed = false;
3406 let mut updated_panes: Vec<usize> = Vec::new();
3407 loop {
3408 match rx.try_recv() {
3409 Ok(Update::Panel(i, result)) => {
3410 match result {
3411 Ok(mut shape) => {
3412 if i != 5 {
3416 if let Shape::List(items) = &mut shape {
3417 items.sort_by_key(|it| {
3418 (it.status.rank(), it.history.is_empty())
3419 });
3420 }
3421 }
3422 self.shapes[i] = Some(shape);
3423 self.updated_at[i] = Some(Instant::now());
3424 updated_panes.push(i);
3425 }
3426 Err(e) => {
3429 if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
3430 self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
3431 }
3432 }
3433 }
3434 self.in_flight -= 1;
3435 changed = true;
3436 }
3437 Ok(Update::Badge(badge)) => {
3438 if badge.is_some() {
3439 self.user_badge = badge;
3440 }
3441 self.in_flight -= 1;
3442 changed = true;
3443 }
3444 Err(mpsc::error::TryRecvError::Empty) => break,
3445 Err(mpsc::error::TryRecvError::Disconnected) => {
3446 self.in_flight = 0;
3447 break;
3448 }
3449 }
3450 }
3451 for i in updated_panes {
3452 self.alert_new_failures(i);
3453 }
3454 if self.in_flight == 0 {
3455 self.loading = false;
3456 self.pending = None;
3457 changed = true;
3458 }
3459 changed
3460 }
3461}
3462
3463#[cfg(test)]
3464mod tests {
3465 use super::{from_table, token_at_cursor};
3466
3467 #[test]
3468 fn token_bare_word() {
3469 let (start, ctx, prefix) = token_at_cursor("SELECT * FROM ma", 16);
3470 assert_eq!((start, ctx.as_str(), prefix.as_str()), (14, "", "ma"));
3471 }
3472
3473 #[test]
3474 fn token_dotted_path() {
3475 let (start, ctx, prefix) = token_at_cursor("SELECT * FROM main.sales.or", 27);
3476 assert_eq!(
3477 (start, ctx.as_str(), prefix.as_str()),
3478 (25, "main.sales", "or")
3479 );
3480 }
3481
3482 #[test]
3483 fn token_trailing_dot() {
3484 let (start, ctx, prefix) = token_at_cursor("main.", 5);
3485 assert_eq!((start, ctx.as_str(), prefix.as_str()), (5, "main", ""));
3486 }
3487
3488 #[test]
3489 fn token_mid_input() {
3490 let (start, ctx, prefix) = token_at_cursor("SELECT co FROM t", 9);
3492 assert_eq!((start, ctx.as_str(), prefix.as_str()), (7, "", "co"));
3493 }
3494
3495 #[test]
3496 fn from_table_fully_qualified() {
3497 assert_eq!(
3498 from_table("SELECT x FROM main.sales.orders WHERE x > 1").as_deref(),
3499 Some("main.sales.orders")
3500 );
3501 }
3502
3503 #[test]
3504 fn from_table_rejects_partial_names() {
3505 assert_eq!(from_table("SELECT x FROM orders"), None);
3506 assert_eq!(from_table("SELECT x FROM main.sales."), None);
3507 assert_eq!(from_table("SELECT 1"), None);
3508 }
3509}