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