905 lines
30 KiB
Rust
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(())
|
|
}
|