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 id(&self) -> &'static str {
114 match self {
115 Panel::Clusters => "compute",
116 Panel::Jobs => "jobs",
117 Panel::Pipelines => "pipelines",
118 Panel::Warehouses => "warehouses",
119 Panel::Dashboards => "dashboards",
120 Panel::Catalog => "catalog",
121 }
122 }
123
124 pub fn cli_group(&self) -> &'static str {
126 match self {
127 Panel::Clusters => "clusters",
128 Panel::Jobs => "jobs",
129 Panel::Pipelines => "pipelines",
130 Panel::Warehouses => "warehouses",
131 Panel::Dashboards => "lakeview",
132 Panel::Catalog => "tables",
133 }
134 }
135}
136
137pub struct Detail {
138 pub panel: Panel,
139 pub name: String,
140 pub id: String,
141 pub kind: Option<String>,
143 pub section: &'static str,
145 pub data: Option<DetailData>,
147 pub show_raw: bool,
149 pub scroll: u16,
150}
151
152pub struct Preview {
154 pub name: String,
155 pub warehouse: String,
157 pub warehouse_id: String,
158 pub data: Option<Result<crate::shape::TableData, String>>,
160 pub scroll: usize,
161}
162
163enum PickTarget {
165 Preview(String),
166 Cost,
167 Lineage(String),
168 Sql(String),
169}
170
171pub struct SqlConsole {
173 pub input: String,
174 pub cursor: usize,
176 pub warehouse: String,
178 pub running: bool,
179 pub data: Option<Result<crate::shape::TableData, String>>,
180 pub last_sql: String,
182 pub scroll: usize,
183}
184
185fn history_path() -> Option<std::path::PathBuf> {
187 let home = std::env::var_os("HOME")?;
188 Some(
189 std::path::PathBuf::from(home)
190 .join(".config")
191 .join("databricks-tui")
192 .join("history"),
193 )
194}
195
196fn load_history() -> Vec<String> {
197 history_path()
198 .and_then(|p| std::fs::read_to_string(p).ok())
199 .map(|s| {
200 s.lines()
201 .filter(|l| !l.trim().is_empty())
202 .map(str::to_string)
203 .collect()
204 })
205 .unwrap_or_default()
206}
207
208fn save_history(history: &[String]) {
209 let Some(path) = history_path() else {
210 return;
211 };
212 if let Some(dir) = path.parent() {
213 let _ = std::fs::create_dir_all(dir);
214 crate::config::restrict(dir, 0o700);
215 }
216 let tail: Vec<&str> = history
218 .iter()
219 .rev()
220 .take(200)
221 .rev()
222 .map(String::as_str)
223 .collect();
224 let _ = std::fs::write(&path, tail.join("\n") + "\n");
226 crate::config::restrict(&path, 0o600);
227}
228
229fn subsequence(haystack: &str, needle: &str) -> bool {
231 let mut chars = haystack.chars();
232 needle.chars().all(|n| chars.any(|h| h == n))
233}
234
235fn byte_at(input: &str, cursor: usize) -> usize {
237 input
238 .char_indices()
239 .nth(cursor)
240 .map(|(i, _)| i)
241 .unwrap_or(input.len())
242}
243
244pub struct WhPicker {
246 pub index: usize,
247 target: PickTarget,
248}
249
250pub struct CostView {
252 pub warehouse: String,
253 pub data: Option<Result<fetchers::cost::CostData, String>>,
254}
255
256pub struct RunView {
259 pub panel: Panel,
261 pub owner_name: String,
262 owner_id: String,
264 pub runs: Vec<(String, Status, String)>,
266 pub idx: usize,
268 pub data: Option<DetailData>,
269 pub show_raw: bool,
270 pub scroll: u16,
271 pub live: bool,
273 fetched_at: Instant,
274}
275
276type RunOpened = (Vec<(String, Status, String)>, DetailData, bool);
278
279enum RunUpdate {
280 Opened(Result<RunOpened, String>),
281 Detail(DetailData, bool),
282}
283
284pub struct Problem {
286 pub panel: usize,
287 pub name: String,
288 pub status: Status,
289 pub note: String,
290}
291
292pub struct Problems {
294 pub items: Vec<Problem>,
295 pub index: usize,
296}
297
298pub struct Confirm {
300 pub message: String,
301 args: Vec<String>,
302}
303
304enum Update {
305 Panel(usize, Result<Shape, String>),
306 Badge(Option<Shape>),
307}
308
309const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
310
311pub struct App {
312 pub focus: Panel,
313 pub theme: ThemeMode,
314 pub zoomed: bool,
315 pub shapes: Vec<Option<Shape>>,
316 pub user_badge: Option<Shape>,
317 pub error: Option<String>,
318 pub refresh_interval: Duration,
319 last_refresh: Instant,
320 pub loading: bool,
321 pub detail: Option<Detail>,
322 pub confirm: Option<Confirm>,
323 pub flash: Option<(String, Instant)>,
324 pub selected: [usize; 6],
325 pub host: Option<String>,
326 pub profiles: Vec<String>,
328 pub profile: Option<String>,
329 pub picker: Option<usize>,
331 pub problems: Option<Problems>,
333 pub uc_path: Vec<String>,
335 uc_rx: Option<oneshot::Receiver<Result<Shape, String>>>,
336 pub preview: Option<Preview>,
337 preview_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
338 pub wh_picker: Option<WhPicker>,
339 pub preview_warehouse: Option<(String, String)>,
341 pub cost: Option<CostView>,
342 #[allow(clippy::type_complexity)]
343 cost_rx: Option<oneshot::Receiver<(Result<fetchers::cost::CostData, String>, Option<String>)>>,
344 workspace_id: Option<String>,
347 pub sql: Option<SqlConsole>,
348 sql_rx: Option<oneshot::Receiver<Result<crate::shape::TableData, String>>>,
349 sql_history: Vec<String>,
351 hist_idx: Option<usize>,
353 hist_draft: String,
355 pub hist_search: Option<(String, usize)>,
357 pub run_view: Option<RunView>,
358 run_rx: Option<oneshot::Receiver<RunUpdate>>,
359 pending: Option<mpsc::UnboundedReceiver<Update>>,
360 detail_rx: Option<oneshot::Receiver<DetailData>>,
361 action_rx: Option<oneshot::Receiver<Result<String, String>>>,
362 host_rx: Option<oneshot::Receiver<Option<String>>>,
363 in_flight: usize,
364 spinner_frame: usize,
365 pub splash_until: Option<Instant>,
367 pub updated_at: [Option<Instant>; 6],
369 pub filters: [String; 6],
371 pub filter_entry: bool,
373 pub config: crate::config::Config,
375 failed_seen: [Option<std::collections::HashSet<String>>; 6],
378 pub jump: Option<Jump>,
380 pub pane_order: Vec<usize>,
382 pub hidden: [bool; 6],
384 pub pane_cfg: Option<usize>,
386 pub help: bool,
388 pub help_scroll: u16,
390 sql_stmt: Option<std::sync::Arc<std::sync::Mutex<Option<String>>>>,
392}
393
394pub struct Jump {
396 pub query: String,
397 pub index: usize,
398}
399
400impl App {
401 pub fn new(refresh_secs: u64, theme: ThemeMode) -> Self {
402 let mut app = Self {
403 focus: Panel::Clusters,
404 theme,
405 zoomed: false,
406 shapes: vec![None; 6],
407 user_badge: None,
408 error: None,
409 refresh_interval: Duration::from_secs(refresh_secs),
410 last_refresh: Instant::now()
411 .checked_sub(Duration::from_secs(refresh_secs + 1))
412 .unwrap_or(Instant::now()),
413 loading: false,
414 detail: None,
415 confirm: None,
416 flash: None,
417 selected: [0; 6],
418 host: None,
419 profiles: Vec::new(),
420 profile: None,
421 picker: None,
422 problems: None,
423 uc_path: Vec::new(),
424 uc_rx: None,
425 preview: None,
426 preview_rx: None,
427 wh_picker: None,
428 preview_warehouse: None,
429 cost: None,
430 cost_rx: None,
431 workspace_id: None,
432 sql: None,
433 sql_rx: None,
434 sql_history: load_history(),
435 hist_idx: None,
436 hist_draft: String::new(),
437 hist_search: None,
438 run_view: None,
439 run_rx: None,
440 pending: None,
441 detail_rx: None,
442 action_rx: None,
443 host_rx: None,
444 in_flight: 0,
445 spinner_frame: 0,
446 splash_until: Some(Instant::now() + Duration::from_millis(1600)),
447 updated_at: [None; 6],
448 filters: Default::default(),
449 filter_entry: false,
450 config: crate::config::Config::load(),
451 failed_seen: Default::default(),
452 jump: None,
453 pane_order: (0..6).collect(),
454 hidden: [false; 6],
455 pane_cfg: None,
456 help: false,
457 help_scroll: 0,
458 sql_stmt: None,
459 };
460 app.load_pane_prefs();
461 app
462 }
463
464 fn load_pane_prefs(&mut self) {
466 let idx_of = |id: &str| Panel::ALL.iter().position(|p| p.id() == id);
467 let mut order: Vec<usize> = self
468 .config
469 .pane_order
470 .iter()
471 .filter_map(|id| idx_of(id))
472 .collect();
473 for i in 0..6 {
474 if !order.contains(&i) {
475 order.push(i);
476 }
477 }
478 self.pane_order = order;
479 for id in &self.config.hidden_panes {
480 if let Some(i) = idx_of(id) {
481 self.hidden[i] = true;
482 }
483 }
484 self.ensure_focus_visible();
485 }
486
487 fn persist_panes(&mut self) {
488 self.config.pane_order = self
489 .pane_order
490 .iter()
491 .map(|&i| Panel::ALL[i].id().to_string())
492 .collect();
493 self.config.hidden_panes = (0..6)
494 .filter(|&i| self.hidden[i])
495 .map(|i| Panel::ALL[i].id().to_string())
496 .collect();
497 self.config.save();
498 }
499
500 pub fn visible_panes(&self) -> Vec<usize> {
502 self.pane_order
503 .iter()
504 .copied()
505 .filter(|&i| !self.hidden[i])
506 .collect()
507 }
508
509 fn ensure_focus_visible(&mut self) {
511 let visible = self.visible_panes();
512 let focus_idx = Panel::ALL
513 .iter()
514 .position(|p| p == &self.focus)
515 .unwrap_or(0);
516 if !visible.contains(&focus_idx) {
517 if let Some(&first) = visible.first() {
518 self.focus = Panel::ALL[first];
519 }
520 }
521 }
522
523 fn reveal_pane(&mut self, idx: usize) {
525 if self.hidden[idx] {
526 self.hidden[idx] = false;
527 self.persist_panes();
528 }
529 }
530
531 pub fn open_pane_cfg(&mut self) {
532 self.pane_cfg = Some(0);
533 }
534
535 pub fn pane_cfg_next(&mut self) {
536 if let Some(i) = self.pane_cfg {
537 self.pane_cfg = Some((i + 1).min(5));
538 }
539 }
540
541 pub fn pane_cfg_prev(&mut self) {
542 if let Some(i) = self.pane_cfg {
543 self.pane_cfg = Some(i.saturating_sub(1));
544 }
545 }
546
547 pub fn pane_cfg_toggle(&mut self) {
550 let Some(pos) = self.pane_cfg else {
551 return;
552 };
553 let idx = self.pane_order[pos];
554 if !self.hidden[idx] && self.visible_panes().len() == 1 {
555 self.flash = Some((
556 "✗ at least one pane has to stay visible".to_string(),
557 Instant::now(),
558 ));
559 return;
560 }
561 self.hidden[idx] = !self.hidden[idx];
562 self.ensure_focus_visible();
563 self.persist_panes();
564 }
565
566 pub fn pane_cfg_move(&mut self, delta: i32) {
568 let Some(pos) = self.pane_cfg else {
569 return;
570 };
571 let new = if delta < 0 {
572 pos.saturating_sub(1)
573 } else {
574 (pos + 1).min(5)
575 };
576 if new != pos {
577 self.pane_order.swap(pos, new);
578 self.pane_cfg = Some(new);
579 self.persist_panes();
580 }
581 }
582
583 fn alert_new_failures(&mut self, idx: usize) {
586 if idx == 5 {
588 return;
589 }
590 let Some(Shape::List(items)) = &self.shapes[idx] else {
591 return;
592 };
593 let failed: std::collections::HashSet<String> = items
594 .iter()
595 .filter(|it| {
596 matches!(it.status, Status::Failed)
597 || it
598 .history
599 .last()
600 .is_some_and(|s| matches!(s, Status::Failed))
601 })
602 .map(|it| it.name.clone())
603 .collect();
604 if let Some(prev) = &self.failed_seen[idx] {
605 let mut newly: Vec<&String> = failed.difference(prev).collect();
606 if !newly.is_empty() {
607 newly.sort();
608 let extra = if newly.len() > 1 {
609 format!(" (+{} more)", newly.len() - 1)
610 } else {
611 String::new()
612 };
613 self.flash = Some((
614 format!("✗ {}{extra} just failed — ! to inspect", newly[0]),
615 Instant::now(),
616 ));
617 print!("\x07");
619 let _ = std::io::Write::flush(&mut std::io::stdout());
620 }
621 }
622 self.failed_seen[idx] = Some(failed);
623 }
624
625 pub fn persist_theme(&mut self) {
627 self.config.theme = Some(self.theme.id().to_string());
628 self.config.save();
629 }
630
631 pub fn restore_warehouse_pref(&mut self) {
633 let profile = self.profile.as_deref().unwrap_or("DEFAULT");
634 self.preview_warehouse = self.config.warehouses.get(profile).cloned();
635 }
636
637 pub fn splash_active(&self) -> bool {
638 self.splash_until
639 .map(|t| Instant::now() < t)
640 .unwrap_or(false)
641 }
642
643 pub fn dismiss_splash(&mut self) {
644 self.splash_until = None;
645 }
646
647 pub fn any_fresh(&self) -> bool {
649 self.updated_at
650 .iter()
651 .flatten()
652 .any(|t| t.elapsed() < Duration::from_millis(1200))
653 }
654
655 pub fn open_picker(&mut self) {
656 if self.profiles.is_empty() {
657 return;
658 }
659 let current = self
660 .profile
661 .as_deref()
662 .and_then(|p| self.profiles.iter().position(|n| n == p))
663 .unwrap_or(0);
664 self.picker = Some(current);
665 }
666
667 pub fn picker_next(&mut self) {
668 if let Some(i) = self.picker {
669 self.picker = Some((i + 1).min(self.profiles.len().saturating_sub(1)));
670 }
671 }
672
673 pub fn picker_prev(&mut self) {
674 if let Some(i) = self.picker {
675 self.picker = Some(i.saturating_sub(1));
676 }
677 }
678
679 pub fn picker_select(&mut self) -> Option<Arc<DatabricksCli>> {
681 let idx = self.picker.take()?;
682 let name = self.profiles.get(idx)?.clone();
683 let profile_arg = if name == "DEFAULT" {
684 None
685 } else {
686 Some(name.clone())
687 };
688 self.profile = Some(name);
689
690 self.shapes = vec![None; 6];
692 self.user_badge = None;
693 self.host = None;
694 self.selected = [0; 6];
695 self.detail = None;
696 self.detail_rx = None;
697 self.confirm = None;
698 self.problems = None;
699 self.uc_path.clear();
700 self.uc_rx = None;
701 self.preview = None;
702 self.preview_rx = None;
703 self.wh_picker = None;
704 self.preview_warehouse = None;
705 self.cost = None;
706 self.cost_rx = None;
707 self.workspace_id = None;
708 self.sql = None;
709 self.sql_rx = None;
710 self.run_view = None;
711 self.run_rx = None;
712 self.pending = None;
713 self.in_flight = 0;
714 self.loading = false;
715 self.zoomed = false;
716 self.filters = Default::default();
717 self.filter_entry = false;
718 self.failed_seen = Default::default();
719 self.jump = None;
720 self.sql_stmt = None;
721 self.restore_warehouse_pref();
722
723 Some(Arc::new(DatabricksCli::new(profile_arg)))
724 }
725
726 pub fn open_jump(&mut self) {
727 self.jump = Some(Jump {
728 query: String::new(),
729 index: 0,
730 });
731 }
732
733 pub fn jump_matches(&self) -> Vec<(usize, String, String)> {
737 let Some(jump) = &self.jump else {
738 return Vec::new();
739 };
740 let q = jump.query.to_lowercase();
741 let mut scored: Vec<(u8, usize, String, String)> = Vec::new();
742 for (i, shape) in self.shapes.iter().enumerate() {
743 let Some(Shape::List(items)) = shape else {
744 continue;
745 };
746 for it in items {
747 let name = it.name.to_lowercase();
748 let rank = if q.is_empty() || name.contains(&q) {
749 0
750 } else if subsequence(&name, &q) {
751 1
752 } else {
753 continue;
754 };
755 scored.push((rank, i, it.name.clone(), it.status.label().to_string()));
756 }
757 }
758 scored.sort_by(|a, b| (a.0, a.2.len(), &a.2).cmp(&(b.0, b.2.len(), &b.2)));
759 scored
760 .into_iter()
761 .take(12)
762 .map(|(_, i, name, label)| (i, name, label))
763 .collect()
764 }
765
766 pub fn jump_push(&mut self, c: char) {
767 if let Some(j) = &mut self.jump {
768 j.query.push(c);
769 j.index = 0;
770 }
771 }
772
773 pub fn jump_pop(&mut self) {
774 if let Some(j) = &mut self.jump {
775 j.query.pop();
776 j.index = 0;
777 }
778 }
779
780 pub fn jump_next(&mut self) {
781 let len = self.jump_matches().len();
782 if let Some(j) = &mut self.jump {
783 j.index = (j.index + 1).min(len.saturating_sub(1));
784 }
785 }
786
787 pub fn jump_prev(&mut self) {
788 if let Some(j) = &mut self.jump {
789 j.index = j.index.saturating_sub(1);
790 }
791 }
792
793 pub fn jump_go(&mut self) {
795 let matches = self.jump_matches();
796 let Some(jump) = self.jump.take() else {
797 return;
798 };
799 let Some((panel_idx, name, _)) = matches.get(jump.index) else {
800 return;
801 };
802 self.reveal_pane(*panel_idx);
803 self.focus = Panel::ALL[*panel_idx];
804 self.filters[*panel_idx].clear();
805 if let Some(Shape::List(items)) = &self.shapes[*panel_idx] {
806 if let Some(pos) = items.iter().position(|i| &i.name == name) {
807 self.selected[*panel_idx] = pos;
808 }
809 }
810 }
811
812 pub fn open_problems(&mut self) {
815 let mut items = Vec::new();
816 for (i, shape) in self.shapes.iter().enumerate() {
817 let Some(Shape::List(list)) = shape else {
818 continue;
819 };
820 for it in list {
821 let failed_now = matches!(it.status, Status::Failed);
822 let failed_last = it
823 .history
824 .last()
825 .is_some_and(|s| matches!(s, Status::Failed));
826 if failed_now || failed_last {
827 let note = if failed_now {
828 it.detail.clone().unwrap_or_default()
829 } else {
830 "latest run failed".to_string()
831 };
832 items.push(Problem {
833 panel: i,
834 name: it.name.clone(),
835 status: it.status.clone(),
836 note,
837 });
838 }
839 }
840 }
841 self.problems = Some(Problems { items, index: 0 });
842 }
843
844 pub fn problems_next(&mut self) {
845 if let Some(pr) = &mut self.problems {
846 pr.index = (pr.index + 1).min(pr.items.len().saturating_sub(1));
847 }
848 }
849
850 pub fn problems_prev(&mut self) {
851 if let Some(pr) = &mut self.problems {
852 pr.index = pr.index.saturating_sub(1);
853 }
854 }
855
856 pub fn problems_jump(&mut self) {
858 let Some(pr) = self.problems.take() else {
859 return;
860 };
861 let Some(problem) = pr.items.get(pr.index) else {
862 return;
863 };
864 self.reveal_pane(problem.panel);
865 self.focus = Panel::ALL[problem.panel];
866 self.filters[problem.panel].clear();
868 if let Some(Shape::List(list)) = &self.shapes[problem.panel] {
869 if let Some(pos) = list.iter().position(|i| i.name == problem.name) {
870 self.selected[problem.panel] = pos;
871 }
872 }
873 }
874
875 pub fn fetch_host(&mut self, cli: &Arc<DatabricksCli>) {
878 let (tx, rx) = oneshot::channel();
879 self.host_rx = Some(rx);
880 let cli = Arc::clone(cli);
881 tokio::spawn(async move {
882 let host = cli.run(&["auth", "describe"]).await.ok().and_then(|json| {
883 json["details"]["host"]
884 .as_str()
885 .or_else(|| json["host"].as_str())
886 .map(str::to_string)
887 });
888 let _ = tx.send(host);
889 });
890 }
891
892 pub fn poll_host(&mut self) {
893 if let Some(rx) = &mut self.host_rx {
894 match rx.try_recv() {
895 Ok(host) => {
896 self.host = host;
897 self.host_rx = None;
898 }
899 Err(oneshot::error::TryRecvError::Empty) => {}
900 Err(oneshot::error::TryRecvError::Closed) => {
901 self.host_rx = None;
902 }
903 }
904 }
905 }
906
907 fn focus_index(&self) -> usize {
908 Panel::ALL
909 .iter()
910 .position(|p| p == &self.focus)
911 .unwrap_or(0)
912 }
913
914 fn list_len(&self, idx: usize) -> usize {
915 match &self.shapes[idx] {
916 Some(Shape::List(items)) => items
917 .iter()
918 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
919 .count(),
920 _ => 0,
921 }
922 }
923
924 pub fn selection(&self, idx: usize) -> usize {
926 self.selected[idx].min(self.list_len(idx).saturating_sub(1))
927 }
928
929 pub fn select_next(&mut self) {
930 let idx = self.focus_index();
931 let len = self.list_len(idx);
932 if len > 0 {
933 self.selected[idx] = (self.selection(idx) + 1).min(len - 1);
934 }
935 }
936
937 pub fn select_prev(&mut self) {
938 let idx = self.focus_index();
939 self.selected[idx] = self.selection(idx).saturating_sub(1);
940 }
941
942 fn selected_item(&self) -> Option<&crate::shape::ListItem> {
945 let idx = self.focus_index();
946 match &self.shapes[idx] {
947 Some(Shape::List(items)) => items
948 .iter()
949 .filter(|it| crate::shape::item_matches(it, &self.filters[idx]))
950 .nth(self.selection(idx)),
951 _ => None,
952 }
953 }
954
955 pub fn filter_start(&mut self) {
957 let idx = self.focus_index();
958 self.filters[idx].clear();
959 self.selected[idx] = 0;
960 self.filter_entry = true;
961 }
962
963 pub fn filter_push(&mut self, c: char) {
964 let idx = self.focus_index();
965 self.filters[idx].push(c);
966 self.selected[idx] = 0;
967 }
968
969 pub fn filter_pop(&mut self) {
970 let idx = self.focus_index();
971 self.filters[idx].pop();
972 self.selected[idx] = 0;
973 }
974
975 pub fn filter_accept(&mut self) {
977 self.filter_entry = false;
978 }
979
980 pub fn filter_clear(&mut self) {
981 let idx = self.focus_index();
982 self.filters[idx].clear();
983 self.selected[idx] = 0;
984 self.filter_entry = false;
985 }
986
987 pub fn active_filter(&self) -> &str {
989 &self.filters[self.focus_index()]
990 }
991
992 pub fn open_detail(&mut self, cli: &Arc<DatabricksCli>) {
993 let Some(item) = self.selected_item() else {
994 return;
995 };
996 let Some(id) = item.id.clone() else {
997 return;
998 };
999 let kind = match &item.status {
1000 Status::Unknown(k) if !k.is_empty() => Some(k.clone()),
1001 _ => None,
1002 };
1003 let section = match self.focus {
1004 Panel::Dashboards => "Contents",
1005 Panel::Catalog => "Columns",
1006 Panel::Warehouses => "Recent queries",
1007 _ => "Recent activity",
1008 };
1009 self.detail = Some(Detail {
1010 panel: self.focus,
1011 name: item.name.clone(),
1012 id: id.clone(),
1013 kind,
1014 section,
1015 data: None,
1016 show_raw: false,
1017 scroll: 0,
1018 });
1019
1020 let (tx, rx) = oneshot::channel();
1021 self.detail_rx = Some(rx);
1022 let cli = Arc::clone(cli);
1023 let kind = self.detail.as_ref().unwrap().kind.clone();
1024 if kind.as_deref() == Some("FILE") {
1026 if let Some(d) = &mut self.detail {
1027 d.section = "File head";
1028 }
1029 tokio::spawn(async move {
1030 let data = fetchers::catalog::file_peek(&cli, &id).await;
1031 let _ = tx.send(data);
1032 });
1033 return;
1034 }
1035 let group = match &kind {
1036 Some(k) if k == "VOLUME" => "volumes",
1037 _ => self.focus.cli_group(),
1038 };
1039 let warehouse = match &kind {
1042 Some(k) if k == "TABLE" => self.preview_warehouse.clone().map(|(id, _)| id),
1043 _ => None,
1044 };
1045 tokio::spawn(async move {
1046 let data = fetchers::detail::fetch(&cli, group, &id, warehouse.as_deref()).await;
1047 let _ = tx.send(data);
1048 });
1049 }
1050
1051 pub fn uc_drill(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1054 if self.focus != Panel::Catalog {
1055 return false;
1056 }
1057 let Some(item) = self.selected_item() else {
1058 return self.uc_path.is_empty(); };
1060 if self.uc_path.len() >= 2 {
1063 let drillable =
1064 matches!(&item.status, Status::Unknown(k) if k == "VOLUME" || k == "DIR");
1065 if !drillable {
1066 return false;
1067 }
1068 }
1069 self.uc_path.push(item.name.clone());
1070 self.refresh_catalog(cli);
1071 true
1072 }
1073
1074 pub fn uc_up(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1076 if self.focus != Panel::Catalog || self.uc_path.is_empty() {
1077 return false;
1078 }
1079 self.uc_path.pop();
1080 self.refresh_catalog(cli);
1081 true
1082 }
1083
1084 fn refresh_catalog(&mut self, cli: &Arc<DatabricksCli>) {
1085 self.shapes[5] = None;
1086 self.selected[5] = 0;
1087 self.filters[5].clear();
1089 let (tx, rx) = oneshot::channel();
1090 self.uc_rx = Some(rx);
1091 let cli = Arc::clone(cli);
1092 let path = self.uc_path.clone();
1093 tokio::spawn(async move {
1094 let result = fetchers::catalog::fetch(&cli, &path)
1095 .await
1096 .map_err(|e| format!("{e:#}"));
1097 let _ = tx.send(result);
1098 });
1099 }
1100
1101 pub fn resource_name(&self, kind: &str, id: &str) -> Option<String> {
1104 let idx = match kind {
1105 "cluster" => 0,
1106 "job" => 1,
1107 "warehouse" => 3,
1108 _ => return None,
1109 };
1110 match &self.shapes[idx] {
1111 Some(Shape::List(items)) => items
1112 .iter()
1113 .find(|i| i.id.as_deref() == Some(id))
1114 .map(|i| i.name.clone()),
1115 _ => None,
1116 }
1117 }
1118
1119 pub fn warehouses(&self) -> Vec<(String, String, bool)> {
1121 let Some(Shape::List(items)) = &self.shapes[3] else {
1122 return Vec::new();
1123 };
1124 items
1125 .iter()
1126 .filter_map(|i| {
1127 let id = i.id.clone()?;
1128 Some((i.name.clone(), id, matches!(i.status, Status::Running)))
1129 })
1130 .collect()
1131 }
1132
1133 pub fn open_preview(&mut self, cli: &Arc<DatabricksCli>, force_pick: bool) {
1137 if self.focus != Panel::Catalog {
1138 return;
1139 }
1140 let Some(item) = self.selected_item() else {
1141 return;
1142 };
1143 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1144 return;
1145 }
1146 let Some(full_name) = item.id.clone() else {
1147 return;
1148 };
1149 let warehouses = self.warehouses();
1150 if warehouses.is_empty() {
1151 self.flash = Some((
1152 "✗ no SQL warehouse available for previews".to_string(),
1153 Instant::now(),
1154 ));
1155 return;
1156 }
1157 if !force_pick {
1158 if let Some((id, name)) = self.preview_warehouse.clone() {
1159 self.start_preview_query(cli, full_name, id, name);
1160 return;
1161 }
1162 if let [(name, id, _)] = warehouses.as_slice() {
1163 self.preview_warehouse = Some((id.clone(), name.clone()));
1164 self.start_preview_query(cli, full_name, id.clone(), name.clone());
1165 return;
1166 }
1167 }
1168 let index = self
1170 .preview_warehouse
1171 .as_ref()
1172 .and_then(|(id, _)| warehouses.iter().position(|(_, wid, _)| wid == id))
1173 .or_else(|| warehouses.iter().position(|(_, _, running)| *running))
1174 .unwrap_or(0);
1175 self.wh_picker = Some(WhPicker {
1176 index,
1177 target: PickTarget::Preview(full_name),
1178 });
1179 }
1180
1181 pub fn open_cost(&mut self, cli: &Arc<DatabricksCli>) {
1183 let warehouses = self.warehouses();
1184 if warehouses.is_empty() {
1185 self.flash = Some((
1186 "✗ no SQL warehouse available to query system tables".to_string(),
1187 Instant::now(),
1188 ));
1189 return;
1190 }
1191 if let Some((id, name)) = self.preview_warehouse.clone() {
1192 self.start_cost_query(cli, id, name);
1193 return;
1194 }
1195 if let [(name, id, _)] = warehouses.as_slice() {
1196 self.preview_warehouse = Some((id.clone(), name.clone()));
1197 self.start_cost_query(cli, id.clone(), name.clone());
1198 return;
1199 }
1200 let index = warehouses
1201 .iter()
1202 .position(|(_, _, running)| *running)
1203 .unwrap_or(0);
1204 self.wh_picker = Some(WhPicker {
1205 index,
1206 target: PickTarget::Cost,
1207 });
1208 }
1209
1210 fn start_cost_query(&mut self, cli: &Arc<DatabricksCli>, id: String, name: String) {
1211 self.cost = Some(CostView {
1212 warehouse: name,
1213 data: None,
1214 });
1215 let (tx, rx) = oneshot::channel();
1216 self.cost_rx = Some(rx);
1217 let cli = Arc::clone(cli);
1218 let host = self.host.clone();
1219 let cached_ws = self.workspace_id.clone();
1220 tokio::spawn(async move {
1221 let ws = match (cached_ws, host) {
1223 (Some(w), _) => Some(w),
1224 (None, Some(h)) => fetchers::cost::resolve_workspace_id(&cli, &id, &h).await,
1225 (None, None) => None,
1226 };
1227 let result = fetchers::cost::fetch(&cli, &id, ws.as_deref()).await;
1228 let _ = tx.send((result, ws));
1229 });
1230 }
1231
1232 pub fn open_lineage(&mut self, cli: &Arc<DatabricksCli>) {
1235 if self.focus != Panel::Catalog {
1236 return;
1237 }
1238 let Some(item) = self.selected_item() else {
1239 return;
1240 };
1241 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1242 return;
1243 }
1244 let Some(full_name) = item.id.clone() else {
1245 return;
1246 };
1247 let warehouses = self.warehouses();
1248 if warehouses.is_empty() {
1249 self.flash = Some((
1250 "✗ no SQL warehouse available to query lineage".to_string(),
1251 Instant::now(),
1252 ));
1253 return;
1254 }
1255 if let Some((id, _)) = self.preview_warehouse.clone() {
1256 self.start_lineage_query(cli, full_name, id);
1257 return;
1258 }
1259 if let [(name, id, _)] = warehouses.as_slice() {
1260 self.preview_warehouse = Some((id.clone(), name.clone()));
1261 let id = id.clone();
1262 self.start_lineage_query(cli, full_name, id);
1263 return;
1264 }
1265 let index = warehouses
1266 .iter()
1267 .position(|(_, _, running)| *running)
1268 .unwrap_or(0);
1269 self.wh_picker = Some(WhPicker {
1270 index,
1271 target: PickTarget::Lineage(full_name),
1272 });
1273 }
1274
1275 fn start_lineage_query(&mut self, cli: &Arc<DatabricksCli>, full_name: String, wh_id: String) {
1276 self.detail = Some(Detail {
1277 panel: Panel::Catalog,
1278 name: full_name.clone(),
1279 id: full_name.clone(),
1280 kind: None,
1281 section: "Lineage",
1282 data: None,
1283 show_raw: false,
1284 scroll: 0,
1285 });
1286 let (tx, rx) = oneshot::channel();
1287 self.detail_rx = Some(rx);
1288 let cli = Arc::clone(cli);
1289 tokio::spawn(async move {
1290 let data = fetchers::lineage::fetch(&cli, &full_name, &wh_id).await;
1291 let _ = tx.send(data);
1292 });
1293 }
1294
1295 pub fn close_cost(&mut self) {
1296 self.cost = None;
1297 self.cost_rx = None;
1298 }
1299
1300 fn selected_table_fqn(&self) -> Option<String> {
1302 if self.focus != Panel::Catalog {
1303 return None;
1304 }
1305 let item = self.selected_item()?;
1306 if !matches!(&item.status, Status::Unknown(k) if k == "TABLE" || k == "VIEW") {
1307 return None;
1308 }
1309 item.id.clone()
1310 }
1311
1312 pub fn open_sql(&mut self) {
1315 if self.sql.is_none() {
1316 let input = self
1317 .selected_table_fqn()
1318 .map(|fqn| format!("SELECT * FROM {fqn} LIMIT 100"))
1319 .unwrap_or_default();
1320 self.sql = Some(SqlConsole {
1321 cursor: input.chars().count(),
1322 input,
1323 warehouse: String::new(),
1324 running: false,
1325 data: None,
1326 last_sql: String::new(),
1327 scroll: 0,
1328 });
1329 }
1330 }
1331
1332 pub fn close_sql(&mut self) {
1333 self.sql = None;
1334 self.sql_rx = None;
1335 self.hist_idx = None;
1336 self.hist_draft.clear();
1337 self.hist_search = None;
1338 }
1339
1340 pub fn sql_input(&self) -> Option<String> {
1342 self.sql.as_ref().map(|c| c.input.clone())
1343 }
1344
1345 pub fn sql_set_input(&mut self, s: &str) {
1347 if let Some(console) = &mut self.sql {
1348 console.input = s.to_string();
1349 console.cursor = console.input.chars().count();
1350 }
1351 }
1352
1353 pub fn hist_search_current(&self) -> Option<&String> {
1355 let (query, nth) = self.hist_search.as_ref()?;
1356 self.sql_history
1357 .iter()
1358 .rev()
1359 .filter(|h| h.to_lowercase().contains(&query.to_lowercase()))
1360 .nth(*nth)
1361 }
1362
1363 pub fn hist_search_start(&mut self) {
1364 if self.sql.is_some() {
1365 self.hist_search = Some((String::new(), 0));
1366 }
1367 }
1368
1369 pub fn hist_search_push(&mut self, c: char) {
1370 if let Some((query, nth)) = &mut self.hist_search {
1371 query.push(c);
1372 *nth = 0;
1373 }
1374 }
1375
1376 pub fn hist_search_pop(&mut self) {
1377 if let Some((query, nth)) = &mut self.hist_search {
1378 query.pop();
1379 *nth = 0;
1380 }
1381 }
1382
1383 pub fn hist_search_older(&mut self) {
1385 let Some((query, nth)) = &self.hist_search else {
1386 return;
1387 };
1388 let q = query.to_lowercase();
1389 let matches = self
1390 .sql_history
1391 .iter()
1392 .filter(|h| h.to_lowercase().contains(&q))
1393 .count();
1394 if nth + 1 < matches {
1395 if let Some((_, n)) = &mut self.hist_search {
1396 *n += 1;
1397 }
1398 }
1399 }
1400
1401 pub fn hist_search_accept(&mut self) {
1402 if let Some(stmt) = self.hist_search_current().cloned() {
1403 self.sql_set_input(&stmt);
1404 }
1405 self.hist_search = None;
1406 }
1407
1408 pub fn hist_search_cancel(&mut self) {
1409 self.hist_search = None;
1410 }
1411
1412 pub fn sql_push(&mut self, c: char) {
1413 if let Some(console) = &mut self.sql {
1414 let at = byte_at(&console.input, console.cursor);
1415 console.input.insert(at, c);
1416 console.cursor += 1;
1417 }
1418 }
1419
1420 pub fn sql_pop(&mut self) {
1422 if let Some(console) = &mut self.sql {
1423 if console.cursor > 0 {
1424 let at = byte_at(&console.input, console.cursor - 1);
1425 console.input.remove(at);
1426 console.cursor -= 1;
1427 }
1428 }
1429 }
1430
1431 pub fn sql_delete(&mut self) {
1433 if let Some(console) = &mut self.sql {
1434 if console.cursor < console.input.chars().count() {
1435 let at = byte_at(&console.input, console.cursor);
1436 console.input.remove(at);
1437 }
1438 }
1439 }
1440
1441 pub fn sql_left(&mut self) {
1442 if let Some(console) = &mut self.sql {
1443 console.cursor = console.cursor.saturating_sub(1);
1444 }
1445 }
1446
1447 pub fn sql_right(&mut self) {
1448 if let Some(console) = &mut self.sql {
1449 console.cursor = (console.cursor + 1).min(console.input.chars().count());
1450 }
1451 }
1452
1453 pub fn sql_hist_prev(&mut self) {
1455 let Some(console) = &mut self.sql else {
1456 return;
1457 };
1458 if self.sql_history.is_empty() {
1459 return;
1460 }
1461 let idx = match self.hist_idx {
1462 None => {
1463 self.hist_draft = console.input.clone();
1464 self.sql_history.len() - 1
1465 }
1466 Some(i) => i.saturating_sub(1),
1467 };
1468 self.hist_idx = Some(idx);
1469 console.input = self.sql_history[idx].clone();
1470 console.cursor = console.input.chars().count();
1471 }
1472
1473 pub fn sql_hist_next(&mut self) {
1475 let Some(console) = &mut self.sql else {
1476 return;
1477 };
1478 let Some(idx) = self.hist_idx else {
1479 return;
1480 };
1481 if idx + 1 < self.sql_history.len() {
1482 self.hist_idx = Some(idx + 1);
1483 console.input = self.sql_history[idx + 1].clone();
1484 } else {
1485 self.hist_idx = None;
1486 console.input = self.hist_draft.clone();
1487 }
1488 console.cursor = console.input.chars().count();
1489 }
1490
1491 pub fn sql_home(&mut self) {
1492 if let Some(console) = &mut self.sql {
1493 console.cursor = 0;
1494 }
1495 }
1496
1497 pub fn sql_end(&mut self) {
1498 if let Some(console) = &mut self.sql {
1499 console.cursor = console.input.chars().count();
1500 }
1501 }
1502
1503 pub fn sql_scroll(&mut self, delta: i32) {
1504 if let Some(console) = &mut self.sql {
1505 let max = match &console.data {
1506 Some(Ok(t)) => t.rows.len().saturating_sub(1),
1507 _ => 0,
1508 };
1509 console.scroll = if delta < 0 {
1510 console.scroll.saturating_sub(delta.unsigned_abs() as usize)
1511 } else {
1512 (console.scroll + delta as usize).min(max)
1513 };
1514 }
1515 }
1516
1517 pub fn sql_run(&mut self, cli: &Arc<DatabricksCli>) {
1519 let Some(console) = &self.sql else {
1520 return;
1521 };
1522 if console.running {
1523 return;
1524 }
1525 let query = console.input.trim().to_string();
1526 if query.is_empty() {
1527 return;
1528 }
1529 if self.sql_history.last() != Some(&query) {
1532 self.sql_history.push(query.clone());
1533 save_history(&self.sql_history);
1534 }
1535 self.hist_idx = None;
1536 self.hist_draft.clear();
1537 let warehouses = self.warehouses();
1538 if warehouses.is_empty() {
1539 self.flash = Some(("✗ no SQL warehouse available".to_string(), Instant::now()));
1540 return;
1541 }
1542 if let Some((id, name)) = self.preview_warehouse.clone() {
1543 self.start_sql_query(cli, query, id, name);
1544 return;
1545 }
1546 if let [(name, id, _)] = warehouses.as_slice() {
1547 self.preview_warehouse = Some((id.clone(), name.clone()));
1548 self.start_sql_query(cli, query, id.clone(), name.clone());
1549 return;
1550 }
1551 let index = warehouses
1552 .iter()
1553 .position(|(_, _, running)| *running)
1554 .unwrap_or(0);
1555 self.wh_picker = Some(WhPicker {
1556 index,
1557 target: PickTarget::Sql(query),
1558 });
1559 }
1560
1561 fn start_sql_query(
1562 &mut self,
1563 cli: &Arc<DatabricksCli>,
1564 query: String,
1565 id: String,
1566 name: String,
1567 ) {
1568 if let Some(console) = &mut self.sql {
1569 console.running = true;
1570 console.warehouse = name;
1571 console.scroll = 0;
1572 console.last_sql = query.clone();
1573 }
1574 let handle = std::sync::Arc::new(std::sync::Mutex::new(None));
1576 self.sql_stmt = Some(std::sync::Arc::clone(&handle));
1577 let (tx, rx) = oneshot::channel();
1578 self.sql_rx = Some(rx);
1579 let cli = Arc::clone(cli);
1580 tokio::spawn(async move {
1581 let result = fetchers::preview::run_sql_tracked(&cli, &query, &id, Some(handle)).await;
1582 let _ = tx.send(result);
1583 });
1584 }
1585
1586 fn export_csv(&mut self, label: &str, data: &crate::shape::TableData) {
1589 let stamp = std::time::SystemTime::now()
1590 .duration_since(std::time::UNIX_EPOCH)
1591 .map(|d| d.as_secs())
1592 .unwrap_or(0);
1593 let slug: String = label
1594 .chars()
1595 .map(|c| if c.is_alphanumeric() { c } else { '-' })
1596 .collect::<String>()
1597 .trim_matches('-')
1598 .chars()
1599 .take(40)
1600 .collect();
1601 let name = format!("databricks-{slug}-{stamp}.csv");
1602 let msg = match std::fs::write(&name, data.to_csv()) {
1603 Ok(()) => {
1604 let cwd = std::env::current_dir()
1605 .map(|d| d.display().to_string())
1606 .unwrap_or_default();
1607 format!("✓ exported {} rows to {cwd}/{name}", data.rows.len())
1608 }
1609 Err(e) => format!("✗ export failed: {e}"),
1610 };
1611 self.flash = Some((msg, Instant::now()));
1612 }
1613
1614 pub fn sql_export(&mut self) {
1616 if let Some(SqlConsole {
1617 data: Some(Ok(data)),
1618 last_sql,
1619 ..
1620 }) = &self.sql
1621 {
1622 let (label, data) = (last_sql.clone(), data.clone());
1623 self.export_csv(&label, &data);
1624 }
1625 }
1626
1627 pub fn preview_export(&mut self) {
1629 if let Some(Preview {
1630 data: Some(Ok(data)),
1631 name,
1632 ..
1633 }) = &self.preview
1634 {
1635 let (label, data) = (name.clone(), data.clone());
1636 self.export_csv(&label, &data);
1637 }
1638 }
1639
1640 pub fn poll_sql(&mut self) -> bool {
1641 let Some(rx) = &mut self.sql_rx else {
1642 return false;
1643 };
1644 match rx.try_recv() {
1645 Ok(result) => {
1646 if let Err(e) = &result {
1649 if e != "statement canceled" {
1650 self.preview_warehouse = None;
1651 }
1652 }
1653 if let Some(console) = &mut self.sql {
1654 console.running = false;
1655 console.data = Some(result);
1656 }
1657 self.sql_rx = None;
1658 self.sql_stmt = None;
1659 true
1660 }
1661 Err(oneshot::error::TryRecvError::Empty) => false,
1662 Err(oneshot::error::TryRecvError::Closed) => {
1663 if let Some(console) = &mut self.sql {
1664 console.running = false;
1665 }
1666 self.sql_rx = None;
1667 true
1668 }
1669 }
1670 }
1671
1672 pub fn poll_cost(&mut self) -> bool {
1673 let Some(rx) = &mut self.cost_rx else {
1674 return false;
1675 };
1676 match rx.try_recv() {
1677 Ok((result, ws)) => {
1678 if result.is_err() {
1679 self.preview_warehouse = None;
1680 }
1681 if ws.is_some() {
1682 self.workspace_id = ws;
1683 }
1684 if let Some(cv) = &mut self.cost {
1685 cv.data = Some(result);
1686 }
1687 self.cost_rx = None;
1688 true
1689 }
1690 Err(oneshot::error::TryRecvError::Empty) => false,
1691 Err(oneshot::error::TryRecvError::Closed) => {
1692 self.cost_rx = None;
1693 true
1694 }
1695 }
1696 }
1697
1698 pub fn wh_picker_next(&mut self) {
1699 let len = self.warehouses().len();
1700 if let Some(p) = &mut self.wh_picker {
1701 p.index = (p.index + 1).min(len.saturating_sub(1));
1702 }
1703 }
1704
1705 pub fn wh_picker_prev(&mut self) {
1706 if let Some(p) = &mut self.wh_picker {
1707 p.index = p.index.saturating_sub(1);
1708 }
1709 }
1710
1711 pub fn wh_picker_cancel(&mut self) {
1712 self.wh_picker = None;
1713 }
1714
1715 pub fn wh_picker_select(&mut self, cli: &Arc<DatabricksCli>) {
1717 let Some(picker) = self.wh_picker.take() else {
1718 return;
1719 };
1720 let warehouses = self.warehouses();
1721 let Some((name, id, _)) = warehouses.get(picker.index) else {
1722 return;
1723 };
1724 self.preview_warehouse = Some((id.clone(), name.clone()));
1725 let profile = self.profile.clone().unwrap_or_else(|| "DEFAULT".into());
1727 self.config
1728 .warehouses
1729 .insert(profile, (id.clone(), name.clone()));
1730 self.config.save();
1731 match picker.target {
1732 PickTarget::Preview(table) => {
1733 self.start_preview_query(cli, table, id.clone(), name.clone())
1734 }
1735 PickTarget::Cost => self.start_cost_query(cli, id.clone(), name.clone()),
1736 PickTarget::Lineage(table) => self.start_lineage_query(cli, table, id.clone()),
1737 PickTarget::Sql(query) => self.start_sql_query(cli, query, id.clone(), name.clone()),
1738 }
1739 }
1740
1741 fn start_preview_query(
1742 &mut self,
1743 cli: &Arc<DatabricksCli>,
1744 full_name: String,
1745 warehouse_id: String,
1746 warehouse_name: String,
1747 ) {
1748 self.preview = Some(Preview {
1749 name: full_name.clone(),
1750 warehouse: warehouse_name,
1751 warehouse_id: warehouse_id.clone(),
1752 data: None,
1753 scroll: 0,
1754 });
1755 let (tx, rx) = oneshot::channel();
1756 self.preview_rx = Some(rx);
1757 let cli = Arc::clone(cli);
1758 tokio::spawn(async move {
1759 let result = fetchers::preview::fetch(&cli, &full_name, &warehouse_id).await;
1760 let _ = tx.send(result);
1761 });
1762 }
1763
1764 pub fn close_preview(&mut self) {
1765 self.preview = None;
1766 self.preview_rx = None;
1767 }
1768
1769 pub fn poll_preview(&mut self) -> bool {
1770 let Some(rx) = &mut self.preview_rx else {
1771 return false;
1772 };
1773 match rx.try_recv() {
1774 Ok(result) => {
1775 if result.is_err() {
1777 self.preview_warehouse = None;
1778 }
1779 if let Some(pv) = &mut self.preview {
1780 pv.data = Some(result);
1781 }
1782 self.preview_rx = None;
1783 true
1784 }
1785 Err(oneshot::error::TryRecvError::Empty) => false,
1786 Err(oneshot::error::TryRecvError::Closed) => {
1787 self.preview_rx = None;
1788 true
1789 }
1790 }
1791 }
1792
1793 pub fn preview_scroll(&mut self, delta: i32) {
1794 if let Some(pv) = &mut self.preview {
1795 let max = match &pv.data {
1796 Some(Ok(t)) => t.rows.len().saturating_sub(1),
1797 _ => 0,
1798 };
1799 pv.scroll = if delta < 0 {
1800 pv.scroll.saturating_sub(delta.unsigned_abs() as usize)
1801 } else {
1802 (pv.scroll + delta as usize).min(max)
1803 };
1804 }
1805 }
1806
1807 pub fn poll_uc(&mut self) -> bool {
1808 let Some(rx) = &mut self.uc_rx else {
1809 return false;
1810 };
1811 match rx.try_recv() {
1812 Ok(result) => {
1813 self.shapes[5] = Some(match result {
1814 Ok(shape) => shape,
1815 Err(e) => Shape::Text(format!("✗ {e}")),
1816 });
1817 self.updated_at[5] = Some(Instant::now());
1818 self.uc_rx = None;
1819 true
1820 }
1821 Err(oneshot::error::TryRecvError::Empty) => false,
1822 Err(oneshot::error::TryRecvError::Closed) => {
1823 self.uc_rx = None;
1824 true
1825 }
1826 }
1827 }
1828
1829 pub fn open_grants(&mut self, cli: &Arc<DatabricksCli>) {
1832 let Some(item) = self.selected_item() else {
1833 return;
1834 };
1835 let Some(id) = item.id.clone() else {
1836 return;
1837 };
1838 let (uc, object_type): (bool, &'static str) = match self.focus {
1839 Panel::Catalog => match &item.status {
1840 Status::Unknown(k) if k == "CATALOG" => (true, "catalog"),
1841 Status::Unknown(k) if k == "SCHEMA" => (true, "schema"),
1842 Status::Unknown(k) if k == "TABLE" || k == "VIEW" => (true, "table"),
1843 Status::Unknown(k) if k == "VOLUME" => (true, "volume"),
1844 _ => return,
1845 },
1846 Panel::Clusters => (false, "clusters"),
1847 Panel::Jobs => (false, "jobs"),
1848 Panel::Pipelines => (false, "pipelines"),
1849 Panel::Warehouses => (false, "warehouses"),
1850 Panel::Dashboards => (false, "dashboards"),
1851 };
1852 self.detail = Some(Detail {
1853 panel: self.focus,
1854 name: item.name.clone(),
1855 id: id.clone(),
1856 kind: None,
1857 section: "Access",
1858 data: None,
1859 show_raw: false,
1860 scroll: 0,
1861 });
1862 let (tx, rx) = oneshot::channel();
1863 self.detail_rx = Some(rx);
1864 let cli = Arc::clone(cli);
1865 tokio::spawn(async move {
1866 let data = fetchers::grants::fetch(&cli, uc, object_type, &id).await;
1867 let _ = tx.send(data);
1868 });
1869 }
1870
1871 pub fn open_run(&mut self, cli: &Arc<DatabricksCli>) {
1874 let Some(d) = &self.detail else {
1875 return;
1876 };
1877 let panel = d.panel;
1878 if !matches!(panel, Panel::Jobs | Panel::Pipelines) || d.section == "Lineage" {
1879 return;
1880 }
1881 let owner_id = d.id.clone();
1882 self.run_view = Some(RunView {
1883 panel,
1884 owner_name: d.name.clone(),
1885 owner_id: owner_id.clone(),
1886 runs: Vec::new(),
1887 idx: 0,
1888 data: None,
1889 show_raw: false,
1890 scroll: 0,
1891 live: false,
1892 fetched_at: Instant::now(),
1893 });
1894 let (tx, rx) = oneshot::channel();
1895 self.run_rx = Some(rx);
1896 let cli = Arc::clone(cli);
1897 tokio::spawn(async move {
1898 let result = async {
1899 let runs = if panel == Panel::Jobs {
1900 fetchers::runs::list(&cli, &owner_id).await?
1901 } else {
1902 fetchers::updates::list(&cli, &owner_id).await?
1903 };
1904 let Some((run_id, _, _)) = runs.first().cloned() else {
1905 return Err("no runs recorded yet".to_string());
1906 };
1907 let (data, live) = if panel == Panel::Jobs {
1908 fetchers::runs::fetch(&cli, &run_id).await
1909 } else {
1910 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
1911 };
1912 Ok((runs, data, live))
1913 }
1914 .await;
1915 let _ = tx.send(RunUpdate::Opened(result));
1916 });
1917 }
1918
1919 pub fn close_run(&mut self) {
1920 self.run_view = None;
1921 self.run_rx = None;
1922 }
1923
1924 pub fn run_nav(&mut self, cli: &Arc<DatabricksCli>, delta: i32) {
1926 if self.run_rx.is_some() {
1927 return;
1928 }
1929 let Some(rv) = &mut self.run_view else {
1930 return;
1931 };
1932 if rv.runs.is_empty() {
1933 return;
1934 }
1935 let new = if delta < 0 {
1936 rv.idx.saturating_sub(delta.unsigned_abs() as usize)
1937 } else {
1938 (rv.idx + delta as usize).min(rv.runs.len() - 1)
1939 };
1940 if new == rv.idx {
1941 return;
1942 }
1943 rv.idx = new;
1944 rv.data = None;
1945 rv.scroll = 0;
1946 rv.show_raw = false;
1947 let run_id = rv.runs[new].0.clone();
1948 self.start_run_fetch(cli, run_id);
1949 }
1950
1951 fn start_run_fetch(&mut self, cli: &Arc<DatabricksCli>, run_id: String) {
1952 let Some(rv) = &self.run_view else {
1953 return;
1954 };
1955 let (panel, owner_id) = (rv.panel, rv.owner_id.clone());
1956 let (tx, rx) = oneshot::channel();
1957 self.run_rx = Some(rx);
1958 let cli = Arc::clone(cli);
1959 tokio::spawn(async move {
1960 let (data, live) = if panel == Panel::Jobs {
1961 fetchers::runs::fetch(&cli, &run_id).await
1962 } else {
1963 fetchers::updates::fetch(&cli, &owner_id, &run_id).await
1964 };
1965 let _ = tx.send(RunUpdate::Detail(data, live));
1966 });
1967 }
1968
1969 pub fn run_toggle_raw(&mut self) {
1970 if let Some(rv) = &mut self.run_view {
1971 rv.show_raw = !rv.show_raw;
1972 rv.scroll = 0;
1973 }
1974 }
1975
1976 pub fn run_scroll(&mut self, delta: i32) {
1977 if let Some(rv) = &mut self.run_view {
1978 rv.scroll = if delta < 0 {
1979 rv.scroll.saturating_sub(delta.unsigned_abs() as u16)
1980 } else {
1981 rv.scroll.saturating_add(delta as u16)
1982 };
1983 }
1984 }
1985
1986 pub fn poll_run(&mut self, cli: &Arc<DatabricksCli>) -> bool {
1989 if let Some(rx) = &mut self.run_rx {
1990 match rx.try_recv() {
1991 Ok(update) => {
1992 self.run_rx = None;
1993 if let Some(rv) = &mut self.run_view {
1994 match update {
1995 RunUpdate::Opened(Ok((runs, data, live))) => {
1996 rv.runs = runs;
1997 rv.idx = 0;
1998 rv.data = Some(data);
1999 rv.live = live;
2000 }
2001 RunUpdate::Opened(Err(e)) => {
2002 rv.data = Some(DetailData {
2003 summary: Vec::new(),
2004 activity: Vec::new(),
2005 raw: format!("✗ {e}"),
2006 });
2007 rv.live = false;
2008 }
2009 RunUpdate::Detail(data, live) => {
2010 rv.data = Some(data);
2011 rv.live = live;
2012 }
2013 }
2014 rv.fetched_at = Instant::now();
2015 }
2016 true
2017 }
2018 Err(oneshot::error::TryRecvError::Empty) => false,
2019 Err(oneshot::error::TryRecvError::Closed) => {
2020 self.run_rx = None;
2021 true
2022 }
2023 }
2024 } else if let Some(rv) = &self.run_view {
2025 if rv.live && rv.data.is_some() && rv.fetched_at.elapsed() >= Duration::from_secs(5) {
2026 if let Some((run_id, _, _)) = rv.runs.get(rv.idx).cloned() {
2027 self.start_run_fetch(cli, run_id);
2028 }
2029 }
2030 false
2031 } else {
2032 false
2033 }
2034 }
2035
2036 pub fn close_detail(&mut self) {
2037 self.detail = None;
2038 self.detail_rx = None;
2039 }
2040
2041 pub fn toggle_raw(&mut self) {
2042 if let Some(d) = &mut self.detail {
2043 d.show_raw = !d.show_raw;
2044 d.scroll = 0;
2045 }
2046 }
2047
2048 pub fn poll_detail(&mut self) -> bool {
2050 let Some(rx) = &mut self.detail_rx else {
2051 return false;
2052 };
2053 match rx.try_recv() {
2054 Ok(data) => {
2055 if let Some(d) = &mut self.detail {
2056 d.data = Some(data);
2057 }
2058 self.detail_rx = None;
2059 true
2060 }
2061 Err(oneshot::error::TryRecvError::Empty) => false,
2062 Err(oneshot::error::TryRecvError::Closed) => {
2063 self.detail_rx = None;
2064 true
2065 }
2066 }
2067 }
2068
2069 pub fn detail_scroll(&mut self, delta: i32) {
2070 if let Some(d) = &mut self.detail {
2071 let max = match &d.data {
2072 Some(data) if d.show_raw => data.raw.lines().count(),
2073 Some(data) => data.summary.len() + data.activity.len() + 3,
2074 None => 0,
2075 } as u16;
2076 d.scroll = if delta < 0 {
2077 d.scroll.saturating_sub(delta.unsigned_abs() as u16)
2078 } else {
2079 (d.scroll + delta as u16).min(max.saturating_sub(1))
2080 };
2081 }
2082 }
2083
2084 pub fn request_action(&mut self) {
2087 if matches!(self.focus, Panel::Dashboards | Panel::Catalog) {
2089 return;
2090 }
2091 let Some(item) = self.selected_item() else {
2092 return;
2093 };
2094 let Some(id) = item.id.clone() else {
2095 return;
2096 };
2097 let name = item.name.clone();
2098 let active = matches!(
2099 item.status,
2100 Status::Running | Status::Pending | Status::Success
2101 );
2102 let group = self.focus.cli_group();
2103 let (verb, action): (&str, &str) = match self.focus {
2104 Panel::Jobs => ("Run", "run-now"),
2105 Panel::Clusters if active => ("Stop", "delete"),
2106 Panel::Pipelines if active => ("Stop", "stop"),
2107 Panel::Pipelines => ("Start update for", "start-update"),
2108 _ if active => ("Stop", "stop"),
2109 _ => ("Start", "start"),
2110 };
2111 self.confirm = Some(Confirm {
2112 message: format!("{verb} {} “{}”?", group.trim_end_matches('s'), name),
2113 args: vec![group.to_string(), action.to_string(), id],
2114 });
2115 }
2116
2117 pub fn request_run_cancel(&mut self) {
2119 let Some(rv) = &self.run_view else {
2120 return;
2121 };
2122 if !rv.live {
2123 self.flash = Some((
2124 "✗ nothing to cancel — this run already finished".to_string(),
2125 Instant::now(),
2126 ));
2127 return;
2128 }
2129 let Some((run_id, _, _)) = rv.runs.get(rv.idx) else {
2130 return;
2131 };
2132 let (message, args) = if rv.panel == Panel::Jobs {
2133 (
2134 format!("Cancel run {run_id} of “{}”?", rv.owner_name),
2135 vec!["jobs".to_string(), "cancel-run".to_string(), run_id.clone()],
2136 )
2137 } else {
2138 (
2139 format!("Stop “{}” (cancels the active update)?", rv.owner_name),
2140 vec![
2141 "pipelines".to_string(),
2142 "stop".to_string(),
2143 rv.owner_id.clone(),
2144 ],
2145 )
2146 };
2147 self.confirm = Some(Confirm { message, args });
2148 }
2149
2150 pub fn sql_cancel(&mut self, cli: &Arc<DatabricksCli>) {
2153 let id = self
2154 .sql_stmt
2155 .as_ref()
2156 .and_then(|h| h.lock().ok().and_then(|g| g.clone()));
2157 let Some(id) = id else {
2158 self.flash = Some((
2159 "✗ statement not submitted yet — try again in a moment".to_string(),
2160 Instant::now(),
2161 ));
2162 return;
2163 };
2164 let cli = Arc::clone(cli);
2165 tokio::spawn(async move {
2166 let path = format!("/api/2.0/sql/statements/{id}/cancel");
2167 let _ = cli.run_action(&["api", "post", &path]).await;
2168 });
2169 self.flash = Some(("⏳ cancel requested".to_string(), Instant::now()));
2170 }
2171
2172 pub fn cancel_confirm(&mut self) {
2173 self.confirm = None;
2174 }
2175
2176 pub fn confirm_execute(&mut self, cli: &Arc<DatabricksCli>) {
2177 let Some(c) = self.confirm.take() else {
2178 return;
2179 };
2180 let base = c.message.trim_end_matches('?').to_string();
2181 self.flash = Some((format!("⏳ {base}…"), Instant::now()));
2182
2183 let (tx, rx) = oneshot::channel();
2184 self.action_rx = Some(rx);
2185 let cli = Arc::clone(cli);
2186 tokio::spawn(async move {
2187 let args: Vec<&str> = c.args.iter().map(String::as_str).collect();
2188 let result = match cli.run_action(&args).await {
2189 Ok(()) => Ok(format!("✓ {base} — done")),
2190 Err(e) => Err(format!("✗ {e:#}")),
2191 };
2192 let _ = tx.send(result);
2193 });
2194 }
2195
2196 pub fn poll_action(&mut self, cli: &Arc<DatabricksCli>) -> bool {
2198 let Some(rx) = &mut self.action_rx else {
2199 return false;
2200 };
2201 match rx.try_recv() {
2202 Ok(result) => {
2203 let ok = result.is_ok();
2204 self.flash = Some((result.unwrap_or_else(|e| e), Instant::now()));
2205 self.action_rx = None;
2206 if ok {
2207 self.start_refresh(cli);
2208 }
2209 true
2210 }
2211 Err(oneshot::error::TryRecvError::Empty) => false,
2212 Err(oneshot::error::TryRecvError::Closed) => {
2213 self.action_rx = None;
2214 true
2215 }
2216 }
2217 }
2218
2219 pub fn expire_flash(&mut self) -> bool {
2221 if let Some((_, since)) = &self.flash {
2222 if since.elapsed() >= Duration::from_secs(5) && self.action_rx.is_none() {
2223 self.flash = None;
2224 return true;
2225 }
2226 }
2227 false
2228 }
2229
2230 pub fn open_in_browser(&self) {
2232 let Some(host) = &self.host else {
2233 return;
2234 };
2235 let (panel, id) = match &self.detail {
2236 Some(d) => (d.panel, Some(d.id.clone())),
2237 None => (self.focus, self.selected_item().and_then(|i| i.id.clone())),
2238 };
2239 let Some(id) = id else {
2240 return;
2241 };
2242 let path = match panel {
2243 Panel::Clusters => format!("compute/clusters/{id}"),
2244 Panel::Jobs => format!("jobs/{id}"),
2245 Panel::Pipelines => format!("pipelines/{id}"),
2246 Panel::Warehouses => format!("sql/warehouses/{id}"),
2247 Panel::Dashboards => format!("sql/dashboardsv3/{id}"),
2248 Panel::Catalog => format!("explore/data/{}", id.replace('.', "/")),
2249 };
2250 let url = format!("{}/{}", host.trim_end_matches('/'), path);
2251 #[cfg(target_os = "macos")]
2252 let opener = "open";
2253 #[cfg(not(target_os = "macos"))]
2254 let opener = "xdg-open";
2255 let _ = std::process::Command::new(opener).arg(url).spawn();
2256 }
2257
2258 pub fn status_counts(&self) -> (usize, usize, usize, usize) {
2260 let (mut ok, mut pending, mut failed, mut idle) = (0, 0, 0, 0);
2261 for shape in self.shapes.iter().flatten() {
2262 if let Shape::List(items) = shape {
2263 for item in items {
2264 match item.status {
2265 Status::Running | Status::Success => ok += 1,
2266 Status::Pending => pending += 1,
2267 Status::Failed => failed += 1,
2268 Status::Stopped => idle += 1,
2269 Status::Unknown(_) => {}
2270 }
2271 }
2272 }
2273 }
2274 (ok, pending, failed, idle)
2275 }
2276
2277 pub fn last_refresh_age(&self) -> Duration {
2278 self.last_refresh.elapsed()
2279 }
2280
2281 pub fn spinner(&self) -> &'static str {
2282 SPINNER_FRAMES[self.spinner_frame % SPINNER_FRAMES.len()]
2283 }
2284
2285 pub fn spinner_frame(&self) -> usize {
2286 self.spinner_frame
2287 }
2288
2289 pub fn busy(&self) -> bool {
2292 self.loading
2293 || self.detail_rx.is_some()
2294 || self.action_rx.is_some()
2295 || self.preview_rx.is_some()
2296 || self.cost_rx.is_some()
2297 || self.sql_rx.is_some()
2298 || self.run_rx.is_some()
2299 }
2300
2301 pub fn tick_spinner(&mut self) {
2302 self.spinner_frame = self.spinner_frame.wrapping_add(1);
2303 }
2304
2305 pub fn toggle_zoom(&mut self) {
2306 self.zoomed = !self.zoomed;
2307 }
2308
2309 pub fn focus_next(&mut self) {
2310 self.cycle_focus(1);
2311 }
2312
2313 pub fn focus_prev(&mut self) {
2314 self.cycle_focus(-1);
2315 }
2316
2317 fn cycle_focus(&mut self, delta: i32) {
2319 let visible = self.visible_panes();
2320 if visible.is_empty() {
2321 return;
2322 }
2323 let focus_idx = Panel::ALL
2324 .iter()
2325 .position(|p| p == &self.focus)
2326 .unwrap_or(0);
2327 let pos = visible.iter().position(|&i| i == focus_idx).unwrap_or(0);
2328 let n = visible.len() as i32;
2329 let next = ((pos as i32 + delta) % n + n) % n;
2330 self.focus = Panel::ALL[visible[next as usize]];
2331 }
2332
2333 pub fn needs_refresh(&self) -> bool {
2334 !self.loading && self.last_refresh.elapsed() >= self.refresh_interval
2335 }
2336
2337 pub fn start_refresh(&mut self, cli: &Arc<DatabricksCli>) {
2338 if self.loading {
2339 return;
2340 }
2341 self.loading = true;
2342 self.error = None;
2343 self.last_refresh = Instant::now();
2344
2345 let (tx, rx) = mpsc::unbounded_channel();
2346 self.pending = Some(rx);
2347 self.in_flight = 7;
2348
2349 macro_rules! spawn_fetch {
2352 ($update:expr, $fetch:path) => {{
2353 let cli = Arc::clone(cli);
2354 let tx = tx.clone();
2355 tokio::spawn(async move {
2356 let result = $fetch(&cli).await.map_err(|e| format!("{e:#}"));
2357 let _ = tx.send($update(result));
2358 });
2359 }};
2360 }
2361
2362 spawn_fetch!(|s| Update::Panel(0, s), fetchers::clusters::fetch);
2363 spawn_fetch!(|s| Update::Panel(1, s), fetchers::jobs::fetch);
2364 spawn_fetch!(|s| Update::Panel(2, s), fetchers::pipelines::fetch);
2365 spawn_fetch!(|s| Update::Panel(3, s), fetchers::warehouses::fetch);
2366 spawn_fetch!(|s| Update::Panel(4, s), fetchers::dashboards::fetch);
2367 spawn_fetch!(
2368 |s: Result<Shape, String>| Update::Badge(s.ok()),
2369 fetchers::current_user::fetch
2370 );
2371 {
2372 let cli = Arc::clone(cli);
2373 let tx = tx.clone();
2374 let path = self.uc_path.clone();
2375 tokio::spawn(async move {
2376 let result = fetchers::catalog::fetch(&cli, &path)
2377 .await
2378 .map_err(|e| format!("{e:#}"));
2379 let _ = tx.send(Update::Panel(5, result));
2380 });
2381 }
2382 }
2383
2384 pub fn poll_refresh(&mut self) -> bool {
2386 let Some(rx) = &mut self.pending else {
2387 return false;
2388 };
2389 let mut changed = false;
2390 let mut updated_panes: Vec<usize> = Vec::new();
2391 loop {
2392 match rx.try_recv() {
2393 Ok(Update::Panel(i, result)) => {
2394 match result {
2395 Ok(mut shape) => {
2396 if i != 5 {
2400 if let Shape::List(items) = &mut shape {
2401 items.sort_by_key(|it| {
2402 (it.status.rank(), it.history.is_empty())
2403 });
2404 }
2405 }
2406 self.shapes[i] = Some(shape);
2407 self.updated_at[i] = Some(Instant::now());
2408 updated_panes.push(i);
2409 }
2410 Err(e) => {
2413 if matches!(self.shapes[i], None | Some(Shape::Text(_))) {
2414 self.shapes[i] = Some(Shape::Text(format!("✗ {e}")));
2415 }
2416 }
2417 }
2418 self.in_flight -= 1;
2419 changed = true;
2420 }
2421 Ok(Update::Badge(badge)) => {
2422 if badge.is_some() {
2423 self.user_badge = badge;
2424 }
2425 self.in_flight -= 1;
2426 changed = true;
2427 }
2428 Err(mpsc::error::TryRecvError::Empty) => break,
2429 Err(mpsc::error::TryRecvError::Disconnected) => {
2430 self.in_flight = 0;
2431 break;
2432 }
2433 }
2434 }
2435 for i in updated_panes {
2436 self.alert_new_failures(i);
2437 }
2438 if self.in_flight == 0 {
2439 self.loading = false;
2440 self.pending = None;
2441 changed = true;
2442 }
2443 changed
2444 }
2445}