use crate::components::file_preview::FilePreview;
use crate::components::footer::Footer;
use crate::components::header::Header;
use crate::components::{FileBrowser, FileBrowserResult};
use crate::config::Config;
use crate::file_manager::Dotfile;
use crate::screens::screen_trait::{RenderContext, Screen, ScreenAction, ScreenContext};
use crate::screens::ActionResult;
use crate::services::SyncService;
use crate::styles::{theme as ui_theme, LIST_HIGHLIGHT_SYMBOL};
use crate::ui::Screen as ScreenId;
use crate::utils::{
create_split_layout, create_standard_layout, focused_border_style, unfocused_border_style,
MouseRegions, TextInput,
};
use crate::widgets::{Dialog, DialogVariant};
use crate::widgets::{TextInputWidget, TextInputWidgetExt};
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::layout::Position;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use tracing::{debug, info, warn};
use ratatui::widgets::{
Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, StatefulWidget, Wrap,
};
use ratatui::Frame;
use std::path::{Path, PathBuf};
use syntect::highlighting::Theme;
use syntect::parsing::SyntaxSet;
#[derive(Debug, Clone, PartialEq)]
enum DisplayItem {
Header(String), File(usize), }
#[derive(Debug, Clone)]
pub enum DotfileAction {
ScanDotfiles,
RefreshFileBrowser,
ToggleFileSync { file_index: usize, is_synced: bool },
AddCustomFileToSync {
full_path: PathBuf,
relative_path: String,
},
SetBackupEnabled { enabled: bool },
MoveToCommon {
file_index: usize,
is_common: bool,
profiles_to_cleanup: Vec<String>,
},
RemoveCustomFile { file_index: usize },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DotfileSelectionFocus {
FilesList, Preview, FileBrowserList, FileBrowserPreview, FileBrowserInput, }
#[derive(Debug)]
pub struct DotfileSelectionState {
pub dotfiles: Vec<Dotfile>,
pub preview_index: Option<usize>,
pub preview_scroll: usize,
pub selected_for_sync: std::collections::HashSet<usize>, pub dotfile_list_scrollbar: ScrollbarState, pub dotfile_list_state: ListState, pub status_message: Option<String>, pub adding_custom_file: bool, pub custom_file_input: TextInput, pub custom_file_focused: bool, pub file_browser_mode: bool, pub file_browser_path: PathBuf, pub file_browser_selected: usize, pub file_browser_entries: Vec<PathBuf>, pub file_browser_scrollbar: ScrollbarState, pub file_browser_list_state: ListState, pub file_browser_preview_scroll: usize, pub file_browser_path_input: TextInput, pub file_browser_path_focused: bool, pub focus: DotfileSelectionFocus, pub backup_enabled: bool, pub show_custom_file_confirm: bool, pub custom_file_confirm_path: Option<PathBuf>, pub custom_file_confirm_relative: Option<String>, pub confirm_move: Option<usize>, pub move_validation: Option<crate::utils::MoveToCommonValidation>, pub confirm_unsync_common: Option<usize>, pub confirm_remove_custom: Option<usize>, }
impl Default for DotfileSelectionState {
fn default() -> Self {
Self {
dotfiles: Vec::new(),
preview_index: None,
preview_scroll: 0,
selected_for_sync: std::collections::HashSet::new(),
dotfile_list_scrollbar: ScrollbarState::new(0),
dotfile_list_state: ListState::default(),
status_message: None,
adding_custom_file: false,
custom_file_input: TextInput::new(),
custom_file_focused: true,
file_browser_mode: false,
file_browser_path: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
file_browser_selected: 0,
file_browser_entries: Vec::new(),
file_browser_scrollbar: ScrollbarState::new(0),
file_browser_list_state: ListState::default(),
file_browser_preview_scroll: 0,
file_browser_path_input: TextInput::new(),
file_browser_path_focused: false,
focus: DotfileSelectionFocus::FilesList, backup_enabled: true, show_custom_file_confirm: false,
custom_file_confirm_path: None,
custom_file_confirm_relative: None,
confirm_move: None,
move_validation: None,
confirm_unsync_common: None,
confirm_remove_custom: None,
}
}
}
pub struct DotfileSelectionScreen {
state: DotfileSelectionState,
file_browser: FileBrowser,
mouse_regions: MouseRegions<usize>,
list_pane_area: Option<Rect>,
preview_pane_area: Option<Rect>,
}
impl DotfileSelectionScreen {
#[must_use]
pub fn new() -> Self {
Self {
state: DotfileSelectionState::default(),
file_browser: FileBrowser::new(),
mouse_regions: MouseRegions::new(),
list_pane_area: None,
preview_pane_area: None,
}
}
#[must_use]
pub fn get_state(&self) -> &DotfileSelectionState {
&self.state
}
pub fn get_state_mut(&mut self) -> &mut DotfileSelectionState {
&mut self.state
}
pub fn set_backup_enabled(&mut self, enabled: bool) {
self.state.backup_enabled = enabled;
}
fn get_display_items(&self, profile_name: &str) -> Vec<DisplayItem> {
let mut items = Vec::new();
let common_indices: Vec<usize> = self
.state
.dotfiles
.iter()
.enumerate()
.filter(|(_, d)| d.is_common)
.map(|(i, _)| i)
.collect();
if !common_indices.is_empty() {
items.push(DisplayItem::Header("Common Files (Shared)".to_string()));
for idx in common_indices {
items.push(DisplayItem::File(idx));
}
}
let profile_indices: Vec<usize> = self
.state
.dotfiles
.iter()
.enumerate()
.filter(|(_, d)| !d.is_common)
.map(|(i, _)| i)
.collect();
if !profile_indices.is_empty() {
if !items.is_empty() {
items.push(DisplayItem::Header(String::new())); }
items.push(DisplayItem::Header(format!(
"Profile Files ({profile_name})"
)));
for idx in profile_indices {
items.push(DisplayItem::File(idx));
}
}
items
}
fn handle_modal_event(&mut self, key_code: KeyCode, config: &Config) -> Result<ScreenAction> {
let action = config
.keymap
.get_action(key_code, crossterm::event::KeyModifiers::NONE);
use crate::keymap::Action;
match action {
Some(Action::Yes | Action::Confirm) => {
let full_path = self.state.custom_file_confirm_path.clone().unwrap();
let relative_path = self.state.custom_file_confirm_relative.clone().unwrap();
self.state.show_custom_file_confirm = false;
self.state.custom_file_confirm_path = None;
self.state.custom_file_confirm_relative = None;
Ok(ScreenAction::AddCustomFileToSync {
full_path,
relative_path,
})
}
Some(Action::No | Action::Cancel) => {
self.state.show_custom_file_confirm = false;
self.state.custom_file_confirm_path = None;
self.state.custom_file_confirm_relative = None;
Ok(ScreenAction::None)
}
_ => Ok(ScreenAction::None),
}
}
fn handle_custom_file_input(
&mut self,
key_code: KeyCode,
config: &Config,
) -> Result<ScreenAction> {
if !self.state.custom_file_focused {
match key_code {
KeyCode::Enter => {
self.state.custom_file_focused = true;
return Ok(ScreenAction::None);
}
KeyCode::Esc => {
self.state.adding_custom_file = false;
self.state.custom_file_input.clear();
return Ok(ScreenAction::None);
}
_ => return Ok(ScreenAction::None),
}
}
match key_code {
KeyCode::Char(c) => {
self.state.custom_file_input.insert_char(c);
}
KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End => {
self.state.custom_file_input.handle_key(key_code);
}
KeyCode::Backspace => {
self.state.custom_file_input.backspace();
}
KeyCode::Delete => {
self.state.custom_file_input.delete();
}
KeyCode::Tab => {
self.state.custom_file_focused = false;
}
KeyCode::Enter => {
let path_str = self.state.custom_file_input.text_trimmed();
if path_str.is_empty() {
return Ok(ScreenAction::ShowMessage {
title: "Invalid Path".to_string(),
content: "File path cannot be empty".to_string(),
});
} else {
let full_path = crate::utils::expand_path(path_str);
if full_path.exists() {
let home_dir = crate::utils::get_home_dir();
let relative_path = match full_path.strip_prefix(&home_dir) {
Ok(p) => p.to_string_lossy().to_string(),
Err(_) => path_str.to_string(),
};
self.state.adding_custom_file = false;
self.state.custom_file_input.clear();
self.state.focus = DotfileSelectionFocus::FilesList;
let repo_path = &config.repo_path;
let (is_safe, reason) = crate::utils::is_safe_to_add(&full_path, repo_path);
if !is_safe {
return Ok(ScreenAction::ShowMessage {
title: "Cannot Add File".to_string(),
content: format!(
"{}.\n\nPath: {}",
reason.unwrap_or_else(|| "Cannot add this file".to_string()),
full_path.display()
),
});
}
self.state.show_custom_file_confirm = true;
self.state.custom_file_confirm_path = Some(full_path);
self.state.custom_file_confirm_relative = Some(relative_path);
} else {
return Ok(ScreenAction::ShowMessage {
title: "File Not Found".to_string(),
content: format!("File does not exist: {full_path:?}"),
});
}
}
}
KeyCode::Esc => {
self.state.adding_custom_file = false;
self.state.custom_file_input.clear();
self.state.focus = DotfileSelectionFocus::FilesList;
}
_ => {}
}
Ok(ScreenAction::None)
}
fn handle_dotfile_list(&mut self, key_code: KeyCode, config: &Config) -> Result<ScreenAction> {
let action = config
.keymap
.get_action(key_code, crossterm::event::KeyModifiers::NONE);
use crate::keymap::Action;
let display_items = self.get_display_items(&config.active_profile);
if let Some(action) = action {
match action {
Action::MoveUp => {
if display_items.is_empty() {
return Ok(ScreenAction::None);
}
let current = self.state.dotfile_list_state.selected().unwrap_or(0);
let mut prev = current;
let mut found = false;
while prev > 0 {
prev -= 1;
if !matches!(display_items[prev], DisplayItem::Header(_)) {
found = true;
break;
}
}
if found {
self.state.dotfile_list_state.select(Some(prev));
self.state.preview_scroll = 0;
} else {
if matches!(display_items[current], DisplayItem::Header(_)) {
for (i, item) in display_items.iter().enumerate() {
if !matches!(item, DisplayItem::Header(_)) {
self.state.dotfile_list_state.select(Some(i));
break;
}
}
}
}
}
Action::MoveDown => {
if display_items.is_empty() {
return Ok(ScreenAction::None);
}
let current = self.state.dotfile_list_state.selected().unwrap_or(0);
let mut next = current + 1;
while next < display_items.len() {
if !matches!(display_items[next], DisplayItem::Header(_)) {
self.state.dotfile_list_state.select(Some(next));
self.state.preview_scroll = 0;
break;
}
next += 1;
}
if next >= display_items.len()
&& matches!(display_items[current], DisplayItem::Header(_))
{
let mut fix_idx = current + 1;
while fix_idx < display_items.len() {
if !matches!(display_items[fix_idx], DisplayItem::Header(_)) {
self.state.dotfile_list_state.select(Some(fix_idx));
break;
}
fix_idx += 1;
}
}
}
Action::Confirm => {
if let Some(idx) = self.state.dotfile_list_state.selected() {
if idx < display_items.len() {
if let DisplayItem::File(file_idx) = &display_items[idx] {
let is_synced = self.state.selected_for_sync.contains(file_idx);
let dotfile = &self.state.dotfiles[*file_idx];
if is_synced && dotfile.is_common {
self.state.confirm_unsync_common = Some(*file_idx);
return Ok(ScreenAction::Refresh);
}
return Ok(ScreenAction::ToggleFileSync {
file_index: *file_idx,
is_synced,
});
}
}
}
}
Action::NextTab => {
self.state.focus = DotfileSelectionFocus::Preview;
}
Action::PageUp => {
if display_items.is_empty() {
return Ok(ScreenAction::None);
}
let current = self.state.dotfile_list_state.selected().unwrap_or(0);
let target = current.saturating_sub(10);
let mut next = target;
if next < display_items.len()
&& matches!(display_items[next], DisplayItem::Header(_))
{
next = next.saturating_add(1); }
if next >= display_items.len() {
next = current;
}
self.state.dotfile_list_state.select(Some(next));
self.state.preview_scroll = 0;
}
Action::PageDown => {
if display_items.is_empty() {
return Ok(ScreenAction::None);
}
let current = self.state.dotfile_list_state.selected().unwrap_or(0);
let target = current.saturating_add(10);
let mut next = target;
if next >= display_items.len() {
next = display_items.len() - 1;
}
if matches!(display_items[next], DisplayItem::Header(_)) {
next = next.saturating_add(1);
}
if next >= display_items.len() {
next = current;
}
self.state.dotfile_list_state.select(Some(next));
self.state.preview_scroll = 0;
}
Action::GoToTop => {
if let Some(first_idx) = display_items
.iter()
.position(|item| matches!(item, DisplayItem::File(_)))
{
self.state.dotfile_list_state.select(Some(first_idx));
}
self.state.preview_scroll = 0;
}
Action::GoToEnd => {
if let Some(last_idx) = display_items
.iter()
.rposition(|item| matches!(item, DisplayItem::File(_)))
{
self.state.dotfile_list_state.select(Some(last_idx));
}
self.state.preview_scroll = 0;
}
Action::Create => {
self.state.adding_custom_file = true;
self.file_browser.open(crate::utils::get_home_dir());
return Ok(ScreenAction::None);
}
Action::ToggleBackup => {
self.state.backup_enabled = !self.state.backup_enabled;
return Ok(ScreenAction::SetBackupEnabled {
enabled: self.state.backup_enabled,
});
}
Action::Delete => {
if let Some(idx) = self.state.dotfile_list_state.selected() {
if idx < display_items.len() {
if let DisplayItem::File(file_idx) = &display_items[idx] {
let dotfile = &self.state.dotfiles[*file_idx];
if dotfile.synced {
return Ok(ScreenAction::ShowToast {
message: "Unsync the file first before removing it".into(),
variant: crate::widgets::ToastVariant::Info,
});
}
if !dotfile.is_custom {
return Ok(ScreenAction::ShowToast {
message:
"Only custom-added files can be removed from the list"
.into(),
variant: crate::widgets::ToastVariant::Info,
});
}
self.state.confirm_remove_custom = Some(*file_idx);
return Ok(ScreenAction::Refresh);
}
}
}
}
Action::Cancel | Action::Quit => {
return Ok(ScreenAction::Navigate(ScreenId::MainMenu));
}
Action::Move => {
if let Some(idx) = self.state.dotfile_list_state.selected() {
if idx < display_items.len() {
if let DisplayItem::File(file_idx) = &display_items[idx] {
let dotfile = &self.state.dotfiles[*file_idx];
if dotfile.synced {
if !dotfile.is_common {
let relative_path =
dotfile.relative_path.to_string_lossy().to_string();
match crate::utils::validate_move_to_common(
&config.repo_path,
&config.active_profile,
&relative_path,
) {
Ok(validation) => {
self.state.move_validation = Some(validation);
self.state.confirm_move = Some(*file_idx);
return Ok(ScreenAction::Refresh);
}
Err(e) => {
return Ok(ScreenAction::ShowMessage {
title: "Validation Error".to_string(),
content: format!(
"Failed to validate move: {e}"
),
});
}
}
}
self.state.confirm_move = Some(*file_idx);
return Ok(ScreenAction::Refresh);
}
}
}
}
}
_ => {}
}
}
Ok(ScreenAction::None)
}
fn handle_mouse_event(
&mut self,
mouse: crossterm::event::MouseEvent,
config: &Config,
) -> Result<ScreenAction> {
let display_items = self.get_display_items(&config.active_profile);
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if let Some(&idx) = self.mouse_regions.hit_test(mouse.column, mouse.row) {
if idx < display_items.len() {
if !matches!(display_items[idx], DisplayItem::Header(_)) {
self.state.dotfile_list_state.select(Some(idx));
self.state.preview_scroll = 0;
self.state.focus = DotfileSelectionFocus::FilesList;
}
}
return Ok(ScreenAction::None);
}
if let Some(area) = self.preview_pane_area {
if area.contains(Position::new(mouse.column, mouse.row)) {
self.state.focus = DotfileSelectionFocus::Preview;
return Ok(ScreenAction::None);
}
}
if let Some(area) = self.list_pane_area {
if area.contains(Position::new(mouse.column, mouse.row)) {
self.state.focus = DotfileSelectionFocus::FilesList;
return Ok(ScreenAction::None);
}
}
}
MouseEventKind::ScrollDown => {
if let Some(area) = self.list_pane_area {
if area.contains(Position::new(mouse.column, mouse.row)) {
let current = self.state.dotfile_list_state.selected().unwrap_or(0);
let mut target = current;
let mut moves = 0;
let mut next = current + 1;
while next < display_items.len() && moves < 3 {
if !matches!(display_items[next], DisplayItem::Header(_)) {
target = next;
moves += 1;
}
next += 1;
}
if target != current {
self.state.dotfile_list_state.select(Some(target));
self.state.preview_scroll = 0;
}
return Ok(ScreenAction::None);
}
}
if let Some(area) = self.preview_pane_area {
if area.contains(Position::new(mouse.column, mouse.row)) {
self.state.preview_scroll = self.state.preview_scroll.saturating_add(3);
return Ok(ScreenAction::None);
}
}
}
MouseEventKind::ScrollUp => {
if let Some(area) = self.list_pane_area {
if area.contains(Position::new(mouse.column, mouse.row)) {
let current = self.state.dotfile_list_state.selected().unwrap_or(0);
let mut target = current;
let mut moves = 0;
let mut prev = current;
while prev > 0 && moves < 3 {
prev -= 1;
if !matches!(display_items[prev], DisplayItem::Header(_)) {
target = prev;
moves += 1;
}
}
if target != current {
self.state.dotfile_list_state.select(Some(target));
self.state.preview_scroll = 0;
}
return Ok(ScreenAction::None);
}
}
if let Some(area) = self.preview_pane_area {
if area.contains(Position::new(mouse.column, mouse.row)) {
self.state.preview_scroll = self.state.preview_scroll.saturating_sub(3);
return Ok(ScreenAction::None);
}
}
}
_ => {}
}
Ok(ScreenAction::None)
}
fn handle_preview(&mut self, key_code: KeyCode, config: &Config) -> Result<ScreenAction> {
let action = config
.keymap
.get_action(key_code, crossterm::event::KeyModifiers::NONE);
use crate::keymap::Action;
if let Some(action) = action {
match action {
Action::MoveUp | Action::ScrollUp => {
self.state.preview_scroll = self.state.preview_scroll.saturating_sub(1);
}
Action::MoveDown | Action::ScrollDown => {
self.state.preview_scroll = self.state.preview_scroll.saturating_add(1);
}
Action::PageUp => {
self.state.preview_scroll = self.state.preview_scroll.saturating_sub(20);
}
Action::PageDown => {
self.state.preview_scroll = self.state.preview_scroll.saturating_add(20);
}
Action::GoToTop => {
self.state.preview_scroll = 0;
}
Action::GoToEnd => {
if let Some(selected_index) = self.state.dotfile_list_state.selected() {
if selected_index < self.state.dotfiles.len() {
let dotfile = &self.state.dotfiles[selected_index];
if let Ok(content) = std::fs::read_to_string(&dotfile.original_path) {
let total_lines = content.lines().count();
let estimated_visible = 20;
self.state.preview_scroll =
total_lines.saturating_sub(estimated_visible);
} else {
self.state.preview_scroll = 10000;
}
}
}
}
Action::NextTab => {
self.state.focus = DotfileSelectionFocus::FilesList;
}
Action::Cancel | Action::Quit => {
return Ok(ScreenAction::Navigate(ScreenId::MainMenu));
}
_ => {}
}
}
Ok(ScreenAction::None)
}
fn render_custom_file_input(
&mut self,
frame: &mut Frame,
content_chunk: Rect,
footer_chunk: Rect,
config: &Config,
) -> Result<()> {
let input_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0),
Constraint::Length(3), ])
.split(content_chunk);
let widget = TextInputWidget::new(&self.state.custom_file_input)
.title("Custom File Path")
.placeholder("Enter file path (e.g., ~/.myconfig or /path/to/file)")
.title_alignment(Alignment::Center)
.focused(self.state.custom_file_focused);
frame.render_text_input_widget(widget, input_chunks[1]);
let k = |a| config.keymap.get_key_display_for_action(a);
let footer_text = format!(
"{}: Add File | {}: Cancel | Tab: Focus/Unfocus",
k(crate::keymap::Action::Confirm),
k(crate::keymap::Action::Quit)
);
let _ = Footer::render(frame, footer_chunk, &footer_text)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn render_dotfile_list(
&mut self,
frame: &mut Frame,
content_chunk: Rect,
footer_chunk: Rect,
config: &Config,
syntax_set: &SyntaxSet,
theme: &Theme,
) -> Result<()> {
let content_chunks = create_split_layout(content_chunk, &[50, 50]);
let left_area = content_chunks[0];
let preview_area = content_chunks[1];
let icons = crate::icons::Icons::from_config(config);
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), Constraint::Length(4), ])
.split(left_area);
let list_area = left_chunks[0];
let description_area = left_chunks[1];
let t = ui_theme();
let display_items = self.get_display_items(&config.active_profile);
let current_sel = self.state.dotfile_list_state.selected().unwrap_or(0);
if !display_items.is_empty() {
let needs_fix = current_sel >= display_items.len()
|| matches!(display_items[current_sel], DisplayItem::Header(_));
if needs_fix {
let mut found = false;
for (i, item) in display_items.iter().enumerate().skip(current_sel) {
if !matches!(item, DisplayItem::Header(_)) {
self.state.dotfile_list_state.select(Some(i));
found = true;
break;
}
}
if !found {
for (i, item) in display_items.iter().enumerate().take(current_sel) {
if !matches!(item, DisplayItem::Header(_)) {
self.state.dotfile_list_state.select(Some(i));
break;
}
}
}
}
}
let common_count = self.state.dotfiles.iter().filter(|d| d.is_common).count();
let profile_count = self.state.dotfiles.len() - common_count;
#[allow(unused)] let items: Vec<ListItem> = display_items
.iter()
.enumerate()
.map(|(list_idx, item)| match item {
DisplayItem::Header(title) => {
if title.is_empty() {
ListItem::new("").style(Style::default())
} else {
ListItem::new(title.clone())
.style(Style::default().fg(t.tertiary).add_modifier(Modifier::BOLD))
}
}
DisplayItem::File(idx) => {
let dotfile = &self.state.dotfiles[*idx];
let is_selected = self.state.selected_for_sync.contains(idx);
let sync_marker = if is_selected {
icons.check()
} else {
icons.uncheck()
};
let prefix = "";
let style = if is_selected {
Style::default().fg(t.success)
} else if dotfile.is_custom {
Style::default().fg(t.secondary)
} else {
t.text_style()
};
let path_str = dotfile.relative_path.to_string_lossy();
let mut spans = vec![
ratatui::text::Span::styled(prefix.to_string(), Style::default()),
ratatui::text::Span::styled(
format!(" {sync_marker}\u{2009}{path_str}"),
style,
),
];
if dotfile.is_custom {
spans.push(ratatui::text::Span::styled(
" [custom]",
Style::default().fg(t.text_muted),
));
}
let content = ratatui::text::Line::from(spans);
ListItem::new(content)
}
})
.collect();
let total_items = display_items.len();
let selected_index = self.state.dotfile_list_state.selected().unwrap_or(0);
self.state.dotfile_list_scrollbar = self
.state
.dotfile_list_scrollbar
.content_length(total_items)
.position(selected_index);
let list_title = if common_count > 0 {
format!(" Dotfiles ({common_count} common, {profile_count} profile) ")
} else {
format!(" Found {} dotfiles ", self.state.dotfiles.len())
};
let list_border_style = if self.state.focus == DotfileSelectionFocus::FilesList {
focused_border_style()
} else {
unfocused_border_style()
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(list_title)
.title_alignment(Alignment::Center)
.border_type(
t.border_type(self.state.focus == DotfileSelectionFocus::FilesList),
)
.border_style(list_border_style),
)
.highlight_style(t.highlight_style())
.highlight_symbol(LIST_HIGHLIGHT_SYMBOL);
StatefulWidget::render(
list,
list_area,
frame.buffer_mut(),
&mut self.state.dotfile_list_state,
);
self.list_pane_area = Some(list_area);
self.preview_pane_area = Some(preview_area);
self.mouse_regions.clear();
let inner = Block::default().borders(Borders::ALL).inner(list_area);
let scroll_offset = self.state.dotfile_list_state.offset();
for (i, _item) in display_items.iter().enumerate() {
if i < scroll_offset {
continue;
}
let visible_row = (i - scroll_offset) as u16;
if visible_row >= inner.height {
break;
}
let row_area = Rect::new(inner.x, inner.y + visible_row, inner.width, 1);
self.mouse_regions.add(row_area, i);
}
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓")),
list_area,
&mut self.state.dotfile_list_scrollbar,
);
let selected_dotfile = if let Some(idx) = self.state.dotfile_list_state.selected() {
if idx < display_items.len() {
if let DisplayItem::File(file_idx) = &display_items[idx] {
Some(&self.state.dotfiles[*file_idx])
} else {
None
}
} else {
None
}
} else {
None
};
if let Some(dotfile) = selected_dotfile {
let description_text = if let Some(desc) = &dotfile.description {
desc.clone()
} else {
format!(
"No description available for {}",
dotfile.relative_path.to_string_lossy()
)
};
let description_para = Paragraph::new(description_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Description ")
.border_type(t.border_type(false))
.title_alignment(Alignment::Center)
.border_style(unfocused_border_style()),
)
.wrap(Wrap { trim: true })
.style(t.text_style());
frame.render_widget(description_para, description_area);
} else {
let empty_desc = Paragraph::new("No file selected").block(
Block::default()
.borders(Borders::ALL)
.title(" Description ")
.border_type(ui_theme().border_type(false))
.title_alignment(Alignment::Center)
.border_style(unfocused_border_style()),
);
frame.render_widget(empty_desc, description_area);
}
if let Some(dotfile) = selected_dotfile {
let is_focused = self.state.focus == DotfileSelectionFocus::Preview;
let preview_title = format!("Preview: {}", dotfile.relative_path.to_string_lossy());
FilePreview::render(
frame,
preview_area,
&dotfile.original_path,
&mut self.state.preview_scroll,
is_focused,
Some(&preview_title),
None,
syntax_set,
theme,
config,
)?;
} else {
let empty_preview = Paragraph::new("No file selected").block(
Block::default()
.borders(Borders::ALL)
.title(" Preview ")
.border_type(ui_theme().border_type(false))
.title_alignment(Alignment::Center),
);
frame.render_widget(empty_preview, preview_area);
}
let backup_status = if self.state.backup_enabled {
"ON"
} else {
"OFF"
};
let k = |a| config.keymap.get_key_display_for_action(a);
let display_items = self.get_display_items(&config.active_profile);
let selected_dotfile = self
.state
.dotfile_list_state
.selected()
.and_then(|idx| display_items.get(idx))
.and_then(|item| match item {
DisplayItem::File(file_idx) => self.state.dotfiles.get(*file_idx),
_ => None,
});
let move_text = selected_dotfile.map_or("Move", |dotfile| {
if dotfile.is_common {
"Move to Profile"
} else {
"Move to Common"
}
});
let is_custom_selected =
selected_dotfile.is_some_and(|dotfile| dotfile.is_custom && !dotfile.synced);
let remove_part = if is_custom_selected {
format!(" | {}: Remove", k(crate::keymap::Action::Delete))
} else {
String::new()
};
let footer_text = format!(
"Tab: Focus | {}: Navigate | Space/{}: Toggle | {}: {} | {}: Add Custom | {}: Backup ({}){} | {}: Back",
config.keymap.navigation_display(),
k(crate::keymap::Action::Confirm),
k(crate::keymap::Action::Move),
move_text,
k(crate::keymap::Action::Create),
k(crate::keymap::Action::ToggleBackup),
backup_status,
remove_part,
k(crate::keymap::Action::Quit)
);
let _ = Footer::render(frame, footer_chunk, &footer_text)?;
Ok(())
}
fn render_custom_file_confirm(
&mut self,
frame: &mut Frame,
area: Rect,
config: &Config,
) -> Result<()> {
let path = self
.state
.custom_file_confirm_path
.as_ref()
.map_or_else(|| "Unknown".to_string(), |p| p.display().to_string());
let content = format!(
"Path: {path}\n\n\
⚠️ This will move this path to the storage repo and replace it with a symlink.\n\
Make sure you know what you are doing."
);
let k = |a| config.keymap.get_key_display_for_action(a);
let footer_text = format!(
"{}: Confirm | {}: Cancel",
k(crate::keymap::Action::Confirm),
k(crate::keymap::Action::Quit)
);
let dialog = Dialog::new("Confirm Add Custom File", &content)
.height(40)
.dim_background(true)
.footer(&footer_text);
frame.render_widget(dialog, area);
Ok(())
}
fn handle_move_confirm(&mut self, key_code: KeyCode, config: &Config) -> Result<ScreenAction> {
let action = config
.keymap
.get_action(key_code, crossterm::event::KeyModifiers::NONE);
if let Some(action) = action {
match action {
crate::keymap::Action::Confirm => {
if let Some(idx) = self.state.confirm_move {
if idx < self.state.dotfiles.len() {
let dotfile = &self.state.dotfiles[idx];
if let Some(ref validation) = self.state.move_validation {
let has_path_conflict = validation.conflicts.iter().any(|c| {
matches!(c, crate::utils::MoveToCommonConflict::PathHierarchyConflict { .. })
});
if has_path_conflict {
self.state.confirm_move = None;
self.state.move_validation = None;
return Ok(ScreenAction::Refresh);
}
}
let profiles_to_cleanup = self
.state
.move_validation
.as_ref()
.map(|v| {
let mut profiles = v.profiles_to_cleanup.clone();
for conflict in &v.conflicts {
if let crate::utils::MoveToCommonConflict::DifferentContentInProfile {
profile_name,
..
} = conflict
{
if !profiles.contains(profile_name) {
profiles.push(profile_name.clone());
}
}
}
profiles
})
.unwrap_or_default();
let action = ScreenAction::MoveToCommon {
file_index: idx,
is_common: dotfile.is_common,
profiles_to_cleanup,
};
self.state.confirm_move = None;
self.state.move_validation = None;
return Ok(action);
}
}
self.state.confirm_move = None;
self.state.move_validation = None;
return Ok(ScreenAction::Refresh);
}
crate::keymap::Action::Quit | crate::keymap::Action::Cancel => {
self.state.confirm_move = None;
self.state.move_validation = None;
return Ok(ScreenAction::Refresh);
}
_ => {}
}
}
match key_code {
KeyCode::Char('y' | 'f') => {
if let Some(idx) = self.state.confirm_move {
if idx < self.state.dotfiles.len() {
let dotfile = &self.state.dotfiles[idx];
if let Some(ref validation) = self.state.move_validation {
let has_path_conflict = validation.conflicts.iter().any(|c| {
matches!(
c,
crate::utils::MoveToCommonConflict::PathHierarchyConflict { .. }
)
});
if has_path_conflict {
self.state.confirm_move = None;
self.state.move_validation = None;
return Ok(ScreenAction::Refresh);
}
}
let profiles_to_cleanup = self
.state
.move_validation
.as_ref()
.map(|v| {
let mut profiles = v.profiles_to_cleanup.clone();
for conflict in &v.conflicts {
if let crate::utils::MoveToCommonConflict::DifferentContentInProfile {
profile_name,
..
} = conflict
{
if !profiles.contains(profile_name) {
profiles.push(profile_name.clone());
}
}
}
profiles
})
.unwrap_or_default();
let action = ScreenAction::MoveToCommon {
file_index: idx,
is_common: dotfile.is_common,
profiles_to_cleanup,
};
self.state.confirm_move = None;
self.state.move_validation = None;
return Ok(action);
}
}
self.state.confirm_move = None;
self.state.move_validation = None;
Ok(ScreenAction::Refresh)
}
KeyCode::Char('n') => {
self.state.confirm_move = None;
self.state.move_validation = None;
Ok(ScreenAction::Refresh)
}
_ => Ok(ScreenAction::None),
}
}
fn handle_unsync_common_confirm(
&mut self,
key_code: KeyCode,
config: &Config,
) -> Result<ScreenAction> {
let action = config
.keymap
.get_action(key_code, crossterm::event::KeyModifiers::NONE);
if let Some(action) = action {
match action {
crate::keymap::Action::Confirm => {
if let Some(idx) = self.state.confirm_unsync_common {
self.state.confirm_unsync_common = None;
return Ok(ScreenAction::ToggleFileSync {
file_index: idx,
is_synced: true,
});
}
self.state.confirm_unsync_common = None;
return Ok(ScreenAction::Refresh);
}
crate::keymap::Action::Quit | crate::keymap::Action::Cancel => {
self.state.confirm_unsync_common = None;
return Ok(ScreenAction::Refresh);
}
_ => {}
}
}
match key_code {
KeyCode::Char('y') => {
if let Some(idx) = self.state.confirm_unsync_common {
self.state.confirm_unsync_common = None;
return Ok(ScreenAction::ToggleFileSync {
file_index: idx,
is_synced: true,
});
}
self.state.confirm_unsync_common = None;
Ok(ScreenAction::Refresh)
}
KeyCode::Char('n') => {
self.state.confirm_unsync_common = None;
Ok(ScreenAction::Refresh)
}
_ => Ok(ScreenAction::None),
}
}
fn handle_remove_custom_confirm(
&mut self,
key_code: KeyCode,
config: &Config,
) -> Result<ScreenAction> {
let action = config
.keymap
.get_action(key_code, crossterm::event::KeyModifiers::NONE);
if let Some(action) = action {
match action {
crate::keymap::Action::Confirm => {
if let Some(idx) = self.state.confirm_remove_custom {
self.state.confirm_remove_custom = None;
return Ok(ScreenAction::RemoveCustomFile { file_index: idx });
}
self.state.confirm_remove_custom = None;
return Ok(ScreenAction::Refresh);
}
crate::keymap::Action::Quit | crate::keymap::Action::Cancel => {
self.state.confirm_remove_custom = None;
return Ok(ScreenAction::Refresh);
}
_ => {}
}
}
match key_code {
KeyCode::Char('y') => {
if let Some(idx) = self.state.confirm_remove_custom {
self.state.confirm_remove_custom = None;
return Ok(ScreenAction::RemoveCustomFile { file_index: idx });
}
self.state.confirm_remove_custom = None;
Ok(ScreenAction::Refresh)
}
KeyCode::Char('n') => {
self.state.confirm_remove_custom = None;
Ok(ScreenAction::Refresh)
}
_ => Ok(ScreenAction::None),
}
}
fn render_move_confirm(&self, frame: &mut Frame, area: Rect, config: &Config) -> Result<()> {
let dotfile_name = if let Some(idx) = self.state.confirm_move {
if idx < self.state.dotfiles.len() {
self.state.dotfiles[idx].relative_path.display().to_string()
} else {
"Unknown".to_string()
}
} else {
"Unknown".to_string()
};
let is_moving_to_common = if let Some(idx) = self.state.confirm_move {
if idx < self.state.dotfiles.len() {
!self.state.dotfiles[idx].is_common
} else {
false
}
} else {
false
};
if let Some(ref validation) = self.state.move_validation {
let has_blocking = validation.conflicts.iter().any(|c| {
matches!(
c,
crate::utils::MoveToCommonConflict::DifferentContentInProfile { .. }
| crate::utils::MoveToCommonConflict::PathHierarchyConflict { .. }
)
});
if has_blocking {
let has_path_conflict = validation.conflicts.iter().any(|c| {
matches!(
c,
crate::utils::MoveToCommonConflict::PathHierarchyConflict { .. }
)
});
if has_path_conflict {
return self.render_move_blocked_dialog(frame, area, config);
}
return self.render_move_force_dialog(frame, area, config);
}
}
let title_text = if is_moving_to_common {
"Confirm Move to Common"
} else {
"Confirm Move to Profile"
};
let msg = if is_moving_to_common {
format!(
"Move '{dotfile_name}' to common files?\nIt will become available to all profiles."
)
} else {
format!(
"Move '{dotfile_name}' back to profile?\nIt will no longer be available to other profiles."
)
};
let k = |a| config.keymap.get_key_display_for_action(a);
let footer_text = format!(
"{}: Confirm | {}: Cancel",
k(crate::keymap::Action::Confirm),
k(crate::keymap::Action::Quit)
);
let dialog = Dialog::new(title_text, &msg)
.height(20)
.footer(&footer_text);
frame.render_widget(dialog, area);
Ok(())
}
fn render_move_force_dialog(
&self,
frame: &mut Frame,
area: Rect,
config: &Config,
) -> Result<()> {
let dotfile_name = if let Some(idx) = self.state.confirm_move {
if idx < self.state.dotfiles.len() {
self.state.dotfiles[idx].relative_path.display().to_string()
} else {
"Unknown".to_string()
}
} else {
"Unknown".to_string()
};
let mut conflict_lines = Vec::new();
if let Some(ref validation) = self.state.move_validation {
for conflict in &validation.conflicts {
if let crate::utils::MoveToCommonConflict::DifferentContentInProfile {
profile_name,
size_diff,
} = conflict
{
let size_text = if let Some((size1, size2)) = size_diff {
format!(" ({} vs {})", format_size(*size1), format_size(*size2))
} else {
String::new()
};
conflict_lines.push(format!(" • {profile_name}{size_text}"));
}
}
}
let conflict_list = conflict_lines.join("\n");
let msg = format!(
"⚠ \"{dotfile_name}\" exists in other profiles with DIFFERENT\n\
content:\n\n{conflict_list}\n\n\
If you proceed, their versions will be DELETED and\n\
replaced with the common version.\n\n\
Tip: To preserve different configs, remove them from\n\
sync first in each profile."
);
let k = |a| config.keymap.get_key_display_for_action(a);
let footer_text = format!(
"{}: Force (delete others) | {}: Cancel",
k(crate::keymap::Action::Confirm),
k(crate::keymap::Action::Quit)
);
let dialog = Dialog::new("Content Differs", &msg)
.variant(DialogVariant::Warning)
.footer(&footer_text);
frame.render_widget(dialog, area);
Ok(())
}
fn render_move_blocked_dialog(
&self,
frame: &mut Frame,
area: Rect,
config: &Config,
) -> Result<()> {
let dotfile_name = if let Some(idx) = self.state.confirm_move {
if idx < self.state.dotfiles.len() {
self.state.dotfiles[idx].relative_path.display().to_string()
} else {
"Unknown".to_string()
}
} else {
"Unknown".to_string()
};
let mut conflict_msg = String::new();
if let Some(ref validation) = self.state.move_validation {
for conflict in &validation.conflicts {
if let crate::utils::MoveToCommonConflict::PathHierarchyConflict {
profile_name,
conflicting_path,
is_parent,
} = conflict
{
if *is_parent {
conflict_msg.push_str(&format!(
" You are trying to move: {dotfile_name}\n\
But profile \"{profile_name}\" has: {conflicting_path} (directory)\n\n"
));
} else {
conflict_msg.push_str(&format!(
" You are trying to move: {dotfile_name} (directory)\n\
But profile \"{profile_name}\" has: {conflicting_path}\n\n"
));
}
}
}
}
let msg = format!(
"✗ Path conflict detected:\n\n{conflict_msg}\
This would create an invalid state.\n\n\
To fix: Remove the conflicting path from sync in the\n\
affected profile first."
);
let k = |a| config.keymap.get_key_display_for_action(a);
let footer_text = format!("{}: OK", k(crate::keymap::Action::Confirm));
let dialog = Dialog::new("Cannot Move to Common", &msg)
.variant(DialogVariant::Error)
.footer(&footer_text);
frame.render_widget(dialog, area);
Ok(())
}
fn render_unsync_common_confirm(
&self,
frame: &mut Frame,
area: Rect,
config: &Config,
) -> Result<()> {
let dotfile_name = if let Some(idx) = self.state.confirm_unsync_common {
if idx < self.state.dotfiles.len() {
self.state.dotfiles[idx].relative_path.display().to_string()
} else {
"Unknown".to_string()
}
} else {
"Unknown".to_string()
};
let msg = format!(
"Remove '{dotfile_name}' from sync?\n\n\
This file is in 'common' and is shared across ALL profiles.\n\
Removing it will affect every profile that uses it."
);
let k = |a| config.keymap.get_key_display_for_action(a);
let footer_text = format!(
"{}/y: Confirm | {}/n: Cancel",
k(crate::keymap::Action::Confirm),
k(crate::keymap::Action::Cancel)
);
let dialog = Dialog::new("Remove Common File", &msg)
.variant(DialogVariant::Warning)
.footer(&footer_text);
frame.render_widget(dialog, area);
Ok(())
}
fn render_remove_custom_confirm(
&self,
frame: &mut Frame,
area: Rect,
config: &Config,
) -> Result<()> {
let dotfile_name = if let Some(idx) = self.state.confirm_remove_custom {
if idx < self.state.dotfiles.len() {
self.state.dotfiles[idx].relative_path.display().to_string()
} else {
"Unknown".to_string()
}
} else {
"Unknown".to_string()
};
let msg = format!(
"Remove '{dotfile_name}' from the file list?\n\n\
This will permanently remove this custom file entry.\n\
The file itself will NOT be deleted from your system."
);
let k = |a| config.keymap.get_key_display_for_action(a);
let footer_text = format!(
"{}/y: Confirm | {}/n: Cancel",
k(crate::keymap::Action::Confirm),
k(crate::keymap::Action::Cancel)
);
let dialog = Dialog::new("Remove Custom File", &msg)
.variant(DialogVariant::Warning)
.footer(&footer_text);
frame.render_widget(dialog, area);
Ok(())
}
pub fn process_action(
&mut self,
action: DotfileAction,
config: &mut Config,
config_path: &Path,
) -> Result<ActionResult> {
debug!("Processing dotfile action: {:?}", action);
match action {
DotfileAction::ScanDotfiles => {
self.scan_dotfiles(config)?;
Ok(ActionResult::None)
}
DotfileAction::RefreshFileBrowser => {
self.refresh_file_browser(config)?;
Ok(ActionResult::None)
}
DotfileAction::ToggleFileSync {
file_index,
is_synced,
} => self.toggle_file_sync(config, file_index, is_synced),
DotfileAction::AddCustomFileToSync {
full_path,
relative_path,
} => self.add_custom_file_to_sync(config, config_path, full_path, relative_path),
DotfileAction::SetBackupEnabled { enabled } => {
self.state.backup_enabled = enabled;
Ok(ActionResult::None)
}
DotfileAction::MoveToCommon {
file_index,
is_common,
profiles_to_cleanup,
} => self.move_to_common(config, file_index, is_common, profiles_to_cleanup),
DotfileAction::RemoveCustomFile { file_index } => {
self.remove_custom_file(config, config_path, file_index)
}
}
}
pub fn scan_dotfiles(&mut self, config: &Config) -> Result<()> {
info!("Scanning for dotfiles...");
let dotfiles = SyncService::scan_dotfiles(config)?;
debug!("Found {} dotfiles", dotfiles.len());
self.state.dotfiles = dotfiles;
self.state.selected_for_sync.clear();
for (i, dotfile) in self.state.dotfiles.iter().enumerate() {
if dotfile.synced {
self.state.selected_for_sync.insert(i);
}
}
self.state.dotfile_list_scrollbar = self
.state
.dotfile_list_scrollbar
.content_length(self.state.dotfiles.len());
if !self.state.dotfiles.is_empty() && self.state.dotfile_list_state.selected().is_none() {
let display_items = self.get_display_items(&config.active_profile);
for (i, item) in display_items.iter().enumerate() {
if matches!(item, DisplayItem::File(_)) {
self.state.dotfile_list_state.select(Some(i));
break;
}
}
}
info!("Dotfile scan complete");
Ok(())
}
pub fn refresh_file_browser(&mut self, _config: &Config) -> Result<()> {
debug!("Refreshing file browser entries");
Ok(())
}
pub fn toggle_file_sync(
&mut self,
config: &Config,
file_index: usize,
is_synced: bool,
) -> Result<ActionResult> {
if file_index >= self.state.dotfiles.len() {
warn!("Invalid file index: {}", file_index);
return Ok(ActionResult::ShowToast {
message: "Invalid file selection".to_string(),
variant: crate::widgets::ToastVariant::Error,
});
}
if is_synced {
self.remove_file_from_sync(config, file_index)
} else {
self.add_file_to_sync(config, file_index)
}
}
fn add_file_to_sync(&mut self, config: &Config, file_index: usize) -> Result<ActionResult> {
let dotfile = &self.state.dotfiles[file_index];
let relative_path = dotfile.relative_path.to_string_lossy().to_string();
let full_path = dotfile.original_path.clone();
info!("Adding file to sync: {}", relative_path);
match SyncService::add_file_to_sync(
config,
&full_path,
&relative_path,
self.state.backup_enabled,
) {
Ok(crate::services::AddFileResult::Success) => {
self.state.selected_for_sync.insert(file_index);
self.state.dotfiles[file_index].synced = true;
info!("Successfully added file to sync: {}", relative_path);
Ok(ActionResult::ShowToast {
message: format!("Added {relative_path} to sync"),
variant: crate::widgets::ToastVariant::Success,
})
}
Ok(crate::services::AddFileResult::AlreadySynced) => {
self.state.selected_for_sync.insert(file_index);
self.state.dotfiles[file_index].synced = true;
Ok(ActionResult::ShowToast {
message: format!("{relative_path} is already synced"),
variant: crate::widgets::ToastVariant::Info,
})
}
Ok(crate::services::AddFileResult::ValidationFailed(msg)) => {
warn!("Validation failed for {}: {}", relative_path, msg);
Ok(ActionResult::ShowDialog {
title: "Cannot Add File".to_string(),
content: msg,
variant: crate::widgets::DialogVariant::Error,
})
}
Err(e) => {
warn!("Error adding file to sync: {}", e);
Ok(ActionResult::ShowToast {
message: format!("Error: {e}"),
variant: crate::widgets::ToastVariant::Error,
})
}
}
}
fn remove_file_from_sync(
&mut self,
config: &Config,
file_index: usize,
) -> Result<ActionResult> {
let dotfile = &self.state.dotfiles[file_index];
let relative_path = dotfile.relative_path.to_string_lossy().to_string();
if dotfile.is_common {
info!("Removing common file from sync: {}", relative_path);
match SyncService::remove_common_file_from_sync(config, &relative_path) {
Ok(crate::services::RemoveFileResult::Success) => {
self.state.selected_for_sync.remove(&file_index);
self.state.dotfiles[file_index].synced = false;
self.state.dotfiles[file_index].is_common = false;
info!(
"Successfully removed common file from sync: {}",
relative_path
);
Ok(ActionResult::ShowToast {
message: format!("Removed {relative_path} from sync"),
variant: crate::widgets::ToastVariant::Success,
})
}
Ok(crate::services::RemoveFileResult::NotSynced) => {
self.state.selected_for_sync.remove(&file_index);
self.state.dotfiles[file_index].synced = false;
Ok(ActionResult::ShowToast {
message: format!("{relative_path} is not synced"),
variant: crate::widgets::ToastVariant::Info,
})
}
Err(e) => {
warn!("Error removing common file from sync: {}", e);
Ok(ActionResult::ShowToast {
message: format!("Error: {e}"),
variant: crate::widgets::ToastVariant::Error,
})
}
}
} else {
info!("Removing file from sync: {}", relative_path);
match SyncService::remove_file_from_sync(config, &relative_path) {
Ok(crate::services::RemoveFileResult::Success) => {
self.state.selected_for_sync.remove(&file_index);
self.state.dotfiles[file_index].synced = false;
info!("Successfully removed file from sync: {}", relative_path);
Ok(ActionResult::ShowToast {
message: format!("Removed {relative_path} from sync"),
variant: crate::widgets::ToastVariant::Success,
})
}
Ok(crate::services::RemoveFileResult::NotSynced) => {
self.state.selected_for_sync.remove(&file_index);
self.state.dotfiles[file_index].synced = false;
Ok(ActionResult::ShowToast {
message: format!("{relative_path} is not synced"),
variant: crate::widgets::ToastVariant::Info,
})
}
Err(e) => {
warn!("Error removing file from sync: {}", e);
Ok(ActionResult::ShowToast {
message: format!("Error: {e}"),
variant: crate::widgets::ToastVariant::Error,
})
}
}
}
}
pub fn add_custom_file_to_sync(
&mut self,
config: &mut Config,
config_path: &Path,
full_path: PathBuf,
relative_path: String,
) -> Result<ActionResult> {
info!("Adding custom file to sync: {}", relative_path);
if !full_path.exists() {
return Ok(ActionResult::ShowDialog {
title: "File Not Found".to_string(),
content: format!("The file {} does not exist", full_path.display()),
variant: crate::widgets::DialogVariant::Error,
});
}
let (is_safe, reason) = crate::utils::is_safe_to_add(&full_path, &config.repo_path);
if !is_safe {
return Ok(ActionResult::ShowDialog {
title: "Cannot Add File".to_string(),
content: reason.unwrap_or_else(|| "Cannot add this file".to_string()),
variant: crate::widgets::DialogVariant::Error,
});
}
match SyncService::add_file_to_sync(
config,
&full_path,
&relative_path,
self.state.backup_enabled,
) {
Ok(crate::services::AddFileResult::Success) => {
if !config.custom_files.contains(&relative_path) {
config.custom_files.push(relative_path.clone());
if let Err(e) = config.save(config_path) {
warn!("Failed to save config: {}", e);
}
}
self.scan_dotfiles(config)?;
info!("Successfully added custom file to sync: {}", relative_path);
Ok(ActionResult::ShowToast {
message: format!("Added {relative_path} to sync"),
variant: crate::widgets::ToastVariant::Success,
})
}
Ok(crate::services::AddFileResult::AlreadySynced) => Ok(ActionResult::ShowToast {
message: format!("{relative_path} is already synced"),
variant: crate::widgets::ToastVariant::Info,
}),
Ok(crate::services::AddFileResult::ValidationFailed(msg)) => {
warn!(
"Validation failed for custom file {}: {}",
relative_path, msg
);
Ok(ActionResult::ShowDialog {
title: "Cannot Add File".to_string(),
content: msg,
variant: crate::widgets::DialogVariant::Error,
})
}
Err(e) => {
warn!("Error adding custom file to sync: {}", e);
Ok(ActionResult::ShowToast {
message: format!("Error: {e}"),
variant: crate::widgets::ToastVariant::Error,
})
}
}
}
fn remove_custom_file(
&mut self,
config: &mut Config,
config_path: &Path,
file_index: usize,
) -> Result<ActionResult> {
if file_index >= self.state.dotfiles.len() {
return Ok(ActionResult::ShowToast {
message: "Invalid file index".into(),
variant: crate::widgets::ToastVariant::Error,
});
}
let dotfile = &self.state.dotfiles[file_index];
if dotfile.synced {
return Ok(ActionResult::ShowToast {
message: "Unsync the file first before removing it".into(),
variant: crate::widgets::ToastVariant::Info,
});
}
if !dotfile.is_custom {
return Ok(ActionResult::ShowToast {
message: "Only custom-added files can be removed from the list".into(),
variant: crate::widgets::ToastVariant::Info,
});
}
let relative_path = dotfile.relative_path.to_string_lossy().to_string();
config.custom_files.retain(|f| f != &relative_path);
if let Err(e) = config.save(config_path) {
warn!("Failed to save config: {}", e);
return Ok(ActionResult::ShowToast {
message: format!("Error saving config: {e}"),
variant: crate::widgets::ToastVariant::Error,
});
}
self.scan_dotfiles(config)?;
info!("Removed custom file entry: {}", relative_path);
Ok(ActionResult::ShowToast {
message: format!("Removed {relative_path} from file list"),
variant: crate::widgets::ToastVariant::Success,
})
}
pub fn move_to_common(
&mut self,
config: &Config,
file_index: usize,
is_common: bool,
profiles_to_cleanup: Vec<String>,
) -> Result<ActionResult> {
if file_index >= self.state.dotfiles.len() {
warn!("Invalid file index: {}", file_index);
return Ok(ActionResult::ShowToast {
message: "Invalid file selection".to_string(),
variant: crate::widgets::ToastVariant::Error,
});
}
let dotfile = &self.state.dotfiles[file_index];
let relative_path = dotfile.relative_path.to_string_lossy().to_string();
if is_common {
info!("Moving {} from common to profile", relative_path);
match SyncService::move_from_common(config, &relative_path) {
Ok(()) => {
self.state.dotfiles[file_index].is_common = false;
info!("Successfully moved {} to profile", relative_path);
Ok(ActionResult::ShowToast {
message: format!("Moved {relative_path} to profile"),
variant: crate::widgets::ToastVariant::Success,
})
}
Err(e) => {
warn!("Error moving file from common: {}", e);
Ok(ActionResult::ShowToast {
message: format!("Error: {e}"),
variant: crate::widgets::ToastVariant::Error,
})
}
}
} else {
info!(
"Moving {} from profile to common (cleanup: {} profiles)",
relative_path,
profiles_to_cleanup.len()
);
let result = if profiles_to_cleanup.is_empty() {
SyncService::move_to_common(config, &relative_path)
} else {
SyncService::move_to_common_with_cleanup(
config,
&relative_path,
&profiles_to_cleanup,
)
};
match result {
Ok(()) => {
self.state.dotfiles[file_index].is_common = true;
info!("Successfully moved {} to common", relative_path);
Ok(ActionResult::ShowToast {
message: format!("Moved {relative_path} to common"),
variant: crate::widgets::ToastVariant::Success,
})
}
Err(e) => {
warn!("Error moving file to common: {}", e);
Ok(ActionResult::ShowToast {
message: format!("Error: {e}"),
variant: crate::widgets::ToastVariant::Error,
})
}
}
}
}
}
fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{bytes}B")
} else if bytes < 1024 * 1024 {
format!("{:.1}KB", bytes as f64 / 1024.0)
} else {
format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
}
}
impl Default for DotfileSelectionScreen {
fn default() -> Self {
Self::new()
}
}
impl Screen for DotfileSelectionScreen {
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &RenderContext) -> Result<()> {
frame.render_widget(Clear, area);
let t = ui_theme();
let background = Block::default().style(t.background_style());
frame.render_widget(background, area);
let (header_chunk, content_chunk, footer_chunk) = create_standard_layout(area, 5, 3);
let _ = Header::render(
frame,
header_chunk,
"DotState - Manage Files",
"Add or remove files to your repository. You can also add custom files. We have automatically detected some common dotfiles for you."
)?;
if self.state.adding_custom_file && !self.file_browser.is_open() {
self.render_custom_file_input(frame, content_chunk, footer_chunk, ctx.config)?;
} else {
self.render_dotfile_list(
frame,
content_chunk,
footer_chunk,
ctx.config,
ctx.syntax_set,
ctx.syntax_theme,
)?;
}
if self.file_browser.is_open() {
self.file_browser
.render(frame, area, ctx.config, ctx.syntax_set, ctx.syntax_theme)?;
}
if self.state.show_custom_file_confirm {
self.render_custom_file_confirm(frame, area, ctx.config)?;
} else if self.state.confirm_move.is_some() {
self.render_move_confirm(frame, area, ctx.config)?;
} else if self.state.confirm_unsync_common.is_some() {
self.render_unsync_common_confirm(frame, area, ctx.config)?;
} else if self.state.confirm_remove_custom.is_some() {
self.render_remove_custom_confirm(frame, area, ctx.config)?;
}
Ok(())
}
fn handle_event(&mut self, event: Event, ctx: &ScreenContext) -> Result<ScreenAction> {
if self.state.show_custom_file_confirm {
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
return self.handle_modal_event(key.code, ctx.config);
}
}
return Ok(ScreenAction::None);
}
if self.state.confirm_move.is_some() {
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
return self.handle_move_confirm(key.code, ctx.config);
}
}
return Ok(ScreenAction::None);
}
if self.state.confirm_unsync_common.is_some() {
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
return self.handle_unsync_common_confirm(key.code, ctx.config);
}
}
return Ok(ScreenAction::None);
}
if self.state.confirm_remove_custom.is_some() {
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
return self.handle_remove_custom_confirm(key.code, ctx.config);
}
}
return Ok(ScreenAction::None);
}
if self.file_browser.is_open() {
let result = self.file_browser.handle_event(event, ctx.config)?;
match result {
FileBrowserResult::None | FileBrowserResult::RefreshNeeded => {
return Ok(ScreenAction::None);
}
FileBrowserResult::Cancelled => {
self.state.adding_custom_file = false;
self.state.focus = DotfileSelectionFocus::FilesList;
return Ok(ScreenAction::None);
}
FileBrowserResult::Selected {
full_path,
relative_path,
} => {
self.state.adding_custom_file = false;
self.state.focus = DotfileSelectionFocus::FilesList;
return Ok(ScreenAction::AddCustomFileToSync {
full_path,
relative_path,
});
}
}
}
if self.state.adding_custom_file && !self.file_browser.is_open() {
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
if let KeyCode::Char(c) = key.code {
if !key.modifiers.intersects(
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
) {
self.state.custom_file_input.insert_char(c);
return Ok(ScreenAction::Refresh);
}
}
return self.handle_custom_file_input(key.code, ctx.config);
}
}
return Ok(ScreenAction::None);
}
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => match self.state.focus {
DotfileSelectionFocus::FilesList => {
return self.handle_dotfile_list(key.code, ctx.config);
}
DotfileSelectionFocus::Preview => {
return self.handle_preview(key.code, ctx.config);
}
_ => {}
},
Event::Mouse(mouse) => {
return self.handle_mouse_event(mouse, ctx.config);
}
_ => {}
}
Ok(ScreenAction::None)
}
fn is_input_focused(&self) -> bool {
if self.file_browser.is_open() {
self.file_browser.is_input_focused()
} else if self.state.adding_custom_file {
self.state.custom_file_focused
} else {
false
}
}
fn on_enter(&mut self, _ctx: &ScreenContext) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dotfile_selection_screen_creation() {
let screen = DotfileSelectionScreen::new();
assert!(!screen.is_input_focused());
assert!(screen.state.dotfiles.is_empty());
}
#[test]
fn test_set_backup_enabled() {
let mut screen = DotfileSelectionScreen::new();
assert!(screen.state.backup_enabled);
screen.set_backup_enabled(false);
assert!(!screen.state.backup_enabled);
}
}