1use crate::components::file_preview::FilePreview;
7use crate::components::footer::Footer;
8use crate::components::header::Header;
9use crate::components::{FileBrowser, FileBrowserResult};
10use crate::config::Config;
11use crate::file_manager::Dotfile;
12use crate::screens::screen_trait::{RenderContext, Screen, ScreenAction, ScreenContext};
13use crate::screens::ActionResult;
14use crate::services::SyncService;
15use crate::styles::{theme as ui_theme, LIST_HIGHLIGHT_SYMBOL};
16use crate::ui::Screen as ScreenId;
17use crate::utils::{
18 create_split_layout, create_standard_layout, focused_border_style, unfocused_border_style,
19 TextInput,
20};
21use crate::widgets::{Dialog, DialogVariant};
22use crate::widgets::{TextInputWidget, TextInputWidgetExt};
23use anyhow::Result;
24use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
25use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
26use ratatui::style::{Modifier, Style};
27use tracing::{debug, info, warn};
28
29use ratatui::widgets::{
30 Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation,
31 ScrollbarState, StatefulWidget, Wrap,
32};
33use ratatui::Frame;
34use std::path::{Path, PathBuf};
35use syntect::highlighting::Theme;
36use syntect::parsing::SyntaxSet;
37
38#[derive(Debug, Clone, PartialEq)]
40enum DisplayItem {
41 Header(String), File(usize), }
44
45#[derive(Debug, Clone)]
47pub enum DotfileAction {
48 ScanDotfiles,
50 RefreshFileBrowser,
52 ToggleFileSync { file_index: usize, is_synced: bool },
54 AddCustomFileToSync {
56 full_path: PathBuf,
57 relative_path: String,
58 },
59 SetBackupEnabled { enabled: bool },
61 MoveToCommon {
63 file_index: usize,
64 is_common: bool,
65 profiles_to_cleanup: Vec<String>,
66 },
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum DotfileSelectionFocus {
72 FilesList, Preview, FileBrowserList, FileBrowserPreview, FileBrowserInput, }
78
79#[derive(Debug)]
81pub struct DotfileSelectionState {
82 pub dotfiles: Vec<Dotfile>,
83 pub preview_index: Option<usize>,
84 pub preview_scroll: usize,
85 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>, }
114
115impl Default for DotfileSelectionState {
116 fn default() -> Self {
117 Self {
118 dotfiles: Vec::new(),
119 preview_index: None,
120 preview_scroll: 0,
121 selected_for_sync: std::collections::HashSet::new(),
122 dotfile_list_scrollbar: ScrollbarState::new(0),
123 dotfile_list_state: ListState::default(),
124 status_message: None,
125 adding_custom_file: false,
126 custom_file_input: TextInput::new(),
127 custom_file_focused: true,
128 file_browser_mode: false,
129 file_browser_path: dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")),
130 file_browser_selected: 0,
131 file_browser_entries: Vec::new(),
132 file_browser_scrollbar: ScrollbarState::new(0),
133 file_browser_list_state: ListState::default(),
134 file_browser_preview_scroll: 0,
135 file_browser_path_input: TextInput::new(),
136 file_browser_path_focused: false,
137 focus: DotfileSelectionFocus::FilesList, backup_enabled: true, show_custom_file_confirm: false,
140 custom_file_confirm_path: None,
141 custom_file_confirm_relative: None,
142 confirm_move: None,
143 move_validation: None,
144 confirm_unsync_common: None,
145 }
146 }
147}
148
149pub struct DotfileSelectionScreen {
151 state: DotfileSelectionState,
152 file_browser: FileBrowser,
154}
155
156impl DotfileSelectionScreen {
157 #[must_use]
159 pub fn new() -> Self {
160 Self {
161 state: DotfileSelectionState::default(),
162 file_browser: FileBrowser::new(),
163 }
164 }
165
166 #[must_use]
168 pub fn get_state(&self) -> &DotfileSelectionState {
169 &self.state
170 }
171
172 pub fn get_state_mut(&mut self) -> &mut DotfileSelectionState {
174 &mut self.state
175 }
176
177 pub fn set_backup_enabled(&mut self, enabled: bool) {
179 self.state.backup_enabled = enabled;
180 }
181
182 fn get_display_items(&self, profile_name: &str) -> Vec<DisplayItem> {
184 let mut items = Vec::new();
185
186 let common_indices: Vec<usize> = self
188 .state
189 .dotfiles
190 .iter()
191 .enumerate()
192 .filter(|(_, d)| d.is_common)
193 .map(|(i, _)| i)
194 .collect();
195
196 if !common_indices.is_empty() {
197 items.push(DisplayItem::Header("Common Files (Shared)".to_string()));
198 for idx in common_indices {
199 items.push(DisplayItem::File(idx));
200 }
201 }
202
203 let profile_indices: Vec<usize> = self
205 .state
206 .dotfiles
207 .iter()
208 .enumerate()
209 .filter(|(_, d)| !d.is_common)
210 .map(|(i, _)| i)
211 .collect();
212
213 if !profile_indices.is_empty() {
214 if !items.is_empty() {
215 items.push(DisplayItem::Header(String::new())); }
217 items.push(DisplayItem::Header(format!(
218 "Profile Files ({profile_name})"
219 )));
220 for idx in profile_indices {
221 items.push(DisplayItem::File(idx));
222 }
223 }
224
225 items
226 }
227
228 fn handle_modal_event(&mut self, key_code: KeyCode, config: &Config) -> Result<ScreenAction> {
230 let action = config
231 .keymap
232 .get_action(key_code, crossterm::event::KeyModifiers::NONE);
233 use crate::keymap::Action;
234
235 match action {
236 Some(Action::Yes | Action::Confirm) => {
237 let full_path = self.state.custom_file_confirm_path.clone().unwrap();
239 let relative_path = self.state.custom_file_confirm_relative.clone().unwrap();
240 self.state.show_custom_file_confirm = false;
241 self.state.custom_file_confirm_path = None;
242 self.state.custom_file_confirm_relative = None;
243
244 Ok(ScreenAction::AddCustomFileToSync {
245 full_path,
246 relative_path,
247 })
248 }
249 Some(Action::No | Action::Cancel) => {
250 self.state.show_custom_file_confirm = false;
252 self.state.custom_file_confirm_path = None;
253 self.state.custom_file_confirm_relative = None;
254 Ok(ScreenAction::None)
255 }
256 _ => Ok(ScreenAction::None),
257 }
258 }
259
260 fn handle_custom_file_input(
262 &mut self,
263 key_code: KeyCode,
264 config: &Config,
265 ) -> Result<ScreenAction> {
266 if !self.state.custom_file_focused {
268 match key_code {
269 KeyCode::Enter => {
270 self.state.custom_file_focused = true;
271 return Ok(ScreenAction::None);
272 }
273 KeyCode::Esc => {
274 self.state.adding_custom_file = false;
275 self.state.custom_file_input.clear();
276 return Ok(ScreenAction::None);
277 }
278 _ => return Ok(ScreenAction::None),
279 }
280 }
281
282 match key_code {
284 KeyCode::Char(c) => {
285 self.state.custom_file_input.insert_char(c);
286 }
287 KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End => {
288 self.state.custom_file_input.handle_key(key_code);
289 }
290 KeyCode::Backspace => {
291 self.state.custom_file_input.backspace();
292 }
293 KeyCode::Delete => {
294 self.state.custom_file_input.delete();
295 }
296 KeyCode::Tab => {
297 self.state.custom_file_focused = false;
298 }
299 KeyCode::Enter => {
300 let path_str = self.state.custom_file_input.text_trimmed();
301 if path_str.is_empty() {
302 return Ok(ScreenAction::ShowMessage {
303 title: "Invalid Path".to_string(),
304 content: "File path cannot be empty".to_string(),
305 });
306 } else {
307 let full_path = crate::utils::expand_path(path_str);
308
309 if full_path.exists() {
310 let home_dir = crate::utils::get_home_dir();
312 let relative_path = match full_path.strip_prefix(&home_dir) {
313 Ok(p) => p.to_string_lossy().to_string(),
314 Err(_) => path_str.to_string(),
315 };
316
317 self.state.adding_custom_file = false;
319 self.state.custom_file_input.clear();
320 self.state.focus = DotfileSelectionFocus::FilesList;
321
322 let repo_path = &config.repo_path;
324 let (is_safe, reason) = crate::utils::is_safe_to_add(&full_path, repo_path);
325 if !is_safe {
326 return Ok(ScreenAction::ShowMessage {
327 title: "Cannot Add File".to_string(),
328 content: format!(
329 "{}.\n\nPath: {}",
330 reason.unwrap_or_else(|| "Cannot add this file".to_string()),
331 full_path.display()
332 ),
333 });
334 }
335
336 self.state.show_custom_file_confirm = true;
338 self.state.custom_file_confirm_path = Some(full_path);
339 self.state.custom_file_confirm_relative = Some(relative_path);
340 } else {
341 return Ok(ScreenAction::ShowMessage {
342 title: "File Not Found".to_string(),
343 content: format!("File does not exist: {full_path:?}"),
344 });
345 }
346 }
347 }
348 KeyCode::Esc => {
349 self.state.adding_custom_file = false;
350 self.state.custom_file_input.clear();
351 self.state.focus = DotfileSelectionFocus::FilesList;
352 }
353 _ => {}
354 }
355
356 Ok(ScreenAction::None)
357 }
358
359 fn handle_dotfile_list(&mut self, key_code: KeyCode, config: &Config) -> Result<ScreenAction> {
361 let action = config
362 .keymap
363 .get_action(key_code, crossterm::event::KeyModifiers::NONE);
364 use crate::keymap::Action;
365
366 let display_items = self.get_display_items(&config.active_profile);
367
368 if let Some(action) = action {
369 match action {
370 Action::MoveUp => {
371 if display_items.is_empty() {
372 return Ok(ScreenAction::None);
373 }
374
375 let current = self.state.dotfile_list_state.selected().unwrap_or(0);
376 let mut prev = current;
378 let mut found = false;
379 while prev > 0 {
380 prev -= 1;
381 if !matches!(display_items[prev], DisplayItem::Header(_)) {
382 found = true;
383 break;
384 }
385 }
386
387 if found {
388 self.state.dotfile_list_state.select(Some(prev));
389 self.state.preview_scroll = 0;
390 } else {
391 if matches!(display_items[current], DisplayItem::Header(_)) {
394 for (i, item) in display_items.iter().enumerate() {
395 if !matches!(item, DisplayItem::Header(_)) {
396 self.state.dotfile_list_state.select(Some(i));
397 break;
398 }
399 }
400 }
401 }
402 }
403 Action::MoveDown => {
404 if display_items.is_empty() {
405 return Ok(ScreenAction::None);
406 }
407
408 let current = self.state.dotfile_list_state.selected().unwrap_or(0);
409 let mut next = current + 1;
411 while next < display_items.len() {
412 if !matches!(display_items[next], DisplayItem::Header(_)) {
413 self.state.dotfile_list_state.select(Some(next));
414 self.state.preview_scroll = 0;
415 break;
416 }
417 next += 1;
418 }
419
420 if next >= display_items.len()
422 && matches!(display_items[current], DisplayItem::Header(_))
423 {
424 let mut fix_idx = current + 1;
426 while fix_idx < display_items.len() {
427 if !matches!(display_items[fix_idx], DisplayItem::Header(_)) {
428 self.state.dotfile_list_state.select(Some(fix_idx));
429 break;
430 }
431 fix_idx += 1;
432 }
433 }
434 }
435 Action::Confirm => {
436 if let Some(idx) = self.state.dotfile_list_state.selected() {
437 if idx < display_items.len() {
438 if let DisplayItem::File(file_idx) = &display_items[idx] {
439 let is_synced = self.state.selected_for_sync.contains(file_idx);
440 let dotfile = &self.state.dotfiles[*file_idx];
441
442 if is_synced && dotfile.is_common {
444 self.state.confirm_unsync_common = Some(*file_idx);
445 return Ok(ScreenAction::Refresh);
446 }
447
448 return Ok(ScreenAction::ToggleFileSync {
449 file_index: *file_idx,
450 is_synced,
451 });
452 }
453 }
454 }
455 }
456 Action::NextTab => {
457 self.state.focus = DotfileSelectionFocus::Preview;
458 }
459 Action::PageUp => {
460 if display_items.is_empty() {
461 return Ok(ScreenAction::None);
462 }
463 let current = self.state.dotfile_list_state.selected().unwrap_or(0);
465 let target = current.saturating_sub(10);
466 let mut next = target;
467
468 if next < display_items.len()
471 && matches!(display_items[next], DisplayItem::Header(_))
472 {
473 next = next.saturating_add(1); }
475 if next >= display_items.len() {
476 next = current;
477 } self.state.dotfile_list_state.select(Some(next));
480 self.state.preview_scroll = 0;
481 }
482 Action::PageDown => {
483 if display_items.is_empty() {
484 return Ok(ScreenAction::None);
485 }
486 let current = self.state.dotfile_list_state.selected().unwrap_or(0);
487 let target = current.saturating_add(10);
488 let mut next = target;
489 if next >= display_items.len() {
490 next = display_items.len() - 1;
491 }
492
493 if matches!(display_items[next], DisplayItem::Header(_)) {
495 next = next.saturating_add(1);
496 }
497 if next >= display_items.len() {
498 next = current;
499 } self.state.dotfile_list_state.select(Some(next));
502 self.state.preview_scroll = 0;
503 }
504 Action::GoToTop => {
505 if let Some(first_idx) = display_items
507 .iter()
508 .position(|item| matches!(item, DisplayItem::File(_)))
509 {
510 self.state.dotfile_list_state.select(Some(first_idx));
511 }
512 self.state.preview_scroll = 0;
513 }
514 Action::GoToEnd => {
515 if let Some(last_idx) = display_items
517 .iter()
518 .rposition(|item| matches!(item, DisplayItem::File(_)))
519 {
520 self.state.dotfile_list_state.select(Some(last_idx));
521 }
522 self.state.preview_scroll = 0;
523 }
524 Action::Create => {
525 self.state.adding_custom_file = true;
527 self.file_browser.open(crate::utils::get_home_dir());
528 return Ok(ScreenAction::None);
529 }
530 Action::ToggleBackup => {
531 self.state.backup_enabled = !self.state.backup_enabled;
532 return Ok(ScreenAction::SetBackupEnabled {
533 enabled: self.state.backup_enabled,
534 });
535 }
536 Action::Cancel | Action::Quit => {
537 return Ok(ScreenAction::Navigate(ScreenId::MainMenu));
538 }
539 Action::Move => {
540 if let Some(idx) = self.state.dotfile_list_state.selected() {
541 if idx < display_items.len() {
542 if let DisplayItem::File(file_idx) = &display_items[idx] {
543 let dotfile = &self.state.dotfiles[*file_idx];
544 if dotfile.synced {
545 if !dotfile.is_common {
547 let relative_path =
549 dotfile.relative_path.to_string_lossy().to_string();
550 match crate::utils::validate_move_to_common(
551 &config.repo_path,
552 &config.active_profile,
553 &relative_path,
554 ) {
555 Ok(validation) => {
556 self.state.move_validation = Some(validation);
557 self.state.confirm_move = Some(*file_idx);
560 return Ok(ScreenAction::Refresh);
561 }
562 Err(e) => {
563 return Ok(ScreenAction::ShowMessage {
565 title: "Validation Error".to_string(),
566 content: format!(
567 "Failed to validate move: {e}"
568 ),
569 });
570 }
571 }
572 }
573 self.state.confirm_move = Some(*file_idx);
575 return Ok(ScreenAction::Refresh);
576 }
577 }
578 }
579 }
580 }
581 _ => {}
582 }
583 }
584
585 Ok(ScreenAction::None)
586 }
587
588 fn handle_preview(&mut self, key_code: KeyCode, config: &Config) -> Result<ScreenAction> {
590 let action = config
591 .keymap
592 .get_action(key_code, crossterm::event::KeyModifiers::NONE);
593 use crate::keymap::Action;
594
595 if let Some(action) = action {
596 match action {
597 Action::MoveUp | Action::ScrollUp => {
598 self.state.preview_scroll = self.state.preview_scroll.saturating_sub(1);
599 }
600 Action::MoveDown | Action::ScrollDown => {
601 self.state.preview_scroll = self.state.preview_scroll.saturating_add(1);
602 }
603 Action::PageUp => {
604 self.state.preview_scroll = self.state.preview_scroll.saturating_sub(20);
605 }
606 Action::PageDown => {
607 self.state.preview_scroll = self.state.preview_scroll.saturating_add(20);
608 }
609 Action::GoToTop => {
610 self.state.preview_scroll = 0;
611 }
612 Action::GoToEnd => {
613 if let Some(selected_index) = self.state.dotfile_list_state.selected() {
615 if selected_index < self.state.dotfiles.len() {
616 let dotfile = &self.state.dotfiles[selected_index];
617 if let Ok(content) = std::fs::read_to_string(&dotfile.original_path) {
618 let total_lines = content.lines().count();
619 let estimated_visible = 20;
620 self.state.preview_scroll =
621 total_lines.saturating_sub(estimated_visible);
622 } else {
623 self.state.preview_scroll = 10000;
624 }
625 }
626 }
627 }
628 Action::NextTab => {
629 self.state.focus = DotfileSelectionFocus::FilesList;
630 }
631 Action::Cancel | Action::Quit => {
632 return Ok(ScreenAction::Navigate(ScreenId::MainMenu));
633 }
634 _ => {}
635 }
636 }
637
638 Ok(ScreenAction::None)
639 }
640
641 fn render_custom_file_input(
642 &mut self,
643 frame: &mut Frame,
644 content_chunk: Rect,
645 footer_chunk: Rect,
646 config: &Config,
647 ) -> Result<()> {
648 let input_chunks = Layout::default()
649 .direction(Direction::Vertical)
650 .constraints([
651 Constraint::Min(0),
652 Constraint::Length(3), ])
654 .split(content_chunk);
655
656 let widget = TextInputWidget::new(&self.state.custom_file_input)
657 .title("Custom File Path")
658 .placeholder("Enter file path (e.g., ~/.myconfig or /path/to/file)")
659 .title_alignment(Alignment::Center)
660 .focused(self.state.custom_file_focused);
661 frame.render_text_input_widget(widget, input_chunks[1]);
662
663 let k = |a| config.keymap.get_key_display_for_action(a);
664 let footer_text = format!(
665 "{}: Add File | {}: Cancel | Tab: Focus/Unfocus",
666 k(crate::keymap::Action::Confirm),
667 k(crate::keymap::Action::Quit)
668 );
669 let _ = Footer::render(frame, footer_chunk, &footer_text)?;
670
671 Ok(())
672 }
673
674 #[allow(clippy::too_many_arguments)]
675 fn render_dotfile_list(
676 &mut self,
677 frame: &mut Frame,
678 content_chunk: Rect,
679 footer_chunk: Rect,
680 config: &Config,
681 syntax_set: &SyntaxSet,
682 theme: &Theme,
683 ) -> Result<()> {
684 let content_chunks = create_split_layout(content_chunk, &[50, 50]);
686 let left_area = content_chunks[0];
687 let preview_area = content_chunks[1];
688 let icons = crate::icons::Icons::from_config(config);
689 let left_chunks = Layout::default()
691 .direction(Direction::Vertical)
692 .constraints([
693 Constraint::Min(0), Constraint::Length(4), ])
696 .split(left_area);
697
698 let list_area = left_chunks[0];
699 let description_area = left_chunks[1];
700
701 let t = ui_theme();
703
704 let display_items = self.get_display_items(&config.active_profile);
706
707 let current_sel = self.state.dotfile_list_state.selected().unwrap_or(0);
710 if !display_items.is_empty() {
711 let needs_fix = current_sel >= display_items.len()
713 || matches!(display_items[current_sel], DisplayItem::Header(_));
714
715 if needs_fix {
716 let mut found = false;
718 for (i, item) in display_items.iter().enumerate().skip(current_sel) {
720 if !matches!(item, DisplayItem::Header(_)) {
721 self.state.dotfile_list_state.select(Some(i));
722 found = true;
723 break;
724 }
725 }
726 if !found {
728 for (i, item) in display_items.iter().enumerate().take(current_sel) {
729 if !matches!(item, DisplayItem::Header(_)) {
730 self.state.dotfile_list_state.select(Some(i));
731 break;
732 }
733 }
734 }
735 }
737 }
738
739 let common_count = self.state.dotfiles.iter().filter(|d| d.is_common).count();
741 let profile_count = self.state.dotfiles.len() - common_count;
742
743 #[allow(unused)] let items: Vec<ListItem> = display_items
745 .iter()
746 .enumerate()
747 .map(|(list_idx, item)| match item {
748 DisplayItem::Header(title) => {
749 if title.is_empty() {
750 ListItem::new("").style(Style::default())
751 } else {
752 ListItem::new(title.clone())
753 .style(Style::default().fg(t.tertiary).add_modifier(Modifier::BOLD))
754 }
755 }
756 DisplayItem::File(idx) => {
757 let dotfile = &self.state.dotfiles[*idx];
758 let is_selected = self.state.selected_for_sync.contains(idx);
759 let sync_marker = if is_selected {
760 icons.check()
761 } else {
762 icons.uncheck()
763 };
764
765 let prefix = "";
778
779 let style = if is_selected {
780 Style::default().fg(t.success)
781 } else {
782 t.text_style()
783 };
784
785 let path_str = dotfile.relative_path.to_string_lossy();
786 let content = ratatui::text::Line::from(vec![
787 ratatui::text::Span::styled(prefix.to_string(), Style::default()),
788 ratatui::text::Span::styled(
789 format!(" {sync_marker}\u{2009}{path_str}"),
790 style,
791 ),
792 ]);
793 ListItem::new(content)
794 }
795 })
796 .collect();
797
798 let total_items = display_items.len();
800 let selected_index = self.state.dotfile_list_state.selected().unwrap_or(0);
801 self.state.dotfile_list_scrollbar = self
802 .state
803 .dotfile_list_scrollbar
804 .content_length(total_items)
805 .position(selected_index);
806
807 let list_title = if common_count > 0 {
809 format!(" Dotfiles ({common_count} common, {profile_count} profile) ")
810 } else {
811 format!(" Found {} dotfiles ", self.state.dotfiles.len())
812 };
813 let list_border_style = if self.state.focus == DotfileSelectionFocus::FilesList {
814 focused_border_style()
815 } else {
816 unfocused_border_style()
817 };
818
819 let list = List::new(items)
820 .block(
821 Block::default()
822 .borders(Borders::ALL)
823 .title(list_title)
824 .title_alignment(Alignment::Center)
825 .border_type(
826 t.border_type(self.state.focus == DotfileSelectionFocus::FilesList),
827 )
828 .border_style(list_border_style),
829 )
830 .highlight_style(t.highlight_style())
831 .highlight_symbol(LIST_HIGHLIGHT_SYMBOL);
832
833 StatefulWidget::render(
834 list,
835 list_area,
836 frame.buffer_mut(),
837 &mut self.state.dotfile_list_state,
838 );
839
840 frame.render_stateful_widget(
842 Scrollbar::new(ScrollbarOrientation::VerticalRight)
843 .begin_symbol(Some("↑"))
844 .end_symbol(Some("↓")),
845 list_area,
846 &mut self.state.dotfile_list_scrollbar,
847 );
848
849 let selected_dotfile = if let Some(idx) = self.state.dotfile_list_state.selected() {
851 if idx < display_items.len() {
852 if let DisplayItem::File(file_idx) = &display_items[idx] {
853 Some(&self.state.dotfiles[*file_idx])
854 } else {
855 None
856 }
857 } else {
858 None
859 }
860 } else {
861 None
862 };
863
864 if let Some(dotfile) = selected_dotfile {
866 let description_text = if let Some(desc) = &dotfile.description {
867 desc.clone()
868 } else {
869 format!(
870 "No description available for {}",
871 dotfile.relative_path.to_string_lossy()
872 )
873 };
874
875 let description_para = Paragraph::new(description_text)
876 .block(
877 Block::default()
878 .borders(Borders::ALL)
879 .title(" Description ")
880 .border_type(t.border_type(false))
881 .title_alignment(Alignment::Center)
882 .border_style(unfocused_border_style()),
883 )
884 .wrap(Wrap { trim: true })
885 .style(t.text_style());
886 frame.render_widget(description_para, description_area);
887 } else {
888 let empty_desc = Paragraph::new("No file selected").block(
889 Block::default()
890 .borders(Borders::ALL)
891 .title(" Description ")
892 .border_type(ui_theme().border_type(false))
893 .title_alignment(Alignment::Center)
894 .border_style(unfocused_border_style()),
895 );
896 frame.render_widget(empty_desc, description_area);
897 }
898
899 if let Some(dotfile) = selected_dotfile {
901 let is_focused = self.state.focus == DotfileSelectionFocus::Preview;
902 let preview_title = format!("Preview: {}", dotfile.relative_path.to_string_lossy());
903
904 FilePreview::render(
905 frame,
906 preview_area,
907 &dotfile.original_path,
908 self.state.preview_scroll,
909 is_focused,
910 Some(&preview_title),
911 None,
912 syntax_set,
913 theme,
914 config,
915 )?;
916 } else {
917 let empty_preview = Paragraph::new("No file selected").block(
918 Block::default()
919 .borders(Borders::ALL)
920 .title(" Preview ")
921 .border_type(ui_theme().border_type(false))
922 .title_alignment(Alignment::Center),
923 );
924 frame.render_widget(empty_preview, preview_area);
925 }
926
927 let backup_status = if self.state.backup_enabled {
929 "ON"
930 } else {
931 "OFF"
932 };
933 let k = |a| config.keymap.get_key_display_for_action(a);
934
935 let display_items = self.get_display_items(&config.active_profile);
937 let move_text = self
938 .state
939 .dotfile_list_state
940 .selected()
941 .and_then(|idx| display_items.get(idx))
942 .and_then(|item| match item {
943 DisplayItem::File(file_idx) => self.state.dotfiles.get(*file_idx),
944 _ => None,
945 })
946 .map_or("Move", |dotfile| {
947 if dotfile.is_common {
948 "Move to Profile"
949 } else {
950 "Move to Common"
951 }
952 });
953
954 let footer_text = format!(
955 "Tab: Focus | {}: Navigate | Space/{}: Toggle | {}: {} | {}: Add Custom | {}: Backup ({}) | {}: Back",
956 config.keymap.navigation_display(),
957 k(crate::keymap::Action::Confirm),
958 k(crate::keymap::Action::Move),
959 move_text,
960 k(crate::keymap::Action::Create),
961 k(crate::keymap::Action::ToggleBackup),
962 backup_status,
963 k(crate::keymap::Action::Quit)
964 );
965
966 let _ = Footer::render(frame, footer_chunk, &footer_text)?;
967
968 Ok(())
969 }
970
971 fn render_custom_file_confirm(
972 &mut self,
973 frame: &mut Frame,
974 area: Rect,
975 config: &Config,
976 ) -> Result<()> {
977 let path = self
978 .state
979 .custom_file_confirm_path
980 .as_ref()
981 .map_or_else(|| "Unknown".to_string(), |p| p.display().to_string());
982
983 let content = format!(
984 "Path: {path}\n\n\
985 ⚠️ This will move this path to the storage repo and replace it with a symlink.\n\
986 Make sure you know what you are doing."
987 );
988
989 let k = |a| config.keymap.get_key_display_for_action(a);
990 let footer_text = format!(
991 "{}: Confirm | {}: Cancel",
992 k(crate::keymap::Action::Confirm),
993 k(crate::keymap::Action::Quit)
994 );
995
996 let dialog = Dialog::new("Confirm Add Custom File", &content)
997 .height(40)
998 .dim_background(true)
999 .footer(&footer_text);
1000 frame.render_widget(dialog, area);
1001
1002 Ok(())
1003 }
1004
1005 fn handle_move_confirm(&mut self, key_code: KeyCode, config: &Config) -> Result<ScreenAction> {
1006 let action = config
1007 .keymap
1008 .get_action(key_code, crossterm::event::KeyModifiers::NONE);
1009
1010 if let Some(action) = action {
1012 match action {
1013 crate::keymap::Action::Confirm => {
1014 if let Some(idx) = self.state.confirm_move {
1015 if idx < self.state.dotfiles.len() {
1016 let dotfile = &self.state.dotfiles[idx];
1017
1018 if let Some(ref validation) = self.state.move_validation {
1020 let has_path_conflict = validation.conflicts.iter().any(|c| {
1021 matches!(c, crate::utils::MoveToCommonConflict::PathHierarchyConflict { .. })
1022 });
1023 if has_path_conflict {
1024 self.state.confirm_move = None;
1026 self.state.move_validation = None;
1027 return Ok(ScreenAction::Refresh);
1028 }
1029 }
1030
1031 let profiles_to_cleanup = self
1034 .state
1035 .move_validation
1036 .as_ref()
1037 .map(|v| {
1038 let mut profiles = v.profiles_to_cleanup.clone();
1039 for conflict in &v.conflicts {
1041 if let crate::utils::MoveToCommonConflict::DifferentContentInProfile {
1042 profile_name,
1043 ..
1044 } = conflict
1045 {
1046 if !profiles.contains(profile_name) {
1047 profiles.push(profile_name.clone());
1048 }
1049 }
1050 }
1051 profiles
1052 })
1053 .unwrap_or_default();
1054
1055 let action = ScreenAction::MoveToCommon {
1056 file_index: idx,
1057 is_common: dotfile.is_common,
1058 profiles_to_cleanup,
1059 };
1060 self.state.confirm_move = None;
1061 self.state.move_validation = None;
1062 return Ok(action);
1063 }
1064 }
1065 self.state.confirm_move = None;
1066 self.state.move_validation = None;
1067 return Ok(ScreenAction::Refresh);
1068 }
1069 crate::keymap::Action::Quit | crate::keymap::Action::Cancel => {
1070 self.state.confirm_move = None;
1071 self.state.move_validation = None;
1072 return Ok(ScreenAction::Refresh);
1073 }
1074 _ => {}
1075 }
1076 }
1077
1078 match key_code {
1080 KeyCode::Char('y' | 'f') => {
1081 if let Some(idx) = self.state.confirm_move {
1082 if idx < self.state.dotfiles.len() {
1083 let dotfile = &self.state.dotfiles[idx];
1084
1085 if let Some(ref validation) = self.state.move_validation {
1087 let has_path_conflict = validation.conflicts.iter().any(|c| {
1088 matches!(
1089 c,
1090 crate::utils::MoveToCommonConflict::PathHierarchyConflict { .. }
1091 )
1092 });
1093 if has_path_conflict {
1094 self.state.confirm_move = None;
1096 self.state.move_validation = None;
1097 return Ok(ScreenAction::Refresh);
1098 }
1099 }
1100
1101 let profiles_to_cleanup = self
1104 .state
1105 .move_validation
1106 .as_ref()
1107 .map(|v| {
1108 let mut profiles = v.profiles_to_cleanup.clone();
1109 for conflict in &v.conflicts {
1111 if let crate::utils::MoveToCommonConflict::DifferentContentInProfile {
1112 profile_name,
1113 ..
1114 } = conflict
1115 {
1116 if !profiles.contains(profile_name) {
1117 profiles.push(profile_name.clone());
1118 }
1119 }
1120 }
1121 profiles
1122 })
1123 .unwrap_or_default();
1124
1125 let action = ScreenAction::MoveToCommon {
1126 file_index: idx,
1127 is_common: dotfile.is_common,
1128 profiles_to_cleanup,
1129 };
1130 self.state.confirm_move = None;
1131 self.state.move_validation = None;
1132 return Ok(action);
1133 }
1134 }
1135 self.state.confirm_move = None;
1136 self.state.move_validation = None;
1137 Ok(ScreenAction::Refresh)
1138 }
1139 KeyCode::Char('n') => {
1140 self.state.confirm_move = None;
1141 self.state.move_validation = None;
1142 Ok(ScreenAction::Refresh)
1143 }
1144 _ => Ok(ScreenAction::None),
1145 }
1146 }
1147
1148 fn handle_unsync_common_confirm(
1149 &mut self,
1150 key_code: KeyCode,
1151 config: &Config,
1152 ) -> Result<ScreenAction> {
1153 let action = config
1154 .keymap
1155 .get_action(key_code, crossterm::event::KeyModifiers::NONE);
1156
1157 if let Some(action) = action {
1159 match action {
1160 crate::keymap::Action::Confirm => {
1161 if let Some(idx) = self.state.confirm_unsync_common {
1162 self.state.confirm_unsync_common = None;
1163 return Ok(ScreenAction::ToggleFileSync {
1164 file_index: idx,
1165 is_synced: true,
1166 });
1167 }
1168 self.state.confirm_unsync_common = None;
1169 return Ok(ScreenAction::Refresh);
1170 }
1171 crate::keymap::Action::Quit | crate::keymap::Action::Cancel => {
1172 self.state.confirm_unsync_common = None;
1173 return Ok(ScreenAction::Refresh);
1174 }
1175 _ => {}
1176 }
1177 }
1178
1179 match key_code {
1181 KeyCode::Char('y') => {
1182 if let Some(idx) = self.state.confirm_unsync_common {
1183 self.state.confirm_unsync_common = None;
1184 return Ok(ScreenAction::ToggleFileSync {
1185 file_index: idx,
1186 is_synced: true,
1187 });
1188 }
1189 self.state.confirm_unsync_common = None;
1190 Ok(ScreenAction::Refresh)
1191 }
1192 KeyCode::Char('n') => {
1193 self.state.confirm_unsync_common = None;
1194 Ok(ScreenAction::Refresh)
1195 }
1196 _ => Ok(ScreenAction::None),
1197 }
1198 }
1199
1200 fn render_move_confirm(&self, frame: &mut Frame, area: Rect, config: &Config) -> Result<()> {
1201 let dotfile_name = if let Some(idx) = self.state.confirm_move {
1202 if idx < self.state.dotfiles.len() {
1203 self.state.dotfiles[idx].relative_path.display().to_string()
1204 } else {
1205 "Unknown".to_string()
1206 }
1207 } else {
1208 "Unknown".to_string()
1209 };
1210
1211 let is_moving_to_common = if let Some(idx) = self.state.confirm_move {
1212 if idx < self.state.dotfiles.len() {
1213 !self.state.dotfiles[idx].is_common
1214 } else {
1215 false
1216 }
1217 } else {
1218 false
1219 };
1220
1221 if let Some(ref validation) = self.state.move_validation {
1223 let has_blocking = validation.conflicts.iter().any(|c| {
1225 matches!(
1226 c,
1227 crate::utils::MoveToCommonConflict::DifferentContentInProfile { .. }
1228 | crate::utils::MoveToCommonConflict::PathHierarchyConflict { .. }
1229 )
1230 });
1231
1232 if has_blocking {
1233 let has_path_conflict = validation.conflicts.iter().any(|c| {
1235 matches!(
1236 c,
1237 crate::utils::MoveToCommonConflict::PathHierarchyConflict { .. }
1238 )
1239 });
1240
1241 if has_path_conflict {
1242 return self.render_move_blocked_dialog(frame, area, config);
1243 }
1244 return self.render_move_force_dialog(frame, area, config);
1246 }
1247 }
1249
1250 let title_text = if is_moving_to_common {
1252 "Confirm Move to Common"
1253 } else {
1254 "Confirm Move to Profile"
1255 };
1256
1257 let msg = if is_moving_to_common {
1259 format!(
1260 "Move '{dotfile_name}' to common files?\nIt will become available to all profiles."
1261 )
1262 } else {
1263 format!(
1264 "Move '{dotfile_name}' back to profile?\nIt will no longer be available to other profiles."
1265 )
1266 };
1267
1268 let k = |a| config.keymap.get_key_display_for_action(a);
1269 let footer_text = format!(
1270 "{}: Confirm | {}: Cancel",
1271 k(crate::keymap::Action::Confirm),
1272 k(crate::keymap::Action::Quit)
1273 );
1274
1275 let dialog = Dialog::new(title_text, &msg)
1276 .height(20)
1277 .footer(&footer_text);
1278 frame.render_widget(dialog, area);
1279
1280 Ok(())
1281 }
1282
1283 fn render_move_force_dialog(
1284 &self,
1285 frame: &mut Frame,
1286 area: Rect,
1287 config: &Config,
1288 ) -> Result<()> {
1289 let dotfile_name = if let Some(idx) = self.state.confirm_move {
1290 if idx < self.state.dotfiles.len() {
1291 self.state.dotfiles[idx].relative_path.display().to_string()
1292 } else {
1293 "Unknown".to_string()
1294 }
1295 } else {
1296 "Unknown".to_string()
1297 };
1298
1299 let mut conflict_lines = Vec::new();
1301 if let Some(ref validation) = self.state.move_validation {
1302 for conflict in &validation.conflicts {
1303 if let crate::utils::MoveToCommonConflict::DifferentContentInProfile {
1304 profile_name,
1305 size_diff,
1306 } = conflict
1307 {
1308 let size_text = if let Some((size1, size2)) = size_diff {
1309 format!(" ({} vs {})", format_size(*size1), format_size(*size2))
1310 } else {
1311 String::new()
1312 };
1313 conflict_lines.push(format!(" • {profile_name}{size_text}"));
1314 }
1315 }
1316 }
1317
1318 let conflict_list = conflict_lines.join("\n");
1319 let msg = format!(
1320 "⚠ \"{dotfile_name}\" exists in other profiles with DIFFERENT\n\
1321 content:\n\n{conflict_list}\n\n\
1322 If you proceed, their versions will be DELETED and\n\
1323 replaced with the common version.\n\n\
1324 Tip: To preserve different configs, remove them from\n\
1325 sync first in each profile."
1326 );
1327
1328 let k = |a| config.keymap.get_key_display_for_action(a);
1329 let footer_text = format!(
1330 "{}: Force (delete others) | {}: Cancel",
1331 k(crate::keymap::Action::Confirm),
1332 k(crate::keymap::Action::Quit)
1333 );
1334
1335 let dialog = Dialog::new("Content Differs", &msg)
1336 .variant(DialogVariant::Warning)
1338 .footer(&footer_text);
1339 frame.render_widget(dialog, area);
1340
1341 Ok(())
1342 }
1343
1344 fn render_move_blocked_dialog(
1345 &self,
1346 frame: &mut Frame,
1347 area: Rect,
1348 config: &Config,
1349 ) -> Result<()> {
1350 let dotfile_name = if let Some(idx) = self.state.confirm_move {
1351 if idx < self.state.dotfiles.len() {
1352 self.state.dotfiles[idx].relative_path.display().to_string()
1353 } else {
1354 "Unknown".to_string()
1355 }
1356 } else {
1357 "Unknown".to_string()
1358 };
1359
1360 let mut conflict_msg = String::new();
1362 if let Some(ref validation) = self.state.move_validation {
1363 for conflict in &validation.conflicts {
1364 if let crate::utils::MoveToCommonConflict::PathHierarchyConflict {
1365 profile_name,
1366 conflicting_path,
1367 is_parent,
1368 } = conflict
1369 {
1370 if *is_parent {
1371 conflict_msg.push_str(&format!(
1372 " You are trying to move: {dotfile_name}\n\
1373 But profile \"{profile_name}\" has: {conflicting_path} (directory)\n\n"
1374 ));
1375 } else {
1376 conflict_msg.push_str(&format!(
1377 " You are trying to move: {dotfile_name} (directory)\n\
1378 But profile \"{profile_name}\" has: {conflicting_path}\n\n"
1379 ));
1380 }
1381 }
1382 }
1383 }
1384
1385 let msg = format!(
1386 "✗ Path conflict detected:\n\n{conflict_msg}\
1387 This would create an invalid state.\n\n\
1388 To fix: Remove the conflicting path from sync in the\n\
1389 affected profile first."
1390 );
1391
1392 let k = |a| config.keymap.get_key_display_for_action(a);
1393 let footer_text = format!("{}: OK", k(crate::keymap::Action::Confirm));
1394
1395 let dialog = Dialog::new("Cannot Move to Common", &msg)
1396 .variant(DialogVariant::Error)
1398 .footer(&footer_text);
1399 frame.render_widget(dialog, area);
1400
1401 Ok(())
1402 }
1403
1404 fn render_unsync_common_confirm(
1405 &self,
1406 frame: &mut Frame,
1407 area: Rect,
1408 config: &Config,
1409 ) -> Result<()> {
1410 let dotfile_name = if let Some(idx) = self.state.confirm_unsync_common {
1411 if idx < self.state.dotfiles.len() {
1412 self.state.dotfiles[idx].relative_path.display().to_string()
1413 } else {
1414 "Unknown".to_string()
1415 }
1416 } else {
1417 "Unknown".to_string()
1418 };
1419
1420 let msg = format!(
1421 "Remove '{dotfile_name}' from sync?\n\n\
1422 This file is in 'common' and is shared across ALL profiles.\n\
1423 Removing it will affect every profile that uses it."
1424 );
1425
1426 let k = |a| config.keymap.get_key_display_for_action(a);
1427 let footer_text = format!(
1428 "{}/y: Confirm | {}/n: Cancel",
1429 k(crate::keymap::Action::Confirm),
1430 k(crate::keymap::Action::Cancel)
1431 );
1432
1433 let dialog = Dialog::new("Remove Common File", &msg)
1434 .variant(DialogVariant::Warning)
1435 .footer(&footer_text);
1436 frame.render_widget(dialog, area);
1437
1438 Ok(())
1439 }
1440
1441 pub fn process_action(
1449 &mut self,
1450 action: DotfileAction,
1451 config: &mut Config,
1452 config_path: &Path,
1453 ) -> Result<ActionResult> {
1454 debug!("Processing dotfile action: {:?}", action);
1455
1456 match action {
1457 DotfileAction::ScanDotfiles => {
1458 self.scan_dotfiles(config)?;
1459 Ok(ActionResult::None)
1460 }
1461 DotfileAction::RefreshFileBrowser => {
1462 self.refresh_file_browser(config)?;
1463 Ok(ActionResult::None)
1464 }
1465 DotfileAction::ToggleFileSync {
1466 file_index,
1467 is_synced,
1468 } => self.toggle_file_sync(config, file_index, is_synced),
1469 DotfileAction::AddCustomFileToSync {
1470 full_path,
1471 relative_path,
1472 } => self.add_custom_file_to_sync(config, config_path, full_path, relative_path),
1473 DotfileAction::SetBackupEnabled { enabled } => {
1474 self.state.backup_enabled = enabled;
1475 Ok(ActionResult::None)
1476 }
1477 DotfileAction::MoveToCommon {
1478 file_index,
1479 is_common,
1480 profiles_to_cleanup,
1481 } => self.move_to_common(config, file_index, is_common, profiles_to_cleanup),
1482 }
1483 }
1484
1485 pub fn scan_dotfiles(&mut self, config: &Config) -> Result<()> {
1487 info!("Scanning for dotfiles...");
1488
1489 let dotfiles = SyncService::scan_dotfiles(config)?;
1490 debug!("Found {} dotfiles", dotfiles.len());
1491
1492 self.state.dotfiles = dotfiles;
1494 self.state.selected_for_sync.clear();
1495
1496 for (i, dotfile) in self.state.dotfiles.iter().enumerate() {
1498 if dotfile.synced {
1499 self.state.selected_for_sync.insert(i);
1500 }
1501 }
1502
1503 self.state.dotfile_list_scrollbar = self
1505 .state
1506 .dotfile_list_scrollbar
1507 .content_length(self.state.dotfiles.len());
1508
1509 if !self.state.dotfiles.is_empty() && self.state.dotfile_list_state.selected().is_none() {
1511 let display_items = self.get_display_items(&config.active_profile);
1513 for (i, item) in display_items.iter().enumerate() {
1514 if matches!(item, DisplayItem::File(_)) {
1515 self.state.dotfile_list_state.select(Some(i));
1516 break;
1517 }
1518 }
1519 }
1520
1521 info!("Dotfile scan complete");
1522 Ok(())
1523 }
1524
1525 pub fn refresh_file_browser(&mut self, _config: &Config) -> Result<()> {
1527 debug!("Refreshing file browser entries");
1528 Ok(())
1531 }
1532
1533 pub fn toggle_file_sync(
1535 &mut self,
1536 config: &Config,
1537 file_index: usize,
1538 is_synced: bool,
1539 ) -> Result<ActionResult> {
1540 if file_index >= self.state.dotfiles.len() {
1541 warn!("Invalid file index: {}", file_index);
1542 return Ok(ActionResult::ShowToast {
1543 message: "Invalid file selection".to_string(),
1544 variant: crate::widgets::ToastVariant::Error,
1545 });
1546 }
1547
1548 if is_synced {
1549 self.remove_file_from_sync(config, file_index)
1550 } else {
1551 self.add_file_to_sync(config, file_index)
1552 }
1553 }
1554
1555 fn add_file_to_sync(&mut self, config: &Config, file_index: usize) -> Result<ActionResult> {
1557 let dotfile = &self.state.dotfiles[file_index];
1558 let relative_path = dotfile.relative_path.to_string_lossy().to_string();
1559 let full_path = dotfile.original_path.clone();
1560
1561 info!("Adding file to sync: {}", relative_path);
1562
1563 match SyncService::add_file_to_sync(
1564 config,
1565 &full_path,
1566 &relative_path,
1567 self.state.backup_enabled,
1568 ) {
1569 Ok(crate::services::AddFileResult::Success) => {
1570 self.state.selected_for_sync.insert(file_index);
1572 self.state.dotfiles[file_index].synced = true;
1573
1574 info!("Successfully added file to sync: {}", relative_path);
1575 Ok(ActionResult::ShowToast {
1576 message: format!("Added {relative_path} to sync"),
1577 variant: crate::widgets::ToastVariant::Success,
1578 })
1579 }
1580 Ok(crate::services::AddFileResult::AlreadySynced) => {
1581 self.state.selected_for_sync.insert(file_index);
1583 self.state.dotfiles[file_index].synced = true;
1584
1585 Ok(ActionResult::ShowToast {
1586 message: format!("{relative_path} is already synced"),
1587 variant: crate::widgets::ToastVariant::Info,
1588 })
1589 }
1590 Ok(crate::services::AddFileResult::ValidationFailed(msg)) => {
1591 warn!("Validation failed for {}: {}", relative_path, msg);
1592 Ok(ActionResult::ShowDialog {
1593 title: "Cannot Add File".to_string(),
1594 content: msg,
1595 variant: crate::widgets::DialogVariant::Error,
1596 })
1597 }
1598 Err(e) => {
1599 warn!("Error adding file to sync: {}", e);
1600 Ok(ActionResult::ShowToast {
1601 message: format!("Error: {e}"),
1602 variant: crate::widgets::ToastVariant::Error,
1603 })
1604 }
1605 }
1606 }
1607
1608 fn remove_file_from_sync(
1610 &mut self,
1611 config: &Config,
1612 file_index: usize,
1613 ) -> Result<ActionResult> {
1614 let dotfile = &self.state.dotfiles[file_index];
1615 let relative_path = dotfile.relative_path.to_string_lossy().to_string();
1616
1617 if dotfile.is_common {
1619 info!("Removing common file from sync: {}", relative_path);
1620
1621 match SyncService::remove_common_file_from_sync(config, &relative_path) {
1622 Ok(crate::services::RemoveFileResult::Success) => {
1623 self.state.selected_for_sync.remove(&file_index);
1625 self.state.dotfiles[file_index].synced = false;
1626 self.state.dotfiles[file_index].is_common = false;
1627
1628 info!(
1629 "Successfully removed common file from sync: {}",
1630 relative_path
1631 );
1632 Ok(ActionResult::ShowToast {
1633 message: format!("Removed {relative_path} from sync"),
1634 variant: crate::widgets::ToastVariant::Success,
1635 })
1636 }
1637 Ok(crate::services::RemoveFileResult::NotSynced) => {
1638 self.state.selected_for_sync.remove(&file_index);
1640 self.state.dotfiles[file_index].synced = false;
1641
1642 Ok(ActionResult::ShowToast {
1643 message: format!("{relative_path} is not synced"),
1644 variant: crate::widgets::ToastVariant::Info,
1645 })
1646 }
1647 Err(e) => {
1648 warn!("Error removing common file from sync: {}", e);
1649 Ok(ActionResult::ShowToast {
1650 message: format!("Error: {e}"),
1651 variant: crate::widgets::ToastVariant::Error,
1652 })
1653 }
1654 }
1655 } else {
1656 info!("Removing file from sync: {}", relative_path);
1657
1658 match SyncService::remove_file_from_sync(config, &relative_path) {
1659 Ok(crate::services::RemoveFileResult::Success) => {
1660 self.state.selected_for_sync.remove(&file_index);
1662 self.state.dotfiles[file_index].synced = false;
1663
1664 info!("Successfully removed file from sync: {}", relative_path);
1665 Ok(ActionResult::ShowToast {
1666 message: format!("Removed {relative_path} from sync"),
1667 variant: crate::widgets::ToastVariant::Success,
1668 })
1669 }
1670 Ok(crate::services::RemoveFileResult::NotSynced) => {
1671 self.state.selected_for_sync.remove(&file_index);
1673 self.state.dotfiles[file_index].synced = false;
1674
1675 Ok(ActionResult::ShowToast {
1676 message: format!("{relative_path} is not synced"),
1677 variant: crate::widgets::ToastVariant::Info,
1678 })
1679 }
1680 Err(e) => {
1681 warn!("Error removing file from sync: {}", e);
1682 Ok(ActionResult::ShowToast {
1683 message: format!("Error: {e}"),
1684 variant: crate::widgets::ToastVariant::Error,
1685 })
1686 }
1687 }
1688 }
1689 }
1690
1691 pub fn add_custom_file_to_sync(
1693 &mut self,
1694 config: &mut Config,
1695 config_path: &Path,
1696 full_path: PathBuf,
1697 relative_path: String,
1698 ) -> Result<ActionResult> {
1699 info!("Adding custom file to sync: {}", relative_path);
1700
1701 if !full_path.exists() {
1703 return Ok(ActionResult::ShowDialog {
1704 title: "File Not Found".to_string(),
1705 content: format!("The file {} does not exist", full_path.display()),
1706 variant: crate::widgets::DialogVariant::Error,
1707 });
1708 }
1709
1710 let (is_safe, reason) = crate::utils::is_safe_to_add(&full_path, &config.repo_path);
1712 if !is_safe {
1713 return Ok(ActionResult::ShowDialog {
1714 title: "Cannot Add File".to_string(),
1715 content: reason.unwrap_or_else(|| "Cannot add this file".to_string()),
1716 variant: crate::widgets::DialogVariant::Error,
1717 });
1718 }
1719
1720 match SyncService::add_file_to_sync(
1722 config,
1723 &full_path,
1724 &relative_path,
1725 self.state.backup_enabled,
1726 ) {
1727 Ok(crate::services::AddFileResult::Success) => {
1728 if !config.custom_files.contains(&relative_path) {
1730 config.custom_files.push(relative_path.clone());
1731 if let Err(e) = config.save(config_path) {
1733 warn!("Failed to save config: {}", e);
1734 }
1735 }
1736
1737 self.scan_dotfiles(config)?;
1739
1740 info!("Successfully added custom file to sync: {}", relative_path);
1741 Ok(ActionResult::ShowToast {
1742 message: format!("Added {relative_path} to sync"),
1743 variant: crate::widgets::ToastVariant::Success,
1744 })
1745 }
1746 Ok(crate::services::AddFileResult::AlreadySynced) => Ok(ActionResult::ShowToast {
1747 message: format!("{relative_path} is already synced"),
1748 variant: crate::widgets::ToastVariant::Info,
1749 }),
1750 Ok(crate::services::AddFileResult::ValidationFailed(msg)) => {
1751 warn!(
1752 "Validation failed for custom file {}: {}",
1753 relative_path, msg
1754 );
1755 Ok(ActionResult::ShowDialog {
1756 title: "Cannot Add File".to_string(),
1757 content: msg,
1758 variant: crate::widgets::DialogVariant::Error,
1759 })
1760 }
1761 Err(e) => {
1762 warn!("Error adding custom file to sync: {}", e);
1763 Ok(ActionResult::ShowToast {
1764 message: format!("Error: {e}"),
1765 variant: crate::widgets::ToastVariant::Error,
1766 })
1767 }
1768 }
1769 }
1770
1771 pub fn move_to_common(
1773 &mut self,
1774 config: &Config,
1775 file_index: usize,
1776 is_common: bool,
1777 profiles_to_cleanup: Vec<String>,
1778 ) -> Result<ActionResult> {
1779 if file_index >= self.state.dotfiles.len() {
1780 warn!("Invalid file index: {}", file_index);
1781 return Ok(ActionResult::ShowToast {
1782 message: "Invalid file selection".to_string(),
1783 variant: crate::widgets::ToastVariant::Error,
1784 });
1785 }
1786
1787 let dotfile = &self.state.dotfiles[file_index];
1788 let relative_path = dotfile.relative_path.to_string_lossy().to_string();
1789
1790 if is_common {
1791 info!("Moving {} from common to profile", relative_path);
1793
1794 match SyncService::move_from_common(config, &relative_path) {
1795 Ok(()) => {
1796 self.state.dotfiles[file_index].is_common = false;
1798
1799 info!("Successfully moved {} to profile", relative_path);
1800 Ok(ActionResult::ShowToast {
1801 message: format!("Moved {relative_path} to profile"),
1802 variant: crate::widgets::ToastVariant::Success,
1803 })
1804 }
1805 Err(e) => {
1806 warn!("Error moving file from common: {}", e);
1807 Ok(ActionResult::ShowToast {
1808 message: format!("Error: {e}"),
1809 variant: crate::widgets::ToastVariant::Error,
1810 })
1811 }
1812 }
1813 } else {
1814 info!(
1816 "Moving {} from profile to common (cleanup: {} profiles)",
1817 relative_path,
1818 profiles_to_cleanup.len()
1819 );
1820
1821 let result = if profiles_to_cleanup.is_empty() {
1822 SyncService::move_to_common(config, &relative_path)
1823 } else {
1824 SyncService::move_to_common_with_cleanup(
1825 config,
1826 &relative_path,
1827 &profiles_to_cleanup,
1828 )
1829 };
1830
1831 match result {
1832 Ok(()) => {
1833 self.state.dotfiles[file_index].is_common = true;
1835
1836 info!("Successfully moved {} to common", relative_path);
1837 Ok(ActionResult::ShowToast {
1838 message: format!("Moved {relative_path} to common"),
1839 variant: crate::widgets::ToastVariant::Success,
1840 })
1841 }
1842 Err(e) => {
1843 warn!("Error moving file to common: {}", e);
1844 Ok(ActionResult::ShowToast {
1845 message: format!("Error: {e}"),
1846 variant: crate::widgets::ToastVariant::Error,
1847 })
1848 }
1849 }
1850 }
1851 }
1852}
1853
1854fn format_size(bytes: u64) -> String {
1856 if bytes < 1024 {
1857 format!("{bytes}B")
1858 } else if bytes < 1024 * 1024 {
1859 format!("{:.1}KB", bytes as f64 / 1024.0)
1860 } else {
1861 format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
1862 }
1863}
1864
1865impl Default for DotfileSelectionScreen {
1866 fn default() -> Self {
1867 Self::new()
1868 }
1869}
1870
1871impl Screen for DotfileSelectionScreen {
1872 fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &RenderContext) -> Result<()> {
1873 frame.render_widget(Clear, area);
1875
1876 let t = ui_theme();
1878 let background = Block::default().style(t.background_style());
1879 frame.render_widget(background, area);
1880
1881 let (header_chunk, content_chunk, footer_chunk) = create_standard_layout(area, 5, 3);
1883
1884 let _ = Header::render(
1886 frame,
1887 header_chunk,
1888 "DotState - Manage Files",
1889 "Add or remove files to your repository. You can also add custom files. We have automatically detected some common dotfiles for you."
1890 )?;
1891
1892 if self.state.adding_custom_file && !self.file_browser.is_open() {
1894 self.render_custom_file_input(frame, content_chunk, footer_chunk, ctx.config)?;
1895 } else {
1896 self.render_dotfile_list(
1898 frame,
1899 content_chunk,
1900 footer_chunk,
1901 ctx.config,
1902 ctx.syntax_set,
1903 ctx.syntax_theme,
1904 )?;
1905 }
1906
1907 if self.file_browser.is_open() {
1909 self.file_browser
1910 .render(frame, area, ctx.config, ctx.syntax_set, ctx.syntax_theme)?;
1911 }
1912
1913 if self.state.show_custom_file_confirm {
1915 self.render_custom_file_confirm(frame, area, ctx.config)?;
1916 } else if self.state.confirm_move.is_some() {
1917 self.render_move_confirm(frame, area, ctx.config)?;
1919 } else if self.state.confirm_unsync_common.is_some() {
1920 self.render_unsync_common_confirm(frame, area, ctx.config)?;
1922 }
1923
1924 Ok(())
1925 }
1926
1927 fn handle_event(&mut self, event: Event, ctx: &ScreenContext) -> Result<ScreenAction> {
1928 if self.state.show_custom_file_confirm {
1930 if let Event::Key(key) = event {
1931 if key.kind == KeyEventKind::Press {
1932 return self.handle_modal_event(key.code, ctx.config);
1933 }
1934 }
1935 return Ok(ScreenAction::None);
1936 }
1937
1938 if self.state.confirm_move.is_some() {
1939 if let Event::Key(key) = event {
1940 if key.kind == KeyEventKind::Press {
1941 return self.handle_move_confirm(key.code, ctx.config);
1942 }
1943 }
1944 return Ok(ScreenAction::None);
1945 }
1946
1947 if self.state.confirm_unsync_common.is_some() {
1948 if let Event::Key(key) = event {
1949 if key.kind == KeyEventKind::Press {
1950 return self.handle_unsync_common_confirm(key.code, ctx.config);
1951 }
1952 }
1953 return Ok(ScreenAction::None);
1954 }
1955
1956 if self.file_browser.is_open() {
1958 let result = self.file_browser.handle_event(event, ctx.config)?;
1959 match result {
1960 FileBrowserResult::None | FileBrowserResult::RefreshNeeded => {
1961 return Ok(ScreenAction::None);
1962 }
1963 FileBrowserResult::Cancelled => {
1964 self.state.adding_custom_file = false;
1965 self.state.focus = DotfileSelectionFocus::FilesList;
1966 return Ok(ScreenAction::None);
1967 }
1968 FileBrowserResult::Selected {
1969 full_path,
1970 relative_path,
1971 } => {
1972 self.state.adding_custom_file = false;
1973 self.state.focus = DotfileSelectionFocus::FilesList;
1974 return Ok(ScreenAction::AddCustomFileToSync {
1975 full_path,
1976 relative_path,
1977 });
1978 }
1979 }
1980 }
1981
1982 if self.state.adding_custom_file && !self.file_browser.is_open() {
1984 if let Event::Key(key) = event {
1985 if key.kind == KeyEventKind::Press {
1986 if let KeyCode::Char(c) = key.code {
1989 if !key.modifiers.intersects(
1990 KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER,
1991 ) {
1992 self.state.custom_file_input.insert_char(c);
1993 return Ok(ScreenAction::Refresh);
1994 }
1995 }
1996 return self.handle_custom_file_input(key.code, ctx.config);
1997 }
1998 }
1999 return Ok(ScreenAction::None);
2000 }
2001
2002 if let Event::Key(key) = event {
2004 if key.kind == KeyEventKind::Press {
2005 match self.state.focus {
2007 DotfileSelectionFocus::FilesList => {
2008 return self.handle_dotfile_list(key.code, ctx.config);
2009 }
2010 DotfileSelectionFocus::Preview => {
2011 return self.handle_preview(key.code, ctx.config);
2012 }
2013 _ => {}
2014 }
2015 }
2016 }
2017
2018 Ok(ScreenAction::None)
2019 }
2020
2021 fn is_input_focused(&self) -> bool {
2022 if self.file_browser.is_open() {
2023 self.file_browser.is_input_focused()
2024 } else if self.state.adding_custom_file {
2025 self.state.custom_file_focused
2026 } else {
2027 false
2028 }
2029 }
2030
2031 fn on_enter(&mut self, _ctx: &ScreenContext) -> Result<()> {
2032 Ok(())
2035 }
2036}
2037
2038#[cfg(test)]
2039mod tests {
2040 use super::*;
2041
2042 #[test]
2043 fn test_dotfile_selection_screen_creation() {
2044 let screen = DotfileSelectionScreen::new();
2045 assert!(!screen.is_input_focused());
2046 assert!(screen.state.dotfiles.is_empty());
2047 }
2048
2049 #[test]
2050 fn test_set_backup_enabled() {
2051 let mut screen = DotfileSelectionScreen::new();
2052 assert!(screen.state.backup_enabled);
2053 screen.set_backup_enabled(false);
2054 assert!(!screen.state.backup_enabled);
2055 }
2056}