Skip to main content

fresh/app/
mouse_input.rs

1//! Mouse input handling.
2//!
3//! This module contains all mouse event handling logic including:
4//! - Click, double-click, and drag handling
5//! - Scrollbar interaction
6//! - Hover target computation
7//! - Split separator dragging
8//! - Text selection via mouse
9
10use super::*;
11use crate::input::keybindings::Action;
12use crate::model::event::{SplitDirection, SplitId};
13use crate::services::plugins::hooks::HookArgs;
14use crate::view::popup_mouse::{popup_areas_to_layout_info, PopupHitTester};
15use crate::view::prompt::PromptType;
16use crate::view::ui::tabs::TabHit;
17use anyhow::Result as AnyhowResult;
18use rust_i18n::t;
19
20impl Editor {
21    /// Handle a mouse event.
22    /// Returns true if a re-render is needed.
23    pub fn handle_mouse(
24        &mut self,
25        mouse_event: crossterm::event::MouseEvent,
26    ) -> AnyhowResult<bool> {
27        use crossterm::event::{MouseButton, MouseEventKind};
28
29        let col = mouse_event.column;
30        let row = mouse_event.row;
31
32        // Detect double-click for left button down events (used by all handlers)
33        let is_double_click = if matches!(mouse_event.kind, MouseEventKind::Down(MouseButton::Left))
34        {
35            let now = self.time_source.now();
36            let is_double = if let (Some(previous_time), Some(previous_pos)) =
37                (self.previous_click_time, self.previous_click_position)
38            {
39                let double_click_threshold =
40                    std::time::Duration::from_millis(self.config.editor.double_click_time_ms);
41                let within_time = now.duration_since(previous_time) < double_click_threshold;
42                let same_position = previous_pos == (col, row);
43                within_time && same_position
44            } else {
45                false
46            };
47
48            // Update click tracking
49            if is_double {
50                self.previous_click_time = None;
51                self.previous_click_position = None;
52            } else {
53                self.previous_click_time = Some(now);
54                self.previous_click_position = Some((col, row));
55            }
56            is_double
57        } else {
58            false
59        };
60
61        // When settings modal is open, capture all mouse events
62        if self.settings_state.as_ref().is_some_and(|s| s.visible) {
63            return self.handle_settings_mouse(mouse_event, is_double_click);
64        }
65
66        // When calibration wizard is active, ignore all mouse events
67        if self.calibration_wizard.is_some() {
68            return Ok(false);
69        }
70
71        // Cancel LSP rename prompt on any mouse interaction
72        let mut needs_render = false;
73        if let Some(ref prompt) = self.prompt {
74            if matches!(prompt.prompt_type, PromptType::LspRename { .. }) {
75                self.cancel_prompt();
76                needs_render = true;
77            }
78        }
79
80        // Update mouse cursor position for software cursor rendering (used by GPM)
81        // When GPM is active, we always need to re-render to update the cursor position
82        let cursor_moved = self.mouse_cursor_position != Some((col, row));
83        self.mouse_cursor_position = Some((col, row));
84        if self.gpm_active && cursor_moved {
85            needs_render = true;
86        }
87
88        tracing::trace!(
89            "handle_mouse: kind={:?}, col={}, row={}",
90            mouse_event.kind,
91            col,
92            row
93        );
94
95        // Check if we should forward mouse events to the terminal
96        // Forward if: in terminal mode, mouse is over terminal buffer, and terminal is in alternate screen mode
97        if let Some(result) = self.try_forward_mouse_to_terminal(col, row, mouse_event) {
98            return result;
99        }
100
101        match mouse_event.kind {
102            MouseEventKind::Down(MouseButton::Left) => {
103                if is_double_click {
104                    // Double click detected - both clicks within time threshold AND at same position
105                    self.handle_mouse_double_click(col, row)?;
106                    needs_render = true;
107                    return Ok(needs_render);
108                }
109                self.handle_mouse_click(col, row, mouse_event.modifiers)?;
110                needs_render = true;
111            }
112            MouseEventKind::Drag(MouseButton::Left) => {
113                self.handle_mouse_drag(col, row)?;
114                needs_render = true;
115            }
116            MouseEventKind::Up(MouseButton::Left) => {
117                // Check if we were dragging a separator to trigger terminal resize
118                let was_dragging_separator = self.mouse_state.dragging_separator.is_some();
119
120                // Check if we were dragging a tab and complete the drop
121                if let Some(drag_state) = self.mouse_state.dragging_tab.take() {
122                    if drag_state.is_dragging() {
123                        if let Some(drop_zone) = drag_state.drop_zone {
124                            self.execute_tab_drop(
125                                drag_state.buffer_id,
126                                drag_state.source_split_id,
127                                drop_zone,
128                            );
129                        }
130                    }
131                }
132
133                // Stop dragging and clear drag state
134                self.mouse_state.dragging_scrollbar = None;
135                self.mouse_state.drag_start_row = None;
136                self.mouse_state.drag_start_top_byte = None;
137                self.mouse_state.dragging_separator = None;
138                self.mouse_state.drag_start_position = None;
139                self.mouse_state.drag_start_ratio = None;
140                self.mouse_state.dragging_file_explorer = false;
141                self.mouse_state.drag_start_explorer_width = None;
142                // Clear text selection drag state (selection remains in cursor)
143                self.mouse_state.dragging_text_selection = false;
144                self.mouse_state.drag_selection_split = None;
145                self.mouse_state.drag_selection_anchor = None;
146                // Clear popup scrollbar drag state
147                self.mouse_state.dragging_popup_scrollbar = None;
148                self.mouse_state.drag_start_popup_scroll = None;
149                // Clear popup text selection drag state (selection remains in popup)
150                self.mouse_state.selecting_in_popup = None;
151
152                // If we finished dragging a separator, resize visible terminals
153                if was_dragging_separator {
154                    self.resize_visible_terminals();
155                }
156
157                needs_render = true;
158            }
159            MouseEventKind::Moved => {
160                // Dispatch MouseMove hook to plugins (fire-and-forget, no blocking check)
161                {
162                    // Find content rect for the split under the mouse
163                    let content_rect = self
164                        .cached_layout
165                        .split_areas
166                        .iter()
167                        .find(|(_, _, content_rect, _, _, _)| {
168                            col >= content_rect.x
169                                && col < content_rect.x + content_rect.width
170                                && row >= content_rect.y
171                                && row < content_rect.y + content_rect.height
172                        })
173                        .map(|(_, _, rect, _, _, _)| *rect);
174
175                    let (content_x, content_y) = content_rect.map(|r| (r.x, r.y)).unwrap_or((0, 0));
176
177                    self.plugin_manager.run_hook(
178                        "mouse_move",
179                        HookArgs::MouseMove {
180                            column: col,
181                            row,
182                            content_x,
183                            content_y,
184                        },
185                    );
186                }
187
188                // Only re-render if hover target actually changed
189                // (preserve needs_render if already set, e.g., for GPM cursor updates)
190                let hover_changed = self.update_hover_target(col, row);
191                needs_render = needs_render || hover_changed;
192
193                // Track LSP hover state for mouse-triggered hover popups
194                self.update_lsp_hover_state(col, row);
195            }
196            MouseEventKind::ScrollUp => {
197                // Check if prompt with suggestions is active and should handle scroll
198                if self.handle_prompt_scroll(-3) {
199                    needs_render = true;
200                } else if self.is_file_open_active() && self.handle_file_open_scroll(-3) {
201                    // Check if file browser is active and should handle scroll
202                    needs_render = true;
203                } else if self.is_mouse_over_any_popup(col, row) {
204                    // Scroll the popup content (works for all popups including completion)
205                    self.scroll_popup(-3);
206                    needs_render = true;
207                } else {
208                    // If in terminal mode, exit to scrollback mode first so scrolling works
209                    if self.terminal_mode && self.is_terminal_buffer(self.active_buffer()) {
210                        self.sync_terminal_to_buffer(self.active_buffer());
211                        self.terminal_mode = false;
212                        self.key_context = crate::input::keybindings::KeyContext::Normal;
213                    }
214                    // Dismiss hover/signature help popups on scroll
215                    self.dismiss_transient_popups();
216                    self.handle_mouse_scroll(col, row, -3)?;
217                    // Sync viewport from SplitViewState to EditorState so rendering sees the scroll
218                    self.sync_split_view_state_to_editor_state();
219                    needs_render = true;
220                }
221            }
222            MouseEventKind::ScrollDown => {
223                // Check if prompt with suggestions is active and should handle scroll
224                if self.handle_prompt_scroll(3) {
225                    needs_render = true;
226                } else if self.is_file_open_active() && self.handle_file_open_scroll(3) {
227                    needs_render = true;
228                } else if self.is_mouse_over_any_popup(col, row) {
229                    // Scroll the popup content (works for all popups including completion)
230                    self.scroll_popup(3);
231                    needs_render = true;
232                } else {
233                    // If in terminal mode, exit to scrollback mode first so scrolling works
234                    if self.terminal_mode && self.is_terminal_buffer(self.active_buffer()) {
235                        self.sync_terminal_to_buffer(self.active_buffer());
236                        self.terminal_mode = false;
237                        self.key_context = crate::input::keybindings::KeyContext::Normal;
238                    }
239                    // Dismiss hover/signature help popups on scroll
240                    self.dismiss_transient_popups();
241                    self.handle_mouse_scroll(col, row, 3)?;
242                    // Sync viewport from SplitViewState to EditorState so rendering sees the scroll
243                    self.sync_split_view_state_to_editor_state();
244                    needs_render = true;
245                }
246            }
247            MouseEventKind::Down(MouseButton::Right) => {
248                // Handle right-click for context menus
249                self.handle_right_click(col, row)?;
250                needs_render = true;
251            }
252            _ => {
253                // Ignore other mouse events for now
254            }
255        }
256
257        self.mouse_state.last_position = Some((col, row));
258        Ok(needs_render)
259    }
260
261    /// Update the current hover target based on mouse position
262    /// Returns true if the hover target changed (requiring a re-render)
263    pub(super) fn update_hover_target(&mut self, col: u16, row: u16) -> bool {
264        let old_target = self.mouse_state.hover_target.clone();
265        let new_target = self.compute_hover_target(col, row);
266        let changed = old_target != new_target;
267        self.mouse_state.hover_target = new_target.clone();
268
269        // If a menu is currently open and we're hovering over a different menu bar item,
270        // switch to that menu automatically
271        if let Some(active_menu_idx) = self.menu_state.active_menu {
272            if let Some(HoverTarget::MenuBarItem(hovered_menu_idx)) = new_target.clone() {
273                if hovered_menu_idx != active_menu_idx {
274                    self.menu_state.open_menu(hovered_menu_idx);
275                    return true; // Force re-render since menu changed
276                }
277            }
278
279            // If hovering over a menu dropdown item, check if it's a submenu and open it
280            if let Some(HoverTarget::MenuDropdownItem(_, item_idx)) = new_target.clone() {
281                let all_menus: Vec<crate::config::Menu> = self
282                    .menus
283                    .menus
284                    .iter()
285                    .chain(self.menu_state.plugin_menus.iter())
286                    .cloned()
287                    .collect();
288
289                // If this item is the parent of the currently open submenu, keep it open.
290                // This prevents blinking when hovering over the parent item of an open submenu.
291                if self.menu_state.submenu_path.first() == Some(&item_idx) {
292                    tracing::trace!(
293                        "menu hover: staying on submenu parent item_idx={}, submenu_path={:?}",
294                        item_idx,
295                        self.menu_state.submenu_path
296                    );
297                    return changed;
298                }
299
300                // Clear any open submenus since we're at a different item in the main dropdown
301                if !self.menu_state.submenu_path.is_empty() {
302                    tracing::trace!(
303                        "menu hover: clearing submenu_path={:?} for different item_idx={}",
304                        self.menu_state.submenu_path,
305                        item_idx
306                    );
307                    self.menu_state.submenu_path.clear();
308                    self.menu_state.highlighted_item = Some(item_idx);
309                    return true;
310                }
311
312                // Check if the hovered item is a submenu
313                if let Some(menu) = all_menus.get(active_menu_idx) {
314                    if let Some(crate::config::MenuItem::Submenu { items, .. }) =
315                        menu.items.get(item_idx)
316                    {
317                        if !items.is_empty() {
318                            tracing::trace!("menu hover: opening submenu at item_idx={}", item_idx);
319                            self.menu_state.submenu_path.push(item_idx);
320                            self.menu_state.highlighted_item = Some(0);
321                            return true;
322                        }
323                    }
324                }
325                // Update highlighted item for non-submenu items too
326                if self.menu_state.highlighted_item != Some(item_idx) {
327                    self.menu_state.highlighted_item = Some(item_idx);
328                    return true;
329                }
330            }
331
332            // If hovering over a submenu item, handle submenu navigation
333            if let Some(HoverTarget::SubmenuItem(depth, item_idx)) = new_target {
334                // If this item is the parent of a currently open nested submenu, keep it open.
335                // This prevents blinking when hovering over the parent item of an open nested submenu.
336                // submenu_path[depth] stores the index of the nested submenu opened from this level.
337                if self.menu_state.submenu_path.len() > depth
338                    && self.menu_state.submenu_path.get(depth) == Some(&item_idx)
339                {
340                    tracing::trace!(
341                        "menu hover: staying on nested submenu parent depth={}, item_idx={}, submenu_path={:?}",
342                        depth,
343                        item_idx,
344                        self.menu_state.submenu_path
345                    );
346                    return changed;
347                }
348
349                // Truncate submenu path to this depth (close any deeper submenus)
350                if self.menu_state.submenu_path.len() > depth {
351                    tracing::trace!(
352                        "menu hover: truncating submenu_path={:?} to depth={} for item_idx={}",
353                        self.menu_state.submenu_path,
354                        depth,
355                        item_idx
356                    );
357                    self.menu_state.submenu_path.truncate(depth);
358                }
359
360                let all_menus: Vec<crate::config::Menu> = self
361                    .menus
362                    .menus
363                    .iter()
364                    .chain(self.menu_state.plugin_menus.iter())
365                    .cloned()
366                    .collect();
367
368                // Get the items at this depth
369                if let Some(items) = self
370                    .menu_state
371                    .get_current_items(&all_menus, active_menu_idx)
372                {
373                    // Check if hovered item is a submenu - if so, open it
374                    if let Some(crate::config::MenuItem::Submenu {
375                        items: sub_items, ..
376                    }) = items.get(item_idx)
377                    {
378                        if !sub_items.is_empty()
379                            && !self.menu_state.submenu_path.contains(&item_idx)
380                        {
381                            tracing::trace!(
382                                "menu hover: opening nested submenu at depth={}, item_idx={}",
383                                depth,
384                                item_idx
385                            );
386                            self.menu_state.submenu_path.push(item_idx);
387                            self.menu_state.highlighted_item = Some(0);
388                            return true;
389                        }
390                    }
391                    // Update highlighted item
392                    if self.menu_state.highlighted_item != Some(item_idx) {
393                        self.menu_state.highlighted_item = Some(item_idx);
394                        return true;
395                    }
396                }
397            }
398        }
399
400        // Handle tab context menu hover - update highlighted item
401        if let Some(HoverTarget::TabContextMenuItem(item_idx)) = new_target.clone() {
402            if let Some(ref mut menu) = self.tab_context_menu {
403                if menu.highlighted != item_idx {
404                    menu.highlighted = item_idx;
405                    return true;
406                }
407            }
408        }
409
410        // Handle file explorer status indicator hover - show tooltip
411        // Always dismiss existing tooltip first when target changes
412        if old_target != new_target
413            && matches!(
414                old_target,
415                Some(HoverTarget::FileExplorerStatusIndicator(_))
416            )
417        {
418            self.dismiss_file_explorer_status_tooltip();
419        }
420
421        if let Some(HoverTarget::FileExplorerStatusIndicator(ref path)) = new_target {
422            // Only show tooltip if this is a new hover (not already showing for this path)
423            if old_target != new_target {
424                self.show_file_explorer_status_tooltip(path.clone(), col, row);
425                return true;
426            }
427        }
428
429        changed
430    }
431
432    /// Update LSP hover state based on mouse position
433    /// Tracks position for debounced hover requests
434    ///
435    /// Hover popup stays visible when:
436    /// - Mouse is over the hover popup itself
437    /// - Mouse is within the hovered symbol range
438    ///
439    /// Hover is dismissed when mouse leaves the editor area entirely.
440    fn update_lsp_hover_state(&mut self, col: u16, row: u16) {
441        tracing::trace!(col, row, "update_lsp_hover_state: raw mouse position");
442
443        // Check if mouse is over a transient popup - if so, keep hover active
444        if self.is_mouse_over_transient_popup(col, row) {
445            return;
446        }
447
448        // Find which split the mouse is over
449        let split_info = self
450            .cached_layout
451            .split_areas
452            .iter()
453            .find(|(_, _, content_rect, _, _, _)| {
454                col >= content_rect.x
455                    && col < content_rect.x + content_rect.width
456                    && row >= content_rect.y
457                    && row < content_rect.y + content_rect.height
458            })
459            .map(|(split_id, buffer_id, content_rect, _, _, _)| {
460                (*split_id, *buffer_id, *content_rect)
461            });
462
463        let Some((split_id, buffer_id, content_rect)) = split_info else {
464            // Mouse is not over editor content - clear hover state and dismiss popup
465            if self.mouse_state.lsp_hover_state.is_some() {
466                self.mouse_state.lsp_hover_state = None;
467                self.mouse_state.lsp_hover_request_sent = false;
468                self.dismiss_transient_popups();
469            }
470            return;
471        };
472
473        // Get cached mappings and gutter width for this split
474        let cached_mappings = self
475            .cached_layout
476            .view_line_mappings
477            .get(&split_id)
478            .cloned();
479        let gutter_width = self
480            .buffers
481            .get(&buffer_id)
482            .map(|s| s.margins.left_total_width() as u16)
483            .unwrap_or(0);
484        let fallback = self
485            .buffers
486            .get(&buffer_id)
487            .map(|s| s.buffer.len())
488            .unwrap_or(0);
489
490        // Convert screen position to buffer byte position
491        let Some(byte_pos) = Self::screen_to_buffer_position(
492            col,
493            row,
494            content_rect,
495            gutter_width,
496            &cached_mappings,
497            fallback,
498            false, // Don't include gutter
499        ) else {
500            // Mouse is in gutter - clear hover state
501            if self.mouse_state.lsp_hover_state.is_some() {
502                self.mouse_state.lsp_hover_state = None;
503                self.mouse_state.lsp_hover_request_sent = false;
504                self.dismiss_transient_popups();
505            }
506            return;
507        };
508
509        // Check if mouse is past the end of line content - don't trigger hover for empty space
510        let content_col = col.saturating_sub(content_rect.x);
511        let text_col = content_col.saturating_sub(gutter_width) as usize;
512        let visual_row = row.saturating_sub(content_rect.y) as usize;
513
514        let line_info = cached_mappings
515            .as_ref()
516            .and_then(|mappings| mappings.get(visual_row))
517            .map(|line_mapping| {
518                (
519                    line_mapping.visual_to_char.len(),
520                    line_mapping.line_end_byte,
521                )
522            });
523
524        let is_past_line_end_or_empty = line_info
525            .map(|(line_len, _)| {
526                // Empty lines (just newline) should not trigger hover
527                if line_len <= 1 {
528                    return true;
529                }
530                text_col >= line_len
531            })
532            // If mouse is below all mapped lines (no mapping), don't trigger hover
533            .unwrap_or(true);
534
535        tracing::trace!(
536            col,
537            row,
538            content_col,
539            text_col,
540            visual_row,
541            gutter_width,
542            byte_pos,
543            ?line_info,
544            is_past_line_end_or_empty,
545            "update_lsp_hover_state: position check"
546        );
547
548        if is_past_line_end_or_empty {
549            tracing::trace!(
550                "update_lsp_hover_state: mouse past line end or empty line, clearing hover"
551            );
552            // Mouse is past end of line content - clear hover state and don't trigger new hover
553            if self.mouse_state.lsp_hover_state.is_some() {
554                self.mouse_state.lsp_hover_state = None;
555                self.mouse_state.lsp_hover_request_sent = false;
556                self.dismiss_transient_popups();
557            }
558            return;
559        }
560
561        // Check if mouse is within the hovered symbol range - if so, keep hover active
562        if let Some((start, end)) = self.hover_symbol_range {
563            if byte_pos >= start && byte_pos < end {
564                // Mouse is still over the hovered symbol - keep hover state
565                return;
566            }
567        }
568
569        // Check if we're still hovering the same position
570        if let Some((old_pos, _, _, _)) = self.mouse_state.lsp_hover_state {
571            if old_pos == byte_pos {
572                // Same position - keep existing state
573                return;
574            }
575            // Position changed outside symbol range - reset state and dismiss popup
576            self.dismiss_transient_popups();
577        }
578
579        // Start tracking new hover position
580        self.mouse_state.lsp_hover_state = Some((byte_pos, std::time::Instant::now(), col, row));
581        self.mouse_state.lsp_hover_request_sent = false;
582    }
583
584    /// Check if mouse position is over a transient popup (hover, signature help)
585    fn is_mouse_over_transient_popup(&self, col: u16, row: u16) -> bool {
586        let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
587        let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
588        hit_tester.is_over_transient_popup(col, row)
589    }
590
591    /// Check if mouse position is over any popup (including non-transient ones like completion)
592    fn is_mouse_over_any_popup(&self, col: u16, row: u16) -> bool {
593        let layouts = popup_areas_to_layout_info(&self.cached_layout.popup_areas);
594        let hit_tester = PopupHitTester::new(&layouts, &self.active_state().popups);
595        hit_tester.is_over_popup(col, row)
596    }
597
598    /// Compute what hover target is at the given position
599    fn compute_hover_target(&self, col: u16, row: u16) -> Option<HoverTarget> {
600        // Check tab context menu first (it's rendered on top)
601        if let Some(ref menu) = self.tab_context_menu {
602            let menu_x = menu.position.0;
603            let menu_y = menu.position.1;
604            let menu_width = 22u16;
605            let items = super::types::TabContextMenuItem::all();
606            let menu_height = items.len() as u16 + 2;
607
608            if col >= menu_x
609                && col < menu_x + menu_width
610                && row > menu_y
611                && row < menu_y + menu_height - 1
612            {
613                let item_idx = (row - menu_y - 1) as usize;
614                if item_idx < items.len() {
615                    return Some(HoverTarget::TabContextMenuItem(item_idx));
616                }
617            }
618        }
619
620        // Check suggestions area first (command palette, autocomplete)
621        if let Some((inner_rect, start_idx, _visible_count, total_count)) =
622            &self.cached_layout.suggestions_area
623        {
624            if col >= inner_rect.x
625                && col < inner_rect.x + inner_rect.width
626                && row >= inner_rect.y
627                && row < inner_rect.y + inner_rect.height
628            {
629                let relative_row = (row - inner_rect.y) as usize;
630                let item_idx = start_idx + relative_row;
631
632                if item_idx < *total_count {
633                    return Some(HoverTarget::SuggestionItem(item_idx));
634                }
635            }
636        }
637
638        // Check popups (they're rendered on top)
639        // Check from top to bottom (reverse order since last popup is on top)
640        for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
641            self.cached_layout.popup_areas.iter().rev()
642        {
643            if col >= inner_rect.x
644                && col < inner_rect.x + inner_rect.width
645                && row >= inner_rect.y
646                && row < inner_rect.y + inner_rect.height
647                && *num_items > 0
648            {
649                // Calculate which item is being hovered
650                let relative_row = (row - inner_rect.y) as usize;
651                let item_idx = scroll_offset + relative_row;
652
653                if item_idx < *num_items {
654                    return Some(HoverTarget::PopupListItem(*popup_idx, item_idx));
655                }
656            }
657        }
658
659        // Check file browser popup
660        if self.is_file_open_active() {
661            if let Some(hover) = self.compute_file_browser_hover(col, row) {
662                return Some(hover);
663            }
664        }
665
666        // Check menu bar (row 0, only when visible)
667        // Check menu bar using cached layout from previous render
668        if self.menu_bar_visible {
669            if let Some(ref menu_layout) = self.cached_layout.menu_layout {
670                if let Some(menu_idx) = menu_layout.menu_at(col, row) {
671                    return Some(HoverTarget::MenuBarItem(menu_idx));
672                }
673            }
674        }
675
676        // Check menu dropdown items if a menu is open (including submenus)
677        if let Some(active_idx) = self.menu_state.active_menu {
678            if let Some(hover) = self.compute_menu_dropdown_hover(col, row, active_idx) {
679                return Some(hover);
680            }
681        }
682
683        // Check file explorer close button and border (for resize)
684        if let Some(explorer_area) = self.cached_layout.file_explorer_area {
685            // Close button is at position: explorer_area.x + explorer_area.width - 3 to -1
686            let close_button_x = explorer_area.x + explorer_area.width.saturating_sub(3);
687            if row == explorer_area.y
688                && col >= close_button_x
689                && col < explorer_area.x + explorer_area.width
690            {
691                return Some(HoverTarget::FileExplorerCloseButton);
692            }
693
694            // Check if hovering over a status indicator in the file explorer content area
695            // Status indicators are in the rightmost 2 characters of each row (before border)
696            let content_start_y = explorer_area.y + 1; // +1 for title bar
697            let content_end_y = explorer_area.y + explorer_area.height.saturating_sub(1); // -1 for bottom border
698            let status_indicator_x = explorer_area.x + explorer_area.width.saturating_sub(3); // 2 chars + 1 border
699
700            if row >= content_start_y
701                && row < content_end_y
702                && col >= status_indicator_x
703                && col < explorer_area.x + explorer_area.width.saturating_sub(1)
704            {
705                // Determine which item is at this row
706                if let Some(ref explorer) = self.file_explorer {
707                    let relative_row = row.saturating_sub(content_start_y) as usize;
708                    let scroll_offset = explorer.get_scroll_offset();
709                    let item_index = relative_row + scroll_offset;
710                    let display_nodes = explorer.get_display_nodes();
711
712                    if item_index < display_nodes.len() {
713                        let (node_id, _indent) = display_nodes[item_index];
714                        if let Some(node) = explorer.tree().get_node(node_id) {
715                            return Some(HoverTarget::FileExplorerStatusIndicator(
716                                node.entry.path.clone(),
717                            ));
718                        }
719                    }
720                }
721            }
722
723            // The border is at the right edge of the file explorer area
724            let border_x = explorer_area.x + explorer_area.width;
725            if col == border_x
726                && row >= explorer_area.y
727                && row < explorer_area.y + explorer_area.height
728            {
729                return Some(HoverTarget::FileExplorerBorder);
730            }
731        }
732
733        // Check split separators
734        for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
735            let is_on_separator = match direction {
736                SplitDirection::Horizontal => {
737                    row == *sep_y && col >= *sep_x && col < sep_x + sep_length
738                }
739                SplitDirection::Vertical => {
740                    col == *sep_x && row >= *sep_y && row < sep_y + sep_length
741                }
742            };
743
744            if is_on_separator {
745                return Some(HoverTarget::SplitSeparator(*split_id, *direction));
746            }
747        }
748
749        // Check tab areas using cached hit regions (computed during rendering)
750        // Check split control buttons first (they're on top of the tab row)
751        for (split_id, btn_row, start_col, end_col) in &self.cached_layout.close_split_areas {
752            if row == *btn_row && col >= *start_col && col < *end_col {
753                return Some(HoverTarget::CloseSplitButton(*split_id));
754            }
755        }
756
757        for (split_id, btn_row, start_col, end_col) in &self.cached_layout.maximize_split_areas {
758            if row == *btn_row && col >= *start_col && col < *end_col {
759                return Some(HoverTarget::MaximizeSplitButton(*split_id));
760            }
761        }
762
763        for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
764            match tab_layout.hit_test(col, row) {
765                Some(TabHit::CloseButton(buffer_id)) => {
766                    return Some(HoverTarget::TabCloseButton(buffer_id, *split_id));
767                }
768                Some(TabHit::TabName(buffer_id)) => {
769                    return Some(HoverTarget::TabName(buffer_id, *split_id));
770                }
771                Some(TabHit::ScrollLeft)
772                | Some(TabHit::ScrollRight)
773                | Some(TabHit::BarBackground)
774                | None => {}
775            }
776        }
777
778        // Check scrollbars
779        for (split_id, _buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end) in
780            &self.cached_layout.split_areas
781        {
782            if col >= scrollbar_rect.x
783                && col < scrollbar_rect.x + scrollbar_rect.width
784                && row >= scrollbar_rect.y
785                && row < scrollbar_rect.y + scrollbar_rect.height
786            {
787                let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
788                let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
789
790                if is_on_thumb {
791                    return Some(HoverTarget::ScrollbarThumb(*split_id));
792                } else {
793                    return Some(HoverTarget::ScrollbarTrack(*split_id));
794                }
795            }
796        }
797
798        // Check status bar indicators
799        if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
800            if row == status_row {
801                // Check line ending indicator area
802                if let Some((le_row, le_start, le_end)) =
803                    self.cached_layout.status_bar_line_ending_area
804                {
805                    if row == le_row && col >= le_start && col < le_end {
806                        return Some(HoverTarget::StatusBarLineEndingIndicator);
807                    }
808                }
809
810                // Check language indicator area
811                if let Some((lang_row, lang_start, lang_end)) =
812                    self.cached_layout.status_bar_language_area
813                {
814                    if row == lang_row && col >= lang_start && col < lang_end {
815                        return Some(HoverTarget::StatusBarLanguageIndicator);
816                    }
817                }
818
819                // Check LSP indicator area
820                if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
821                {
822                    if row == lsp_row && col >= lsp_start && col < lsp_end {
823                        return Some(HoverTarget::StatusBarLspIndicator);
824                    }
825                }
826
827                // Check warning badge area
828                if let Some((warn_row, warn_start, warn_end)) =
829                    self.cached_layout.status_bar_warning_area
830                {
831                    if row == warn_row && col >= warn_start && col < warn_end {
832                        return Some(HoverTarget::StatusBarWarningBadge);
833                    }
834                }
835            }
836        }
837
838        // Check search options bar checkboxes
839        if let Some(ref layout) = self.cached_layout.search_options_layout {
840            use crate::view::ui::status_bar::SearchOptionsHover;
841            if let Some(hover) = layout.checkbox_at(col, row) {
842                return Some(match hover {
843                    SearchOptionsHover::CaseSensitive => HoverTarget::SearchOptionCaseSensitive,
844                    SearchOptionsHover::WholeWord => HoverTarget::SearchOptionWholeWord,
845                    SearchOptionsHover::Regex => HoverTarget::SearchOptionRegex,
846                    SearchOptionsHover::ConfirmEach => HoverTarget::SearchOptionConfirmEach,
847                    SearchOptionsHover::None => return None,
848                });
849            }
850        }
851
852        // No hover target
853        None
854    }
855
856    /// Handle mouse double click (down event)
857    /// Double-click in editor area selects the word under the cursor.
858    pub(super) fn handle_mouse_double_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
859        tracing::debug!("handle_mouse_double_click at col={}, row={}", col, row);
860
861        // Handle popups: dismiss if clicking outside, block if clicking inside
862        if self.is_mouse_over_any_popup(col, row) {
863            // Double-click inside popup - block from reaching editor
864            return Ok(());
865        } else {
866            // Double-click outside popup - dismiss transient popups
867            self.dismiss_transient_popups();
868        }
869
870        // Is it in the file open dialog?
871        if self.handle_file_open_double_click(col, row) {
872            return Ok(());
873        }
874
875        // Is it in the file explorer? Double-click opens file AND focuses editor
876        if let Some(explorer_area) = self.cached_layout.file_explorer_area {
877            if col >= explorer_area.x
878                && col < explorer_area.x + explorer_area.width
879                && row > explorer_area.y // Skip title bar
880                && row < explorer_area.y + explorer_area.height
881            {
882                // Open file and focus editor (via file_explorer_open_file which calls focus_editor)
883                self.file_explorer_open_file()?;
884                return Ok(());
885            }
886        }
887
888        // Find which split/buffer was clicked and handle double-click
889        let split_areas = self.cached_layout.split_areas.clone();
890        for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
891            &split_areas
892        {
893            if col >= content_rect.x
894                && col < content_rect.x + content_rect.width
895                && row >= content_rect.y
896                && row < content_rect.y + content_rect.height
897            {
898                // Double-clicked on an editor split
899                if self.is_terminal_buffer(*buffer_id) {
900                    self.key_context = crate::input::keybindings::KeyContext::Terminal;
901                    // Don't select word in terminal buffers
902                    return Ok(());
903                }
904
905                self.key_context = crate::input::keybindings::KeyContext::Normal;
906
907                // Position cursor at click location and select word
908                self.handle_editor_double_click(col, row, *split_id, *buffer_id, *content_rect)?;
909                return Ok(());
910            }
911        }
912
913        Ok(())
914    }
915
916    /// Handle double-click in editor content area - selects the word under cursor
917    fn handle_editor_double_click(
918        &mut self,
919        col: u16,
920        row: u16,
921        split_id: crate::model::event::SplitId,
922        buffer_id: BufferId,
923        content_rect: ratatui::layout::Rect,
924    ) -> AnyhowResult<()> {
925        use crate::model::event::Event;
926
927        // Focus this split
928        self.focus_split(split_id, buffer_id);
929
930        // Get cached view line mappings for this split
931        let cached_mappings = self
932            .cached_layout
933            .view_line_mappings
934            .get(&split_id)
935            .cloned();
936
937        // Get fallback from SplitViewState viewport
938        let fallback = self
939            .split_view_states
940            .get(&split_id)
941            .map(|vs| vs.viewport.top_byte)
942            .unwrap_or(0);
943
944        // Calculate clicked position in buffer
945        if let Some(state) = self.buffers.get_mut(&buffer_id) {
946            let gutter_width = state.margins.left_total_width() as u16;
947
948            let Some(target_position) = Self::screen_to_buffer_position(
949                col,
950                row,
951                content_rect,
952                gutter_width,
953                &cached_mappings,
954                fallback,
955                true, // Allow gutter clicks
956            ) else {
957                return Ok(());
958            };
959
960            // Move cursor to clicked position first
961            let primary_cursor_id = state.cursors.primary_id();
962            let event = Event::MoveCursor {
963                cursor_id: primary_cursor_id,
964                old_position: 0,
965                new_position: target_position,
966                old_anchor: None,
967                new_anchor: None,
968                old_sticky_column: 0,
969                new_sticky_column: 0,
970            };
971
972            if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
973                event_log.append(event.clone());
974            }
975            state.apply(&event);
976        }
977
978        // Now select the word under cursor
979        self.handle_action(Action::SelectWord)?;
980
981        Ok(())
982    }
983    /// Handle mouse click (down event)
984    pub(super) fn handle_mouse_click(
985        &mut self,
986        col: u16,
987        row: u16,
988        modifiers: crossterm::event::KeyModifiers,
989    ) -> AnyhowResult<()> {
990        // Check if click is on tab context menu first
991        if self.tab_context_menu.is_some() {
992            if let Some(result) = self.handle_tab_context_menu_click(col, row) {
993                return result;
994            }
995        }
996
997        // Dismiss transient popups (like hover) when clicking outside them
998        // This check must happen before we process the click elsewhere
999        if !self.is_mouse_over_any_popup(col, row) {
1000            self.dismiss_transient_popups();
1001        }
1002
1003        // Check if click is on suggestions (command palette, autocomplete)
1004        if let Some((inner_rect, start_idx, _visible_count, total_count)) =
1005            &self.cached_layout.suggestions_area.clone()
1006        {
1007            if col >= inner_rect.x
1008                && col < inner_rect.x + inner_rect.width
1009                && row >= inner_rect.y
1010                && row < inner_rect.y + inner_rect.height
1011            {
1012                let relative_row = (row - inner_rect.y) as usize;
1013                let item_idx = start_idx + relative_row;
1014
1015                if item_idx < *total_count {
1016                    // Select and execute the clicked suggestion
1017                    if let Some(prompt) = &mut self.prompt {
1018                        prompt.selected_suggestion = Some(item_idx);
1019                    }
1020                    // Execute the suggestion (same as pressing Enter)
1021                    return self.handle_action(Action::PromptConfirm);
1022                }
1023            }
1024        }
1025
1026        // Check if click is on a popup scrollbar first (they're rendered on top)
1027        // Collect scroll info first to avoid borrow conflicts
1028        let scrollbar_scroll_info: Option<(usize, i32)> =
1029            self.cached_layout.popup_areas.iter().rev().find_map(
1030                |(
1031                    popup_idx,
1032                    _popup_rect,
1033                    inner_rect,
1034                    _scroll_offset,
1035                    _num_items,
1036                    scrollbar_rect,
1037                    total_lines,
1038                )| {
1039                    let sb_rect = scrollbar_rect.as_ref()?;
1040                    if col >= sb_rect.x
1041                        && col < sb_rect.x + sb_rect.width
1042                        && row >= sb_rect.y
1043                        && row < sb_rect.y + sb_rect.height
1044                    {
1045                        let relative_row = (row - sb_rect.y) as usize;
1046                        let track_height = sb_rect.height as usize;
1047                        let visible_lines = inner_rect.height as usize;
1048
1049                        if track_height > 0 && *total_lines > visible_lines {
1050                            let max_scroll = total_lines.saturating_sub(visible_lines);
1051                            let target_scroll = if track_height > 1 {
1052                                (relative_row * max_scroll) / (track_height.saturating_sub(1))
1053                            } else {
1054                                0
1055                            };
1056                            Some((*popup_idx, target_scroll as i32))
1057                        } else {
1058                            Some((*popup_idx, 0))
1059                        }
1060                    } else {
1061                        None
1062                    }
1063                },
1064            );
1065
1066        if let Some((popup_idx, target_scroll)) = scrollbar_scroll_info {
1067            // Set up drag state for popup scrollbar (reuse drag_start_row like editor scrollbar)
1068            self.mouse_state.dragging_popup_scrollbar = Some(popup_idx);
1069            self.mouse_state.drag_start_row = Some(row);
1070            // Get current scroll offset before mutable borrow
1071            let current_scroll = self
1072                .active_state()
1073                .popups
1074                .get(popup_idx)
1075                .map(|p| p.scroll_offset)
1076                .unwrap_or(0);
1077            self.mouse_state.drag_start_popup_scroll = Some(current_scroll);
1078            // Now do the scroll
1079            let state = self.active_state_mut();
1080            if let Some(popup) = state.popups.get_mut(popup_idx) {
1081                let delta = target_scroll - current_scroll as i32;
1082                popup.scroll_by(delta);
1083            }
1084            return Ok(());
1085        }
1086
1087        // Check if click is on a popup content area (they're rendered on top)
1088        for (popup_idx, _popup_rect, inner_rect, scroll_offset, num_items, _, _) in
1089            self.cached_layout.popup_areas.iter().rev()
1090        {
1091            if col >= inner_rect.x
1092                && col < inner_rect.x + inner_rect.width
1093                && row >= inner_rect.y
1094                && row < inner_rect.y + inner_rect.height
1095            {
1096                // Calculate relative position within the popup content area
1097                let relative_col = (col - inner_rect.x) as usize;
1098                let relative_row = (row - inner_rect.y) as usize;
1099
1100                // First, check if this is a markdown popup with a link
1101                let link_url = {
1102                    let state = self.active_state();
1103                    state
1104                        .popups
1105                        .top()
1106                        .and_then(|popup| popup.link_at_position(relative_col, relative_row))
1107                };
1108
1109                if let Some(url) = link_url {
1110                    // Open the URL in the default browser
1111                    #[cfg(feature = "runtime")]
1112                    if let Err(e) = open::that(&url) {
1113                        self.set_status_message(format!("Failed to open URL: {}", e));
1114                    } else {
1115                        self.set_status_message(format!("Opening: {}", url));
1116                    }
1117                    return Ok(());
1118                }
1119
1120                // For list popups, handle item selection
1121                if *num_items > 0 {
1122                    let item_idx = scroll_offset + relative_row;
1123
1124                    if item_idx < *num_items {
1125                        // Select and execute the clicked item
1126                        let state = self.active_state_mut();
1127                        if let Some(popup) = state.popups.top_mut() {
1128                            if let crate::view::popup::PopupContent::List { items: _, selected } =
1129                                &mut popup.content
1130                            {
1131                                *selected = item_idx;
1132                            }
1133                        }
1134                        // Execute the popup selection (same as pressing Enter)
1135                        return self.handle_action(Action::PopupConfirm);
1136                    }
1137                }
1138
1139                // For text/markdown popups, start text selection
1140                let is_text_popup = {
1141                    let state = self.active_state();
1142                    state.popups.top().is_some_and(|p| {
1143                        matches!(
1144                            p.content,
1145                            crate::view::popup::PopupContent::Text(_)
1146                                | crate::view::popup::PopupContent::Markdown(_)
1147                        )
1148                    })
1149                };
1150
1151                if is_text_popup {
1152                    let line = scroll_offset + relative_row;
1153                    let popup_idx_copy = *popup_idx; // Copy before mutable borrow
1154                    let state = self.active_state_mut();
1155                    if let Some(popup) = state.popups.top_mut() {
1156                        popup.start_selection(line, relative_col);
1157                    }
1158                    // Track that we're selecting in a popup
1159                    self.mouse_state.selecting_in_popup = Some(popup_idx_copy);
1160                    return Ok(());
1161                }
1162            }
1163        }
1164
1165        // If click is inside a popup's outer bounds but wasn't handled above,
1166        // block it from reaching the editor (e.g., clicking on popup border)
1167        if self.is_mouse_over_any_popup(col, row) {
1168            return Ok(());
1169        }
1170
1171        // Check if click is on the file browser popup
1172        if self.is_file_open_active() && self.handle_file_open_click(col, row) {
1173            return Ok(());
1174        }
1175
1176        // Check if click is on menu bar using cached layout
1177        if self.menu_bar_visible {
1178            if let Some(ref menu_layout) = self.cached_layout.menu_layout {
1179                if let Some(menu_idx) = menu_layout.menu_at(col, row) {
1180                    // Toggle menu: if same menu is open, close it; otherwise open clicked menu
1181                    if self.menu_state.active_menu == Some(menu_idx) {
1182                        self.close_menu_with_auto_hide();
1183                    } else {
1184                        // Dismiss transient popups and clear hover state when opening menu
1185                        self.on_editor_focus_lost();
1186                        self.menu_state.open_menu(menu_idx);
1187                    }
1188                    return Ok(());
1189                } else if row == 0 {
1190                    // Clicked on menu bar background but not on a menu label - close any open menu
1191                    self.close_menu_with_auto_hide();
1192                    return Ok(());
1193                }
1194            }
1195        }
1196
1197        // Check if click is on an open menu dropdown
1198        if let Some(active_idx) = self.menu_state.active_menu {
1199            let all_menus: Vec<crate::config::Menu> = self
1200                .menus
1201                .menus
1202                .iter()
1203                .chain(self.menu_state.plugin_menus.iter())
1204                .cloned()
1205                .collect();
1206
1207            if let Some(menu) = all_menus.get(active_idx) {
1208                // Handle click on menu dropdown chain (including submenus)
1209                if let Some(click_result) = self.handle_menu_dropdown_click(col, row, menu)? {
1210                    return click_result;
1211                }
1212            }
1213
1214            // Click outside the dropdown - close the menu
1215            self.close_menu_with_auto_hide();
1216            return Ok(());
1217        }
1218
1219        // Check if click is on file explorer
1220        if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1221            if col >= explorer_area.x
1222                && col < explorer_area.x + explorer_area.width
1223                && row >= explorer_area.y
1224                && row < explorer_area.y + explorer_area.height
1225            {
1226                self.handle_file_explorer_click(col, row, explorer_area)?;
1227                return Ok(());
1228            }
1229        }
1230
1231        // Check if click is on a scrollbar
1232        let scrollbar_hit = self.cached_layout.split_areas.iter().find_map(
1233            |(split_id, buffer_id, _content_rect, scrollbar_rect, thumb_start, thumb_end)| {
1234                if col >= scrollbar_rect.x
1235                    && col < scrollbar_rect.x + scrollbar_rect.width
1236                    && row >= scrollbar_rect.y
1237                    && row < scrollbar_rect.y + scrollbar_rect.height
1238                {
1239                    let relative_row = row.saturating_sub(scrollbar_rect.y) as usize;
1240                    let is_on_thumb = relative_row >= *thumb_start && relative_row < *thumb_end;
1241                    Some((*split_id, *buffer_id, *scrollbar_rect, is_on_thumb))
1242                } else {
1243                    None
1244                }
1245            },
1246        );
1247
1248        if let Some((split_id, buffer_id, scrollbar_rect, is_on_thumb)) = scrollbar_hit {
1249            self.focus_split(split_id, buffer_id);
1250
1251            if is_on_thumb {
1252                // Click on thumb - start drag from current position (don't jump)
1253                self.mouse_state.dragging_scrollbar = Some(split_id);
1254                self.mouse_state.drag_start_row = Some(row);
1255                // Record the current viewport position from SplitViewState
1256                if let Some(view_state) = self.split_view_states.get(&split_id) {
1257                    self.mouse_state.drag_start_top_byte = Some(view_state.viewport.top_byte);
1258                }
1259            } else {
1260                // Click on track - jump to position
1261                self.mouse_state.dragging_scrollbar = Some(split_id);
1262                self.handle_scrollbar_jump(col, row, split_id, buffer_id, scrollbar_rect)?;
1263            }
1264            return Ok(());
1265        }
1266
1267        // Check if click is on status bar indicators
1268        if let Some((status_row, _status_x, _status_width)) = self.cached_layout.status_bar_area {
1269            if row == status_row {
1270                // Check line ending indicator - click opens line ending selector
1271                if let Some((le_row, le_start, le_end)) =
1272                    self.cached_layout.status_bar_line_ending_area
1273                {
1274                    if row == le_row && col >= le_start && col < le_end {
1275                        return self.handle_action(Action::SetLineEnding);
1276                    }
1277                }
1278
1279                // Check language indicator - click opens language selector
1280                if let Some((lang_row, lang_start, lang_end)) =
1281                    self.cached_layout.status_bar_language_area
1282                {
1283                    if row == lang_row && col >= lang_start && col < lang_end {
1284                        return self.handle_action(Action::SetLanguage);
1285                    }
1286                }
1287
1288                // Check LSP indicator - click opens LSP status popup
1289                if let Some((lsp_row, lsp_start, lsp_end)) = self.cached_layout.status_bar_lsp_area
1290                {
1291                    if row == lsp_row && col >= lsp_start && col < lsp_end {
1292                        return self.handle_action(Action::ShowLspStatus);
1293                    }
1294                }
1295
1296                // Check warning badge - click opens warning log
1297                if let Some((warn_row, warn_start, warn_end)) =
1298                    self.cached_layout.status_bar_warning_area
1299                {
1300                    if row == warn_row && col >= warn_start && col < warn_end {
1301                        return self.handle_action(Action::ShowWarnings);
1302                    }
1303                }
1304
1305                // Check message area - click opens status log
1306                if let Some((msg_row, msg_start, msg_end)) =
1307                    self.cached_layout.status_bar_message_area
1308                {
1309                    if row == msg_row && col >= msg_start && col < msg_end {
1310                        return self.handle_action(Action::ShowStatusLog);
1311                    }
1312                }
1313            }
1314        }
1315
1316        // Check if click is on search options checkboxes
1317        if let Some(ref layout) = self.cached_layout.search_options_layout.clone() {
1318            use crate::view::ui::status_bar::SearchOptionsHover;
1319            if let Some(hover) = layout.checkbox_at(col, row) {
1320                match hover {
1321                    SearchOptionsHover::CaseSensitive => {
1322                        return self.handle_action(Action::ToggleSearchCaseSensitive);
1323                    }
1324                    SearchOptionsHover::WholeWord => {
1325                        return self.handle_action(Action::ToggleSearchWholeWord);
1326                    }
1327                    SearchOptionsHover::Regex => {
1328                        return self.handle_action(Action::ToggleSearchRegex);
1329                    }
1330                    SearchOptionsHover::ConfirmEach => {
1331                        return self.handle_action(Action::ToggleSearchConfirmEach);
1332                    }
1333                    SearchOptionsHover::None => {}
1334                }
1335            }
1336        }
1337
1338        // Check if click is on file explorer border (for drag resizing)
1339        if let Some(explorer_area) = self.cached_layout.file_explorer_area {
1340            let border_x = explorer_area.x + explorer_area.width;
1341            if col == border_x
1342                && row >= explorer_area.y
1343                && row < explorer_area.y + explorer_area.height
1344            {
1345                // Start file explorer border drag
1346                self.mouse_state.dragging_file_explorer = true;
1347                self.mouse_state.drag_start_position = Some((col, row));
1348                self.mouse_state.drag_start_explorer_width = Some(self.file_explorer_width_percent);
1349                return Ok(());
1350            }
1351        }
1352
1353        // Check if click is on a split separator (for drag resizing)
1354        for (split_id, direction, sep_x, sep_y, sep_length) in &self.cached_layout.separator_areas {
1355            let is_on_separator = match direction {
1356                SplitDirection::Horizontal => {
1357                    // Horizontal separator: spans full width at a specific y
1358                    row == *sep_y && col >= *sep_x && col < sep_x + sep_length
1359                }
1360                SplitDirection::Vertical => {
1361                    // Vertical separator: spans full height at a specific x
1362                    col == *sep_x && row >= *sep_y && row < sep_y + sep_length
1363                }
1364            };
1365
1366            if is_on_separator {
1367                // Start separator drag
1368                self.mouse_state.dragging_separator = Some((*split_id, *direction));
1369                self.mouse_state.drag_start_position = Some((col, row));
1370                // Store the initial ratio
1371                if let Some(ratio) = self.split_manager.get_ratio(*split_id) {
1372                    self.mouse_state.drag_start_ratio = Some(ratio);
1373                }
1374                return Ok(());
1375            }
1376        }
1377
1378        // Check if click is on a close split button
1379        let close_split_click = self
1380            .cached_layout
1381            .close_split_areas
1382            .iter()
1383            .find(|(_, btn_row, start_col, end_col)| {
1384                row == *btn_row && col >= *start_col && col < *end_col
1385            })
1386            .map(|(split_id, _, _, _)| *split_id);
1387
1388        if let Some(split_id) = close_split_click {
1389            if let Err(e) = self.split_manager.close_split(split_id) {
1390                self.set_status_message(
1391                    t!("error.cannot_close_split", error = e.to_string()).to_string(),
1392                );
1393            } else {
1394                // Update active buffer to match the new active split
1395                let new_active_split = self.split_manager.active_split();
1396                if let Some(buffer_id) = self.split_manager.buffer_for_split(new_active_split) {
1397                    self.set_active_buffer(buffer_id);
1398                }
1399                self.set_status_message(t!("split.closed").to_string());
1400            }
1401            return Ok(());
1402        }
1403
1404        // Check if click is on a maximize split button
1405        let maximize_split_click = self
1406            .cached_layout
1407            .maximize_split_areas
1408            .iter()
1409            .find(|(_, btn_row, start_col, end_col)| {
1410                row == *btn_row && col >= *start_col && col < *end_col
1411            })
1412            .map(|(split_id, _, _, _)| *split_id);
1413
1414        if let Some(_split_id) = maximize_split_click {
1415            // Toggle maximize state
1416            match self.split_manager.toggle_maximize() {
1417                Ok(maximized) => {
1418                    if maximized {
1419                        self.set_status_message(t!("split.maximized").to_string());
1420                    } else {
1421                        self.set_status_message(t!("split.restored").to_string());
1422                    }
1423                }
1424                Err(e) => self.set_status_message(e),
1425            }
1426            return Ok(());
1427        }
1428
1429        // Check if click is on a tab using cached tab layouts (computed during rendering)
1430        // Debug: show tab layout info
1431        for (split_id, tab_layout) in &self.cached_layout.tab_layouts {
1432            tracing::debug!(
1433                "Tab layout for split {:?}: bar_area={:?}, left_scroll={:?}, right_scroll={:?}",
1434                split_id,
1435                tab_layout.bar_area,
1436                tab_layout.left_scroll_area,
1437                tab_layout.right_scroll_area
1438            );
1439        }
1440
1441        let tab_hit = self
1442            .cached_layout
1443            .tab_layouts
1444            .iter()
1445            .find_map(|(split_id, tab_layout)| {
1446                let hit = tab_layout.hit_test(col, row);
1447                tracing::debug!(
1448                    "Tab hit_test at ({}, {}) for split {:?} returned {:?}",
1449                    col,
1450                    row,
1451                    split_id,
1452                    hit
1453                );
1454                hit.map(|h| (*split_id, h))
1455            });
1456
1457        if let Some((split_id, hit)) = tab_hit {
1458            match hit {
1459                TabHit::CloseButton(buffer_id) => {
1460                    self.focus_split(split_id, buffer_id);
1461                    self.close_tab_in_split(buffer_id, split_id);
1462                    return Ok(());
1463                }
1464                TabHit::TabName(buffer_id) => {
1465                    self.focus_split(split_id, buffer_id);
1466                    // Start potential tab drag (will only become active after moving threshold)
1467                    self.mouse_state.dragging_tab = Some(super::types::TabDragState::new(
1468                        buffer_id,
1469                        split_id,
1470                        (col, row),
1471                    ));
1472                    return Ok(());
1473                }
1474                TabHit::ScrollLeft => {
1475                    // Scroll tabs left by one tab width (use 5 chars as estimate)
1476                    self.set_status_message("ScrollLeft clicked!".to_string());
1477                    if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1478                        view_state.tab_scroll_offset =
1479                            view_state.tab_scroll_offset.saturating_sub(10);
1480                    }
1481                    return Ok(());
1482                }
1483                TabHit::ScrollRight => {
1484                    // Scroll tabs right by one tab width (use 5 chars as estimate)
1485                    self.set_status_message("ScrollRight clicked!".to_string());
1486                    if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
1487                        view_state.tab_scroll_offset =
1488                            view_state.tab_scroll_offset.saturating_add(10);
1489                    }
1490                    return Ok(());
1491                }
1492                TabHit::BarBackground => {}
1493            }
1494        }
1495
1496        // Check if click is in editor content area
1497        tracing::debug!(
1498            "handle_mouse_click: checking {} split_areas for click at ({}, {})",
1499            self.cached_layout.split_areas.len(),
1500            col,
1501            row
1502        );
1503        for (split_id, buffer_id, content_rect, _scrollbar_rect, _thumb_start, _thumb_end) in
1504            &self.cached_layout.split_areas
1505        {
1506            tracing::debug!(
1507                "  split_id={:?}, content_rect=({}, {}, {}x{})",
1508                split_id,
1509                content_rect.x,
1510                content_rect.y,
1511                content_rect.width,
1512                content_rect.height
1513            );
1514            if col >= content_rect.x
1515                && col < content_rect.x + content_rect.width
1516                && row >= content_rect.y
1517                && row < content_rect.y + content_rect.height
1518            {
1519                // Click in editor - focus split and position cursor
1520                tracing::debug!("  -> HIT! calling handle_editor_click");
1521                self.handle_editor_click(
1522                    col,
1523                    row,
1524                    *split_id,
1525                    *buffer_id,
1526                    *content_rect,
1527                    modifiers,
1528                )?;
1529                return Ok(());
1530            }
1531        }
1532        tracing::debug!("  -> No split area hit");
1533
1534        Ok(())
1535    }
1536
1537    /// Handle mouse drag event
1538    pub(super) fn handle_mouse_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1539        // If dragging scrollbar, update scroll position
1540        if let Some(dragging_split_id) = self.mouse_state.dragging_scrollbar {
1541            // Find the buffer and scrollbar rect for this split
1542            for (split_id, buffer_id, _content_rect, scrollbar_rect, _thumb_start, _thumb_end) in
1543                &self.cached_layout.split_areas
1544            {
1545                if *split_id == dragging_split_id {
1546                    // Check if we started dragging from the thumb (have drag_start_row)
1547                    if self.mouse_state.drag_start_row.is_some() {
1548                        // Relative drag from thumb
1549                        self.handle_scrollbar_drag_relative(
1550                            row,
1551                            *split_id,
1552                            *buffer_id,
1553                            *scrollbar_rect,
1554                        )?;
1555                    } else {
1556                        // Jump drag (started from track)
1557                        self.handle_scrollbar_jump(
1558                            col,
1559                            row,
1560                            *split_id,
1561                            *buffer_id,
1562                            *scrollbar_rect,
1563                        )?;
1564                    }
1565                    return Ok(());
1566                }
1567            }
1568        }
1569
1570        // If selecting text in popup, extend selection
1571        if let Some(popup_idx) = self.mouse_state.selecting_in_popup {
1572            // Find the popup area from cached layout
1573            if let Some((_, _, inner_rect, scroll_offset, _, _, _)) = self
1574                .cached_layout
1575                .popup_areas
1576                .iter()
1577                .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
1578            {
1579                // Check if mouse is within the popup inner area
1580                if col >= inner_rect.x
1581                    && col < inner_rect.x + inner_rect.width
1582                    && row >= inner_rect.y
1583                    && row < inner_rect.y + inner_rect.height
1584                {
1585                    let relative_col = (col - inner_rect.x) as usize;
1586                    let relative_row = (row - inner_rect.y) as usize;
1587                    let line = scroll_offset + relative_row;
1588
1589                    let state = self.active_state_mut();
1590                    if let Some(popup) = state.popups.get_mut(popup_idx) {
1591                        popup.extend_selection(line, relative_col);
1592                    }
1593                }
1594            }
1595            return Ok(());
1596        }
1597
1598        // If dragging popup scrollbar, update popup scroll position
1599        if let Some(popup_idx) = self.mouse_state.dragging_popup_scrollbar {
1600            // Find the popup's scrollbar rect from cached layout
1601            if let Some((_, _, inner_rect, _, _, Some(sb_rect), total_lines)) = self
1602                .cached_layout
1603                .popup_areas
1604                .iter()
1605                .find(|(idx, _, _, _, _, _, _)| *idx == popup_idx)
1606            {
1607                let track_height = sb_rect.height as usize;
1608                let visible_lines = inner_rect.height as usize;
1609
1610                if track_height > 0 && *total_lines > visible_lines {
1611                    let relative_row = row.saturating_sub(sb_rect.y) as usize;
1612                    let max_scroll = total_lines.saturating_sub(visible_lines);
1613                    let target_scroll = if track_height > 1 {
1614                        (relative_row * max_scroll) / (track_height.saturating_sub(1))
1615                    } else {
1616                        0
1617                    };
1618
1619                    let state = self.active_state_mut();
1620                    if let Some(popup) = state.popups.get_mut(popup_idx) {
1621                        let current_scroll = popup.scroll_offset as i32;
1622                        let delta = target_scroll as i32 - current_scroll;
1623                        popup.scroll_by(delta);
1624                    }
1625                }
1626            }
1627            return Ok(());
1628        }
1629
1630        // If dragging separator, update split ratio
1631        if let Some((split_id, direction)) = self.mouse_state.dragging_separator {
1632            self.handle_separator_drag(col, row, split_id, direction)?;
1633            return Ok(());
1634        }
1635
1636        // If dragging file explorer border, update width
1637        if self.mouse_state.dragging_file_explorer {
1638            self.handle_file_explorer_border_drag(col)?;
1639            return Ok(());
1640        }
1641
1642        // If dragging to select text
1643        if self.mouse_state.dragging_text_selection {
1644            self.handle_text_selection_drag(col, row)?;
1645            return Ok(());
1646        }
1647
1648        // If dragging a tab, update position and compute drop zone
1649        if self.mouse_state.dragging_tab.is_some() {
1650            self.handle_tab_drag(col, row)?;
1651            return Ok(());
1652        }
1653
1654        Ok(())
1655    }
1656
1657    /// Handle text selection drag - extends selection from anchor to current position
1658    fn handle_text_selection_drag(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1659        use crate::model::event::Event;
1660
1661        let Some(split_id) = self.mouse_state.drag_selection_split else {
1662            return Ok(());
1663        };
1664        let Some(anchor_position) = self.mouse_state.drag_selection_anchor else {
1665            return Ok(());
1666        };
1667
1668        // Find the buffer for this split
1669        let buffer_id = self
1670            .cached_layout
1671            .split_areas
1672            .iter()
1673            .find(|(sid, _, _, _, _, _)| *sid == split_id)
1674            .map(|(_, bid, _, _, _, _)| *bid);
1675
1676        let Some(buffer_id) = buffer_id else {
1677            return Ok(());
1678        };
1679
1680        // Find the content rect for this split
1681        let content_rect = self
1682            .cached_layout
1683            .split_areas
1684            .iter()
1685            .find(|(sid, _, _, _, _, _)| *sid == split_id)
1686            .map(|(_, _, rect, _, _, _)| *rect);
1687
1688        let Some(content_rect) = content_rect else {
1689            return Ok(());
1690        };
1691
1692        // Get cached view line mappings for this split
1693        let cached_mappings = self
1694            .cached_layout
1695            .view_line_mappings
1696            .get(&split_id)
1697            .cloned();
1698
1699        // Get fallback from SplitViewState viewport
1700        let fallback = self
1701            .split_view_states
1702            .get(&split_id)
1703            .map(|vs| vs.viewport.top_byte)
1704            .unwrap_or(0);
1705
1706        // Calculate the target position from screen coordinates
1707        if let Some(state) = self.buffers.get_mut(&buffer_id) {
1708            let gutter_width = state.margins.left_total_width() as u16;
1709
1710            let Some(target_position) = Self::screen_to_buffer_position(
1711                col,
1712                row,
1713                content_rect,
1714                gutter_width,
1715                &cached_mappings,
1716                fallback,
1717                true, // Allow gutter clicks for drag selection
1718            ) else {
1719                return Ok(());
1720            };
1721
1722            // Move cursor to target position while keeping anchor to create selection
1723            let primary_cursor_id = state.cursors.primary_id();
1724            let event = Event::MoveCursor {
1725                cursor_id: primary_cursor_id,
1726                old_position: 0,
1727                new_position: target_position,
1728                old_anchor: None,
1729                new_anchor: Some(anchor_position), // Keep anchor to maintain selection
1730                old_sticky_column: 0,
1731                new_sticky_column: 0,
1732            };
1733
1734            if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1735                event_log.append(event.clone());
1736            }
1737            state.apply(&event);
1738        }
1739
1740        Ok(())
1741    }
1742
1743    /// Handle file explorer border drag for resizing
1744    pub(super) fn handle_file_explorer_border_drag(&mut self, col: u16) -> AnyhowResult<()> {
1745        let Some((start_col, _start_row)) = self.mouse_state.drag_start_position else {
1746            return Ok(());
1747        };
1748        let Some(start_width) = self.mouse_state.drag_start_explorer_width else {
1749            return Ok(());
1750        };
1751
1752        // Calculate the delta in screen space
1753        let delta = col as i32 - start_col as i32;
1754        let total_width = self.terminal_width as i32;
1755
1756        if total_width > 0 {
1757            // Convert screen delta to percentage delta
1758            let percent_delta = delta as f32 / total_width as f32;
1759            // Clamp the new width between 10% and 50%
1760            let new_width = (start_width + percent_delta).clamp(0.1, 0.5);
1761            self.file_explorer_width_percent = new_width;
1762        }
1763
1764        Ok(())
1765    }
1766
1767    /// Handle separator drag for split resizing
1768    pub(super) fn handle_separator_drag(
1769        &mut self,
1770        col: u16,
1771        row: u16,
1772        split_id: SplitId,
1773        direction: SplitDirection,
1774    ) -> AnyhowResult<()> {
1775        let Some((start_col, start_row)) = self.mouse_state.drag_start_position else {
1776            return Ok(());
1777        };
1778        let Some(start_ratio) = self.mouse_state.drag_start_ratio else {
1779            return Ok(());
1780        };
1781        let Some(editor_area) = self.cached_layout.editor_content_area else {
1782            return Ok(());
1783        };
1784
1785        // Calculate the delta in screen space
1786        let (delta, total_size) = match direction {
1787            SplitDirection::Horizontal => {
1788                // For horizontal splits, we move the separator up/down (row changes)
1789                let delta = row as i32 - start_row as i32;
1790                let total = editor_area.height as i32;
1791                (delta, total)
1792            }
1793            SplitDirection::Vertical => {
1794                // For vertical splits, we move the separator left/right (col changes)
1795                let delta = col as i32 - start_col as i32;
1796                let total = editor_area.width as i32;
1797                (delta, total)
1798            }
1799        };
1800
1801        // Convert screen delta to ratio delta
1802        // The ratio represents the fraction of space the first split gets
1803        if total_size > 0 {
1804            let ratio_delta = delta as f32 / total_size as f32;
1805            let new_ratio = (start_ratio + ratio_delta).clamp(0.1, 0.9);
1806
1807            // Update the split ratio
1808            let _ = self.split_manager.set_ratio(split_id, new_ratio);
1809        }
1810
1811        Ok(())
1812    }
1813
1814    /// Handle right-click event
1815    pub(super) fn handle_right_click(&mut self, col: u16, row: u16) -> AnyhowResult<()> {
1816        // First check if a tab context menu is open and the click is on a menu item
1817        if let Some(ref menu) = self.tab_context_menu {
1818            let menu_x = menu.position.0;
1819            let menu_y = menu.position.1;
1820            let menu_width = 22u16; // "Close to the Right" + padding
1821            let menu_height = super::types::TabContextMenuItem::all().len() as u16 + 2; // items + borders
1822
1823            // Check if click is inside the menu
1824            if col >= menu_x
1825                && col < menu_x + menu_width
1826                && row >= menu_y
1827                && row < menu_y + menu_height
1828            {
1829                // Click inside menu - let left-click handler deal with it
1830                return Ok(());
1831            }
1832        }
1833
1834        // Check if right-click is on a tab
1835        let tab_hit =
1836            self.cached_layout.tab_layouts.iter().find_map(
1837                |(split_id, tab_layout)| match tab_layout.hit_test(col, row) {
1838                    Some(TabHit::TabName(buffer_id) | TabHit::CloseButton(buffer_id)) => {
1839                        Some((*split_id, buffer_id))
1840                    }
1841                    _ => None,
1842                },
1843            );
1844
1845        if let Some((split_id, buffer_id)) = tab_hit {
1846            // Open tab context menu
1847            self.tab_context_menu = Some(TabContextMenu::new(buffer_id, split_id, col, row + 1));
1848        } else {
1849            // Click outside tab - close context menu if open
1850            self.tab_context_menu = None;
1851        }
1852
1853        Ok(())
1854    }
1855
1856    /// Handle left-click on tab context menu
1857    pub(super) fn handle_tab_context_menu_click(
1858        &mut self,
1859        col: u16,
1860        row: u16,
1861    ) -> Option<AnyhowResult<()>> {
1862        let menu = self.tab_context_menu.as_ref()?;
1863        let menu_x = menu.position.0;
1864        let menu_y = menu.position.1;
1865        let menu_width = 22u16;
1866        let items = super::types::TabContextMenuItem::all();
1867        let menu_height = items.len() as u16 + 2; // items + borders
1868
1869        // Check if click is inside the menu area
1870        if col < menu_x || col >= menu_x + menu_width || row < menu_y || row >= menu_y + menu_height
1871        {
1872            // Click outside menu - close it
1873            self.tab_context_menu = None;
1874            return Some(Ok(()));
1875        }
1876
1877        // Check if click is on the border (first or last row)
1878        if row == menu_y || row == menu_y + menu_height - 1 {
1879            return Some(Ok(()));
1880        }
1881
1882        // Calculate which item was clicked (accounting for border)
1883        let item_idx = (row - menu_y - 1) as usize;
1884        if item_idx >= items.len() {
1885            return Some(Ok(()));
1886        }
1887
1888        // Get the menu state before closing it
1889        let buffer_id = menu.buffer_id;
1890        let split_id = menu.split_id;
1891        let item = items[item_idx];
1892
1893        // Close the menu
1894        self.tab_context_menu = None;
1895
1896        // Execute the action
1897        Some(self.execute_tab_context_menu_action(item, buffer_id, split_id))
1898    }
1899
1900    /// Execute a tab context menu action
1901    fn execute_tab_context_menu_action(
1902        &mut self,
1903        item: super::types::TabContextMenuItem,
1904        buffer_id: BufferId,
1905        split_id: SplitId,
1906    ) -> AnyhowResult<()> {
1907        use super::types::TabContextMenuItem;
1908
1909        match item {
1910            TabContextMenuItem::Close => {
1911                self.close_tab_in_split(buffer_id, split_id);
1912            }
1913            TabContextMenuItem::CloseOthers => {
1914                self.close_other_tabs_in_split(buffer_id, split_id);
1915            }
1916            TabContextMenuItem::CloseToRight => {
1917                self.close_tabs_to_right_in_split(buffer_id, split_id);
1918            }
1919            TabContextMenuItem::CloseToLeft => {
1920                self.close_tabs_to_left_in_split(buffer_id, split_id);
1921            }
1922            TabContextMenuItem::CloseAll => {
1923                self.close_all_tabs_in_split(split_id);
1924            }
1925        }
1926
1927        Ok(())
1928    }
1929
1930    /// Show a tooltip for a file explorer status indicator
1931    fn show_file_explorer_status_tooltip(&mut self, path: std::path::PathBuf, col: u16, row: u16) {
1932        use crate::view::popup::{Popup, PopupPosition};
1933        use ratatui::style::Style;
1934
1935        let is_directory = path.is_dir();
1936
1937        // Get the decoration for this file to determine the status
1938        let decoration = self
1939            .file_explorer_decoration_cache
1940            .direct_for_path(&path)
1941            .cloned();
1942
1943        // For directories, also check bubbled decoration
1944        let bubbled_decoration = if is_directory && decoration.is_none() {
1945            self.file_explorer_decoration_cache
1946                .bubbled_for_path(&path)
1947                .cloned()
1948        } else {
1949            None
1950        };
1951
1952        // Check if file/folder has unsaved changes in editor
1953        let has_unsaved_changes = if is_directory {
1954            // Check if any buffer under this directory has unsaved changes
1955            self.buffers.iter().any(|(buffer_id, state)| {
1956                if state.buffer.is_modified() {
1957                    if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
1958                        if let Some(file_path) = metadata.file_path() {
1959                            return file_path.starts_with(&path);
1960                        }
1961                    }
1962                }
1963                false
1964            })
1965        } else {
1966            self.buffers.iter().any(|(buffer_id, state)| {
1967                if state.buffer.is_modified() {
1968                    if let Some(metadata) = self.buffer_metadata.get(buffer_id) {
1969                        return metadata.file_path() == Some(&path);
1970                    }
1971                }
1972                false
1973            })
1974        };
1975
1976        // Build tooltip content
1977        let mut lines: Vec<String> = Vec::new();
1978
1979        if let Some(decoration) = &decoration {
1980            let symbol = &decoration.symbol;
1981            let explanation = match symbol.as_str() {
1982                "U" => "Untracked - File is not tracked by git",
1983                "M" => "Modified - File has unstaged changes",
1984                "A" => "Added - File is staged for commit",
1985                "D" => "Deleted - File is staged for deletion",
1986                "R" => "Renamed - File has been renamed",
1987                "C" => "Copied - File has been copied",
1988                "!" => "Conflicted - File has merge conflicts",
1989                "●" => "Has changes - Contains modified files",
1990                _ => "Unknown status",
1991            };
1992            lines.push(format!("{} - {}", symbol, explanation));
1993        } else if bubbled_decoration.is_some() {
1994            lines.push("● - Contains modified files".to_string());
1995        } else if has_unsaved_changes {
1996            if is_directory {
1997                lines.push("● - Contains unsaved changes".to_string());
1998            } else {
1999                lines.push("● - Unsaved changes in editor".to_string());
2000            }
2001        } else {
2002            return; // No status to show
2003        }
2004
2005        // For directories, show list of modified files
2006        if is_directory {
2007            // get_modified_files_in_directory returns None if no files, so no need to check is_empty()
2008            if let Some(modified_files) = self.get_modified_files_in_directory(&path) {
2009                lines.push(String::new()); // Empty line separator
2010                lines.push("Modified files:".to_string());
2011                // Resolve symlinks for proper prefix stripping
2012                let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
2013                const MAX_FILES: usize = 8;
2014                for (i, file) in modified_files.iter().take(MAX_FILES).enumerate() {
2015                    // Show relative path from the directory
2016                    let display_name = file
2017                        .strip_prefix(&resolved_path)
2018                        .unwrap_or(file)
2019                        .to_string_lossy()
2020                        .to_string();
2021                    lines.push(format!("  {}", display_name));
2022                    if i == MAX_FILES - 1 && modified_files.len() > MAX_FILES {
2023                        lines.push(format!(
2024                            "  ... and {} more",
2025                            modified_files.len() - MAX_FILES
2026                        ));
2027                        break;
2028                    }
2029                }
2030            }
2031        } else {
2032            // For files, try to get git diff stats
2033            if let Some(stats) = self.get_git_diff_stats(&path) {
2034                lines.push(String::new()); // Empty line separator
2035                lines.push(stats);
2036            }
2037        }
2038
2039        if lines.is_empty() {
2040            return;
2041        }
2042
2043        // Create popup
2044        let mut popup = Popup::text(lines, &self.theme);
2045        popup.title = Some("Git Status".to_string());
2046        popup.transient = true;
2047        popup.position = PopupPosition::Fixed { x: col, y: row + 1 };
2048        popup.width = 50;
2049        popup.max_height = 15;
2050        popup.border_style = Style::default().fg(self.theme.popup_border_fg);
2051        popup.background_style = Style::default().bg(self.theme.popup_bg);
2052
2053        // Show the popup
2054        if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2055            state.popups.show(popup);
2056        }
2057    }
2058
2059    /// Dismiss the file explorer status tooltip
2060    fn dismiss_file_explorer_status_tooltip(&mut self) {
2061        // Dismiss any transient popups
2062        if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
2063            state.popups.dismiss_transient();
2064        }
2065    }
2066
2067    /// Get git diff stats for a file (insertions/deletions)
2068    fn get_git_diff_stats(&self, path: &std::path::Path) -> Option<String> {
2069        use std::process::Command;
2070
2071        // Run git diff --numstat for the file
2072        let output = Command::new("git")
2073            .args(["diff", "--numstat", "--"])
2074            .arg(path)
2075            .current_dir(&self.working_dir)
2076            .output()
2077            .ok()?;
2078
2079        if !output.status.success() {
2080            return None;
2081        }
2082
2083        let stdout = String::from_utf8_lossy(&output.stdout);
2084        let line = stdout.lines().next()?;
2085        let parts: Vec<&str> = line.split('\t').collect();
2086
2087        if parts.len() >= 2 {
2088            let insertions = parts[0];
2089            let deletions = parts[1];
2090
2091            // Handle binary files (shows as -)
2092            if insertions == "-" && deletions == "-" {
2093                return Some("Binary file changed".to_string());
2094            }
2095
2096            let ins: i32 = insertions.parse().unwrap_or(0);
2097            let del: i32 = deletions.parse().unwrap_or(0);
2098
2099            if ins > 0 || del > 0 {
2100                return Some(format!("+{} -{} lines", ins, del));
2101            }
2102        }
2103
2104        // Also check staged changes
2105        let staged_output = Command::new("git")
2106            .args(["diff", "--numstat", "--cached", "--"])
2107            .arg(path)
2108            .current_dir(&self.working_dir)
2109            .output()
2110            .ok()?;
2111
2112        if staged_output.status.success() {
2113            let staged_stdout = String::from_utf8_lossy(&staged_output.stdout);
2114            if let Some(line) = staged_stdout.lines().next() {
2115                let parts: Vec<&str> = line.split('\t').collect();
2116                if parts.len() >= 2 {
2117                    let insertions = parts[0];
2118                    let deletions = parts[1];
2119
2120                    if insertions == "-" && deletions == "-" {
2121                        return Some("Binary file staged".to_string());
2122                    }
2123
2124                    let ins: i32 = insertions.parse().unwrap_or(0);
2125                    let del: i32 = deletions.parse().unwrap_or(0);
2126
2127                    if ins > 0 || del > 0 {
2128                        return Some(format!("+{} -{} lines (staged)", ins, del));
2129                    }
2130                }
2131            }
2132        }
2133
2134        None
2135    }
2136
2137    /// Get list of modified files in a directory
2138    fn get_modified_files_in_directory(
2139        &self,
2140        dir_path: &std::path::Path,
2141    ) -> Option<Vec<std::path::PathBuf>> {
2142        use std::process::Command;
2143
2144        // Resolve symlinks to get the actual directory path
2145        let resolved_path = dir_path
2146            .canonicalize()
2147            .unwrap_or_else(|_| dir_path.to_path_buf());
2148
2149        // Run git status --porcelain to get list of modified files
2150        let output = Command::new("git")
2151            .args(["status", "--porcelain", "--"])
2152            .arg(&resolved_path)
2153            .current_dir(&self.working_dir)
2154            .output()
2155            .ok()?;
2156
2157        if !output.status.success() {
2158            return None;
2159        }
2160
2161        let stdout = String::from_utf8_lossy(&output.stdout);
2162        let modified_files: Vec<std::path::PathBuf> = stdout
2163            .lines()
2164            .filter_map(|line| {
2165                // Git porcelain format: XY filename
2166                // where XY is the status (M, A, D, ??, etc.)
2167                if line.len() > 3 {
2168                    let file_part = &line[3..];
2169                    // Handle renamed files (old -> new format)
2170                    let file_name = if file_part.contains(" -> ") {
2171                        file_part.split(" -> ").last().unwrap_or(file_part)
2172                    } else {
2173                        file_part
2174                    };
2175                    Some(self.working_dir.join(file_name))
2176                } else {
2177                    None
2178                }
2179            })
2180            .collect();
2181
2182        if modified_files.is_empty() {
2183            None
2184        } else {
2185            Some(modified_files)
2186        }
2187    }
2188}