doot/crates/doot-cli/src/commands/tui.rs

905 lines
30 KiB
Rust

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<PathBuf>) -> 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<DotfileItem>,
packages: Vec<PackageItem>,
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<String>,
}
#[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<PathBuf>) -> anyhow::Result<Self> {
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<DotfileItem> = 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<PackageItem> = 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<CrosstermBackend<io::Stdout>>,
config_path: Option<PathBuf>,
) -> 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<ListItem> = 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<ListItem> = 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<ListItem> = 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(())
}