use super::{find_config_file, parse_config, type_check}; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use doot_core::config::Config; use doot_core::deploy::Linker; use doot_core::state::{DeployMode, StateStore}; use doot_lang::Evaluator; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph, Tabs}, }; use std::io; use std::path::PathBuf; /// Launches the interactive TUI for managing dotfiles. #[tracing::instrument(skip_all)] pub fn run(config_path: Option) -> anyhow::Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let result = run_app(&mut terminal, config_path); disable_raw_mode()?; execute!( terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture )?; terminal.show_cursor()?; result } #[derive(Clone, Copy, PartialEq)] enum Tab { Dotfiles, Packages, Secrets, Status, } #[derive(Clone, Copy, PartialEq)] enum ApplyState { Idle, Applying, Done, NeedsSudo, } #[derive(Clone, PartialEq)] enum InputMode { Normal, Password, } struct App { tab: Tab, dotfiles: Vec, packages: Vec, dotfile_state: ListState, package_state: ListState, source_dir: PathBuf, apply_state: ApplyState, apply_progress: usize, apply_total: usize, apply_logs: Vec<(String, LogLevel)>, log_scroll: usize, input_mode: InputMode, password_input: String, sudo_password: Option, } #[derive(Clone, Copy)] enum LogLevel { Info, Success, Error, } struct DotfileItem { source: PathBuf, target: PathBuf, status: FileStatus, selected: bool, deploy_mode: DeployMode, } struct PackageItem { name: String, installed: bool, selected: bool, } #[derive(Clone, Copy, PartialEq)] enum FileStatus { Synced, Modified, Pending, Error, } impl App { #[tracing::instrument(skip_all)] fn new(config_path: Option) -> anyhow::Result { let path = find_config_file(config_path)?; let source = std::fs::read_to_string(&path)?; let program = parse_config(&path)?; type_check(&program, &source, &path.display().to_string())?; let source_dir = path.parent().unwrap_or(&PathBuf::from(".")).to_path_buf(); let mut evaluator = Evaluator::new().with_source_dir(source_dir.clone()); let result = evaluator.eval_sync(&program)?; let config = Config::new(source_dir.clone()); let state = StateStore::new(&config.state_file); let dotfiles: Vec = result .dotfiles .iter() .map(|d| { let full_source = source_dir.join(&d.source); let deploy_mode = match d.deploy { doot_lang::evaluator::DeployMode::Copy => DeployMode::Copy, doot_lang::evaluator::DeployMode::Link => DeployMode::Link, }; let status = if !full_source.exists() { FileStatus::Error } else { match state.check_sync_status(&full_source, &d.target) { doot_core::state::SyncStatus::Synced => FileStatus::Synced, doot_core::state::SyncStatus::SourceChanged => FileStatus::Modified, doot_core::state::SyncStatus::TargetChanged => FileStatus::Modified, doot_core::state::SyncStatus::Conflict => FileStatus::Modified, doot_core::state::SyncStatus::PermissionsChanged => FileStatus::Modified, doot_core::state::SyncStatus::NotDeployed => FileStatus::Pending, doot_core::state::SyncStatus::TargetMissing => FileStatus::Pending, doot_core::state::SyncStatus::SourceMissing => FileStatus::Error, } }; DotfileItem { source: d.source.clone(), target: d.target.clone(), status, selected: !matches!(status, FileStatus::Error | FileStatus::Synced), deploy_mode, } }) .collect(); let manager = doot_core::package::detect_package_manager(); let packages: Vec = result .packages .iter() .filter_map(|p| p.default.clone()) .map(|name| { let installed = manager .as_ref() .map(|m| m.is_installed(&name).unwrap_or(false)) .unwrap_or(false); PackageItem { name, installed, selected: !installed, } }) .collect(); let mut dotfile_state = ListState::default(); if !dotfiles.is_empty() { dotfile_state.select(Some(0)); } let mut package_state = ListState::default(); if !packages.is_empty() { package_state.select(Some(0)); } Ok(Self { tab: Tab::Dotfiles, dotfiles, packages, dotfile_state, package_state, source_dir, apply_state: ApplyState::Idle, apply_progress: 0, apply_total: 0, apply_logs: Vec::new(), log_scroll: 0, input_mode: InputMode::Normal, password_input: String::new(), sudo_password: None, }) } fn next_tab(&mut self) { if self.apply_state == ApplyState::Applying { return; } self.tab = match self.tab { Tab::Dotfiles => Tab::Packages, Tab::Packages => Tab::Secrets, Tab::Secrets => Tab::Status, Tab::Status => Tab::Dotfiles, }; } fn prev_tab(&mut self) { if self.apply_state == ApplyState::Applying { return; } self.tab = match self.tab { Tab::Dotfiles => Tab::Status, Tab::Packages => Tab::Dotfiles, Tab::Secrets => Tab::Packages, Tab::Status => Tab::Secrets, }; } fn next_item(&mut self) { if self.apply_state == ApplyState::Applying { return; } match self.tab { Tab::Dotfiles => { let len = self.dotfiles.len(); if len > 0 { let i = self .dotfile_state .selected() .map(|i| (i + 1) % len) .unwrap_or(0); self.dotfile_state.select(Some(i)); } } Tab::Packages => { let len = self.packages.len(); if len > 0 { let i = self .package_state .selected() .map(|i| (i + 1) % len) .unwrap_or(0); self.package_state.select(Some(i)); } } _ => {} } } fn prev_item(&mut self) { if self.apply_state == ApplyState::Applying { return; } match self.tab { Tab::Dotfiles => { let len = self.dotfiles.len(); if len > 0 { let i = self .dotfile_state .selected() .map(|i| if i == 0 { len - 1 } else { i - 1 }) .unwrap_or(0); self.dotfile_state.select(Some(i)); } } Tab::Packages => { let len = self.packages.len(); if len > 0 { let i = self .package_state .selected() .map(|i| if i == 0 { len - 1 } else { i - 1 }) .unwrap_or(0); self.package_state.select(Some(i)); } } _ => {} } } fn toggle_selected(&mut self) { if self.apply_state == ApplyState::Applying { return; } match self.tab { Tab::Dotfiles => { if let Some(i) = self.dotfile_state.selected() && let Some(item) = self.dotfiles.get_mut(i) && item.status != FileStatus::Error { item.selected = !item.selected; } } Tab::Packages => { if let Some(i) = self.package_state.selected() && let Some(item) = self.packages.get_mut(i) { item.selected = !item.selected; } } _ => {} } } fn select_all(&mut self) { if self.apply_state == ApplyState::Applying { return; } match self.tab { Tab::Dotfiles => { for item in &mut self.dotfiles { if item.status != FileStatus::Error && item.status != FileStatus::Synced { item.selected = true; } } } Tab::Packages => { for item in &mut self.packages { if !item.installed { item.selected = true; } } } _ => {} } } fn select_none(&mut self) { if self.apply_state == ApplyState::Applying { return; } match self.tab { Tab::Dotfiles => { for item in &mut self.dotfiles { item.selected = false; } } Tab::Packages => { for item in &mut self.packages { item.selected = false; } } _ => {} } } #[tracing::instrument(skip(self))] fn apply(&mut self) { if self.apply_state == ApplyState::Applying { return; } self.apply_logs.clear(); self.log_scroll = 0; let selected_dotfiles: Vec<_> = self .dotfiles .iter() .enumerate() .filter(|(_, d)| d.selected && d.status != FileStatus::Error) .map(|(i, _)| i) .collect(); let selected_packages: Vec<_> = self .packages .iter() .enumerate() .filter(|(_, p)| p.selected && !p.installed) .map(|(i, _)| i) .collect(); if selected_dotfiles.is_empty() && selected_packages.is_empty() { self.apply_logs .push(("Nothing to apply".to_string(), LogLevel::Info)); self.apply_state = ApplyState::Done; return; } // Check if we need sudo for packages if !selected_packages.is_empty() && self.needs_sudo() && self.sudo_password.is_none() { self.apply_state = ApplyState::NeedsSudo; return; } self.apply_state = ApplyState::Applying; self.apply_with_sudo(); } fn dismiss_apply(&mut self) { if self.apply_state == ApplyState::Done { self.apply_state = ApplyState::Idle; self.sudo_password = None; } } fn needs_sudo(&self) -> bool { let has_packages = self.packages.iter().any(|p| p.selected && !p.installed); let has_owner = self.dotfiles.iter().any(|d| d.selected); if has_packages && let Some(manager) = doot_core::package::detect_package_manager() { return manager.needs_sudo(); } has_owner } #[tracing::instrument(skip(self))] fn apply_with_sudo(&mut self) { let selected_dotfiles: Vec<_> = self .dotfiles .iter() .enumerate() .filter(|(_, d)| d.selected && d.status != FileStatus::Error) .map(|(i, _)| i) .collect(); let selected_packages: Vec<_> = self .packages .iter() .enumerate() .filter(|(_, p)| p.selected && !p.installed) .map(|(i, _)| i) .collect(); self.apply_total = selected_dotfiles.len() + selected_packages.len(); self.apply_progress = 0; // Apply dotfiles let config = Config::new(self.source_dir.clone()); let linker = Linker::new(config.clone()); let mut state = StateStore::new(&config.state_file); for idx in selected_dotfiles { let dotfile = &self.dotfiles[idx]; let full_source = self.source_dir.join(&dotfile.source); let target = &dotfile.target; let action_name = match dotfile.deploy_mode { DeployMode::Copy => "Copying", DeployMode::Link => "Linking", }; self.apply_logs.push(( format!( "{} {} -> {}", action_name, dotfile.source.display(), target.display() ), LogLevel::Info, )); let result: Result<(), String> = match dotfile.deploy_mode { DeployMode::Link => linker .link(&full_source, target) .map(|_| ()) .map_err(|e| e.to_string()), DeployMode::Copy => copy_file(&full_source, target), }; match result { Ok(_) => { state.record_deployment(&full_source, target, dotfile.deploy_mode); let done_msg = match dotfile.deploy_mode { DeployMode::Copy => format!(" ✓ Copied {}", dotfile.source.display()), DeployMode::Link => format!(" ✓ Linked {}", dotfile.source.display()), }; self.apply_logs.push((done_msg, LogLevel::Success)); self.dotfiles[idx].status = FileStatus::Synced; self.dotfiles[idx].selected = false; } Err(e) => { self.apply_logs .push((format!(" ✗ Failed: {}", e), LogLevel::Error)); self.dotfiles[idx].status = FileStatus::Error; } } self.apply_progress += 1; } let _ = state.save(); // Install packages with sudo if needed if let Some(manager) = doot_core::package::detect_package_manager() { for idx in selected_packages { let package = &self.packages[idx]; self.apply_logs.push(( format!("Installing {} via {}", package.name, manager.name()), LogLevel::Info, )); let result = if manager.needs_sudo() { if let Some(ref password) = self.sudo_password { manager.install_with_sudo(std::slice::from_ref(&package.name), password) } else { manager.install(std::slice::from_ref(&package.name)) } } else { manager.install(std::slice::from_ref(&package.name)) }; match result { Ok(_) => { self.apply_logs .push((format!(" ✓ Installed {}", package.name), LogLevel::Success)); self.packages[idx].installed = true; self.packages[idx].selected = false; } Err(e) => { self.apply_logs .push((format!(" ✗ Failed: {}", e), LogLevel::Error)); } } self.apply_progress += 1; } } else { self.apply_logs .push(("No package manager available".to_string(), LogLevel::Error)); } self.apply_state = ApplyState::Done; } fn scroll_log_up(&mut self) { if self.log_scroll > 0 { self.log_scroll -= 1; } } fn scroll_log_down(&mut self) { if self.log_scroll < self.apply_logs.len().saturating_sub(1) { self.log_scroll += 1; } } } #[tracing::instrument(skip_all)] fn run_app( terminal: &mut Terminal>, config_path: Option, ) -> anyhow::Result<()> { let mut app = App::new(config_path)?; loop { terminal.draw(|f| ui(f, &mut app))?; if let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { match app.input_mode { InputMode::Password => match key.code { KeyCode::Enter => { app.sudo_password = Some(app.password_input.clone()); app.password_input.clear(); app.input_mode = InputMode::Normal; app.apply_state = ApplyState::Applying; app.apply_with_sudo(); } KeyCode::Esc => { app.password_input.clear(); app.input_mode = InputMode::Normal; app.apply_state = ApplyState::Idle; } KeyCode::Backspace => { app.password_input.pop(); } KeyCode::Char(c) => { app.password_input.push(c); } _ => {} }, InputMode::Normal => match app.apply_state { ApplyState::Idle => match key.code { KeyCode::Char('q') => return Ok(()), KeyCode::Tab => app.next_tab(), KeyCode::BackTab => app.prev_tab(), KeyCode::Down | KeyCode::Char('j') => app.next_item(), KeyCode::Up | KeyCode::Char('k') => app.prev_item(), KeyCode::Char(' ') => app.toggle_selected(), KeyCode::Char('a') => app.select_all(), KeyCode::Char('n') => app.select_none(), KeyCode::Enter => app.apply(), KeyCode::Char('1') => app.tab = Tab::Dotfiles, KeyCode::Char('2') => app.tab = Tab::Packages, KeyCode::Char('3') => app.tab = Tab::Secrets, KeyCode::Char('4') => app.tab = Tab::Status, _ => {} }, ApplyState::Applying => { // Can't do anything while applying } ApplyState::NeedsSudo => match key.code { KeyCode::Char('y') | KeyCode::Enter => { app.input_mode = InputMode::Password; } KeyCode::Char('n') | KeyCode::Esc => { app.apply_state = ApplyState::Idle; } _ => {} }, ApplyState::Done => match key.code { KeyCode::Enter | KeyCode::Esc | KeyCode::Char('q') => app.dismiss_apply(), KeyCode::Up | KeyCode::Char('k') => app.scroll_log_up(), KeyCode::Down | KeyCode::Char('j') => app.scroll_log_down(), _ => {} }, }, } } } } fn ui(f: &mut Frame, app: &mut App) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ]) .split(f.area()); let title = format!("Doot - {}", app.source_dir.display()); let tabs = Tabs::new(vec!["Dotfiles", "Packages", "Secrets", "Status"]) .block(Block::default().borders(Borders::ALL).title(title)) .select(match app.tab { Tab::Dotfiles => 0, Tab::Packages => 1, Tab::Secrets => 2, Tab::Status => 3, }) .style(Style::default().fg(Color::Cyan)) .highlight_style( Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ); f.render_widget(tabs, chunks[0]); match app.input_mode { InputMode::Password => { render_password_input(f, app, chunks[1]); let help = Paragraph::new("[enter] submit [esc] cancel") .block(Block::default().borders(Borders::ALL)); f.render_widget(help, chunks[2]); } InputMode::Normal => match app.apply_state { ApplyState::Idle => { match app.tab { Tab::Dotfiles => render_dotfiles(f, app, chunks[1]), Tab::Packages => render_packages(f, app, chunks[1]), Tab::Secrets => render_secrets(f, chunks[1]), Tab::Status => render_status(f, app, chunks[1]), } let help = Paragraph::new("[tab] switch [j/k] navigate [space] toggle [a] all [n] none [enter] apply [q] quit") .block(Block::default().borders(Borders::ALL)); f.render_widget(help, chunks[2]); } ApplyState::NeedsSudo => { render_sudo_prompt(f, chunks[1]); let help = Paragraph::new("[y/enter] enter password [n/esc] cancel") .block(Block::default().borders(Borders::ALL)); f.render_widget(help, chunks[2]); } ApplyState::Applying | ApplyState::Done => { render_apply_progress(f, app, chunks[1]); let help_text = if app.apply_state == ApplyState::Done { "[enter/esc] dismiss [j/k] scroll" } else { "Applying..." }; let help = Paragraph::new(help_text).block(Block::default().borders(Borders::ALL)); f.render_widget(help, chunks[2]); } }, } } fn render_apply_progress(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(3), Constraint::Min(0)]) .split(area); // Progress bar let progress = if app.apply_total > 0 { (app.apply_progress as f64 / app.apply_total as f64 * 100.0) as u16 } else { 100 }; let label = format!("{}/{}", app.apply_progress, app.apply_total); let gauge = Gauge::default() .block(Block::default().borders(Borders::ALL).title("Progress")) .gauge_style(Style::default().fg(Color::Green)) .percent(progress) .label(label); f.render_widget(gauge, chunks[0]); // Log output let visible_height = chunks[1].height.saturating_sub(2) as usize; let start = app.log_scroll; let end = (start + visible_height).min(app.apply_logs.len()); let items: Vec = app.apply_logs[start..end] .iter() .map(|(msg, level)| { let color = match level { LogLevel::Info => Color::White, LogLevel::Success => Color::Green, LogLevel::Error => Color::Red, }; ListItem::new(Line::from(Span::styled( msg.as_str(), Style::default().fg(color), ))) }) .collect(); let title = if app.apply_state == ApplyState::Done { "Complete - Press Enter to continue" } else { "Applying..." }; let list = List::new(items).block(Block::default().borders(Borders::ALL).title(title)); f.render_widget(list, chunks[1]); } fn render_dotfiles(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { let items: Vec = app .dotfiles .iter() .map(|d| { let checkbox = if d.selected { "☑" } else { "☐" }; let status = match d.status { FileStatus::Synced => ("✓", Color::Green), FileStatus::Modified => ("~", Color::Yellow), FileStatus::Pending => ("○", Color::Gray), FileStatus::Error => ("✗", Color::Red), }; let line = Line::from(vec![ Span::raw(format!("{} ", checkbox)), Span::raw(format!("{} ", d.source.display())), Span::raw("→ "), Span::raw(format!("{} ", d.target.display())), Span::styled(status.0, Style::default().fg(status.1)), ]); ListItem::new(line) }) .collect(); let selected_count = app.dotfiles.iter().filter(|d| d.selected).count(); let title = format!("Dotfiles ({} selected)", selected_count); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(title)) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); f.render_stateful_widget(list, area, &mut app.dotfile_state); } fn render_packages(f: &mut Frame, app: &mut App, area: ratatui::layout::Rect) { let items: Vec = app .packages .iter() .map(|p| { let checkbox = if p.selected { "☑" } else { "☐" }; let status = if p.installed { ("✓", Color::Green) } else { ("○", Color::Gray) }; let line = Line::from(vec![ Span::raw(format!("{} ", checkbox)), Span::raw(format!("{} ", p.name)), Span::styled(status.0, Style::default().fg(status.1)), ]); ListItem::new(line) }) .collect(); let selected_count = app.packages.iter().filter(|p| p.selected).count(); let title = format!("Packages ({} selected)", selected_count); let list = List::new(items) .block(Block::default().borders(Borders::ALL).title(title)) .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); f.render_stateful_widget(list, area, &mut app.package_state); } fn render_secrets(f: &mut Frame, area: ratatui::layout::Rect) { let text = Paragraph::new("No secrets configured") .block(Block::default().borders(Borders::ALL).title("Secrets")); f.render_widget(text, area); } fn render_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let synced = app .dotfiles .iter() .filter(|d| matches!(d.status, FileStatus::Synced)) .count(); let pending = app .dotfiles .iter() .filter(|d| matches!(d.status, FileStatus::Pending)) .count(); let modified = app .dotfiles .iter() .filter(|d| matches!(d.status, FileStatus::Modified)) .count(); let errors = app .dotfiles .iter() .filter(|d| matches!(d.status, FileStatus::Error)) .count(); let installed = app.packages.iter().filter(|p| p.installed).count(); let text = format!( "Source: {}\n\nDotfiles:\n Synced: {}\n Pending: {}\n Modified: {}\n Errors: {}\n\nPackages:\n Installed: {}/{}", app.source_dir.display(), synced, pending, modified, errors, installed, app.packages.len() ); let paragraph = Paragraph::new(text).block(Block::default().borders(Borders::ALL).title("Status")); f.render_widget(paragraph, area); } fn render_sudo_prompt(f: &mut Frame, area: ratatui::layout::Rect) { let text = "Package installation requires sudo privileges.\n\nDo you want to enter your password?"; let paragraph = Paragraph::new(text).block( Block::default() .borders(Borders::ALL) .title("Sudo Required"), ); f.render_widget(paragraph, area); } fn render_password_input(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { let masked: String = "*".repeat(app.password_input.len()); let text = format!("Password: {}_", masked); let paragraph = Paragraph::new(text).block( Block::default() .borders(Borders::ALL) .title("Enter sudo password"), ); f.render_widget(paragraph, area); } fn copy_file(source: &PathBuf, target: &PathBuf) -> Result<(), String> { if let Some(parent) = target.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } if source.is_dir() { copy_dir_recursive(source, target).map_err(|e| e.to_string()) } else { std::fs::copy(source, target) .map(|_| ()) .map_err(|e| e.to_string()) } } fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> std::io::Result<()> { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; let ty = entry.file_type()?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); if ty.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; } else { std::fs::copy(&src_path, &dst_path)?; } } Ok(()) }