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}
79
80impl Panel {
81 pub const ALL: &'static [Panel] = &[
82 Panel::Clusters,
83 Panel::Jobs,
84 Panel::Pipelines,
85 Panel::Warehouses,
86 Panel::Dashboards,
87 Panel::Catalog,
88 ];
89
90 pub fn title(&self) -> &'static str {
91 match self {
92 Panel::Clusters => "Compute",
93 Panel::Jobs => "Lakeflow Jobs",
94 Panel::Pipelines => "Lakeflow Pipelines",
95 Panel::Warehouses => "SQL Warehouses",
96 Panel::Dashboards => "AI/BI Dashboards",
97 Panel::Catalog => "Unity Catalog",
98 }
99 }
100
101 pub fn icon(&self) -> &'static str {
102 match self {
103 Panel::Clusters => "⌬",
104 Panel::Jobs => "⟳",
105 Panel::Pipelines => "⋙",
106 Panel::Warehouses => "⌁",
107 Panel::Dashboards => "▦",
108 Panel::Catalog => "⧉",
109 }
110 }
111
112 pub fn cli_group(&self) -> &'static str {
114 match self {
115 Panel::Clusters => "clusters",
116 Panel::Jobs => "jobs",
117 Panel::Pipelines => "pipelines",
118 Panel::Warehouses => "warehouses",
119 Panel::Dashboards => "lakeview",
120 Panel::Catalog => "tables",
121 }
122 }
123}
124
125pub struct Detail {
126 pub panel: Panel,
127 pub name: String,
128 pub id: String,
129 pub kind: Option<String>,
131 pub section: &'static str,
133 pub data: Option<DetailData>,
135 pub show_raw: bool,
137 pub scroll: u16,
138}
139
140pub struct Preview {
142 pub name: String,
143 pub warehouse: String,
145 pub warehouse_id: String,
146 pub data: Option<Result<crate::shape::TableData, String>>,
148 pub scroll: usize,
149}
150
151enum PickTarget {
153 Preview(String),
154 Cost,
155 Lineage(String),
156 Sql(String),
157}
158
159pub struct SqlConsole {
161 pub input: String,
162 pub cursor: usize,
164 pub warehouse: String,
166 pub running: bool,
167 pub data: Option<Result<crate::shape::TableData, String>>,
168 pub last_sql: String,
170 pub scroll: usize,
171}
172
173fn history_path() -> Option<std::path::PathBuf> {
175 let home = std::env::var_os("HOME")?;
176 Some(
177 std::path::PathBuf::from(home)
178 .join(".config")
179 .join("databricks-tui")
180 .join("history"),
181 )
182}
183
184fn load_history() -> Vec<String> {
185 history_path()
186 .and_then(|p| std::fs::read_to_string(p).ok())
187 .map(|s| {
188 s.lines()
189 .filter(|l| !l.trim().is_empty())
190 .map(str::to_string)
191 .collect()
192 })
193 .unwrap_or_default()
194}
195
196fn save_history(history: &[String]) {
197 let Some(path) = history_path() else {
198 return;
199 };
200 if let Some(dir) = path.parent() {
201 let _ = std::fs::create_dir_all(dir);
202 crate::config::restrict(dir, 0o700);
203 }
204 let tail: Vec<&str> = history
206 .iter()
207 .rev()
208 .take(200)
209 .rev()
210 .map(String::as_str)
211 .collect();
212 let _ = std::fs::write(&path, tail.join("\n") + "\n");
214 crate::config::restrict(&path, 0o600);
215}
216
217fn byte_at(input: &str, cursor: usize) -> usize {
219 input
220 .char_indices()
221 .nth(cursor)
222 .map(|(i, _)| i)
223 .unwrap_or(input.len())
224}
225
226pub struct WhPicker {
228 pub index: usize,
229 target: PickTarget,
230}
231
232pub struct CostView {
234 pub warehouse: String,
235 pub data: Option<Result<fetchers::cost::CostData, String>>,
236}
237
238pub struct RunView {
241 pub panel: Panel,
243 pub owner_name: String,
244 owner_id: String,
246 pub runs: Vec<(String, Status, String)>,
248 pub idx: usize,
250 pub data: Option<DetailData>,
251 pub show_raw: bool,
252 pub scroll: u16,
253 pub live: bool,
255 fetched_at: Instant,
256}
257
258type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
260
261enum RunUpdate {
262 Opened(Result<RunOpened, String>),
263 Detail(DetailData, bool),
264}
265
266pub struct Problem {
268 pub panel: usize,
269 pub name: String,
270 pub status: Status,
271 pub note: String,
272}
273
274pub struct Problems {
276 pub items: Vec<Problem>,
277 pub index: usize,
278}
279
280pub struct Confirm {
282 pub message: String,
283 args: Vec<String>,
284}
285
286enum Update {
287 Panel(usize, Result<Shape, String>),
288 Badge(Option<Shape>),
289}
290
291const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
292
293pub struct App {
294 pub focus: Panel,
295 pub theme: ThemeMode,
296 pub zoomed: bool,
297 pub shapes: Vec<Option<Shape>>,
298 pub user_badge: Option<Shape>,
299 pub error: Option<String>,
300 pub refresh_interval: Duration,
301 last_refresh: Instant,
302 pub loading: bool,
303 pub detail: Option<Detail>,
304 pub confirm: Option<Confirm>,
305 pub flash: Option<(String, Instant)>,
306 pub selected: [usize; 6],
307 pub host: Option<String>,
308 pub profiles: Vec<String>,
310 pub profile: Option<String>,
311 pub picker: Option<usize>,
313 pub problems: Option<Problems>,
315 pub uc_path: Vec<String>,
317 uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
318 pub preview: Option<Preview>,
319 preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
320 pub wh_picker: Option<WhPicker>,
321 pub preview_warehouse: Option<(String, String)>,
323 pub cost: Option<CostView>,
324 #[allow(clippy::type_complexity)]
325 cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
326 workspace_id: Option<String>,
329 pub sql: Option<SqlConsole>,
330 sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
331 sql_history: Vec<String>,
333 hist_idx: Option<usize>,
335 hist_draft: String,
337 pub hist_search: Option<(String, usize)>,
339 pub run_view: Option<RunView>,
340 run_rx: Option<oneshot::Receiver<RunUpdate>>,
341 pending: Option<mpsc::UnboundedReceiver<Update>>,
342 detail_rx: Option<oneshot::Receiver<DetailData>>,
343 action_rx: Option<oneshot::Receiver<Result<String, String>>>,
344 host_rx: Option<oneshot::Receiver<Option<String>>>,
345 in_flight: usize,
346 spinner_frame: usize,
347 pub splash_until: Option<Instant>,
349 pub updated_at: [Option<Instant>; 6],
351 pub filters: [String; 6],
353 pub filter_entry: bool,
355 pub config: crate::config::Config,
357}
358
359impl App {
360 pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
361 Self {
362 focus: Panel::Clusters,
363 theme,
364 zoomed: false,
365 shapes: vec![None; 6],
366 user_badge: None,
367 error: None,
368 refresh_interval: Duration::from_secs(refresh_secs),
369 last_refresh: Instant::now()
370 .checked_sub(Duration::from_secs(refresh_secs + 1))
371 .unwrap_or(Instant::now()),
372 loading: false,
373 detail: None,
374 confirm: None,
375 flash: None,
376 selected: [0; 6],
377 host: None,
378 profiles: Vec::new(),
379 profile: None,
380 picker: None,
381 problems: None,
382 uc_path: Vec::new(),
383 uc_rx: None,
384 preview: None,
385 preview_rx: None,
386 wh_picker: None,
387 preview_warehouse: None,
388 cost: None,
389 cost_rx: None,
390 workspace_id: None,
391 sql: None,
392 sql_rx: None,
393 sql_history: load_history(),
394 hist_idx: None,
395 hist_draft: String::new(),
396 hist_search: None,
397 run_view: None,
398 run_rx: None,
399 pending: None,
400 detail_rx: None,
401 action_rx: None,
402 host_rx: None,
403 in_flight: 0,
404 spinner_frame: 0,
405 splash_until: Some(Instant::now() + Duration::from_millis(1600)),
406 updated_at: [None; 6],
407 filters: Default::default(),
408 filter_entry: false,
409 config: crate::config::Config::load(),
410 }
411 }
412
413 pub fn persist_theme(&mut self) {
415 self.config.theme = Some(self.theme.id().to_string());
416 self.config.save();
417 }
418
419 pub fn restore_warehouse_pref(&mut self) {
421 let profile = self.profile.as_deref().unwrap_or("DEFAULT");
422 self.preview_warehouse = self.config.warehouses.get(profile).cloned();
423 }
424
425 pub fn splash_active(&self) -> bool {
426 self.splash_until
427 .map(|t| Instant::now() < t)
428 .unwrap_or(false)
429 }
430
431 pub fn dismiss_splash(&mut self) {
432 self.splash_until = None;
433 }
434
435 pub fn any_fresh(&self) -> bool {
437 self.updated_at
438 .iter()
439 .flatten()
440 .any(|t| t.elapsed() < Duration::from_millis(1200))
441 }
442
443 pub fn open_picker(&mut self) {
444 if self.profiles.is_empty() {
445 return;
446 }
447 let current = self
448 .profile
449 .as_deref()
450 .and_then(|p| self.profiles.iter().position(|n| n == p))
451 .unwrap_or(0);
452 self.picker = Some(current);
453 }
454
455 pub fn picker_next(&mut self) {
456 if let Some(i) = self.picker {
457 self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
458 }
459 }
460
461 pub fn picker_prev(&mut self) {
462 if let Some(i) = self.picker {
463 self.picker = Some(i.saturating_sub(1));
464 }
465 }
466
467 pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
469 let idx = self.picker.take()?;
470 let name = self.profiles.get(idx)?.clone();
471 let profile_arg = if name == "DEFAULT" {
472 None
473 } else {
474 Some(name.clone())
475 };
476 self.profile = Some(name);
477
478 self.shapes = vec![None; 6];
480 self.user_badge = None;
481 self.host = None;
482 self.selected = [0; 6];
483 self.detail = None;
484 self.detail_rx = None;
485 self.confirm = None;
486 self.problems = None;
487 self.uc_path.clear();
488 self.uc_rx = None;
489 self.preview = None;
490 self.preview_rx = None;
491 self.wh_picker = None;
492 self.preview_warehouse = None;
493 self.cost = None;
494 self.cost_rx = None;
495 self.workspace_id = None;
496 self.sql = None;
497 self.sql_rx = None;
498 self.run_view = None;
499 self.run_rx = None;
500 self.pending = None;
501 self.in_flight = 0;
502 self.loading = false;
503 self.zoomed = false;
504 self.filters = Default::default();
505 self.filter_entry = false;
506 self.restore_warehouse_pref();
507
508 Some(Arc::new(DatabricksCli::new(profile_arg)))
509 }
510
511 pub fn open_problems(&mut self) {
514 let mut items = Vec::new();
515 for (i, shape) in self.shapes.iter().enumerate() {
516 let Some(Shape::List(list)) = shape else {
517 continue;
518 };
519 for it in list {
520 let failed_now = matches!(it.status, Status::Failed);
521 let failed_last = it
522 .history
523 .last()
524 .is_some_and(|s| matches!(s, Status::Failed));
525 if failed_now || failed_last {
526 let note = if failed_now {
527 it.detail.clone().unwrap_or_default()
528 } else {
529 "latest run failed".to_string()
530 };
531 items.push(Problem {
532 panel: i,
533 name: it.name.clone(),
534 status: it.status.clone(),
535 note,
536 });
537 }
538 }
539 }
540 self.problems = Some(Problems { items, index: 0 });
541 }
542
543 pub fn problems_next(&mut self) {
544 if let Some(pr) = &mut self.problems {
545 pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
546 }
547 }
548
549 pub fn problems_prev(&mut self) {
550 if let Some(pr) = &mut self.problems {
551 pr.index = pr.index.saturating_sub(1);
552 }
553 }
554
555 pub fn problems_jump(&mut self) {
557 let Some(pr) = self.problems.take() else {
558 return;
559 };
560 let Some(problem) = pr.items.get(pr.index) else {
561 return;
562 };
563 self.focus = Panel::ALL[problem.panel];
564 self.filters[problem.panel].clear();
566 if let Some(Shape::List(list)) = &self.shapes[problem.panel] {
567 if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
568 self.selected[problem.panel] = pos;
569 }
570 }
571 }
572
573 pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
576 let (tx, rx) = oneshot::channel();
577 self.host_rx = Some(rx);
578 let cli = Arc::clone(cli);
579 tokio::spawn(async move {
580 let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
581 json["details"]["host"]
582 .as_str()
583 .or_else(|| json["host"].as_str())
584 .map(str::to_string)
585 });
586 let _ = tx.send(host);
587 });
588 }
589
590 pub fn poll_host(&mut self) {
591 if let Some(rx) = &mut self.host_rx {
592 match rx.try_recv() {
593 Ok(host) => {
594 self.host = host;
595 self.host_rx = None;
596 }
597 Err(oneshot::error::TryRecvError::Empty) => {}
598 Err(oneshot::error::TryRecvError::Closed) => {
599 self.host_rx = None;
600 }
601 }
602 }
603 }
604
605 fn focus_index(&self) -> usize {
606 Panel::ALL
607 .iter()
608 .position(|p| p == &self.focus)
609 .unwrap_or(0)
610 }
611
612 fn list_len(&self, idx: usize) -> usize {
613 match &self.shapes[idx] {
614 Some(Shape::List(items)) => items
615 .iter()
616 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
617 .count(),
618 _ => 0,
619 }
620 }
621
622 pub fn selection(&self, idx: usize) -> usize {
624 self.selected[idx].min(self.list_len(idx).saturating_sub(1))
625 }
626
627 pub fn select_next(&mut self) {
628 let idx = self.focus_index();
629 let len = self.list_len(idx);
630 if len > 0 {
631 self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
632 }
633 }
634
635 pub fn select_prev(&mut self) {
636 let idx = self.focus_index();
637 self.selected[idx] = self.selection(idx).saturating_sub(1);
638 }
639
640 fn selected_item(&self) -> Option<&crate::shape::ListItem> {
643 let idx = self.focus_index();
644 match &self.shapes[idx] {
645 Some(Shape::List(items)) => items
646 .iter()
647 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
648 .nth(self.selection(idx)),
649 _ => None,
650 }
651 }
652
653 pub fn filter_start(&mut self) {
655 let idx = self.focus_index();
656 self.filters[idx].clear();
657 self.selected[idx] = 0;
658 self.filter_entry = true;
659 }
660
661 pub fn filter_push(&mut self, c: char) {
662 let idx = self.focus_index();
663 self.filters[idx].push(c);
664 self.selected[idx] = 0;
665 }
666
667 pub fn filter_pop(&mut self) {
668 let idx = self.focus_index();
669 self.filters[idx].pop();
670 self.selected[idx] = 0;
671 }
672
673 pub fn filter_accept(&mut self) {
675 self.filter_entry = false;
676 }
677
678 pub fn filter_clear(&mut self) {
679 let idx = self.focus_index();
680 self.filters[idx].clear();
681 self.selected[idx] = 0;
682 self.filter_entry = false;
683 }
684
685 pub fn active_filter(&self) -> &str {
687 &self.filters[self.focus_index()]
688 }
689
690 pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
691 let Some(item) = self.selected_item() else {
692 return;
693 };
694 let Some(id) = item.id.clone() else {
695 return;
696 };
697 let kind = match &item.status {
698 Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
699 _ => None,
700 };
701 let section = match self.focus {
702 Panel::Dashboards => "Contents",
703 Panel::Catalog => "Columns",
704 Panel::Warehouses => "Recent queries",
705 _ => "Recent activity",
706 };
707 self.detail = Some(Detail {
708 panel: self.focus,
709 name: item.name.clone(),
710 id: id.clone(),
711 kind,
712 section,
713 data: None,
714 show_raw: false,
715 scroll: 0,
716 });
717
718 let (tx, rx) = oneshot::channel();
719 self.detail_rx = Some(rx);
720 let cli = Arc::clone(cli);
721 let group = match &self.detail.as_ref().unwrap().kind {
722 Some(k) if k == "VOLUME" => "volumes",
723 _ => self.focus.cli_group(),
724 };
725 tokio::spawn(async move {
726 let data = fetchers::detail::fetch(&cli, group, &id).await;
727 let _ = tx.send(data);
728 });
729 }
730
731 pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
734 if self.focus != Panel::Catalog {
735 return false;
736 }
737 let Some(item) = self.selected_item() else {
738 return self.uc_path.is_empty(); };
740 if self.uc_path.len() >= 2 {
743 let drillable =
744 matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
745 if !drillable {
746 return false;
747 }
748 }
749 self.uc_path.push(item.name.clone());
750 self.refresh_catalog(cli);
751 true
752 }
753
754 pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
756 if self.focus != Panel::Catalog || self.uc_path.is_empty() {
757 return false;
758 }
759 self.uc_path.pop();
760 self.refresh_catalog(cli);
761 true
762 }
763
764 fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
765 self.shapes[5] = None;
766 self.selected[5] = 0;
767 self.filters[5].clear();
769 let (tx, rx) = oneshot::channel();
770 self.uc_rx = Some(rx);
771 let cli = Arc::clone(cli);
772 let path = self.uc_path.clone();
773 tokio::spawn(async move {
774 let result = fetchers::catalog::fetch(&cli, &path)
775 .await
776 .map_err(|e| format!("{e:#}"));
777 let _ = tx.send(result);
778 });
779 }
780
781 pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
784 let idx = match kind {
785 "cluster" => 0,
786 "job" => 1,
787 "warehouse" => 3,
788 _ => return None,
789 };
790 match &self.shapes[idx] {
791 Some(Shape::List(items)) => items
792 .iter()
793 .find(|i| i.id.as_deref() == Some(id))
794 .map(|i| i.name.clone()),
795 _ => None,
796 }
797 }
798
799 pub fn warehouses(&self) -> Vec<(String, String, bool)> {
801 let Some(Shape::List(items)) = &self.shapes[3] else {
802 return Vec::new();
803 };
804 items
805 .iter()
806 .filter_map(|i| {
807 let id = i.id.clone()?;
808 Some((i.name.clone(), id, matches!(i.status, Status::Running)))
809 })
810 .collect()
811 }
812
813 pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
817 if self.focus != Panel::Catalog {
818 return;
819 }
820 let Some(item) = self.selected_item() else {
821 return;
822 };
823 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
824 return;
825 }
826 let Some(full_name) = item.id.clone() else {
827 return;
828 };
829 let warehouses = self.warehouses();
830 if warehouses.is_empty() {
831 self.flash = Some((
832 "✗ no SQL warehouse available for previews".to_string(),
833 Instant::now(),
834 ));
835 return;
836 }
837 if !force_pick {
838 if let Some((id, name)) = self.preview_warehouse.clone() {
839 self.start_preview_query(cli, full_name, id, name);
840 return;
841 }
842 if let [(name, id, _)] = warehouses.as_slice() {
843 self.preview_warehouse = Some((id.clone(), name.clone()));
844 self.start_preview_query(cli, full_name, id.clone(), name.clone());
845 return;
846 }
847 }
848 let index = self
850 .preview_warehouse
851 .as_ref()
852 .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
853 .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
854 .unwrap_or(0);
855 self.wh_picker = Some(WhPicker {
856 index,
857 target: PickTarget::Preview(full_name),
858 });
859 }
860
861 pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
863 let warehouses = self.warehouses();
864 if warehouses.is_empty() {
865 self.flash = Some((
866 "✗ no SQL warehouse available to query system tables".to_string(),
867 Instant::now(),
868 ));
869 return;
870 }
871 if let Some((id, name)) = self.preview_warehouse.clone() {
872 self.start_cost_query(cli, id, name);
873 return;
874 }
875 if let [(name, id, _)] = warehouses.as_slice() {
876 self.preview_warehouse = Some((id.clone(), name.clone()));
877 self.start_cost_query(cli, id.clone(), name.clone());
878 return;
879 }
880 let index = warehouses
881 .iter()
882 .position(|(_, _, running)| *running)
883 .unwrap_or(0);
884 self.wh_picker = Some(WhPicker {
885 index,
886 target: PickTarget::Cost,
887 });
888 }
889
890 fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
891 self.cost = Some(CostView {
892 warehouse: name,
893 data: None,
894 });
895 let (tx, rx) = oneshot::channel();
896 self.cost_rx = Some(rx);
897 let cli = Arc::clone(cli);
898 let host = self.host.clone();
899 let cached_ws = self.workspace_id.clone();
900 tokio::spawn(async move {
901 let ws = match (cached_ws, host) {
903 (Some(w), _) => Some(w),
904 (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
905 (None, None) => None,
906 };
907 let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
908 let _ = tx.send((result, ws));
909 });
910 }
911
912 pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
915 if self.focus != Panel::Catalog {
916 return;
917 }
918 let Some(item) = self.selected_item() else {
919 return;
920 };
921 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
922 return;
923 }
924 let Some(full_name) = item.id.clone() else {
925 return;
926 };
927 let warehouses = self.warehouses();
928 if warehouses.is_empty() {
929 self.flash = Some((
930 "✗ no SQL warehouse available to query lineage".to_string(),
931 Instant::now(),
932 ));
933 return;
934 }
935 if let Some((id, _)) = self.preview_warehouse.clone() {
936 self.start_lineage_query(cli, full_name, id);
937 return;
938 }
939 if let [(name, id, _)] = warehouses.as_slice() {
940 self.preview_warehouse = Some((id.clone(), name.clone()));
941 let id = id.clone();
942 self.start_lineage_query(cli, full_name, id);
943 return;
944 }
945 let index = warehouses
946 .iter()
947 .position(|(_, _, running)| *running)
948 .unwrap_or(0);
949 self.wh_picker = Some(WhPicker {
950 index,
951 target: PickTarget::Lineage(full_name),
952 });
953 }
954
955 fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
956 self.detail = Some(Detail {
957 panel: Panel::Catalog,
958 name: full_name.clone(),
959 id: full_name.clone(),
960 kind: None,
961 section: "Lineage",
962 data: None,
963 show_raw: false,
964 scroll: 0,
965 });
966 let (tx, rx) = oneshot::channel();
967 self.detail_rx = Some(rx);
968 let cli = Arc::clone(cli);
969 tokio::spawn(async move {
970 let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
971 let _ = tx.send(data);
972 });
973 }
974
975 pub fn close_cost(&mut self) {
976 self.cost = None;
977 self.cost_rx = None;
978 }
979
980 fn selected_table_fqn(&self) -> Option<String> {
982 if self.focus != Panel::Catalog {
983 return None;
984 }
985 let item = self.selected_item()?;
986 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
987 return None;
988 }
989 item.id.clone()
990 }
991
992 pub fn open_sql(&mut self) {
995 if self.sql.is_none() {
996 let input = self
997 .selected_table_fqn()
998 .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
999 .unwrap_or_default();
1000 self.sql = Some(SqlConsole {
1001 cursor: input.chars().count(),
1002 input,
1003 warehouse: String::new(),
1004 running: false,
1005 data: None,
1006 last_sql: String::new(),
1007 scroll: 0,
1008 });
1009 }
1010 }
1011
1012 pub fn close_sql(&mut self) {
1013 self.sql = None;
1014 self.sql_rx = None;
1015 self.hist_idx = None;
1016 self.hist_draft.clear();
1017 self.hist_search = None;
1018 }
1019
1020 pub fn sql_input(&self) -> Option<String> {
1022 self.sql.as_ref().map(|c| c.input.clone())
1023 }
1024
1025 pub fn sql_set_input(&mut self, s: &str) {
1027 if let Some(console) = &mut self.sql {
1028 console.input = s.to_string();
1029 console.cursor = console.input.chars().count();
1030 }
1031 }
1032
1033 pub fn hist_search_current(&self) -> Option<&String> {
1035 let (query, nth) = self.hist_search.as_ref()?;
1036 self.sql_history
1037 .iter()
1038 .rev()
1039 .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
1040 .nth(*nth)
1041 }
1042
1043 pub fn hist_search_start(&mut self) {
1044 if self.sql.is_some() {
1045 self.hist_search = Some((String::new(), 0));
1046 }
1047 }
1048
1049 pub fn hist_search_push(&mut self, c: char) {
1050 if let Some((query, nth)) = &mut self.hist_search {
1051 query.push(c);
1052 *nth = 0;
1053 }
1054 }
1055
1056 pub fn hist_search_pop(&mut self) {
1057 if let Some((query, nth)) = &mut self.hist_search {
1058 query.pop();
1059 *nth = 0;
1060 }
1061 }
1062
1063 pub fn hist_search_older(&mut self) {
1065 let Some((query, nth)) = &self.hist_search else {
1066 return;
1067 };
1068 let q = query.to_lowercase();
1069 let matches = self
1070 .sql_history
1071 .iter()
1072 .filter(|h| h.to_lowercase().contains(&q))
1073 .count();
1074 if nth + 1 < matches {
1075 if let Some((_, n)) = &mut self.hist_search {
1076 *n += 1;
1077 }
1078 }
1079 }
1080
1081 pub fn hist_search_accept(&mut self) {
1082 if let Some(stmt) = self.hist_search_current().cloned() {
1083 self.sql_set_input(&stmt);
1084 }
1085 self.hist_search = None;
1086 }
1087
1088 pub fn hist_search_cancel(&mut self) {
1089 self.hist_search = None;
1090 }
1091
1092 pub fn sql_push(&mut self, c: char) {
1093 if let Some(console) = &mut self.sql {
1094 let at = byte_at(&console.input, console.cursor);
1095 console.input.insert(at, c);
1096 console.cursor += 1;
1097 }
1098 }
1099
1100 pub fn sql_pop(&mut self) {
1102 if let Some(console) = &mut self.sql {
1103 if console.cursor > 0 {
1104 let at = byte_at(&console.input, console.cursor - 1);
1105 console.input.remove(at);
1106 console.cursor -= 1;
1107 }
1108 }
1109 }
1110
1111 pub fn sql_delete(&mut self) {
1113 if let Some(console) = &mut self.sql {
1114 if console.cursor < console.input.chars().count() {
1115 let at = byte_at(&console.input, console.cursor);
1116 console.input.remove(at);
1117 }
1118 }
1119 }
1120
1121 pub fn sql_left(&mut self) {
1122 if let Some(console) = &mut self.sql {
1123 console.cursor = console.cursor.saturating_sub(1);
1124 }
1125 }
1126
1127 pub fn sql_right(&mut self) {
1128 if let Some(console) = &mut self.sql {
1129 console.cursor = (console.cursor + 1).min(console.input.chars().count());
1130 }
1131 }
1132
1133 pub fn sql_hist_prev(&mut self) {
1135 let Some(console) = &mut self.sql else {
1136 return;
1137 };
1138 if self.sql_history.is_empty() {
1139 return;
1140 }
1141 let idx = match self.hist_idx {
1142 None => {
1143 self.hist_draft = console.input.clone();
1144 self.sql_history.len() - 1
1145 }
1146 Some(i) => i.saturating_sub(1),
1147 };
1148 self.hist_idx = Some(idx);
1149 console.input = self.sql_history[idx].clone();
1150 console.cursor = console.input.chars().count();
1151 }
1152
1153 pub fn sql_hist_next(&mut self) {
1155 let Some(console) = &mut self.sql else {
1156 return;
1157 };
1158 let Some(idx) = self.hist_idx else {
1159 return;
1160 };
1161 if idx + 1 < self.sql_history.len() {
1162 self.hist_idx = Some(idx + 1);
1163 console.input = self.sql_history[idx + 1].clone();
1164 } else {
1165 self.hist_idx = None;
1166 console.input = self.hist_draft.clone();
1167 }
1168 console.cursor = console.input.chars().count();
1169 }
1170
1171 pub fn sql_home(&mut self) {
1172 if let Some(console) = &mut self.sql {
1173 console.cursor = 0;
1174 }
1175 }
1176
1177 pub fn sql_end(&mut self) {
1178 if let Some(console) = &mut self.sql {
1179 console.cursor = console.input.chars().count();
1180 }
1181 }
1182
1183 pub fn sql_scroll(&mut self, delta: i32) {
1184 if let Some(console) = &mut self.sql {
1185 let max = match &console.data {
1186 Some(Ok(t)) => t.rows.len().saturating_sub(1),
1187 _ => 0,
1188 };
1189 console.scroll = if delta < 0 {
1190 console.scroll.saturating_sub(delta.unsigned_abs() as usize)
1191 } else {
1192 (console.scroll + delta as usize).min(max)
1193 };
1194 }
1195 }
1196
1197 pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
1199 let Some(console) = &self.sql else {
1200 return;
1201 };
1202 if console.running {
1203 return;
1204 }
1205 let query = console.input.trim().to_string();
1206 if query.is_empty() {
1207 return;
1208 }
1209 if self.sql_history.last() != Some(&query) {
1212 self.sql_history.push(query.clone());
1213 save_history(&self.sql_history);
1214 }
1215 self.hist_idx = None;
1216 self.hist_draft.clear();
1217 let warehouses = self.warehouses();
1218 if warehouses.is_empty() {
1219 self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
1220 return;
1221 }
1222 if let Some((id, name)) = self.preview_warehouse.clone() {
1223 self.start_sql_query(cli, query, id, name);
1224 return;
1225 }
1226 if let [(name, id, _)] = warehouses.as_slice() {
1227 self.preview_warehouse = Some((id.clone(), name.clone()));
1228 self.start_sql_query(cli, query, id.clone(), name.clone());
1229 return;
1230 }
1231 let index = warehouses
1232 .iter()
1233 .position(|(_, _, running)| *running)
1234 .unwrap_or(0);
1235 self.wh_picker = Some(WhPicker {
1236 index,
1237 target: PickTarget::Sql(query),
1238 });
1239 }
1240
1241 fn start_sql_query(
1242 &mut self,
1243 cli: &Arc<DatabricksCli>,
1244 query: String,
1245 id: String,
1246 name: String,
1247 ) {
1248 if let Some(console) = &mut self.sql {
1249 console.running = true;
1250 console.warehouse = name;
1251 console.scroll = 0;
1252 console.last_sql = query.clone();
1253 }
1254 let (tx, rx) = oneshot::channel();
1255 self.sql_rx = Some(rx);
1256 let cli = Arc::clone(cli);
1257 tokio::spawn(async move {
1258 let result = fetchers::preview::run_sql(&cli, &query, &id).await;
1259 let _ = tx.send(result);
1260 });
1261 }
1262
1263 fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
1266 let stamp = std::time::SystemTime::now()
1267 .duration_since(std::time::UNIX_EPOCH)
1268 .map(|d| d.as_secs())
1269 .unwrap_or(0);
1270 let slug: String = label
1271 .chars()
1272 .map(|c| if c.is_alphanumeric() { c } else { '-' })
1273 .collect::<String>()
1274 .trim_matches('-')
1275 .chars()
1276 .take(40)
1277 .collect();
1278 let name = format!("databricks-{slug}-{stamp}.csv");
1279 let msg = match std::fs::write(&name, data.to_csv()) {
1280 Ok(()) => {
1281 let cwd = std::env::current_dir()
1282 .map(|d| d.display().to_string())
1283 .unwrap_or_default();
1284 format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
1285 }
1286 Err(e) => format!("✗ export failed: {e}"),
1287 };
1288 self.flash = Some((msg, Instant::now()));
1289 }
1290
1291 pub fn sql_export(&mut self) {
1293 if let Some(SqlConsole {
1294 data: Some(Ok(data)),
1295 last_sql,
1296 ..
1297 }) = &self.sql
1298 {
1299 let (label, data) = (last_sql.clone(), data.clone());
1300 self.export_csv(&label, &data);
1301 }
1302 }
1303
1304 pub fn preview_export(&mut self) {
1306 if let Some(Preview {
1307 data: Some(Ok(data)),
1308 name,
1309 ..
1310 }) = &self.preview
1311 {
1312 let (label, data) = (name.clone(), data.clone());
1313 self.export_csv(&label, &data);
1314 }
1315 }
1316
1317 pub fn poll_sql(&mut self) -> bool {
1318 let Some(rx) = &mut self.sql_rx else {
1319 return false;
1320 };
1321 match rx.try_recv() {
1322 Ok(result) => {
1323 if result.is_err() {
1325 self.preview_warehouse = None;
1326 }
1327 if let Some(console) = &mut self.sql {
1328 console.running = false;
1329 console.data = Some(result);
1330 }
1331 self.sql_rx = None;
1332 true
1333 }
1334 Err(oneshot::error::TryRecvError::Empty) => false,
1335 Err(oneshot::error::TryRecvError::Closed) => {
1336 if let Some(console) = &mut self.sql {
1337 console.running = false;
1338 }
1339 self.sql_rx = None;
1340 true
1341 }
1342 }
1343 }
1344
1345 pub fn poll_cost(&mut self) -> bool {
1346 let Some(rx) = &mut self.cost_rx else {
1347 return false;
1348 };
1349 match rx.try_recv() {
1350 Ok((result, ws)) => {
1351 if result.is_err() {
1352 self.preview_warehouse = None;
1353 }
1354 if ws.is_some() {
1355 self.workspace_id = ws;
1356 }
1357 if let Some(cv) = &mut self.cost {
1358 cv.data = Some(result);
1359 }
1360 self.cost_rx = None;
1361 true
1362 }
1363 Err(oneshot::error::TryRecvError::Empty) => false,
1364 Err(oneshot::error::TryRecvError::Closed) => {
1365 self.cost_rx = None;
1366 true
1367 }
1368 }
1369 }
1370
1371 pub fn wh_picker_next(&mut self) {
1372 let len = self.warehouses().len();
1373 if let Some(p) = &mut self.wh_picker {
1374 p.index = (p.index + 1).min(len.saturating_sub(1));
1375 }
1376 }
1377
1378 pub fn wh_picker_prev(&mut self) {
1379 if let Some(p) = &mut self.wh_picker {
1380 p.index = p.index.saturating_sub(1);
1381 }
1382 }
1383
1384 pub fn wh_picker_cancel(&mut self) {
1385 self.wh_picker = None;
1386 }
1387
1388 pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
1390 let Some(picker) = self.wh_picker.take() else {
1391 return;
1392 };
1393 let warehouses = self.warehouses();
1394 let Some((name, id, _)) = warehouses.get(picker.index) else {
1395 return;
1396 };
1397 self.preview_warehouse = Some((id.clone(), name.clone()));
1398 let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
1400 self.config
1401 .warehouses
1402 .insert(profile, (id.clone(), name.clone()));
1403 self.config.save();
1404 match picker.target {
1405 PickTarget::Preview(table) => {
1406 self.start_preview_query(cli, table, id.clone(), name.clone())
1407 }
1408 PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
1409 PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
1410 PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
1411 }
1412 }
1413
1414 fn start_preview_query(
1415 &mut self,
1416 cli: &Arc<DatabricksCli>,
1417 full_name: String,
1418 warehouse_id: String,
1419 warehouse_name: String,
1420 ) {
1421 self.preview = Some(Preview {
1422 name: full_name.clone(),
1423 warehouse: warehouse_name,
1424 warehouse_id: warehouse_id.clone(),
1425 data: None,
1426 scroll: 0,
1427 });
1428 let (tx, rx) = oneshot::channel();
1429 self.preview_rx = Some(rx);
1430 let cli = Arc::clone(cli);
1431 tokio::spawn(async move {
1432 let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
1433 let _ = tx.send(result);
1434 });
1435 }
1436
1437 pub fn close_preview(&mut self) {
1438 self.preview = None;
1439 self.preview_rx = None;
1440 }
1441
1442 pub fn poll_preview(&mut self) -> bool {
1443 let Some(rx) = &mut self.preview_rx else {
1444 return false;
1445 };
1446 match rx.try_recv() {
1447 Ok(result) => {
1448 if result.is_err() {
1450 self.preview_warehouse = None;
1451 }
1452 if let Some(pv) = &mut self.preview {
1453 pv.data = Some(result);
1454 }
1455 self.preview_rx = None;
1456 true
1457 }
1458 Err(oneshot::error::TryRecvError::Empty) => false,
1459 Err(oneshot::error::TryRecvError::Closed) => {
1460 self.preview_rx = None;
1461 true
1462 }
1463 }
1464 }
1465
1466 pub fn preview_scroll(&mut self, delta: i32) {
1467 if let Some(pv) = &mut self.preview {
1468 let max = match &pv.data {
1469 Some(Ok(t)) => t.rows.len().saturating_sub(1),
1470 _ => 0,
1471 };
1472 pv.scroll = if delta < 0 {
1473 pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
1474 } else {
1475 (pv.scroll + delta as usize).min(max)
1476 };
1477 }
1478 }
1479
1480 pub fn poll_uc(&mut self) -> bool {
1481 let Some(rx) = &mut self.uc_rx else {
1482 return false;
1483 };
1484 match rx.try_recv() {
1485 Ok(result) => {
1486 self.shapes[5] = Some(match result {
1487 Ok(shape) => shape,
1488 Err(e) => Shape::Text(format!("✗ {e}")),
1489 });
1490 self.updated_at[5] = Some(Instant::now());
1491 self.uc_rx = None;
1492 true
1493 }
1494 Err(oneshot::error::TryRecvError::Empty) => false,
1495 Err(oneshot::error::TryRecvError::Closed) => {
1496 self.uc_rx = None;
1497 true
1498 }
1499 }
1500 }
1501
1502 pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
1505 let Some(item) = self.selected_item() else {
1506 return;
1507 };
1508 let Some(id) = item.id.clone() else {
1509 return;
1510 };
1511 let (uc, object_type): (bool, &'static str) = match self.focus {
1512 Panel::Catalog => match &item.status {
1513 Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
1514 Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
1515 Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
1516 Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
1517 _ => return,
1518 },
1519 Panel::Clusters => (false, "clusters"),
1520 Panel::Jobs => (false, "jobs"),
1521 Panel::Pipelines => (false, "pipelines"),
1522 Panel::Warehouses => (false, "warehouses"),
1523 Panel::Dashboards => (false, "dashboards"),
1524 };
1525 self.detail = Some(Detail {
1526 panel: self.focus,
1527 name: item.name.clone(),
1528 id: id.clone(),
1529 kind: None,
1530 section: "Access",
1531 data: None,
1532 show_raw: false,
1533 scroll: 0,
1534 });
1535 let (tx, rx) = oneshot::channel();
1536 self.detail_rx = Some(rx);
1537 let cli = Arc::clone(cli);
1538 tokio::spawn(async move {
1539 let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
1540 let _ = tx.send(data);
1541 });
1542 }
1543
1544 pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
1547 let Some(d) = &self.detail else {
1548 return;
1549 };
1550 let panel = d.panel;
1551 if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
1552 return;
1553 }
1554 let owner_id = d.id.clone();
1555 self.run_view = Some(RunView {
1556 panel,
1557 owner_name: d.name.clone(),
1558 owner_id: owner_id.clone(),
1559 runs: Vec::new(),
1560 idx: 0,
1561 data: None,
1562 show_raw: false,
1563 scroll: 0,
1564 live: false,
1565 fetched_at: Instant::now(),
1566 });
1567 let (tx, rx) = oneshot::channel();
1568 self.run_rx = Some(rx);
1569 let cli = Arc::clone(cli);
1570 tokio::spawn(async move {
1571 let result = async {
1572 let runs = if panel == Panel::Jobs {
1573 fetchers::runs::list(&cli, &owner_id).await?
1574 } else {
1575 fetchers::updates::list(&cli, &owner_id).await?
1576 };
1577 let Some((run_id, _, _)) = runs.first().cloned() else {
1578 return Err("no runs recorded yet".to_string());
1579 };
1580 let (data, live) = if panel == Panel::Jobs {
1581 fetchers::runs::fetch(&cli, &run_id).await
1582 } else {
1583 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
1584 };
1585 Ok((runs, data, live))
1586 }
1587 .await;
1588 let _ = tx.send(RunUpdate::Opened(result));
1589 });
1590 }
1591
1592 pub fn close_run(&mut self) {
1593 self.run_view = None;
1594 self.run_rx = None;
1595 }
1596
1597 pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
1599 if self.run_rx.is_some() {
1600 return;
1601 }
1602 let Some(rv) = &mut self.run_view else {
1603 return;
1604 };
1605 if rv.runs.is_empty() {
1606 return;
1607 }
1608 let new = if delta < 0 {
1609 rv.idx.saturating_sub(delta.unsigned_abs() as usize)
1610 } else {
1611 (rv.idx + delta as usize).min(rv.runs.len() - 1)
1612 };
1613 if new == rv.idx {
1614 return;
1615 }
1616 rv.idx = new;
1617 rv.data = None;
1618 rv.scroll = 0;
1619 rv.show_raw = false;
1620 let run_id = rv.runs[new].0.clone();
1621 self.start_run_fetch(cli, run_id);
1622 }
1623
1624 fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
1625 let Some(rv) = &self.run_view else {
1626 return;
1627 };
1628 let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
1629 let (tx, rx) = oneshot::channel();
1630 self.run_rx = Some(rx);
1631 let cli = Arc::clone(cli);
1632 tokio::spawn(async move {
1633 let (data, live) = if panel == Panel::Jobs {
1634 fetchers::runs::fetch(&cli, &run_id).await
1635 } else {
1636 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
1637 };
1638 let _ = tx.send(RunUpdate::Detail(data, live));
1639 });
1640 }
1641
1642 pub fn run_toggle_raw(&mut self) {
1643 if let Some(rv) = &mut self.run_view {
1644 rv.show_raw = !rv.show_raw;
1645 rv.scroll = 0;
1646 }
1647 }
1648
1649 pub fn run_scroll(&mut self, delta: i32) {
1650 if let Some(rv) = &mut self.run_view {
1651 rv.scroll = if delta < 0 {
1652 rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
1653 } else {
1654 rv.scroll.saturating_add(delta as u16)
1655 };
1656 }
1657 }
1658
1659 pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1662 if let Some(rx) = &mut self.run_rx {
1663 match rx.try_recv() {
1664 Ok(update) => {
1665 self.run_rx = None;
1666 if let Some(rv) = &mut self.run_view {
1667 match update {
1668 RunUpdate::Opened(Ok((runs, data, live))) => {
1669 rv.runs = runs;
1670 rv.idx = 0;
1671 rv.data = Some(data);
1672 rv.live = live;
1673 }
1674 RunUpdate::Opened(Err(e)) => {
1675 rv.data = Some(DetailData {
1676 summary: Vec::new(),
1677 activity: Vec::new(),
1678 raw: format!("✗ {e}"),
1679 });
1680 rv.live = false;
1681 }
1682 RunUpdate::Detail(data, live) => {
1683 rv.data = Some(data);
1684 rv.live = live;
1685 }
1686 }
1687 rv.fetched_at = Instant::now();
1688 }
1689 true
1690 }
1691 Err(oneshot::error::TryRecvError::Empty) => false,
1692 Err(oneshot::error::TryRecvError::Closed) => {
1693 self.run_rx = None;
1694 true
1695 }
1696 }
1697 } else if let Some(rv) = &self.run_view {
1698 if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
1699 if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
1700 self.start_run_fetch(cli, run_id);
1701 }
1702 }
1703 false
1704 } else {
1705 false
1706 }
1707 }
1708
1709 pub fn close_detail(&mut self) {
1710 self.detail = None;
1711 self.detail_rx = None;
1712 }
1713
1714 pub fn toggle_raw(&mut self) {
1715 if let Some(d) = &mut self.detail {
1716 d.show_raw = !d.show_raw;
1717 d.scroll = 0;
1718 }
1719 }
1720
1721 pub fn poll_detail(&mut self) -> bool {
1723 let Some(rx) = &mut self.detail_rx else {
1724 return false;
1725 };
1726 match rx.try_recv() {
1727 Ok(data) => {
1728 if let Some(d) = &mut self.detail {
1729 d.data = Some(data);
1730 }
1731 self.detail_rx = None;
1732 true
1733 }
1734 Err(oneshot::error::TryRecvError::Empty) => false,
1735 Err(oneshot::error::TryRecvError::Closed) => {
1736 self.detail_rx = None;
1737 true
1738 }
1739 }
1740 }
1741
1742 pub fn detail_scroll(&mut self, delta: i32) {
1743 if let Some(d) = &mut self.detail {
1744 let max = match &d.data {
1745 Some(data) if d.show_raw => data.raw.lines().count(),
1746 Some(data) => data.summary.len() + data.activity.len() + 3,
1747 None => 0,
1748 } as u16;
1749 d.scroll = if delta < 0 {
1750 d.scroll.saturating_sub(delta.unsigned_abs() as u16)
1751 } else {
1752 (d.scroll + delta as u16).min(max.saturating_sub(1))
1753 };
1754 }
1755 }
1756
1757 pub fn request_action(&mut self) {
1760 if matches!(self.focus, Panel::Dashboards | Panel::Catalog) {
1762 return;
1763 }
1764 let Some(item) = self.selected_item() else {
1765 return;
1766 };
1767 let Some(id) = item.id.clone() else {
1768 return;
1769 };
1770 let name = item.name.clone();
1771 let active = matches!(
1772 item.status,
1773 Status::Running | Status::Pending | Status::Success
1774 );
1775 let group = self.focus.cli_group();
1776 let (verb, action): (&str, &str) = match self.focus {
1777 Panel::Jobs => ("Run", "run-now"),
1778 Panel::Clusters if active => ("Stop", "delete"),
1779 Panel::Pipelines if active => ("Stop", "stop"),
1780 Panel::Pipelines => ("Start update for", "start-update"),
1781 _ if active => ("Stop", "stop"),
1782 _ => ("Start", "start"),
1783 };
1784 self.confirm = Some(Confirm {
1785 message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
1786 args: vec![group.to_string(), action.to_string(), id],
1787 });
1788 }
1789
1790 pub fn cancel_confirm(&mut self) {
1791 self.confirm = None;
1792 }
1793
1794 pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
1795 let Some(c) = self.confirm.take() else {
1796 return;
1797 };
1798 let base = c.message.trim_end_matches('?').to_string();
1799 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
1800
1801 let (tx, rx) = oneshot::channel();
1802 self.action_rx = Some(rx);
1803 let cli = Arc::clone(cli);
1804 tokio::spawn(async move {
1805 let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
1806 let result = match cli.run_action(&args).await {
1807 Ok(()) => Ok(format!("✓ {base} — done")),
1808 Err(e) => Err(format!("✗ {e:#}")),
1809 };
1810 let _ = tx.send(result);
1811 });
1812 }
1813
1814 pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1816 let Some(rx) = &mut self.action_rx else {
1817 return false;
1818 };
1819 match rx.try_recv() {
1820 Ok(result) => {
1821 let ok = result.is_ok();
1822 self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
1823 self.action_rx = None;
1824 if ok {
1825 self.start_refresh(cli);
1826 }
1827 true
1828 }
1829 Err(oneshot::error::TryRecvError::Empty) => false,
1830 Err(oneshot::error::TryRecvError::Closed) => {
1831 self.action_rx = None;
1832 true
1833 }
1834 }
1835 }
1836
1837 pub fn expire_flash(&mut self) -> bool {
1839 if let Some((_, since)) = &self.flash {
1840 if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
1841 self.flash = None;
1842 return true;
1843 }
1844 }
1845 false
1846 }
1847
1848 pub fn open_in_browser(&self) {
1850 let Some(host) = &self.host else {
1851 return;
1852 };
1853 let (panel, id) = match &self.detail {
1854 Some(d) => (d.panel, Some(d.id.clone())),
1855 None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
1856 };
1857 let Some(id) = id else {
1858 return;
1859 };
1860 let path = match panel {
1861 Panel::Clusters => format!("compute/clusters/{id}"),
1862 Panel::Jobs => format!("jobs/{id}"),
1863 Panel::Pipelines => format!("pipelines/{id}"),
1864 Panel::Warehouses => format!("sql/warehouses/{id}"),
1865 Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
1866 Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
1867 };
1868 let url = format!("{}/{}", host.trim_end_matches('/'), path);
1869 #[cfg(target_os = "macos")]
1870 let opener = "open";
1871 #[cfg(not(target_os = "macos"))]
1872 let opener = "xdg-open";
1873 let _ = std::process::Command::new(opener).arg(url).spawn();
1874 }
1875
1876 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
1878 let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
1879 for shape in self.shapes.iter().flatten() {
1880 if let Shape::List(items) = shape {
1881 for item in items {
1882 match item.status {
1883 Status::Running | Status::Success => ok += 1,
1884 Status::Pending => pending += 1,
1885 Status::Failed => failed += 1,
1886 Status::Stopped => idle += 1,
1887 Status::Unknown(_) => {}
1888 }
1889 }
1890 }
1891 }
1892 (ok, pending, failed, idle)
1893 }
1894
1895 pub fn last_refresh_age(&self) -> Duration {
1896 self.last_refresh.elapsed()
1897 }
1898
1899 pub fn spinner(&self) -> &'static str {
1900 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
1901 }
1902
1903 pub fn spinner_frame(&self) -> usize {
1904 self.spinner_frame
1905 }
1906
1907 pub fn busy(&self) -> bool {
1910 self.loading
1911 || self.detail_rx.is_some()
1912 || self.action_rx.is_some()
1913 || self.preview_rx.is_some()
1914 || self.cost_rx.is_some()
1915 || self.sql_rx.is_some()
1916 || self.run_rx.is_some()
1917 }
1918
1919 pub fn tick_spinner(&mut self) {
1920 self.spinner_frame = self.spinner_frame.wrapping_add(1);
1921 }
1922
1923 pub fn toggle_zoom(&mut self) {
1924 self.zoomed = !self.zoomed;
1925 }
1926
1927 pub fn focus_next(&mut self) {
1928 let idx = Panel::ALL
1929 .iter()
1930 .position(|p| p == &self.focus)
1931 .unwrap_or(0);
1932 self.focus = Panel::ALL[(idx + 1) % Panel::ALL.len()];
1933 }
1934
1935 pub fn focus_prev(&mut self) {
1936 let idx = Panel::ALL
1937 .iter()
1938 .position(|p| p == &self.focus)
1939 .unwrap_or(0);
1940 self.focus = Panel::ALL[(idx + Panel::ALL.len() - 1) % Panel::ALL.len()];
1941 }
1942
1943 pub fn needs_refresh(&self) -> bool {
1944 !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
1945 }
1946
1947 pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
1948 if self.loading {
1949 return;
1950 }
1951 self.loading = true;
1952 self.error = None;
1953 self.last_refresh = Instant::now();
1954
1955 let (tx, rx) = mpsc::unbounded_channel();
1956 self.pending = Some(rx);
1957 self.in_flight = 7;
1958
1959 macro_rules! spawn_fetch {
1962 ($update:expr, $fetch:path) => {{
1963 let cli = Arc::clone(cli);
1964 let tx = tx.clone();
1965 tokio::spawn(async move {
1966 let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
1967 let _ = tx.send($update(result));
1968 });
1969 }};
1970 }
1971
1972 spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
1973 spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
1974 spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
1975 spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
1976 spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
1977 spawn_fetch!(
1978 |s: Result<Shape, String>| Update::Badge(s.ok()),
1979 fetchers::current_user::fetch
1980 );
1981 {
1982 let cli = Arc::clone(cli);
1983 let tx = tx.clone();
1984 let path = self.uc_path.clone();
1985 tokio::spawn(async move {
1986 let result = fetchers::catalog::fetch(&cli, &path)
1987 .await
1988 .map_err(|e| format!("{e:#}"));
1989 let _ = tx.send(Update::Panel(5, result));
1990 });
1991 }
1992 }
1993
1994 pub fn poll_refresh(&mut self) -> bool {
1996 let Some(rx) = &mut self.pending else {
1997 return false;
1998 };
1999 let mut changed = false;
2000 loop {
2001 match rx.try_recv() {
2002 Ok(Update::Panel(i, result)) => {
2003 match result {
2004 Ok(mut shape) => {
2005 if i != 5 {
2009 if let Shape::List(items) = &mut shape {
2010 items.sort_by_key(|it| {
2011 (it.status.rank(), it.history.is_empty())
2012 });
2013 }
2014 }
2015 self.shapes[i] = Some(shape);
2016 self.updated_at[i] = Some(Instant::now());
2017 }
2018 Err(e) => {
2021 if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
2022 self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
2023 }
2024 }
2025 }
2026 self.in_flight -= 1;
2027 changed = true;
2028 }
2029 Ok(Update::Badge(badge)) => {
2030 if badge.is_some() {
2031 self.user_badge = badge;
2032 }
2033 self.in_flight -= 1;
2034 changed = true;
2035 }
2036 Err(mpsc::error::TryRecvError::Empty) => break,
2037 Err(mpsc::error::TryRecvError::Disconnected) => {
2038 self.in_flight = 0;
2039 break;
2040 }
2041 }
2042 }
2043 if self.in_flight == 0 {
2044 self.loading = false;
2045 self.pending = None;
2046 changed = true;
2047 }
2048 changed
2049 }
2050}