Skip to main content

fresh/app/
types.rs

1use crate::app::file_open::SortMode;
2use crate::model::event::{BufferId, ContainerId, LeafId, SplitDirection};
3use crate::services::async_bridge::LspMessageType;
4use ratatui::layout::Rect;
5use rust_i18n::t;
6use std::collections::{HashMap, HashSet};
7use std::ops::Range;
8use std::path::{Path, PathBuf};
9
10pub const DEFAULT_BACKGROUND_FILE: &str = "scripts/landscape-wide.txt";
11
12pub const FILE_EXPLORER_CONTEXT_MENU_WIDTH: u16 = 18;
13
14/// Unique identifier for a buffer group
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct BufferGroupId(pub usize);
17
18/// Layout node for a buffer group
19#[derive(Debug, Clone)]
20pub enum GroupLayoutNode {
21    /// A scrollable panel backed by a real buffer
22    Scrollable {
23        /// Panel name (e.g., "tree", "picker")
24        id: String,
25        /// Buffer ID for this panel (set during creation)
26        buffer_id: Option<BufferId>,
27        /// Split leaf ID (set during creation)
28        split_id: Option<LeafId>,
29    },
30    /// A fixed-height panel (header, footer, toolbar)
31    Fixed {
32        /// Panel name
33        id: String,
34        /// Height in rows
35        height: u16,
36        /// Buffer ID (set during creation)
37        buffer_id: Option<BufferId>,
38        /// Split leaf ID (set during creation)
39        split_id: Option<LeafId>,
40    },
41    /// A horizontal or vertical split containing two children
42    Split {
43        direction: SplitDirection,
44        /// Ratio for the first child (0.0 to 1.0)
45        ratio: f32,
46        first: Box<GroupLayoutNode>,
47        second: Box<GroupLayoutNode>,
48    },
49}
50
51/// A buffer group: multiple splits/buffers appearing as one tab.
52///
53/// Each panel is a real buffer with its own viewport, scrollbar,
54/// and cursor. The group presents them as a single logical entity
55/// in the tab bar and buffer list.
56#[derive(Debug)]
57pub struct BufferGroup {
58    /// Unique ID
59    pub id: BufferGroupId,
60    /// Display name (shown in tab bar)
61    pub name: String,
62    /// Mode for keybindings
63    pub mode: String,
64    /// Layout tree
65    pub layout: GroupLayoutNode,
66    /// All buffer IDs in this group (panel name → buffer ID)
67    pub panel_buffers: HashMap<String, BufferId>,
68    /// All split leaf IDs in this group
69    pub panel_splits: HashMap<String, LeafId>,
70    /// The "representative" split that owns the tab entry.
71    /// This is typically the first scrollable panel.
72    pub representative_split: Option<LeafId>,
73}
74
75/// Pre-calculated line information for an event
76/// Calculated BEFORE buffer modification so line numbers are accurate
77#[derive(Debug, Clone, Default)]
78pub(super) struct EventLineInfo {
79    /// Start line (0-indexed) where the change begins
80    pub start_line: usize,
81    /// End line (0-indexed) where the change ends (in original buffer for deletes)
82    pub end_line: usize,
83    /// Number of lines added (for inserts) or removed (for deletes)
84    pub line_delta: i32,
85}
86
87/// Search state for find/replace functionality
88#[derive(Debug, Clone)]
89pub(super) struct SearchState {
90    /// The search query
91    pub query: String,
92    /// All match positions in the buffer (byte offsets)
93    pub matches: Vec<usize>,
94    /// Match lengths parallel to `matches` (needed for viewport overlay creation)
95    pub match_lengths: Vec<usize>,
96    /// Index of the currently selected match
97    pub current_match_index: Option<usize>,
98    /// Whether search wraps around at document boundaries
99    pub wrap_search: bool,
100    /// Optional search range (for search in selection)
101    pub search_range: Option<Range<usize>>,
102    /// True if the match count was capped at MAX_MATCHES
103    #[allow(dead_code)]
104    pub capped: bool,
105}
106
107impl SearchState {
108    /// Maximum number of search matches to collect before stopping.
109    /// Prevents unbounded memory usage when searching for common patterns
110    /// in large files.
111    pub const MAX_MATCHES: usize = 100_000;
112}
113
114/// State for interactive replace (query-replace)
115#[derive(Debug, Clone)]
116pub(super) struct InteractiveReplaceState {
117    /// The search pattern
118    pub search: String,
119    /// The replacement text
120    pub replacement: String,
121    /// Current match position (byte offset of the match we're at)
122    pub current_match_pos: usize,
123    /// Length of the current match in bytes (may differ from search.len() for regex)
124    pub current_match_len: usize,
125    /// Starting position (to detect when we've wrapped around full circle)
126    pub start_pos: usize,
127    /// Whether we've wrapped around to the beginning
128    pub has_wrapped: bool,
129    /// Number of replacements made so far
130    pub replacements_made: usize,
131    /// Compiled regex for regex-mode replace (None when regex mode is off)
132    pub regex: Option<regex::bytes::Regex>,
133}
134
135/// The kind of buffer (file-backed or virtual)
136#[derive(Debug, Clone, PartialEq)]
137pub enum BufferKind {
138    /// A buffer backed by a file on disk
139    File {
140        /// Host-side path to the file. Filesystem APIs and the
141        /// editor's own buffer state always speak in host paths.
142        path: PathBuf,
143        /// LSP-facing URI for the file. Already translated for the
144        /// active authority, so handing this to the LSP server is
145        /// always correct. See [`LspUri`] for the why.
146        uri: Option<LspUri>,
147    },
148    /// A virtual buffer (not backed by a file)
149    /// Used for special buffers like *Diagnostics*, *Grep*, etc.
150    Virtual {
151        /// The buffer's mode (e.g., "diagnostics-list", "grep-results")
152        mode: String,
153    },
154}
155
156/// Metadata associated with a buffer
157#[derive(Debug, Clone)]
158pub struct BufferMetadata {
159    /// The kind of buffer (file or virtual)
160    pub kind: BufferKind,
161
162    /// Display name for the buffer (project-relative path or filename or *BufferName*)
163    pub display_name: String,
164
165    /// Whether LSP is enabled for this buffer (always false for virtual buffers)
166    pub lsp_enabled: bool,
167
168    /// Reason LSP is disabled (if applicable)
169    pub lsp_disabled_reason: Option<String>,
170
171    /// Whether the buffer is read-only (typically true for virtual buffers)
172    pub read_only: bool,
173
174    /// Whether the buffer contains binary content
175    /// Binary buffers are automatically read-only and render unprintable chars as code points
176    pub binary: bool,
177
178    /// LSP server instance IDs that have received didOpen for this buffer.
179    /// Used to ensure didOpen is sent before any requests to a new/restarted server.
180    /// When a server restarts, it gets a new ID, so didOpen is automatically resent.
181    /// Old IDs are harmless - they just remain in the set but don't match any active server.
182    pub lsp_opened_with: HashSet<u64>,
183
184    /// Whether this buffer should be hidden from tabs (used for composite source buffers)
185    pub hidden_from_tabs: bool,
186
187    /// Whether this buffer is opened in "preview" mode (ephemeral).
188    /// A preview buffer is one opened by a single-click in the file explorer
189    /// (or a similar soft-open gesture). Its tab is rendered in italic and
190    /// it is replaced the next time another file is opened the same way.
191    /// The flag is cleared ("promoted") when the user edits the buffer,
192    /// double-clicks the file, or otherwise signals commitment to the file.
193    ///
194    /// Intentionally ephemeral — never serialized into workspace or
195    /// recovery state. Restarting the editor always brings buffers back
196    /// as permanent tabs; preview status belongs to the current session's
197    /// exploration flow only.
198    pub is_preview: bool,
199
200    /// Stable recovery ID for unnamed buffers.
201    /// For file-backed buffers, recovery ID is computed from the path hash.
202    /// For unnamed buffers, this is generated once and reused across auto-saves.
203    pub recovery_id: Option<String>,
204}
205
206impl BufferMetadata {
207    /// Get the file path if this is a file-backed buffer
208    pub fn file_path(&self) -> Option<&PathBuf> {
209        match &self.kind {
210            BufferKind::File { path, .. } => Some(path),
211            BufferKind::Virtual { .. } => None,
212        }
213    }
214
215    /// Get the LSP-facing URI if this is a file-backed buffer.
216    ///
217    /// The URI is already translated for the active authority — i.e.
218    /// it carries the in-container path on a devcontainer authority
219    /// and the host path elsewhere. Hand it to the LSP server
220    /// directly; do NOT pass it to filesystem APIs (use
221    /// [`Self::file_path`] for that).
222    pub fn file_uri(&self) -> Option<&LspUri> {
223        match &self.kind {
224            BufferKind::File { uri, .. } => uri.as_ref(),
225            BufferKind::Virtual { .. } => None,
226        }
227    }
228
229    /// Check if this is a virtual buffer
230    pub fn is_virtual(&self) -> bool {
231        matches!(self.kind, BufferKind::Virtual { .. })
232    }
233
234    /// Get the mode name for virtual buffers
235    pub fn virtual_mode(&self) -> Option<&str> {
236        match &self.kind {
237            BufferKind::Virtual { mode } => Some(mode),
238            BufferKind::File { .. } => None,
239        }
240    }
241}
242
243impl Default for BufferMetadata {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl BufferMetadata {
250    /// Create new metadata for a buffer (unnamed, file-backed)
251    pub fn new() -> Self {
252        Self {
253            kind: BufferKind::File {
254                path: PathBuf::new(),
255                uri: None,
256            },
257            display_name: t!("buffer.no_name").to_string(),
258            lsp_enabled: true,
259            lsp_disabled_reason: None,
260            read_only: false,
261            binary: false,
262            lsp_opened_with: HashSet::new(),
263            hidden_from_tabs: false,
264            is_preview: false,
265            recovery_id: None,
266        }
267    }
268
269    /// Create new metadata for an unnamed buffer with a custom display name
270    /// Used for buffers created from stdin or other non-file sources
271    pub fn new_unnamed(display_name: String) -> Self {
272        Self {
273            kind: BufferKind::File {
274                path: PathBuf::new(),
275                uri: None,
276            },
277            display_name,
278            lsp_enabled: false, // No file path, so no LSP
279            lsp_disabled_reason: Some(t!("lsp.disabled.unnamed").to_string()),
280            read_only: false,
281            binary: false,
282            lsp_opened_with: HashSet::new(),
283            hidden_from_tabs: false,
284            is_preview: false,
285            recovery_id: None,
286        }
287    }
288
289    /// Create metadata for a file-backed buffer
290    ///
291    /// # Arguments
292    /// * `canonical_path` - The canonical (symlink-resolved) absolute path to the file
293    /// * `display_path` - The user-visible path before canonicalization (for library detection)
294    /// * `working_dir` - The canonical working directory for computing relative display name
295    /// * `path_translation` - Active authority's host↔remote workspace mapping;
296    ///   used to build the LSP-facing `file_uri` so an in-container LSP sees
297    ///   in-container paths. `None` for local/SSH authorities.
298    pub fn with_file(
299        canonical_path: PathBuf,
300        display_path: &Path,
301        working_dir: &Path,
302        path_translation: Option<&crate::services::authority::PathTranslation>,
303    ) -> Self {
304        // Compute URI from the absolute path. When the active authority
305        // has a host↔remote mapping (devcontainer attach), this is
306        // where the host path gets rewritten into the container path
307        // the LSP server actually understands.
308        let file_uri = LspUri::from_host_path(&canonical_path, path_translation);
309
310        // Compute display name (project-relative when under working_dir, else absolute path).
311        // Use canonicalized forms first to handle macOS /var -> /private/var differences.
312        let display_name = Self::display_name_for_path(&canonical_path, working_dir);
313
314        // Check if this is a library file (in vendor directories or standard libraries).
315        // Library files are read-only (to prevent accidental edits) but LSP stays
316        // enabled so that Goto Definition, Hover, Find References, etc. still work
317        // when the user navigates into library source code (issue #1344).
318        //
319        // A file is only considered a library file if BOTH the canonical path and the
320        // user-visible path are in a library directory. This prevents symlinked dotfiles
321        // (e.g., ~/.bash_profile -> /nix/store/...) from being marked read-only when
322        // the user explicitly opened a non-library path (issue #1469).
323        let is_library = Self::is_library_path(&canonical_path, working_dir)
324            && Self::is_library_path(display_path, working_dir);
325
326        Self {
327            kind: BufferKind::File {
328                path: canonical_path,
329                uri: file_uri,
330            },
331            display_name,
332            lsp_enabled: true,
333            lsp_disabled_reason: None,
334            read_only: is_library,
335            binary: false,
336            lsp_opened_with: HashSet::new(),
337            hidden_from_tabs: false,
338            is_preview: false,
339            recovery_id: None,
340        }
341    }
342
343    /// Create metadata for a buffer fetched from inside a container.
344    ///
345    /// Used by `Editor::open_lsp_uri_target` when a Goto-Definition
346    /// (or similar) URI lands on a path that exists only inside the
347    /// container — typically a stdlib / site-packages entry that
348    /// isn't bind-mounted onto the host. The buffer is read-only
349    /// because there's no host-side writeback path; LSP stays enabled
350    /// so further navigation from the fetched buffer (hover, more
351    /// goto-defs) keeps working.
352    ///
353    /// The supplied `uri` is the wire URI the LSP returned (already
354    /// in container-side coordinates) and is cached verbatim — no
355    /// host→remote translation, because the path *is* the remote
356    /// path. The display name is the file name, since the container
357    /// path has nothing to relativize against the host working dir.
358    pub fn with_container_file(container_path: PathBuf, uri: LspUri) -> Self {
359        let display_name = container_path
360            .file_name()
361            .and_then(|n| n.to_str())
362            .map(|n| n.to_string())
363            .unwrap_or_else(|| container_path.to_string_lossy().to_string());
364        Self {
365            kind: BufferKind::File {
366                path: container_path,
367                uri: Some(uri),
368            },
369            display_name,
370            lsp_enabled: true,
371            lsp_disabled_reason: None,
372            read_only: true,
373            binary: false,
374            lsp_opened_with: HashSet::new(),
375            hidden_from_tabs: false,
376            is_preview: false,
377            recovery_id: None,
378        }
379    }
380
381    /// Check if a path is a library file (in vendor directories or standard libraries)
382    ///
383    /// Library files include:
384    /// - Files in common vendor/dependency directories (.cargo, node_modules, etc.)
385    /// - Standard library / toolchain files (rustup toolchains, system includes, etc.)
386    pub fn is_library_path(path: &Path, _working_dir: &Path) -> bool {
387        // Check for common library paths
388        let path_str = path.to_string_lossy();
389
390        // Rust: .cargo directory (can be within project for vendor'd crates)
391        if path_str.contains("/.cargo/") || path_str.contains("\\.cargo\\") {
392            return true;
393        }
394
395        // Rust: rustup toolchains (standard library source files)
396        if path_str.contains("/rustup/toolchains/") || path_str.contains("\\rustup\\toolchains\\") {
397            return true;
398        }
399
400        // Node.js: node_modules
401        if path_str.contains("/node_modules/") || path_str.contains("\\node_modules\\") {
402            return true;
403        }
404
405        // Python: site-packages, dist-packages
406        if path_str.contains("/site-packages/")
407            || path_str.contains("\\site-packages\\")
408            || path_str.contains("/dist-packages/")
409            || path_str.contains("\\dist-packages\\")
410        {
411            return true;
412        }
413
414        // Go: pkg/mod
415        if path_str.contains("/pkg/mod/") || path_str.contains("\\pkg\\mod\\") {
416            return true;
417        }
418
419        // Ruby: gems
420        if path_str.contains("/gems/") || path_str.contains("\\gems\\") {
421            return true;
422        }
423
424        // Java/Gradle: .gradle
425        if path_str.contains("/.gradle/") || path_str.contains("\\.gradle\\") {
426            return true;
427        }
428
429        // Maven: .m2
430        if path_str.contains("/.m2/") || path_str.contains("\\.m2\\") {
431            return true;
432        }
433
434        // C/C++: system include directories
435        if path_str.starts_with("/usr/include/") || path_str.starts_with("/usr/local/include/") {
436            return true;
437        }
438
439        // Nix store (system-managed packages)
440        if path_str.starts_with("/nix/store/") {
441            return true;
442        }
443
444        // Homebrew (macOS system-managed packages)
445        if path_str.starts_with("/opt/homebrew/Cellar/")
446            || path_str.starts_with("/usr/local/Cellar/")
447        {
448            return true;
449        }
450
451        // .NET / C#: NuGet packages
452        if path_str.contains("/.nuget/") || path_str.contains("\\.nuget\\") {
453            return true;
454        }
455
456        // Swift / Xcode toolchains
457        if path_str.contains("/Xcode.app/Contents/Developer/")
458            || path_str.contains("/CommandLineTools/SDKs/")
459        {
460            return true;
461        }
462
463        false
464    }
465
466    /// Compute display name relative to working_dir when possible, otherwise absolute
467    pub fn display_name_for_path(path: &Path, working_dir: &Path) -> String {
468        // Canonicalize working_dir to normalize platform-specific prefixes
469        let canonical_working_dir = working_dir
470            .canonicalize()
471            .unwrap_or_else(|_| working_dir.to_path_buf());
472
473        // Try to canonicalize the file path; if it fails (e.g., new file), fall back to absolute
474        let absolute_path = if path.is_absolute() {
475            path.to_path_buf()
476        } else {
477            // If we were given a relative path, anchor it to working_dir
478            canonical_working_dir.join(path)
479        };
480        let canonical_path = absolute_path
481            .canonicalize()
482            .unwrap_or_else(|_| absolute_path.clone());
483
484        // Prefer canonical comparison first, then raw prefix as a fallback
485        let relative = canonical_path
486            .strip_prefix(&canonical_working_dir)
487            .or_else(|_| path.strip_prefix(working_dir))
488            .ok()
489            .and_then(|rel| rel.to_str().map(|s| s.to_string()));
490
491        relative
492            .or_else(|| canonical_path.to_str().map(|s| s.to_string()))
493            .unwrap_or_else(|| t!("buffer.unknown").to_string())
494    }
495
496    /// Create metadata for a virtual buffer (not backed by a file)
497    ///
498    /// # Arguments
499    /// * `name` - Display name (e.g., "*Diagnostics*")
500    /// * `mode` - Buffer mode for keybindings (e.g., "diagnostics-list")
501    /// * `read_only` - Whether the buffer should be read-only
502    pub fn virtual_buffer(name: String, mode: String, read_only: bool) -> Self {
503        Self {
504            kind: BufferKind::Virtual { mode },
505            display_name: name,
506            lsp_enabled: false, // Virtual buffers don't use LSP
507            lsp_disabled_reason: Some(t!("lsp.disabled.virtual").to_string()),
508            read_only,
509            binary: false,
510            lsp_opened_with: HashSet::new(),
511            hidden_from_tabs: false,
512            is_preview: false,
513            recovery_id: None,
514        }
515    }
516
517    /// Create metadata for a hidden virtual buffer (for composite source buffers)
518    /// These buffers are not shown in tabs and are managed by their parent composite buffer.
519    /// Hidden buffers are always read-only to prevent accidental edits.
520    pub fn hidden_virtual_buffer(name: String, mode: String) -> Self {
521        Self {
522            kind: BufferKind::Virtual { mode },
523            display_name: name,
524            lsp_enabled: false,
525            lsp_disabled_reason: Some(t!("lsp.disabled.virtual").to_string()),
526            read_only: true, // Hidden buffers are always read-only
527            binary: false,
528            lsp_opened_with: HashSet::new(),
529            hidden_from_tabs: true,
530            is_preview: false,
531            recovery_id: None,
532        }
533    }
534
535    /// Disable LSP for this buffer with a reason
536    pub fn disable_lsp(&mut self, reason: String) {
537        self.lsp_enabled = false;
538        self.lsp_disabled_reason = Some(reason);
539    }
540}
541
542/// LSP progress information
543#[derive(Debug, Clone)]
544pub(super) struct LspProgressInfo {
545    pub language: String,
546    pub title: String,
547    pub message: Option<String>,
548    pub percentage: Option<u32>,
549}
550
551/// LSP message entry (for window messages and logs)
552#[derive(Debug, Clone)]
553#[allow(dead_code)]
554pub(super) struct LspMessageEntry {
555    pub language: String,
556    pub message_type: LspMessageType,
557    pub message: String,
558    pub timestamp: std::time::Instant,
559}
560
561/// Types of UI elements that can be hovered over
562#[derive(Debug, Clone, PartialEq)]
563pub enum HoverTarget {
564    /// Hovering over a split separator (container_id, direction)
565    SplitSeparator(ContainerId, SplitDirection),
566    /// Hovering over a scrollbar thumb (split_id)
567    ScrollbarThumb(LeafId),
568    /// Hovering over a scrollbar track (split_id, relative_row)
569    ScrollbarTrack(LeafId, u16),
570    /// Hovering over a menu bar item (menu_index)
571    MenuBarItem(usize),
572    /// Hovering over a menu dropdown item (menu_index, item_index)
573    MenuDropdownItem(usize, usize),
574    /// Hovering over a submenu item (depth, item_index) - depth 1+ for nested submenus
575    SubmenuItem(usize, usize),
576    /// Hovering over a popup list item (popup_index in stack, item_index)
577    PopupListItem(usize, usize),
578    /// Hovering over a suggestion item (item_index)
579    SuggestionItem(usize),
580    /// Hovering over the file explorer border (for resize)
581    FileExplorerBorder,
582    /// Hovering over a file browser navigation shortcut
583    FileBrowserNavShortcut(usize),
584    /// Hovering over a file browser file/directory entry
585    FileBrowserEntry(usize),
586    /// Hovering over a file browser column header
587    FileBrowserHeader(SortMode),
588    /// Hovering over the file browser scrollbar
589    FileBrowserScrollbar,
590    /// Hovering over the file browser "Show Hidden" checkbox
591    FileBrowserShowHiddenCheckbox,
592    /// Hovering over the file browser "Detect Encoding" checkbox
593    FileBrowserDetectEncodingCheckbox,
594    /// Hovering over a tab name (target, split_id) - for non-active tabs
595    TabName(crate::view::split::TabTarget, LeafId),
596    /// Hovering over a tab close button (target, split_id)
597    TabCloseButton(crate::view::split::TabTarget, LeafId),
598    /// Hovering over a close split button (split_id)
599    CloseSplitButton(LeafId),
600    /// Hovering over a maximize/unmaximize split button (split_id)
601    MaximizeSplitButton(LeafId),
602    /// Hovering over the file explorer close button
603    FileExplorerCloseButton,
604    /// Hovering over a file explorer item's status indicator (path)
605    FileExplorerStatusIndicator(std::path::PathBuf),
606    /// Hovering over the status bar LSP indicator
607    StatusBarLspIndicator,
608    /// Hovering over the status bar remote-authority indicator
609    StatusBarRemoteIndicator,
610    /// Hovering over the status bar warning badge
611    StatusBarWarningBadge,
612    /// Hovering over the status bar line ending indicator
613    StatusBarLineEndingIndicator,
614    /// Hovering over the status bar encoding indicator
615    StatusBarEncodingIndicator,
616    /// Hovering over the status bar language indicator
617    StatusBarLanguageIndicator,
618    /// Hovering over the search options "Case Sensitive" checkbox
619    SearchOptionCaseSensitive,
620    /// Hovering over the search options "Whole Word" checkbox
621    SearchOptionWholeWord,
622    /// Hovering over the search options "Regex" checkbox
623    SearchOptionRegex,
624    /// Hovering over the search options "Confirm Each" checkbox
625    SearchOptionConfirmEach,
626    /// Hovering over a tab context menu item (item_index)
627    TabContextMenuItem(usize),
628    /// Hovering over a file explorer context menu item (item_index)
629    FileExplorerContextMenuItem(usize),
630}
631
632/// Tab context menu items
633#[derive(Debug, Clone, Copy, PartialEq, Eq)]
634pub enum TabContextMenuItem {
635    /// Close this tab
636    Close,
637    /// Close all other tabs
638    CloseOthers,
639    /// Close tabs to the right
640    CloseToRight,
641    /// Close tabs to the left
642    CloseToLeft,
643    /// Close all tabs
644    CloseAll,
645}
646
647impl TabContextMenuItem {
648    /// Get all menu items in order
649    pub fn all() -> &'static [Self] {
650        &[
651            Self::Close,
652            Self::CloseOthers,
653            Self::CloseToRight,
654            Self::CloseToLeft,
655            Self::CloseAll,
656        ]
657    }
658
659    /// Get the display label for this menu item
660    pub fn label(&self) -> String {
661        match self {
662            Self::Close => t!("tab.close").to_string(),
663            Self::CloseOthers => t!("tab.close_others").to_string(),
664            Self::CloseToRight => t!("tab.close_to_right").to_string(),
665            Self::CloseToLeft => t!("tab.close_to_left").to_string(),
666            Self::CloseAll => t!("tab.close_all").to_string(),
667        }
668    }
669}
670
671/// State for tab context menu (right-click popup on tabs)
672#[derive(Debug, Clone)]
673pub struct TabContextMenu {
674    /// The buffer ID this context menu is for
675    pub buffer_id: BufferId,
676    /// The split ID where the tab is located
677    pub split_id: LeafId,
678    /// Screen position where the menu should appear (x, y)
679    pub position: (u16, u16),
680    /// Currently highlighted menu item index
681    pub highlighted: usize,
682}
683
684impl TabContextMenu {
685    /// Create a new tab context menu
686    pub fn new(buffer_id: BufferId, split_id: LeafId, x: u16, y: u16) -> Self {
687        Self {
688            buffer_id,
689            split_id,
690            position: (x, y),
691            highlighted: 0,
692        }
693    }
694
695    /// Get the currently highlighted item
696    pub fn highlighted_item(&self) -> TabContextMenuItem {
697        TabContextMenuItem::all()[self.highlighted]
698    }
699
700    /// Move highlight down
701    pub fn next_item(&mut self) {
702        let items = TabContextMenuItem::all();
703        self.highlighted = (self.highlighted + 1) % items.len();
704    }
705
706    /// Move highlight up
707    pub fn prev_item(&mut self) {
708        let items = TabContextMenuItem::all();
709        self.highlighted = if self.highlighted == 0 {
710            items.len() - 1
711        } else {
712            self.highlighted - 1
713        };
714    }
715}
716
717/// File explorer context menu items
718#[derive(Debug, Clone, Copy, PartialEq, Eq)]
719pub enum FileExplorerContextMenuItem {
720    NewFile,
721    NewDirectory,
722    Rename,
723    Cut,
724    Copy,
725    Paste,
726    Delete,
727}
728
729impl FileExplorerContextMenuItem {
730    pub fn all() -> &'static [Self] {
731        &[
732            Self::NewFile,
733            Self::NewDirectory,
734            Self::Rename,
735            Self::Cut,
736            Self::Copy,
737            Self::Paste,
738            Self::Delete,
739        ]
740    }
741
742    pub fn multi_selection() -> &'static [Self] {
743        &[Self::Cut, Self::Copy, Self::Paste, Self::Delete]
744    }
745
746    pub fn root_single_selection() -> &'static [Self] {
747        &[Self::NewFile, Self::NewDirectory, Self::Paste]
748    }
749
750    pub fn label(&self) -> String {
751        match self {
752            Self::NewFile => t!("explorer.context.new_file").to_string(),
753            Self::NewDirectory => t!("explorer.context.new_directory").to_string(),
754            Self::Rename => t!("explorer.context.rename").to_string(),
755            Self::Cut => t!("explorer.context.cut").to_string(),
756            Self::Copy => t!("explorer.context.copy").to_string(),
757            Self::Paste => t!("explorer.context.paste").to_string(),
758            Self::Delete => t!("explorer.context.delete").to_string(),
759        }
760    }
761}
762
763/// State for file explorer context menu (right-click popup in the file explorer)
764#[derive(Debug, Clone)]
765pub struct FileExplorerContextMenu {
766    /// Screen position where the menu should appear (x, y)
767    pub position: (u16, u16),
768    /// Currently highlighted menu item index
769    pub highlighted: usize,
770    /// Whether the menu was opened with multiple items selected
771    pub is_multi_selection: bool,
772    /// Whether the sole selected node is the project root
773    pub is_root_selected: bool,
774}
775
776impl FileExplorerContextMenu {
777    pub fn new(x: u16, y: u16, is_multi_selection: bool, is_root_selected: bool) -> Self {
778        Self {
779            position: (x, y),
780            highlighted: 0,
781            is_multi_selection,
782            is_root_selected,
783        }
784    }
785
786    pub fn items(&self) -> &'static [FileExplorerContextMenuItem] {
787        if self.is_multi_selection {
788            FileExplorerContextMenuItem::multi_selection()
789        } else if self.is_root_selected {
790            FileExplorerContextMenuItem::root_single_selection()
791        } else {
792            FileExplorerContextMenuItem::all()
793        }
794    }
795
796    pub fn height(&self) -> u16 {
797        self.items().len() as u16 + 2
798    }
799
800    pub fn clamped_position(&self, screen_width: u16, screen_height: u16) -> (u16, u16) {
801        let x = if self.position.0 + FILE_EXPLORER_CONTEXT_MENU_WIDTH > screen_width {
802            screen_width.saturating_sub(FILE_EXPLORER_CONTEXT_MENU_WIDTH)
803        } else {
804            self.position.0
805        };
806        let h = self.height();
807        let y = if self.position.1 + h > screen_height {
808            screen_height.saturating_sub(h)
809        } else {
810            self.position.1
811        };
812        (x, y)
813    }
814
815    pub fn next_item(&mut self) {
816        let len = self.items().len();
817        self.highlighted = (self.highlighted + 1) % len;
818    }
819
820    pub fn prev_item(&mut self) {
821        let len = self.items().len();
822        self.highlighted = if self.highlighted == 0 {
823            len - 1
824        } else {
825            self.highlighted - 1
826        };
827    }
828}
829
830/// Lightweight per-cell theme key provenance recorded during rendering.
831/// Stored in `CachedLayout::cell_theme_map` so the theme inspector popup
832/// can look up the exact keys used for any screen position.
833#[derive(Debug, Clone, Default)]
834pub struct CellThemeInfo {
835    /// Foreground theme key (e.g. "syntax.keyword", "editor.fg")
836    pub fg_key: Option<&'static str>,
837    /// Background theme key (e.g. "editor.bg", "diagnostic.warning_bg")
838    pub bg_key: Option<&'static str>,
839    /// Short region label (e.g. "Line Numbers", "Editor Content")
840    pub region: &'static str,
841    /// Dynamic region suffix (e.g. syntax category display name appended to "Syntax: ")
842    pub syntax_category: Option<&'static str>,
843}
844
845/// Information about which theme key(s) style a specific screen position.
846/// Used by the Ctrl+Right-Click theme inspector popup.
847#[derive(Debug, Clone)]
848pub struct ThemeKeyInfo {
849    /// The foreground theme key path (e.g., "syntax.keyword", "editor.fg")
850    pub fg_key: Option<String>,
851    /// The background theme key path (e.g., "editor.bg", "editor.selection_bg")
852    pub bg_key: Option<String>,
853    /// Human-readable description of the UI region
854    pub region: String,
855    /// The actual foreground color value currently applied
856    pub fg_color: Option<ratatui::style::Color>,
857    /// The actual background color value currently applied
858    pub bg_color: Option<ratatui::style::Color>,
859    /// For syntax highlights: the HighlightCategory display name
860    pub syntax_category: Option<String>,
861}
862
863/// State for the theme inspector popup (Ctrl+Right-Click)
864#[derive(Debug, Clone)]
865pub struct ThemeInfoPopup {
866    /// Screen position where popup appears (x, y)
867    pub position: (u16, u16),
868    /// Resolved theme key information
869    pub info: ThemeKeyInfo,
870    /// Whether the "Open in Theme Editor" button is highlighted (mouse hover)
871    pub button_highlighted: bool,
872}
873
874/// Drop zone for tab drag-and-drop
875/// Indicates where a dragged tab will be placed when released
876#[derive(Debug, Clone, Copy, PartialEq, Eq)]
877pub enum TabDropZone {
878    /// Drop into an existing split's tab bar (before tab at index, or at end if None)
879    /// (target_split_id, insert_index)
880    TabBar(LeafId, Option<usize>),
881    /// Create a new split on the left edge of the target split
882    SplitLeft(LeafId),
883    /// Create a new split on the right edge of the target split
884    SplitRight(LeafId),
885    /// Create a new split on the top edge of the target split
886    SplitTop(LeafId),
887    /// Create a new split on the bottom edge of the target split
888    SplitBottom(LeafId),
889    /// Drop into the center of a split (switch to that split's tab bar)
890    SplitCenter(LeafId),
891}
892
893impl TabDropZone {
894    /// Get the split ID this drop zone is associated with
895    pub fn split_id(&self) -> LeafId {
896        match self {
897            Self::TabBar(id, _)
898            | Self::SplitLeft(id)
899            | Self::SplitRight(id)
900            | Self::SplitTop(id)
901            | Self::SplitBottom(id)
902            | Self::SplitCenter(id) => *id,
903        }
904    }
905}
906
907/// State for a tab being dragged
908#[derive(Debug, Clone)]
909pub struct TabDragState {
910    /// The buffer being dragged
911    pub buffer_id: BufferId,
912    /// The split the tab was dragged from
913    pub source_split_id: LeafId,
914    /// Starting mouse position when drag began
915    pub start_position: (u16, u16),
916    /// Current mouse position
917    pub current_position: (u16, u16),
918    /// Currently detected drop zone (if any)
919    pub drop_zone: Option<TabDropZone>,
920}
921
922impl TabDragState {
923    /// Create a new tab drag state
924    pub fn new(buffer_id: BufferId, source_split_id: LeafId, start_position: (u16, u16)) -> Self {
925        Self {
926            buffer_id,
927            source_split_id,
928            start_position,
929            current_position: start_position,
930            drop_zone: None,
931        }
932    }
933
934    /// Check if the drag has moved enough to be considered a real drag (not just a click)
935    pub fn is_dragging(&self) -> bool {
936        let dx = (self.current_position.0 as i32 - self.start_position.0 as i32).abs();
937        let dy = (self.current_position.1 as i32 - self.start_position.1 as i32).abs();
938        dx > 3 || dy > 3 // Threshold of 3 pixels before drag activates
939    }
940}
941
942/// Mouse state tracking
943#[derive(Debug, Clone, Default)]
944pub(super) struct MouseState {
945    /// Whether we're currently dragging a vertical scrollbar
946    pub dragging_scrollbar: Option<LeafId>,
947    /// Whether we're currently dragging a horizontal scrollbar
948    pub dragging_horizontal_scrollbar: Option<LeafId>,
949    /// Initial mouse column when starting horizontal scrollbar drag
950    pub drag_start_hcol: Option<u16>,
951    /// Initial left_column when starting horizontal scrollbar drag
952    pub drag_start_left_column: Option<usize>,
953    /// Last mouse position
954    pub last_position: Option<(u16, u16)>,
955    /// Mouse hover for LSP: byte position being hovered, timer start, and screen position
956    /// Format: (byte_position, hover_start_instant, screen_x, screen_y)
957    pub lsp_hover_state: Option<(usize, std::time::Instant, u16, u16)>,
958    /// Whether we've already sent a hover request for the current position
959    pub lsp_hover_request_sent: bool,
960    /// Initial mouse row when starting to drag the scrollbar thumb
961    /// Used to calculate relative movement rather than jumping
962    pub drag_start_row: Option<u16>,
963    /// Initial viewport top_byte when starting to drag the scrollbar thumb
964    pub drag_start_top_byte: Option<usize>,
965    /// Initial viewport top_view_line_offset when starting to drag the scrollbar thumb
966    /// This is needed for proper visual row calculation when scrolled into a wrapped line
967    pub drag_start_view_line_offset: Option<usize>,
968    /// Whether we're currently dragging a split separator
969    /// Stores (split_id, direction) for the separator being dragged
970    pub dragging_separator: Option<(ContainerId, SplitDirection)>,
971    /// Initial mouse position when starting to drag a separator
972    pub drag_start_position: Option<(u16, u16)>,
973    /// Initial split ratio when starting to drag a separator
974    pub drag_start_ratio: Option<f32>,
975    /// Whether we're currently dragging the file explorer border
976    pub dragging_file_explorer: bool,
977    /// File explorer width at the moment the drag started. Drag
978    /// preserves the active variant: a drag that begins in `Percent`
979    /// stays in `Percent`, and likewise for `Columns`.
980    pub drag_start_explorer_width: Option<crate::config::ExplorerWidth>,
981    /// Current hover target (if any)
982    pub hover_target: Option<HoverTarget>,
983    /// Whether we're currently doing a text selection drag
984    pub dragging_text_selection: bool,
985    /// The split where text selection started
986    pub drag_selection_split: Option<LeafId>,
987    /// The buffer byte position where the selection anchor is
988    pub drag_selection_anchor: Option<usize>,
989    /// When true, dragging extends selection by whole words (set by double-click)
990    pub drag_selection_by_words: bool,
991    /// The end of the initially double-clicked word (used as anchor when dragging backward)
992    pub drag_selection_word_end: Option<usize>,
993    /// Tab drag state (for drag-to-split functionality)
994    pub dragging_tab: Option<TabDragState>,
995    /// Whether we're currently dragging a popup scrollbar (popup index)
996    pub dragging_popup_scrollbar: Option<usize>,
997    /// Initial scroll offset when starting to drag popup scrollbar
998    pub drag_start_popup_scroll: Option<usize>,
999    /// Whether we're currently selecting text in a popup (popup index)
1000    pub selecting_in_popup: Option<usize>,
1001    /// Initial composite scroll_row when starting to drag the scrollbar thumb
1002    /// Used for composite buffer scrollbar drag
1003    pub drag_start_composite_scroll_row: Option<usize>,
1004}
1005
1006/// Mapping from visual row to buffer positions for mouse click handling
1007/// Each entry represents one visual row with byte position info for click handling
1008#[derive(Debug, Clone, Default)]
1009pub struct ViewLineMapping {
1010    /// Source byte offset for each character (None for injected/virtual content)
1011    pub char_source_bytes: Vec<Option<usize>>,
1012    /// Character index at each visual column (for O(1) mouse clicks)
1013    pub visual_to_char: Vec<usize>,
1014    /// Last valid byte position in this visual row (newline for real lines, last char for wrapped)
1015    /// Clicks past end of visible text position cursor here
1016    pub line_end_byte: usize,
1017}
1018
1019impl ViewLineMapping {
1020    /// Get source byte at a given visual column (O(1) for mouse clicks)
1021    #[inline]
1022    pub fn source_byte_at_visual_col(&self, visual_col: usize) -> Option<usize> {
1023        let char_idx = self.visual_to_char.get(visual_col).copied()?;
1024        self.char_source_bytes.get(char_idx).copied().flatten()
1025    }
1026
1027    /// Find the nearest source byte to a given visual column, searching outward.
1028    /// Returns the source byte at the closest valid visual column.
1029    pub fn nearest_source_byte(&self, goal_col: usize) -> Option<usize> {
1030        let width = self.visual_to_char.len();
1031        if width == 0 {
1032            return None;
1033        }
1034        // Search outward from goal_col: try +1, -1, +2, -2, ...
1035        for delta in 1..width {
1036            if goal_col + delta < width {
1037                if let Some(byte) = self.source_byte_at_visual_col(goal_col + delta) {
1038                    return Some(byte);
1039                }
1040            }
1041            if delta <= goal_col {
1042                if let Some(byte) = self.source_byte_at_visual_col(goal_col - delta) {
1043                    return Some(byte);
1044                }
1045            }
1046        }
1047        None
1048    }
1049
1050    /// Check if this visual row contains the given byte position
1051    #[inline]
1052    pub fn contains_byte(&self, byte_pos: usize) -> bool {
1053        // A row contains a byte if it's in the char_source_bytes range
1054        // The first valid source byte marks the start, line_end_byte marks the end
1055        if let Some(first_byte) = self.char_source_bytes.iter().find_map(|b| *b) {
1056            byte_pos >= first_byte && byte_pos <= self.line_end_byte
1057        } else {
1058            // Empty/virtual row - only matches if byte_pos equals line_end_byte
1059            byte_pos == self.line_end_byte
1060        }
1061    }
1062
1063    /// Get the first source byte position in this row (if any)
1064    #[inline]
1065    pub fn first_source_byte(&self) -> Option<usize> {
1066        self.char_source_bytes.iter().find_map(|b| *b)
1067    }
1068}
1069
1070/// Type alias for popup area layout information used in mouse hit testing.
1071/// Fields: (popup_index, rect, inner_rect, scroll_offset, num_items, scrollbar_rect, total_lines)
1072pub(crate) type PopupAreaLayout = (usize, Rect, Rect, usize, usize, Option<Rect>, usize);
1073
1074/// Cached layout information for mouse hit testing
1075#[derive(Debug, Clone, Default)]
1076pub(crate) struct CachedLayout {
1077    /// File explorer area (if visible)
1078    pub file_explorer_area: Option<Rect>,
1079    /// Editor content area (excluding file explorer)
1080    pub editor_content_area: Option<Rect>,
1081    /// Individual split areas with their scrollbar areas and thumb positions
1082    /// (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end)
1083    pub split_areas: Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
1084    /// Horizontal scrollbar areas per split
1085    /// (split_id, buffer_id, horizontal_scrollbar_rect, max_content_width, thumb_start_col, thumb_end_col)
1086    pub horizontal_scrollbar_areas: Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
1087    /// Split separator positions for drag resize
1088    /// (container_id, direction, x, y, length)
1089    pub separator_areas: Vec<(ContainerId, SplitDirection, u16, u16, u16)>,
1090    /// Popup areas for mouse hit testing
1091    /// scrollbar_rect is Some if popup has a scrollbar
1092    pub popup_areas: Vec<PopupAreaLayout>,
1093    /// Editor-level popup areas (e.g. plugin action popups) for mouse hit
1094    /// testing. Stored separately from buffer popups because they're owned by
1095    /// `Editor.global_popups` rather than the active buffer's state.
1096    /// Fields: (popup_index, rect, inner_rect, scroll_offset, num_items)
1097    pub global_popup_areas: Vec<(usize, Rect, Rect, usize, usize)>,
1098    /// Suggestions area for mouse hit testing
1099    /// (inner_rect, scroll_start_idx, visible_count, total_count)
1100    pub suggestions_area: Option<(Rect, usize, usize, usize)>,
1101    /// Tab layouts per split for mouse interaction
1102    pub tab_layouts: HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
1103    /// Close split button hit areas
1104    /// (split_id, row, start_col, end_col)
1105    pub close_split_areas: Vec<(LeafId, u16, u16, u16)>,
1106    /// Maximize split button hit areas
1107    /// (split_id, row, start_col, end_col)
1108    pub maximize_split_areas: Vec<(LeafId, u16, u16, u16)>,
1109    /// View line mappings for accurate mouse click positioning per split
1110    /// Maps visual row index to character position mappings
1111    /// Used to translate screen coordinates to buffer byte positions
1112    pub view_line_mappings: HashMap<LeafId, Vec<ViewLineMapping>>,
1113    /// Settings modal layout for hit testing
1114    pub settings_layout: Option<crate::view::settings::SettingsLayout>,
1115    /// Status bar area (row, x, width)
1116    pub status_bar_area: Option<(u16, u16, u16)>,
1117    /// Status bar LSP indicator area (row, start_col, end_col)
1118    pub status_bar_lsp_area: Option<(u16, u16, u16)>,
1119    /// Status bar warning badge area (row, start_col, end_col)
1120    pub status_bar_warning_area: Option<(u16, u16, u16)>,
1121    /// Status bar line ending indicator area (row, start_col, end_col)
1122    pub status_bar_line_ending_area: Option<(u16, u16, u16)>,
1123    /// Status bar encoding indicator area (row, start_col, end_col)
1124    pub status_bar_encoding_area: Option<(u16, u16, u16)>,
1125    /// Status bar language indicator area (row, start_col, end_col)
1126    pub status_bar_language_area: Option<(u16, u16, u16)>,
1127    /// Status bar message area (row, start_col, end_col) - clickable to show status log
1128    pub status_bar_message_area: Option<(u16, u16, u16)>,
1129    /// Status bar remote-authority indicator area (row, start_col, end_col)
1130    /// — clickable to open the remote-authority context menu.
1131    pub status_bar_remote_area: Option<(u16, u16, u16)>,
1132    /// Search options layout for checkbox hit testing
1133    pub search_options_layout: Option<crate::view::ui::status_bar::SearchOptionsLayout>,
1134    /// Menu bar layout for hit testing
1135    pub menu_layout: Option<crate::view::ui::menu::MenuLayout>,
1136    /// Last frame dimensions — used by recompute_layout for macro replay
1137    pub last_frame_width: u16,
1138    pub last_frame_height: u16,
1139    /// Per-cell theme key provenance recorded during rendering.
1140    /// Flat vec indexed as `row * width + col` where `width = last_frame_width`.
1141    pub cell_theme_map: Vec<CellThemeInfo>,
1142}
1143
1144impl CachedLayout {
1145    /// Reset the cell theme map for a new frame
1146    pub fn reset_cell_theme_map(&mut self) {
1147        let total = self.last_frame_width as usize * self.last_frame_height as usize;
1148        self.cell_theme_map.clear();
1149        self.cell_theme_map.resize(total, CellThemeInfo::default());
1150    }
1151
1152    /// Look up the theme info for a screen position
1153    pub fn cell_theme_at(&self, col: u16, row: u16) -> Option<&CellThemeInfo> {
1154        let idx = row as usize * self.last_frame_width as usize + col as usize;
1155        self.cell_theme_map.get(idx)
1156    }
1157
1158    /// Find which visual row contains the given byte position for a split
1159    pub fn find_visual_row(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
1160        let mappings = self.view_line_mappings.get(&split_id)?;
1161        mappings.iter().position(|m| m.contains_byte(byte_pos))
1162    }
1163
1164    /// Get the visual column of a byte position within its visual row
1165    pub fn byte_to_visual_column(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
1166        let mappings = self.view_line_mappings.get(&split_id)?;
1167        let row_idx = self.find_visual_row(split_id, byte_pos)?;
1168        let row = mappings.get(row_idx)?;
1169
1170        // Find the visual column that maps to this byte position
1171        for (visual_col, &char_idx) in row.visual_to_char.iter().enumerate() {
1172            if let Some(source_byte) = row.char_source_bytes.get(char_idx).and_then(|b| *b) {
1173                if source_byte == byte_pos {
1174                    return Some(visual_col);
1175                }
1176                // If we've passed the byte position, return previous column
1177                if source_byte > byte_pos {
1178                    return Some(visual_col.saturating_sub(1));
1179                }
1180            }
1181        }
1182        // Byte is at or past end of row - return column after last character
1183        // This handles cursor positions at end of line (e.g., after last char before newline)
1184        Some(row.visual_to_char.len())
1185    }
1186
1187    /// Move by visual line using the cached mappings
1188    /// Returns (new_position, new_visual_column) or None if at boundary
1189    pub fn move_visual_line(
1190        &self,
1191        split_id: LeafId,
1192        current_pos: usize,
1193        goal_visual_col: usize,
1194        direction: i8, // -1 = up, 1 = down
1195    ) -> Option<(usize, usize)> {
1196        let mappings = self.view_line_mappings.get(&split_id)?;
1197        let current_row = self.find_visual_row(split_id, current_pos)?;
1198
1199        // Walk past purely-virtual rows (e.g. markdown_compose table top/
1200        // bottom borders and inter-row separators).  Those rows have no
1201        // source mapping at all — their `char_source_bytes` are all `None`
1202        // and their `line_end_byte` is inherited from the adjacent content
1203        // row.  If MoveDown/MoveUp stopped on them the cursor would land on
1204        // a byte that's already at the row above's end, which in turn
1205        // causes Down-after-table to teleport back to an earlier position
1206        // (regression exposed by markdown_compose's table border feature).
1207        //
1208        // A row is "navigable" iff at least one of its visual columns maps
1209        // to a real source byte.  Skip entirely-virtual rows in the move
1210        // direction until we hit a navigable one or run off the edge.
1211        let mut target_row = current_row;
1212        let navigable = |idx: usize| -> bool {
1213            mappings
1214                .get(idx)
1215                .map(|m| m.char_source_bytes.iter().any(|b| b.is_some()))
1216                .unwrap_or(false)
1217        };
1218        loop {
1219            target_row = if direction < 0 {
1220                target_row.checked_sub(1)?
1221            } else {
1222                let next = target_row + 1;
1223                if next >= mappings.len() {
1224                    return None;
1225                }
1226                next
1227            };
1228            // Either the next row has real source content, or we've reached
1229            // a legitimate non-source row that the rest of the editor
1230            // already treats as a cursor stop (trailing empty line at EOF,
1231            // implicit blank final line).  In either case stop walking.
1232            if navigable(target_row) {
1233                break;
1234            }
1235            let mapping = mappings.get(target_row)?;
1236            let is_plugin_virtual =
1237                mapping.visual_to_char.is_empty() || mapping.char_source_bytes.is_empty();
1238            if !is_plugin_virtual {
1239                // The row has columns but none carry a source byte — most
1240                // likely a plugin-injected decoration with padding.  Keep
1241                // looking.
1242                continue;
1243            }
1244            // Empty mapping (no visual columns) is how EOF-related virtual
1245            // rows are represented; those are legitimate cursor stops so we
1246            // accept them and fall out of the loop.
1247            break;
1248        }
1249
1250        let target_mapping = mappings.get(target_row)?;
1251
1252        // Try to get byte at goal visual column.  If the goal column is past
1253        // the end of visible content, land at line_end_byte (the newline or
1254        // end of buffer).  If the column exists but has no source byte (e.g.
1255        // padding on a wrapped continuation line), search outward for the
1256        // nearest valid source byte at minimal visual distance.
1257        let new_pos = if goal_visual_col >= target_mapping.visual_to_char.len() {
1258            target_mapping.line_end_byte
1259        } else {
1260            target_mapping
1261                .source_byte_at_visual_col(goal_visual_col)
1262                .or_else(|| target_mapping.nearest_source_byte(goal_visual_col))
1263                .unwrap_or(target_mapping.line_end_byte)
1264        };
1265
1266        Some((new_pos, goal_visual_col))
1267    }
1268
1269    /// Get the start byte position of the visual row containing the given byte position.
1270    /// If the cursor is already at the visual row start and this is a wrapped continuation,
1271    /// moves to the previous visual row's start (within the same logical line).
1272    /// Get the start byte position of the visual row containing the given byte position.
1273    /// When `allow_advance` is true and the cursor is already at the row start,
1274    /// moves to the previous visual row's start.
1275    pub fn visual_line_start(
1276        &self,
1277        split_id: LeafId,
1278        byte_pos: usize,
1279        allow_advance: bool,
1280    ) -> Option<usize> {
1281        let mappings = self.view_line_mappings.get(&split_id)?;
1282        let row_idx = self.find_visual_row(split_id, byte_pos)?;
1283        let row = mappings.get(row_idx)?;
1284        let row_start = row.first_source_byte()?;
1285
1286        if allow_advance && byte_pos == row_start && row_idx > 0 {
1287            let prev_row = mappings.get(row_idx - 1)?;
1288            prev_row.first_source_byte()
1289        } else {
1290            Some(row_start)
1291        }
1292    }
1293
1294    /// Get the end byte position of the visual row containing the given byte position.
1295    /// If the cursor is already at the visual row end and the next row is a wrapped continuation,
1296    /// moves to the next visual row's end (within the same logical line).
1297    /// Get the end byte position of the visual row containing the given byte position.
1298    /// When `allow_advance` is true and the cursor is already at the row end,
1299    /// advances to the next visual row's end.
1300    pub fn visual_line_end(
1301        &self,
1302        split_id: LeafId,
1303        byte_pos: usize,
1304        allow_advance: bool,
1305    ) -> Option<usize> {
1306        let mappings = self.view_line_mappings.get(&split_id)?;
1307        let row_idx = self.find_visual_row(split_id, byte_pos)?;
1308        let row = mappings.get(row_idx)?;
1309
1310        if allow_advance && byte_pos == row.line_end_byte && row_idx + 1 < mappings.len() {
1311            let next_row = mappings.get(row_idx + 1)?;
1312            Some(next_row.line_end_byte)
1313        } else {
1314            Some(row.line_end_byte)
1315        }
1316    }
1317}
1318
1319/// Convert a file path to an `lsp_types::Uri`.
1320pub fn file_path_to_lsp_uri(path: &Path) -> Option<lsp_types::Uri> {
1321    fresh_core::file_uri::path_to_lsp_uri(path)
1322}
1323
1324/// LSP-facing URI: a URI as it appears on the wire to or from a
1325/// language server. This is a newtype around `lsp_types::Uri`. The
1326/// type-system point is to force every URI that crosses the
1327/// editor↔LSP boundary through one of the two checked constructors:
1328///
1329///   * [`LspUri::from_host_path`] — given a host path and the active
1330///     authority's host↔remote translation, produces an `LspUri` that
1331///     carries the in-container path on container authorities (and
1332///     the host path everywhere else).
1333///   * [`LspUri::from_wire`] — wraps a raw `lsp_types::Uri` that was
1334///     received from the LSP server. The wrapped URI is "remote-side"
1335///     under a container authority and must be passed back through
1336///     [`LspUri::to_host_path`] before any filesystem-facing code
1337///     sees it.
1338///
1339/// Conversely, the only ways to extract a path are:
1340///
1341///   * [`LspUri::to_host_path`] — applies remote→host translation
1342///     symmetrically with `from_host_path`. This is the host-side
1343///     `PathBuf` filesystem APIs accept. Untranslated extraction
1344///     (`as_uri().path()`) is intentionally not exposed as a method —
1345///     callers that genuinely want the wire-side path string read
1346///     `as_str()` and document why a host-path interpretation isn't
1347///     wanted.
1348///
1349/// Storing buffer URIs in [`BufferMetadata`] as `LspUri` (not
1350/// `lsp_types::Uri`) keeps the cached form already translated for the
1351/// active authority, so the dozens of `metadata.file_uri()` call
1352/// sites can't accidentally ship a host URI to a container LSP.
1353#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1354pub struct LspUri(lsp_types::Uri);
1355
1356impl LspUri {
1357    /// Build an LSP-facing URI from a host path, applying the
1358    /// authority's host→remote translation when one is set. Returns
1359    /// `None` for relative paths (matches the pre-newtype helper).
1360    pub fn from_host_path(
1361        path: &Path,
1362        translation: Option<&crate::services::authority::PathTranslation>,
1363    ) -> Option<Self> {
1364        let mapped = translation
1365            .and_then(|t| t.host_to_remote(path))
1366            .unwrap_or_else(|| path.to_path_buf());
1367        fresh_core::file_uri::path_to_lsp_uri(&mapped).map(Self)
1368    }
1369
1370    /// Wrap a raw URI received from the LSP wire. The caller must
1371    /// subsequently translate via [`Self::to_host_path`] before
1372    /// opening the file or comparing with host paths — that's the
1373    /// whole point of having the newtype.
1374    pub fn from_wire(uri: lsp_types::Uri) -> Self {
1375        Self(uri)
1376    }
1377
1378    /// Borrow the underlying raw URI for serialization to the LSP
1379    /// wire (e.g. into JSON-RPC params). Only the LSP transport layer
1380    /// should call this; editor-level code never sees a bare
1381    /// `lsp_types::Uri`.
1382    pub fn as_uri(&self) -> &lsp_types::Uri {
1383        &self.0
1384    }
1385
1386    /// String form, for log messages and equality comparisons against
1387    /// other URI strings (e.g. when matching a buffer against an
1388    /// incoming notification's URI). Does not strip the
1389    /// host-vs-container ambiguity — comparisons must be between two
1390    /// `LspUri`s, not between a wire URI and a host URI.
1391    pub fn as_str(&self) -> &str {
1392        self.0.as_str()
1393    }
1394
1395    /// Decode this URI to a host path, applying the authority's
1396    /// remote→host translation when one is set. Returns `None` for
1397    /// non-`file://` URIs.
1398    pub fn to_host_path(
1399        &self,
1400        translation: Option<&crate::services::authority::PathTranslation>,
1401    ) -> Option<PathBuf> {
1402        let raw = fresh_core::file_uri::lsp_uri_to_path(&self.0)?;
1403        Some(
1404            translation
1405                .and_then(|t| t.remote_to_host(&raw))
1406                .unwrap_or(raw),
1407        )
1408    }
1409}
1410
1411/// Build the LSP-facing URI for a host-side `path`, applying the
1412/// authority's host→remote translation when one is set.
1413///
1414/// Thin shim around [`LspUri::from_host_path`] that returns the
1415/// inner [`lsp_types::Uri`] for the few callers (root_uri building
1416/// inside `LspManager`, code-action workspace folder hand-off) that
1417/// have to feed a raw `Uri` into a third-party API. New code should
1418/// prefer `LspUri::from_host_path` directly so the host-vs-LSP side
1419/// stays type-checked.
1420pub fn file_path_to_lsp_uri_with_translation(
1421    path: &Path,
1422    translation: Option<&crate::services::authority::PathTranslation>,
1423) -> Option<lsp_types::Uri> {
1424    LspUri::from_host_path(path, translation).map(|u| u.into_inner())
1425}
1426
1427impl LspUri {
1428    /// Consume `self` and return the raw `lsp_types::Uri`. Reserved
1429    /// for the wire layer (LSP transport, lsp_types interop). Editor
1430    /// code uses [`Self::as_uri`] when it just needs to borrow.
1431    pub fn into_inner(self) -> lsp_types::Uri {
1432        self.0
1433    }
1434}
1435
1436// `LspUri` translation algebra works on any platform but the unit-test
1437// fixtures use POSIX-shaped paths (the only side that ever exists for a
1438// container's interior) and a Linux-style URI without a drive letter.
1439// On Windows `lsp_types::Uri::parse(\"file:///workspaces/...\")` returns
1440// `None` for lack of a drive letter, which would make these tests fail
1441// for reasons unrelated to the algebra they're verifying. Gate to Unix
1442// — the cross-platform URI encoding is covered separately by
1443// `uri_encoding_tests`.
1444#[cfg(all(test, unix))]
1445mod lsp_uri_tests {
1446    use super::*;
1447    use crate::services::authority::PathTranslation;
1448
1449    fn translation() -> PathTranslation {
1450        PathTranslation {
1451            host_root: PathBuf::from("/tmp/.tmpA1B2"),
1452            remote_root: PathBuf::from("/workspaces/proj"),
1453        }
1454    }
1455
1456    #[test]
1457    fn from_host_path_under_workspace_translates_to_remote_uri() {
1458        let host = PathBuf::from("/tmp/.tmpA1B2/src/util.py");
1459        let lsp_uri = LspUri::from_host_path(&host, Some(&translation())).expect("absolute path");
1460        assert_eq!(lsp_uri.as_str(), "file:///workspaces/proj/src/util.py");
1461    }
1462
1463    #[test]
1464    fn from_host_path_outside_workspace_passes_through() {
1465        // System headers / library sources sit outside the mounted
1466        // workspace; translation returns `None` and the host URI is
1467        // shipped to the LSP unchanged. The point of the newtype is
1468        // just to make the decision explicit.
1469        let host = PathBuf::from("/usr/include/stdio.h");
1470        let lsp_uri = LspUri::from_host_path(&host, Some(&translation())).expect("absolute path");
1471        assert_eq!(lsp_uri.as_str(), "file:///usr/include/stdio.h");
1472    }
1473
1474    #[test]
1475    fn to_host_path_under_remote_root_translates_back() {
1476        let wire: lsp_types::Uri = "file:///workspaces/proj/src/util.py".parse().unwrap();
1477        let host = LspUri::from_wire(wire)
1478            .to_host_path(Some(&translation()))
1479            .expect("file:// URI");
1480        assert_eq!(host, PathBuf::from("/tmp/.tmpA1B2/src/util.py"));
1481    }
1482
1483    #[test]
1484    fn to_host_path_outside_remote_root_passes_through() {
1485        let wire: lsp_types::Uri = "file:///usr/include/stdio.h".parse().unwrap();
1486        let host = LspUri::from_wire(wire)
1487            .to_host_path(Some(&translation()))
1488            .expect("file:// URI");
1489        assert_eq!(host, PathBuf::from("/usr/include/stdio.h"));
1490    }
1491
1492    #[test]
1493    fn round_trip_host_to_wire_to_host_under_workspace() {
1494        // The whole point of the symmetry: anything that goes out
1495        // through `from_host_path` must come back through
1496        // `to_host_path` byte-identical. This is the property the
1497        // editor relies on so a buffer's host file_path matches the
1498        // path resolved from a server-returned `Location`.
1499        let host = PathBuf::from("/tmp/.tmpA1B2/main.py");
1500        let lsp_uri = LspUri::from_host_path(&host, Some(&translation())).unwrap();
1501        let back = lsp_uri.to_host_path(Some(&translation())).unwrap();
1502        assert_eq!(back, host);
1503    }
1504
1505    #[test]
1506    fn no_translation_is_identity() {
1507        let host = PathBuf::from("/some/host/path/file.rs");
1508        let lsp_uri = LspUri::from_host_path(&host, None).unwrap();
1509        assert_eq!(lsp_uri.as_str(), "file:///some/host/path/file.rs");
1510        let back = lsp_uri.to_host_path(None).unwrap();
1511        assert_eq!(back, host);
1512    }
1513}
1514
1515#[cfg(test)]
1516mod uri_encoding_tests {
1517    use super::*;
1518
1519    /// Helper to get a platform-appropriate absolute path for testing.
1520    fn abs_path(suffix: &str) -> PathBuf {
1521        std::env::temp_dir().join(suffix)
1522    }
1523
1524    #[test]
1525    fn test_brackets_in_path() {
1526        let path = abs_path("MY_PROJECTS [temp]/gogame/main.go");
1527        let uri = file_path_to_lsp_uri(&path);
1528        assert!(
1529            uri.is_some(),
1530            "URI should be computed for path with brackets"
1531        );
1532        let uri = uri.unwrap();
1533        assert!(
1534            uri.as_str().contains("%5Btemp%5D"),
1535            "Brackets should be percent-encoded: {}",
1536            uri.as_str()
1537        );
1538    }
1539
1540    #[test]
1541    fn test_spaces_in_path() {
1542        let path = abs_path("My Projects/src/main.go");
1543        let uri = file_path_to_lsp_uri(&path);
1544        assert!(uri.is_some(), "URI should be computed for path with spaces");
1545    }
1546
1547    #[test]
1548    fn test_normal_path() {
1549        let path = abs_path("project/main.go");
1550        let uri = file_path_to_lsp_uri(&path);
1551        assert!(uri.is_some(), "URI should be computed for normal path");
1552        let s = uri.unwrap().as_str().to_string();
1553        assert!(s.starts_with("file:///"), "Should be a file URI: {}", s);
1554        assert!(
1555            s.ends_with("project/main.go"),
1556            "Should end with the path: {}",
1557            s
1558        );
1559    }
1560
1561    #[test]
1562    fn test_relative_path_returns_none() {
1563        let path = PathBuf::from("main.go");
1564        assert!(file_path_to_lsp_uri(&path).is_none());
1565    }
1566
1567    #[test]
1568    fn test_all_special_chars() {
1569        let path = abs_path("a[b]c{d}e^g`h/file.rs");
1570        let uri = file_path_to_lsp_uri(&path);
1571        assert!(uri.is_some(), "Should handle all special characters");
1572        let s = uri.unwrap().as_str().to_string();
1573        assert!(!s.contains('['), "[ should be encoded in {}", s);
1574        assert!(!s.contains(']'), "] should be encoded in {}", s);
1575        assert!(!s.contains('{'), "{{ should be encoded in {}", s);
1576        assert!(!s.contains('}'), "}} should be encoded in {}", s);
1577        assert!(!s.contains('^'), "^ should be encoded in {}", s);
1578        assert!(!s.contains('`'), "` should be encoded in {}", s);
1579    }
1580}