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