Skip to main content

dotstate/screens/
dotfile_selection.rs

1//! Dotfile selection screen controller.
2//!
3//! This screen handles selecting and managing dotfiles for syncing.
4//! It owns all state and rendering logic (self-contained screen).
5
6use 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/// Display item for the dotfile list (header or file)
39#[derive(Debug, Clone, PartialEq)]
40enum DisplayItem {
41    Header(String), // Section header
42    File(usize),    // Index into state.dotfiles
43}
44
45/// Actions that can be processed by the dotfile selection screen
46#[derive(Debug, Clone)]
47pub enum DotfileAction {
48    /// Scan for dotfiles and refresh the list
49    ScanDotfiles,
50    /// Refresh the file browser entries
51    RefreshFileBrowser,
52    /// Toggle file sync status (add or remove from sync)
53    ToggleFileSync { file_index: usize, is_synced: bool },
54    /// Add a custom file to sync
55    AddCustomFileToSync {
56        full_path: PathBuf,
57        relative_path: String,
58    },
59    /// Update backup enabled setting
60    SetBackupEnabled { enabled: bool },
61    /// Move a file to/from common
62    MoveToCommon {
63        file_index: usize,
64        is_common: bool,
65        profiles_to_cleanup: Vec<String>,
66    },
67}
68
69/// Focus area in dotfile selection screen
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum DotfileSelectionFocus {
72    FilesList,          // Files list pane is focused
73    Preview,            // Preview pane is focused
74    FileBrowserList,    // File browser list pane is focused
75    FileBrowserPreview, // File browser preview pane is focused
76    FileBrowserInput,   // File browser path input is focused
77}
78
79/// Dotfile selection state
80#[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>, // Indices of selected files
86    pub dotfile_list_scrollbar: ScrollbarState,              // Scrollbar state for dotfile list
87    pub dotfile_list_state: ListState, // ListState for main dotfile list (handles selection and scrolling)
88    pub status_message: Option<String>, // For sync summary
89    pub adding_custom_file: bool,      // Whether we're in "add custom file" mode
90    pub custom_file_input: TextInput,  // Input for custom file path
91    pub custom_file_focused: bool,     // Whether custom file input is focused
92    pub file_browser_mode: bool,       // Whether we're in file browser mode
93    pub file_browser_path: PathBuf,    // Current directory in file browser
94    pub file_browser_selected: usize,  // Selected file index in browser
95    pub file_browser_entries: Vec<PathBuf>, // Files/dirs in current directory
96    pub file_browser_scrollbar: ScrollbarState, // Scrollbar state for file browser
97    pub file_browser_list_state: ListState, // ListState for file browser (handles selection and scrolling)
98    pub file_browser_preview_scroll: usize, // Scroll offset for file browser preview
99    pub file_browser_path_input: TextInput, // Path input for file browser
100    pub file_browser_path_focused: bool,    // Whether path input is focused
101    pub focus: DotfileSelectionFocus,       // Which pane currently has focus
102    pub backup_enabled: bool,               // Whether backups are enabled (tracks config value)
103    // Custom file confirmation modal
104    pub show_custom_file_confirm: bool, // Whether to show confirmation modal
105    pub custom_file_confirm_path: Option<PathBuf>, // Full path to confirm
106    pub custom_file_confirm_relative: Option<String>, // Relative path for confirmation
107    // Move to/from common confirmation
108    pub confirm_move: Option<usize>, // Index of dotfile to move (in dotfiles vec)
109    // Move to common validation
110    pub move_validation: Option<crate::utils::MoveToCommonValidation>, // Validation result when conflicts detected
111    // Unsync common file confirmation
112    pub confirm_unsync_common: Option<usize>, // Index of common file to unsync
113}
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, // Start with files list focused
138            backup_enabled: true,                    // Default to enabled
139            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
149/// Dotfile selection screen controller.
150pub struct DotfileSelectionScreen {
151    state: DotfileSelectionState,
152    /// File browser component
153    file_browser: FileBrowser,
154}
155
156impl DotfileSelectionScreen {
157    /// Create a new dotfile selection screen.
158    #[must_use]
159    pub fn new() -> Self {
160        Self {
161            state: DotfileSelectionState::default(),
162            file_browser: FileBrowser::new(),
163        }
164    }
165
166    /// Get the current state.
167    #[must_use]
168    pub fn get_state(&self) -> &DotfileSelectionState {
169        &self.state
170    }
171
172    /// Get mutable state.
173    pub fn get_state_mut(&mut self) -> &mut DotfileSelectionState {
174        &mut self.state
175    }
176
177    /// Set backup enabled state.
178    pub fn set_backup_enabled(&mut self, enabled: bool) {
179        self.state.backup_enabled = enabled;
180    }
181
182    /// Generate display items for the list (headers and files)
183    fn get_display_items(&self, profile_name: &str) -> Vec<DisplayItem> {
184        let mut items = Vec::new();
185
186        // 1. Common Files
187        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        // 2. Profile Files
204        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())); // Spacer
216            }
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    /// Handle modal confirmation events.
229    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                // YES logic - extract values and close modal
238                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                // NO logic - close modal
251                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    /// Handle custom file input (legacy mode, less common).
261    fn handle_custom_file_input(
262        &mut self,
263        key_code: KeyCode,
264        config: &Config,
265    ) -> Result<ScreenAction> {
266        // When input is not focused, only allow Enter to focus or Esc to cancel
267        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        // When focused, handle all input
283        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                        // Calculate relative path
311                        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                        // Close input mode
318                        self.state.adding_custom_file = false;
319                        self.state.custom_file_input.clear();
320                        self.state.focus = DotfileSelectionFocus::FilesList;
321
322                        // Validate before showing confirmation
323                        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                        // Show confirmation modal
337                        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    /// Handle main dotfile list navigation and selection.
360    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                    // Find previous non-header item
377                    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 current is a header (which shouldn't happen usually but can at init),
392                        // try to find first valid item from top
393                        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                    // Find next non-header item
410                    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 we didn't move and we are currently on a header (e.g. init), move to first valid
421                    if next >= display_items.len()
422                        && matches!(display_items[current], DisplayItem::Header(_))
423                    {
424                        // Try finding valid item from current downwards
425                        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 unsyncing a common file, show confirmation dialog
443                                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                    // Simple page up, then fix selection if on header
464                    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                    // Ensure we don't go below 0 (handled by usize)
469                    // Fix if on header
470                    if next < display_items.len()
471                        && matches!(display_items[next], DisplayItem::Header(_))
472                    {
473                        next = next.saturating_add(1); // Move down one
474                    }
475                    if next >= display_items.len() {
476                        next = current;
477                    } // Fallback
478
479                    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                    // Fix if on header
494                    if matches!(display_items[next], DisplayItem::Header(_)) {
495                        next = next.saturating_add(1);
496                    }
497                    if next >= display_items.len() {
498                        next = current;
499                    } // Fallback
500
501                    self.state.dotfile_list_state.select(Some(next));
502                    self.state.preview_scroll = 0;
503                }
504                Action::GoToTop => {
505                    // Find first non-header item
506                    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                    // Find last non-header item
516                    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                    // Open file browser
526                    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                                    // Validate before showing confirmation
546                                    if !dotfile.is_common {
547                                        // Moving from profile to common - validate first
548                                        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                                                // If there are blocking conflicts, we'll show a different dialog
558                                                // Otherwise, proceed with normal confirmation
559                                                self.state.confirm_move = Some(*file_idx);
560                                                return Ok(ScreenAction::Refresh);
561                                            }
562                                            Err(e) => {
563                                                // Validation error - show error message
564                                                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                                    // Moving from common to profile - no validation needed
574                                    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    /// Handle preview pane navigation.
589    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                    // Calculate max scroll based on file content
614                    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), // Input field
653            ])
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        // Split content into left (list + description) and right (preview)
685        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        // Split left area into list (top) and description (bottom)
690        let left_chunks = Layout::default()
691            .direction(Direction::Vertical)
692            .constraints([
693                Constraint::Min(0),    // List takes remaining space
694                Constraint::Length(4), // Description block (3 lines + 1 border)
695            ])
696            .split(left_area);
697
698        let list_area = left_chunks[0];
699        let description_area = left_chunks[1];
700
701        // File list using ListState - simplified, no descriptions inline
702        let t = ui_theme();
703
704        // Get display items (headers + files)
705        let display_items = self.get_display_items(&config.active_profile);
706
707        // Ensure valid selection (skip headers/spacers)
708        // This handles initialization or if state gets desynced
709        let current_sel = self.state.dotfile_list_state.selected().unwrap_or(0);
710        if !display_items.is_empty() {
711            // If selected index is out of bounds or points to a header, fix it
712            let needs_fix = current_sel >= display_items.len()
713                || matches!(display_items[current_sel], DisplayItem::Header(_));
714
715            if needs_fix {
716                // Try to find valid item from current position onwards
717                let mut found = false;
718                // First try current to end
719                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 not found, try from beginning
727                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                // If still not found (e.g. only headers?), do nothing (or select None)
736            }
737        }
738
739        // Count common vs profile files for title
740        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)] // list_idx is unused but is needed if we want to show tree structure
744        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                    // Indent files under headers
766                    // Check if this is the last file in the section (next is header or end of list)
767
768                    // let is_last_in_section = list_idx + 1 >= display_items.len()
769                    //     || matches!(display_items[list_idx + 1], DisplayItem::Header(_));
770
771                    // let prefix = if is_last_in_section {
772                    //     "\u{2514}" // └
773                    // } else {
774                    //     "\u{251c}" // ├
775                    // };
776
777                    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        // Update scrollbar state
799        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        // Add focus indicator to files list with common/profile breakdown
808        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        // Render scrollbar
841        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        // Get selected dotfile (if any)
850        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        // Description block
865        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        // Preview panel
900        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        // Footer
928        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        // Determine move action text based on selected file
936        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        // Handle common actions
1011        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                            // Check if we're in a blocked dialog (path conflict)
1019                            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                                    // Just close the dialog - can't proceed
1025                                    self.state.confirm_move = None;
1026                                    self.state.move_validation = None;
1027                                    return Ok(ScreenAction::Refresh);
1028                                }
1029                            }
1030
1031                            // Get profiles to cleanup from validation
1032                            // Include both auto-resolvable (same content) AND forced (different content) profiles
1033                            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                                    // When user confirms/forces, also include profiles with different content
1040                                    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        // Handle explicit chars 'y' and 'n'
1079        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                        // Check if we're in a blocked dialog (path conflict)
1086                        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                                // Just close the dialog - can't proceed
1095                                self.state.confirm_move = None;
1096                                self.state.move_validation = None;
1097                                return Ok(ScreenAction::Refresh);
1098                            }
1099                        }
1100
1101                        // Get profiles to cleanup from validation
1102                        // Include both auto-resolvable (same content) AND forced (different content) profiles
1103                        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                                // When user forces, also include profiles with different content
1110                                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        // Handle common actions
1158        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        // Handle explicit chars 'y' and 'n'
1180        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        // Check validation result to determine which dialog to show
1222        if let Some(ref validation) = self.state.move_validation {
1223            // Check for blocking conflicts
1224            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                // Check if it's a path hierarchy conflict (most critical)
1234                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                // Different content conflict
1245                return self.render_move_force_dialog(frame, area, config);
1246            }
1247            // Otherwise fall through to normal confirmation (same content conflicts are auto-resolved)
1248        }
1249
1250        // Normal confirmation dialog (no blocking conflicts)
1251        let title_text = if is_moving_to_common {
1252            "Confirm Move to Common"
1253        } else {
1254            "Confirm Move to Profile"
1255        };
1256
1257        // Message
1258        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        // Build conflict list
1300        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            // .height(40)
1337            .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        // Build conflict message
1361        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            // .height(35)
1397            .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    // ============================================================================
1442    // Action Processing Methods
1443    // ============================================================================
1444
1445    /// Process a dotfile action and return the result.
1446    ///
1447    /// This is the main dispatcher for all dotfile-related actions.
1448    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    /// Scan for dotfiles and update the state.
1486    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        // Update state
1493        self.state.dotfiles = dotfiles;
1494        self.state.selected_for_sync.clear();
1495
1496        // Mark synced files as selected
1497        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        // Update scrollbar
1504        self.state.dotfile_list_scrollbar = self
1505            .state
1506            .dotfile_list_scrollbar
1507            .content_length(self.state.dotfiles.len());
1508
1509        // Select first file if list is not empty
1510        if !self.state.dotfiles.is_empty() && self.state.dotfile_list_state.selected().is_none() {
1511            // Find first non-header item
1512            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    /// Refresh the file browser entries.
1526    pub fn refresh_file_browser(&mut self, _config: &Config) -> Result<()> {
1527        debug!("Refreshing file browser entries");
1528        // The file browser component handles its own refresh
1529        // This is a placeholder for any additional refresh logic needed
1530        Ok(())
1531    }
1532
1533    /// Toggle file sync status (add or remove from sync).
1534    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    /// Add a file to sync.
1556    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                // Update state
1571                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                // Already synced - just update state to be consistent
1582                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    /// Remove a file from sync.
1609    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        // Check if this is a common file
1618        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                    // Update state
1624                    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                    // Not synced - just update state to be consistent
1639                    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                    // Update state
1661                    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                    // Not synced - just update state to be consistent
1672                    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    /// Add a custom file to sync.
1692    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        // Validate the file exists
1702        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        // Validate the file is safe to add
1711        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        // Add to sync using SyncService
1721        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                // Add to custom files in config if not already present
1729                if !config.custom_files.contains(&relative_path) {
1730                    config.custom_files.push(relative_path.clone());
1731                    // Save config
1732                    if let Err(e) = config.save(config_path) {
1733                        warn!("Failed to save config: {}", e);
1734                    }
1735                }
1736
1737                // Refresh dotfile list
1738                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    /// Move a file to/from common.
1772    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            // Move from common to profile
1792            info!("Moving {} from common to profile", relative_path);
1793
1794            match SyncService::move_from_common(config, &relative_path) {
1795                Ok(()) => {
1796                    // Update state
1797                    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            // Move from profile to common
1815            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                    // Update state
1834                    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
1854// Helper function to format file sizes
1855fn 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        // Clear the entire area first to prevent background bleed-through
1874        frame.render_widget(Clear, area);
1875
1876        // Background - use Reset to inherit terminal's native background
1877        let t = ui_theme();
1878        let background = Block::default().style(t.background_style());
1879        frame.render_widget(background, area);
1880
1881        // Layout: Title/Description, Content (list + preview), Footer
1882        let (header_chunk, content_chunk, footer_chunk) = create_standard_layout(area, 5, 3);
1883
1884        // Header: Use common header component
1885        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        // Render main content (either custom file input or dotfile list)
1893        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            // Render main dotfile list content
1897            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        // Render file browser as overlay on top (Popup handles dimming)
1908        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        // Render modals on top of the content (not instead of it)
1914        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            // Move confirmation modals render on top of the main content
1918            self.render_move_confirm(frame, area, ctx.config)?;
1919        } else if self.state.confirm_unsync_common.is_some() {
1920            // Unsync common file confirmation
1921            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        // 1. Modal first - captures all events
1929        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        // 2. File browser mode - delegate to component
1957        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        // 3. Custom file input mode (legacy)
1983        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                    // For plain character keys, ALWAYS insert the character first
1987                    // This ensures vim bindings like h/l don't interfere with typing
1988                    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        // 4. Normal navigation based on focus
2003        if let Event::Key(key) = event {
2004            if key.kind == KeyEventKind::Press {
2005                // Normal mode navigation
2006                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        // Request scan on enter - this will be handled by app
2033        // Note: We return None here but app should call scan_dotfiles when navigating to this screen
2034        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}