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 /// Path to the file
141 path: PathBuf,
142 /// LSP URI for the file
143 uri: Option<lsp_types::Uri>,
144 },
145 /// A virtual buffer (not backed by a file)
146 /// Used for special buffers like *Diagnostics*, *Grep*, etc.
147 Virtual {
148 /// The buffer's mode (e.g., "diagnostics-list", "grep-results")
149 mode: String,
150 },
151}
152
153/// Metadata associated with a buffer
154#[derive(Debug, Clone)]
155pub struct BufferMetadata {
156 /// The kind of buffer (file or virtual)
157 pub kind: BufferKind,
158
159 /// Display name for the buffer (project-relative path or filename or *BufferName*)
160 pub display_name: String,
161
162 /// Whether LSP is enabled for this buffer (always false for virtual buffers)
163 pub lsp_enabled: bool,
164
165 /// Reason LSP is disabled (if applicable)
166 pub lsp_disabled_reason: Option<String>,
167
168 /// Whether the buffer is read-only (typically true for virtual buffers)
169 pub read_only: bool,
170
171 /// Whether the buffer contains binary content
172 /// Binary buffers are automatically read-only and render unprintable chars as code points
173 pub binary: bool,
174
175 /// LSP server instance IDs that have received didOpen for this buffer.
176 /// Used to ensure didOpen is sent before any requests to a new/restarted server.
177 /// When a server restarts, it gets a new ID, so didOpen is automatically resent.
178 /// Old IDs are harmless - they just remain in the set but don't match any active server.
179 pub lsp_opened_with: HashSet<u64>,
180
181 /// Whether this buffer should be hidden from tabs (used for composite source buffers)
182 pub hidden_from_tabs: bool,
183
184 /// Whether this buffer is opened in "preview" mode (ephemeral).
185 /// A preview buffer is one opened by a single-click in the file explorer
186 /// (or a similar soft-open gesture). Its tab is rendered in italic and
187 /// it is replaced the next time another file is opened the same way.
188 /// The flag is cleared ("promoted") when the user edits the buffer,
189 /// double-clicks the file, or otherwise signals commitment to the file.
190 ///
191 /// Intentionally ephemeral — never serialized into workspace or
192 /// recovery state. Restarting the editor always brings buffers back
193 /// as permanent tabs; preview status belongs to the current session's
194 /// exploration flow only.
195 pub is_preview: bool,
196
197 /// Stable recovery ID for unnamed buffers.
198 /// For file-backed buffers, recovery ID is computed from the path hash.
199 /// For unnamed buffers, this is generated once and reused across auto-saves.
200 pub recovery_id: Option<String>,
201}
202
203impl BufferMetadata {
204 /// Get the file path if this is a file-backed buffer
205 pub fn file_path(&self) -> Option<&PathBuf> {
206 match &self.kind {
207 BufferKind::File { path, .. } => Some(path),
208 BufferKind::Virtual { .. } => None,
209 }
210 }
211
212 /// Get the file URI if this is a file-backed buffer
213 pub fn file_uri(&self) -> Option<&lsp_types::Uri> {
214 match &self.kind {
215 BufferKind::File { uri, .. } => uri.as_ref(),
216 BufferKind::Virtual { .. } => None,
217 }
218 }
219
220 /// Check if this is a virtual buffer
221 pub fn is_virtual(&self) -> bool {
222 matches!(self.kind, BufferKind::Virtual { .. })
223 }
224
225 /// Get the mode name for virtual buffers
226 pub fn virtual_mode(&self) -> Option<&str> {
227 match &self.kind {
228 BufferKind::Virtual { mode } => Some(mode),
229 BufferKind::File { .. } => None,
230 }
231 }
232}
233
234impl Default for BufferMetadata {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240impl BufferMetadata {
241 /// Create new metadata for a buffer (unnamed, file-backed)
242 pub fn new() -> Self {
243 Self {
244 kind: BufferKind::File {
245 path: PathBuf::new(),
246 uri: None,
247 },
248 display_name: t!("buffer.no_name").to_string(),
249 lsp_enabled: true,
250 lsp_disabled_reason: None,
251 read_only: false,
252 binary: false,
253 lsp_opened_with: HashSet::new(),
254 hidden_from_tabs: false,
255 is_preview: false,
256 recovery_id: None,
257 }
258 }
259
260 /// Create new metadata for an unnamed buffer with a custom display name
261 /// Used for buffers created from stdin or other non-file sources
262 pub fn new_unnamed(display_name: String) -> Self {
263 Self {
264 kind: BufferKind::File {
265 path: PathBuf::new(),
266 uri: None,
267 },
268 display_name,
269 lsp_enabled: false, // No file path, so no LSP
270 lsp_disabled_reason: Some(t!("lsp.disabled.unnamed").to_string()),
271 read_only: false,
272 binary: false,
273 lsp_opened_with: HashSet::new(),
274 hidden_from_tabs: false,
275 is_preview: false,
276 recovery_id: None,
277 }
278 }
279
280 /// Create metadata for a file-backed buffer
281 ///
282 /// # Arguments
283 /// * `canonical_path` - The canonical (symlink-resolved) absolute path to the file
284 /// * `display_path` - The user-visible path before canonicalization (for library detection)
285 /// * `working_dir` - The canonical working directory for computing relative display name
286 pub fn with_file(canonical_path: PathBuf, display_path: &Path, working_dir: &Path) -> Self {
287 // Compute URI from the absolute path
288 let file_uri = file_path_to_lsp_uri(&canonical_path);
289
290 // Compute display name (project-relative when under working_dir, else absolute path).
291 // Use canonicalized forms first to handle macOS /var -> /private/var differences.
292 let display_name = Self::display_name_for_path(&canonical_path, working_dir);
293
294 // Check if this is a library file (in vendor directories or standard libraries).
295 // Library files are read-only (to prevent accidental edits) but LSP stays
296 // enabled so that Goto Definition, Hover, Find References, etc. still work
297 // when the user navigates into library source code (issue #1344).
298 //
299 // A file is only considered a library file if BOTH the canonical path and the
300 // user-visible path are in a library directory. This prevents symlinked dotfiles
301 // (e.g., ~/.bash_profile -> /nix/store/...) from being marked read-only when
302 // the user explicitly opened a non-library path (issue #1469).
303 let is_library = Self::is_library_path(&canonical_path, working_dir)
304 && Self::is_library_path(display_path, working_dir);
305
306 Self {
307 kind: BufferKind::File {
308 path: canonical_path,
309 uri: file_uri,
310 },
311 display_name,
312 lsp_enabled: true,
313 lsp_disabled_reason: None,
314 read_only: is_library,
315 binary: false,
316 lsp_opened_with: HashSet::new(),
317 hidden_from_tabs: false,
318 is_preview: false,
319 recovery_id: None,
320 }
321 }
322
323 /// Check if a path is a library file (in vendor directories or standard libraries)
324 ///
325 /// Library files include:
326 /// - Files in common vendor/dependency directories (.cargo, node_modules, etc.)
327 /// - Standard library / toolchain files (rustup toolchains, system includes, etc.)
328 pub fn is_library_path(path: &Path, _working_dir: &Path) -> bool {
329 // Check for common library paths
330 let path_str = path.to_string_lossy();
331
332 // Rust: .cargo directory (can be within project for vendor'd crates)
333 if path_str.contains("/.cargo/") || path_str.contains("\\.cargo\\") {
334 return true;
335 }
336
337 // Rust: rustup toolchains (standard library source files)
338 if path_str.contains("/rustup/toolchains/") || path_str.contains("\\rustup\\toolchains\\") {
339 return true;
340 }
341
342 // Node.js: node_modules
343 if path_str.contains("/node_modules/") || path_str.contains("\\node_modules\\") {
344 return true;
345 }
346
347 // Python: site-packages, dist-packages
348 if path_str.contains("/site-packages/")
349 || path_str.contains("\\site-packages\\")
350 || path_str.contains("/dist-packages/")
351 || path_str.contains("\\dist-packages\\")
352 {
353 return true;
354 }
355
356 // Go: pkg/mod
357 if path_str.contains("/pkg/mod/") || path_str.contains("\\pkg\\mod\\") {
358 return true;
359 }
360
361 // Ruby: gems
362 if path_str.contains("/gems/") || path_str.contains("\\gems\\") {
363 return true;
364 }
365
366 // Java/Gradle: .gradle
367 if path_str.contains("/.gradle/") || path_str.contains("\\.gradle\\") {
368 return true;
369 }
370
371 // Maven: .m2
372 if path_str.contains("/.m2/") || path_str.contains("\\.m2\\") {
373 return true;
374 }
375
376 // C/C++: system include directories
377 if path_str.starts_with("/usr/include/") || path_str.starts_with("/usr/local/include/") {
378 return true;
379 }
380
381 // Nix store (system-managed packages)
382 if path_str.starts_with("/nix/store/") {
383 return true;
384 }
385
386 // Homebrew (macOS system-managed packages)
387 if path_str.starts_with("/opt/homebrew/Cellar/")
388 || path_str.starts_with("/usr/local/Cellar/")
389 {
390 return true;
391 }
392
393 // .NET / C#: NuGet packages
394 if path_str.contains("/.nuget/") || path_str.contains("\\.nuget\\") {
395 return true;
396 }
397
398 // Swift / Xcode toolchains
399 if path_str.contains("/Xcode.app/Contents/Developer/")
400 || path_str.contains("/CommandLineTools/SDKs/")
401 {
402 return true;
403 }
404
405 false
406 }
407
408 /// Compute display name relative to working_dir when possible, otherwise absolute
409 pub fn display_name_for_path(path: &Path, working_dir: &Path) -> String {
410 // Canonicalize working_dir to normalize platform-specific prefixes
411 let canonical_working_dir = working_dir
412 .canonicalize()
413 .unwrap_or_else(|_| working_dir.to_path_buf());
414
415 // Try to canonicalize the file path; if it fails (e.g., new file), fall back to absolute
416 let absolute_path = if path.is_absolute() {
417 path.to_path_buf()
418 } else {
419 // If we were given a relative path, anchor it to working_dir
420 canonical_working_dir.join(path)
421 };
422 let canonical_path = absolute_path
423 .canonicalize()
424 .unwrap_or_else(|_| absolute_path.clone());
425
426 // Prefer canonical comparison first, then raw prefix as a fallback
427 let relative = canonical_path
428 .strip_prefix(&canonical_working_dir)
429 .or_else(|_| path.strip_prefix(working_dir))
430 .ok()
431 .and_then(|rel| rel.to_str().map(|s| s.to_string()));
432
433 relative
434 .or_else(|| canonical_path.to_str().map(|s| s.to_string()))
435 .unwrap_or_else(|| t!("buffer.unknown").to_string())
436 }
437
438 /// Create metadata for a virtual buffer (not backed by a file)
439 ///
440 /// # Arguments
441 /// * `name` - Display name (e.g., "*Diagnostics*")
442 /// * `mode` - Buffer mode for keybindings (e.g., "diagnostics-list")
443 /// * `read_only` - Whether the buffer should be read-only
444 pub fn virtual_buffer(name: String, mode: String, read_only: bool) -> Self {
445 Self {
446 kind: BufferKind::Virtual { mode },
447 display_name: name,
448 lsp_enabled: false, // Virtual buffers don't use LSP
449 lsp_disabled_reason: Some(t!("lsp.disabled.virtual").to_string()),
450 read_only,
451 binary: false,
452 lsp_opened_with: HashSet::new(),
453 hidden_from_tabs: false,
454 is_preview: false,
455 recovery_id: None,
456 }
457 }
458
459 /// Create metadata for a hidden virtual buffer (for composite source buffers)
460 /// These buffers are not shown in tabs and are managed by their parent composite buffer.
461 /// Hidden buffers are always read-only to prevent accidental edits.
462 pub fn hidden_virtual_buffer(name: String, mode: String) -> Self {
463 Self {
464 kind: BufferKind::Virtual { mode },
465 display_name: name,
466 lsp_enabled: false,
467 lsp_disabled_reason: Some(t!("lsp.disabled.virtual").to_string()),
468 read_only: true, // Hidden buffers are always read-only
469 binary: false,
470 lsp_opened_with: HashSet::new(),
471 hidden_from_tabs: true,
472 is_preview: false,
473 recovery_id: None,
474 }
475 }
476
477 /// Disable LSP for this buffer with a reason
478 pub fn disable_lsp(&mut self, reason: String) {
479 self.lsp_enabled = false;
480 self.lsp_disabled_reason = Some(reason);
481 }
482}
483
484/// LSP progress information
485#[derive(Debug, Clone)]
486pub(super) struct LspProgressInfo {
487 pub language: String,
488 pub title: String,
489 pub message: Option<String>,
490 pub percentage: Option<u32>,
491}
492
493/// LSP message entry (for window messages and logs)
494#[derive(Debug, Clone)]
495#[allow(dead_code)]
496pub(super) struct LspMessageEntry {
497 pub language: String,
498 pub message_type: LspMessageType,
499 pub message: String,
500 pub timestamp: std::time::Instant,
501}
502
503/// Types of UI elements that can be hovered over
504#[derive(Debug, Clone, PartialEq)]
505pub enum HoverTarget {
506 /// Hovering over a split separator (container_id, direction)
507 SplitSeparator(ContainerId, SplitDirection),
508 /// Hovering over a scrollbar thumb (split_id)
509 ScrollbarThumb(LeafId),
510 /// Hovering over a scrollbar track (split_id, relative_row)
511 ScrollbarTrack(LeafId, u16),
512 /// Hovering over a menu bar item (menu_index)
513 MenuBarItem(usize),
514 /// Hovering over a menu dropdown item (menu_index, item_index)
515 MenuDropdownItem(usize, usize),
516 /// Hovering over a submenu item (depth, item_index) - depth 1+ for nested submenus
517 SubmenuItem(usize, usize),
518 /// Hovering over a popup list item (popup_index in stack, item_index)
519 PopupListItem(usize, usize),
520 /// Hovering over a suggestion item (item_index)
521 SuggestionItem(usize),
522 /// Hovering over the file explorer border (for resize)
523 FileExplorerBorder,
524 /// Hovering over a file browser navigation shortcut
525 FileBrowserNavShortcut(usize),
526 /// Hovering over a file browser file/directory entry
527 FileBrowserEntry(usize),
528 /// Hovering over a file browser column header
529 FileBrowserHeader(SortMode),
530 /// Hovering over the file browser scrollbar
531 FileBrowserScrollbar,
532 /// Hovering over the file browser "Show Hidden" checkbox
533 FileBrowserShowHiddenCheckbox,
534 /// Hovering over the file browser "Detect Encoding" checkbox
535 FileBrowserDetectEncodingCheckbox,
536 /// Hovering over a tab name (target, split_id) - for non-active tabs
537 TabName(crate::view::split::TabTarget, LeafId),
538 /// Hovering over a tab close button (target, split_id)
539 TabCloseButton(crate::view::split::TabTarget, LeafId),
540 /// Hovering over a close split button (split_id)
541 CloseSplitButton(LeafId),
542 /// Hovering over a maximize/unmaximize split button (split_id)
543 MaximizeSplitButton(LeafId),
544 /// Hovering over the file explorer close button
545 FileExplorerCloseButton,
546 /// Hovering over a file explorer item's status indicator (path)
547 FileExplorerStatusIndicator(std::path::PathBuf),
548 /// Hovering over the status bar LSP indicator
549 StatusBarLspIndicator,
550 /// Hovering over the status bar remote-authority indicator
551 StatusBarRemoteIndicator,
552 /// Hovering over the status bar warning badge
553 StatusBarWarningBadge,
554 /// Hovering over the status bar line ending indicator
555 StatusBarLineEndingIndicator,
556 /// Hovering over the status bar encoding indicator
557 StatusBarEncodingIndicator,
558 /// Hovering over the status bar language indicator
559 StatusBarLanguageIndicator,
560 /// Hovering over the search options "Case Sensitive" checkbox
561 SearchOptionCaseSensitive,
562 /// Hovering over the search options "Whole Word" checkbox
563 SearchOptionWholeWord,
564 /// Hovering over the search options "Regex" checkbox
565 SearchOptionRegex,
566 /// Hovering over the search options "Confirm Each" checkbox
567 SearchOptionConfirmEach,
568 /// Hovering over a tab context menu item (item_index)
569 TabContextMenuItem(usize),
570 /// Hovering over a file explorer context menu item (item_index)
571 FileExplorerContextMenuItem(usize),
572}
573
574/// Tab context menu items
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576pub enum TabContextMenuItem {
577 /// Close this tab
578 Close,
579 /// Close all other tabs
580 CloseOthers,
581 /// Close tabs to the right
582 CloseToRight,
583 /// Close tabs to the left
584 CloseToLeft,
585 /// Close all tabs
586 CloseAll,
587}
588
589impl TabContextMenuItem {
590 /// Get all menu items in order
591 pub fn all() -> &'static [Self] {
592 &[
593 Self::Close,
594 Self::CloseOthers,
595 Self::CloseToRight,
596 Self::CloseToLeft,
597 Self::CloseAll,
598 ]
599 }
600
601 /// Get the display label for this menu item
602 pub fn label(&self) -> String {
603 match self {
604 Self::Close => t!("tab.close").to_string(),
605 Self::CloseOthers => t!("tab.close_others").to_string(),
606 Self::CloseToRight => t!("tab.close_to_right").to_string(),
607 Self::CloseToLeft => t!("tab.close_to_left").to_string(),
608 Self::CloseAll => t!("tab.close_all").to_string(),
609 }
610 }
611}
612
613/// State for tab context menu (right-click popup on tabs)
614#[derive(Debug, Clone)]
615pub struct TabContextMenu {
616 /// The buffer ID this context menu is for
617 pub buffer_id: BufferId,
618 /// The split ID where the tab is located
619 pub split_id: LeafId,
620 /// Screen position where the menu should appear (x, y)
621 pub position: (u16, u16),
622 /// Currently highlighted menu item index
623 pub highlighted: usize,
624}
625
626impl TabContextMenu {
627 /// Create a new tab context menu
628 pub fn new(buffer_id: BufferId, split_id: LeafId, x: u16, y: u16) -> Self {
629 Self {
630 buffer_id,
631 split_id,
632 position: (x, y),
633 highlighted: 0,
634 }
635 }
636
637 /// Get the currently highlighted item
638 pub fn highlighted_item(&self) -> TabContextMenuItem {
639 TabContextMenuItem::all()[self.highlighted]
640 }
641
642 /// Move highlight down
643 pub fn next_item(&mut self) {
644 let items = TabContextMenuItem::all();
645 self.highlighted = (self.highlighted + 1) % items.len();
646 }
647
648 /// Move highlight up
649 pub fn prev_item(&mut self) {
650 let items = TabContextMenuItem::all();
651 self.highlighted = if self.highlighted == 0 {
652 items.len() - 1
653 } else {
654 self.highlighted - 1
655 };
656 }
657}
658
659/// File explorer context menu items
660#[derive(Debug, Clone, Copy, PartialEq, Eq)]
661pub enum FileExplorerContextMenuItem {
662 NewFile,
663 NewDirectory,
664 Rename,
665 Cut,
666 Copy,
667 Paste,
668 Delete,
669}
670
671impl FileExplorerContextMenuItem {
672 pub fn all() -> &'static [Self] {
673 &[
674 Self::NewFile,
675 Self::NewDirectory,
676 Self::Rename,
677 Self::Cut,
678 Self::Copy,
679 Self::Paste,
680 Self::Delete,
681 ]
682 }
683
684 pub fn multi_selection() -> &'static [Self] {
685 &[Self::Cut, Self::Copy, Self::Paste, Self::Delete]
686 }
687
688 pub fn root_single_selection() -> &'static [Self] {
689 &[Self::NewFile, Self::NewDirectory, Self::Paste]
690 }
691
692 pub fn label(&self) -> String {
693 match self {
694 Self::NewFile => t!("explorer.context.new_file").to_string(),
695 Self::NewDirectory => t!("explorer.context.new_directory").to_string(),
696 Self::Rename => t!("explorer.context.rename").to_string(),
697 Self::Cut => t!("explorer.context.cut").to_string(),
698 Self::Copy => t!("explorer.context.copy").to_string(),
699 Self::Paste => t!("explorer.context.paste").to_string(),
700 Self::Delete => t!("explorer.context.delete").to_string(),
701 }
702 }
703}
704
705/// State for file explorer context menu (right-click popup in the file explorer)
706#[derive(Debug, Clone)]
707pub struct FileExplorerContextMenu {
708 /// Screen position where the menu should appear (x, y)
709 pub position: (u16, u16),
710 /// Currently highlighted menu item index
711 pub highlighted: usize,
712 /// Whether the menu was opened with multiple items selected
713 pub is_multi_selection: bool,
714 /// Whether the sole selected node is the project root
715 pub is_root_selected: bool,
716}
717
718impl FileExplorerContextMenu {
719 pub fn new(x: u16, y: u16, is_multi_selection: bool, is_root_selected: bool) -> Self {
720 Self {
721 position: (x, y),
722 highlighted: 0,
723 is_multi_selection,
724 is_root_selected,
725 }
726 }
727
728 pub fn items(&self) -> &'static [FileExplorerContextMenuItem] {
729 if self.is_multi_selection {
730 FileExplorerContextMenuItem::multi_selection()
731 } else if self.is_root_selected {
732 FileExplorerContextMenuItem::root_single_selection()
733 } else {
734 FileExplorerContextMenuItem::all()
735 }
736 }
737
738 pub fn height(&self) -> u16 {
739 self.items().len() as u16 + 2
740 }
741
742 pub fn clamped_position(&self, screen_width: u16, screen_height: u16) -> (u16, u16) {
743 let x = if self.position.0 + FILE_EXPLORER_CONTEXT_MENU_WIDTH > screen_width {
744 screen_width.saturating_sub(FILE_EXPLORER_CONTEXT_MENU_WIDTH)
745 } else {
746 self.position.0
747 };
748 let h = self.height();
749 let y = if self.position.1 + h > screen_height {
750 screen_height.saturating_sub(h)
751 } else {
752 self.position.1
753 };
754 (x, y)
755 }
756
757 pub fn next_item(&mut self) {
758 let len = self.items().len();
759 self.highlighted = (self.highlighted + 1) % len;
760 }
761
762 pub fn prev_item(&mut self) {
763 let len = self.items().len();
764 self.highlighted = if self.highlighted == 0 {
765 len - 1
766 } else {
767 self.highlighted - 1
768 };
769 }
770}
771
772/// Lightweight per-cell theme key provenance recorded during rendering.
773/// Stored in `CachedLayout::cell_theme_map` so the theme inspector popup
774/// can look up the exact keys used for any screen position.
775#[derive(Debug, Clone, Default)]
776pub struct CellThemeInfo {
777 /// Foreground theme key (e.g. "syntax.keyword", "editor.fg")
778 pub fg_key: Option<&'static str>,
779 /// Background theme key (e.g. "editor.bg", "diagnostic.warning_bg")
780 pub bg_key: Option<&'static str>,
781 /// Short region label (e.g. "Line Numbers", "Editor Content")
782 pub region: &'static str,
783 /// Dynamic region suffix (e.g. syntax category display name appended to "Syntax: ")
784 pub syntax_category: Option<&'static str>,
785}
786
787/// Information about which theme key(s) style a specific screen position.
788/// Used by the Ctrl+Right-Click theme inspector popup.
789#[derive(Debug, Clone)]
790pub struct ThemeKeyInfo {
791 /// The foreground theme key path (e.g., "syntax.keyword", "editor.fg")
792 pub fg_key: Option<String>,
793 /// The background theme key path (e.g., "editor.bg", "editor.selection_bg")
794 pub bg_key: Option<String>,
795 /// Human-readable description of the UI region
796 pub region: String,
797 /// The actual foreground color value currently applied
798 pub fg_color: Option<ratatui::style::Color>,
799 /// The actual background color value currently applied
800 pub bg_color: Option<ratatui::style::Color>,
801 /// For syntax highlights: the HighlightCategory display name
802 pub syntax_category: Option<String>,
803}
804
805/// State for the theme inspector popup (Ctrl+Right-Click)
806#[derive(Debug, Clone)]
807pub struct ThemeInfoPopup {
808 /// Screen position where popup appears (x, y)
809 pub position: (u16, u16),
810 /// Resolved theme key information
811 pub info: ThemeKeyInfo,
812 /// Whether the "Open in Theme Editor" button is highlighted (mouse hover)
813 pub button_highlighted: bool,
814}
815
816/// Drop zone for tab drag-and-drop
817/// Indicates where a dragged tab will be placed when released
818#[derive(Debug, Clone, Copy, PartialEq, Eq)]
819pub enum TabDropZone {
820 /// Drop into an existing split's tab bar (before tab at index, or at end if None)
821 /// (target_split_id, insert_index)
822 TabBar(LeafId, Option<usize>),
823 /// Create a new split on the left edge of the target split
824 SplitLeft(LeafId),
825 /// Create a new split on the right edge of the target split
826 SplitRight(LeafId),
827 /// Create a new split on the top edge of the target split
828 SplitTop(LeafId),
829 /// Create a new split on the bottom edge of the target split
830 SplitBottom(LeafId),
831 /// Drop into the center of a split (switch to that split's tab bar)
832 SplitCenter(LeafId),
833}
834
835impl TabDropZone {
836 /// Get the split ID this drop zone is associated with
837 pub fn split_id(&self) -> LeafId {
838 match self {
839 Self::TabBar(id, _)
840 | Self::SplitLeft(id)
841 | Self::SplitRight(id)
842 | Self::SplitTop(id)
843 | Self::SplitBottom(id)
844 | Self::SplitCenter(id) => *id,
845 }
846 }
847}
848
849/// State for a tab being dragged
850#[derive(Debug, Clone)]
851pub struct TabDragState {
852 /// The buffer being dragged
853 pub buffer_id: BufferId,
854 /// The split the tab was dragged from
855 pub source_split_id: LeafId,
856 /// Starting mouse position when drag began
857 pub start_position: (u16, u16),
858 /// Current mouse position
859 pub current_position: (u16, u16),
860 /// Currently detected drop zone (if any)
861 pub drop_zone: Option<TabDropZone>,
862}
863
864impl TabDragState {
865 /// Create a new tab drag state
866 pub fn new(buffer_id: BufferId, source_split_id: LeafId, start_position: (u16, u16)) -> Self {
867 Self {
868 buffer_id,
869 source_split_id,
870 start_position,
871 current_position: start_position,
872 drop_zone: None,
873 }
874 }
875
876 /// Check if the drag has moved enough to be considered a real drag (not just a click)
877 pub fn is_dragging(&self) -> bool {
878 let dx = (self.current_position.0 as i32 - self.start_position.0 as i32).abs();
879 let dy = (self.current_position.1 as i32 - self.start_position.1 as i32).abs();
880 dx > 3 || dy > 3 // Threshold of 3 pixels before drag activates
881 }
882}
883
884/// Mouse state tracking
885#[derive(Debug, Clone, Default)]
886pub(super) struct MouseState {
887 /// Whether we're currently dragging a vertical scrollbar
888 pub dragging_scrollbar: Option<LeafId>,
889 /// Whether we're currently dragging a horizontal scrollbar
890 pub dragging_horizontal_scrollbar: Option<LeafId>,
891 /// Initial mouse column when starting horizontal scrollbar drag
892 pub drag_start_hcol: Option<u16>,
893 /// Initial left_column when starting horizontal scrollbar drag
894 pub drag_start_left_column: Option<usize>,
895 /// Last mouse position
896 pub last_position: Option<(u16, u16)>,
897 /// Mouse hover for LSP: byte position being hovered, timer start, and screen position
898 /// Format: (byte_position, hover_start_instant, screen_x, screen_y)
899 pub lsp_hover_state: Option<(usize, std::time::Instant, u16, u16)>,
900 /// Whether we've already sent a hover request for the current position
901 pub lsp_hover_request_sent: bool,
902 /// Initial mouse row when starting to drag the scrollbar thumb
903 /// Used to calculate relative movement rather than jumping
904 pub drag_start_row: Option<u16>,
905 /// Initial viewport top_byte when starting to drag the scrollbar thumb
906 pub drag_start_top_byte: Option<usize>,
907 /// Initial viewport top_view_line_offset when starting to drag the scrollbar thumb
908 /// This is needed for proper visual row calculation when scrolled into a wrapped line
909 pub drag_start_view_line_offset: Option<usize>,
910 /// Whether we're currently dragging a split separator
911 /// Stores (split_id, direction) for the separator being dragged
912 pub dragging_separator: Option<(ContainerId, SplitDirection)>,
913 /// Initial mouse position when starting to drag a separator
914 pub drag_start_position: Option<(u16, u16)>,
915 /// Initial split ratio when starting to drag a separator
916 pub drag_start_ratio: Option<f32>,
917 /// Whether we're currently dragging the file explorer border
918 pub dragging_file_explorer: bool,
919 /// File explorer width at the moment the drag started. Drag
920 /// preserves the active variant: a drag that begins in `Percent`
921 /// stays in `Percent`, and likewise for `Columns`.
922 pub drag_start_explorer_width: Option<crate::config::ExplorerWidth>,
923 /// Current hover target (if any)
924 pub hover_target: Option<HoverTarget>,
925 /// Whether we're currently doing a text selection drag
926 pub dragging_text_selection: bool,
927 /// The split where text selection started
928 pub drag_selection_split: Option<LeafId>,
929 /// The buffer byte position where the selection anchor is
930 pub drag_selection_anchor: Option<usize>,
931 /// When true, dragging extends selection by whole words (set by double-click)
932 pub drag_selection_by_words: bool,
933 /// The end of the initially double-clicked word (used as anchor when dragging backward)
934 pub drag_selection_word_end: Option<usize>,
935 /// Tab drag state (for drag-to-split functionality)
936 pub dragging_tab: Option<TabDragState>,
937 /// Whether we're currently dragging a popup scrollbar (popup index)
938 pub dragging_popup_scrollbar: Option<usize>,
939 /// Initial scroll offset when starting to drag popup scrollbar
940 pub drag_start_popup_scroll: Option<usize>,
941 /// Whether we're currently selecting text in a popup (popup index)
942 pub selecting_in_popup: Option<usize>,
943 /// Initial composite scroll_row when starting to drag the scrollbar thumb
944 /// Used for composite buffer scrollbar drag
945 pub drag_start_composite_scroll_row: Option<usize>,
946}
947
948/// Mapping from visual row to buffer positions for mouse click handling
949/// Each entry represents one visual row with byte position info for click handling
950#[derive(Debug, Clone, Default)]
951pub struct ViewLineMapping {
952 /// Source byte offset for each character (None for injected/virtual content)
953 pub char_source_bytes: Vec<Option<usize>>,
954 /// Character index at each visual column (for O(1) mouse clicks)
955 pub visual_to_char: Vec<usize>,
956 /// Last valid byte position in this visual row (newline for real lines, last char for wrapped)
957 /// Clicks past end of visible text position cursor here
958 pub line_end_byte: usize,
959}
960
961impl ViewLineMapping {
962 /// Get source byte at a given visual column (O(1) for mouse clicks)
963 #[inline]
964 pub fn source_byte_at_visual_col(&self, visual_col: usize) -> Option<usize> {
965 let char_idx = self.visual_to_char.get(visual_col).copied()?;
966 self.char_source_bytes.get(char_idx).copied().flatten()
967 }
968
969 /// Find the nearest source byte to a given visual column, searching outward.
970 /// Returns the source byte at the closest valid visual column.
971 pub fn nearest_source_byte(&self, goal_col: usize) -> Option<usize> {
972 let width = self.visual_to_char.len();
973 if width == 0 {
974 return None;
975 }
976 // Search outward from goal_col: try +1, -1, +2, -2, ...
977 for delta in 1..width {
978 if goal_col + delta < width {
979 if let Some(byte) = self.source_byte_at_visual_col(goal_col + delta) {
980 return Some(byte);
981 }
982 }
983 if delta <= goal_col {
984 if let Some(byte) = self.source_byte_at_visual_col(goal_col - delta) {
985 return Some(byte);
986 }
987 }
988 }
989 None
990 }
991
992 /// Check if this visual row contains the given byte position
993 #[inline]
994 pub fn contains_byte(&self, byte_pos: usize) -> bool {
995 // A row contains a byte if it's in the char_source_bytes range
996 // The first valid source byte marks the start, line_end_byte marks the end
997 if let Some(first_byte) = self.char_source_bytes.iter().find_map(|b| *b) {
998 byte_pos >= first_byte && byte_pos <= self.line_end_byte
999 } else {
1000 // Empty/virtual row - only matches if byte_pos equals line_end_byte
1001 byte_pos == self.line_end_byte
1002 }
1003 }
1004
1005 /// Get the first source byte position in this row (if any)
1006 #[inline]
1007 pub fn first_source_byte(&self) -> Option<usize> {
1008 self.char_source_bytes.iter().find_map(|b| *b)
1009 }
1010}
1011
1012/// Type alias for popup area layout information used in mouse hit testing.
1013/// Fields: (popup_index, rect, inner_rect, scroll_offset, num_items, scrollbar_rect, total_lines)
1014pub(crate) type PopupAreaLayout = (usize, Rect, Rect, usize, usize, Option<Rect>, usize);
1015
1016/// Cached layout information for mouse hit testing
1017#[derive(Debug, Clone, Default)]
1018pub(crate) struct CachedLayout {
1019 /// File explorer area (if visible)
1020 pub file_explorer_area: Option<Rect>,
1021 /// Editor content area (excluding file explorer)
1022 pub editor_content_area: Option<Rect>,
1023 /// Individual split areas with their scrollbar areas and thumb positions
1024 /// (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end)
1025 pub split_areas: Vec<(LeafId, BufferId, Rect, Rect, usize, usize)>,
1026 /// Horizontal scrollbar areas per split
1027 /// (split_id, buffer_id, horizontal_scrollbar_rect, max_content_width, thumb_start_col, thumb_end_col)
1028 pub horizontal_scrollbar_areas: Vec<(LeafId, BufferId, Rect, usize, usize, usize)>,
1029 /// Split separator positions for drag resize
1030 /// (container_id, direction, x, y, length)
1031 pub separator_areas: Vec<(ContainerId, SplitDirection, u16, u16, u16)>,
1032 /// Popup areas for mouse hit testing
1033 /// scrollbar_rect is Some if popup has a scrollbar
1034 pub popup_areas: Vec<PopupAreaLayout>,
1035 /// Editor-level popup areas (e.g. plugin action popups) for mouse hit
1036 /// testing. Stored separately from buffer popups because they're owned by
1037 /// `Editor.global_popups` rather than the active buffer's state.
1038 /// Fields: (popup_index, rect, inner_rect, scroll_offset, num_items)
1039 pub global_popup_areas: Vec<(usize, Rect, Rect, usize, usize)>,
1040 /// Suggestions area for mouse hit testing
1041 /// (inner_rect, scroll_start_idx, visible_count, total_count)
1042 pub suggestions_area: Option<(Rect, usize, usize, usize)>,
1043 /// Tab layouts per split for mouse interaction
1044 pub tab_layouts: HashMap<LeafId, crate::view::ui::tabs::TabLayout>,
1045 /// Close split button hit areas
1046 /// (split_id, row, start_col, end_col)
1047 pub close_split_areas: Vec<(LeafId, u16, u16, u16)>,
1048 /// Maximize split button hit areas
1049 /// (split_id, row, start_col, end_col)
1050 pub maximize_split_areas: Vec<(LeafId, u16, u16, u16)>,
1051 /// View line mappings for accurate mouse click positioning per split
1052 /// Maps visual row index to character position mappings
1053 /// Used to translate screen coordinates to buffer byte positions
1054 pub view_line_mappings: HashMap<LeafId, Vec<ViewLineMapping>>,
1055 /// Settings modal layout for hit testing
1056 pub settings_layout: Option<crate::view::settings::SettingsLayout>,
1057 /// Status bar area (row, x, width)
1058 pub status_bar_area: Option<(u16, u16, u16)>,
1059 /// Status bar LSP indicator area (row, start_col, end_col)
1060 pub status_bar_lsp_area: Option<(u16, u16, u16)>,
1061 /// Status bar warning badge area (row, start_col, end_col)
1062 pub status_bar_warning_area: Option<(u16, u16, u16)>,
1063 /// Status bar line ending indicator area (row, start_col, end_col)
1064 pub status_bar_line_ending_area: Option<(u16, u16, u16)>,
1065 /// Status bar encoding indicator area (row, start_col, end_col)
1066 pub status_bar_encoding_area: Option<(u16, u16, u16)>,
1067 /// Status bar language indicator area (row, start_col, end_col)
1068 pub status_bar_language_area: Option<(u16, u16, u16)>,
1069 /// Status bar message area (row, start_col, end_col) - clickable to show status log
1070 pub status_bar_message_area: Option<(u16, u16, u16)>,
1071 /// Status bar remote-authority indicator area (row, start_col, end_col)
1072 /// — clickable to open the remote-authority context menu.
1073 pub status_bar_remote_area: Option<(u16, u16, u16)>,
1074 /// Search options layout for checkbox hit testing
1075 pub search_options_layout: Option<crate::view::ui::status_bar::SearchOptionsLayout>,
1076 /// Menu bar layout for hit testing
1077 pub menu_layout: Option<crate::view::ui::menu::MenuLayout>,
1078 /// Last frame dimensions — used by recompute_layout for macro replay
1079 pub last_frame_width: u16,
1080 pub last_frame_height: u16,
1081 /// Per-cell theme key provenance recorded during rendering.
1082 /// Flat vec indexed as `row * width + col` where `width = last_frame_width`.
1083 pub cell_theme_map: Vec<CellThemeInfo>,
1084}
1085
1086impl CachedLayout {
1087 /// Reset the cell theme map for a new frame
1088 pub fn reset_cell_theme_map(&mut self) {
1089 let total = self.last_frame_width as usize * self.last_frame_height as usize;
1090 self.cell_theme_map.clear();
1091 self.cell_theme_map.resize(total, CellThemeInfo::default());
1092 }
1093
1094 /// Look up the theme info for a screen position
1095 pub fn cell_theme_at(&self, col: u16, row: u16) -> Option<&CellThemeInfo> {
1096 let idx = row as usize * self.last_frame_width as usize + col as usize;
1097 self.cell_theme_map.get(idx)
1098 }
1099
1100 /// Find which visual row contains the given byte position for a split
1101 pub fn find_visual_row(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
1102 let mappings = self.view_line_mappings.get(&split_id)?;
1103 mappings.iter().position(|m| m.contains_byte(byte_pos))
1104 }
1105
1106 /// Get the visual column of a byte position within its visual row
1107 pub fn byte_to_visual_column(&self, split_id: LeafId, byte_pos: usize) -> Option<usize> {
1108 let mappings = self.view_line_mappings.get(&split_id)?;
1109 let row_idx = self.find_visual_row(split_id, byte_pos)?;
1110 let row = mappings.get(row_idx)?;
1111
1112 // Find the visual column that maps to this byte position
1113 for (visual_col, &char_idx) in row.visual_to_char.iter().enumerate() {
1114 if let Some(source_byte) = row.char_source_bytes.get(char_idx).and_then(|b| *b) {
1115 if source_byte == byte_pos {
1116 return Some(visual_col);
1117 }
1118 // If we've passed the byte position, return previous column
1119 if source_byte > byte_pos {
1120 return Some(visual_col.saturating_sub(1));
1121 }
1122 }
1123 }
1124 // Byte is at or past end of row - return column after last character
1125 // This handles cursor positions at end of line (e.g., after last char before newline)
1126 Some(row.visual_to_char.len())
1127 }
1128
1129 /// Move by visual line using the cached mappings
1130 /// Returns (new_position, new_visual_column) or None if at boundary
1131 pub fn move_visual_line(
1132 &self,
1133 split_id: LeafId,
1134 current_pos: usize,
1135 goal_visual_col: usize,
1136 direction: i8, // -1 = up, 1 = down
1137 ) -> Option<(usize, usize)> {
1138 let mappings = self.view_line_mappings.get(&split_id)?;
1139 let current_row = self.find_visual_row(split_id, current_pos)?;
1140
1141 // Walk past purely-virtual rows (e.g. markdown_compose table top/
1142 // bottom borders and inter-row separators). Those rows have no
1143 // source mapping at all — their `char_source_bytes` are all `None`
1144 // and their `line_end_byte` is inherited from the adjacent content
1145 // row. If MoveDown/MoveUp stopped on them the cursor would land on
1146 // a byte that's already at the row above's end, which in turn
1147 // causes Down-after-table to teleport back to an earlier position
1148 // (regression exposed by markdown_compose's table border feature).
1149 //
1150 // A row is "navigable" iff at least one of its visual columns maps
1151 // to a real source byte. Skip entirely-virtual rows in the move
1152 // direction until we hit a navigable one or run off the edge.
1153 let mut target_row = current_row;
1154 let navigable = |idx: usize| -> bool {
1155 mappings
1156 .get(idx)
1157 .map(|m| m.char_source_bytes.iter().any(|b| b.is_some()))
1158 .unwrap_or(false)
1159 };
1160 loop {
1161 target_row = if direction < 0 {
1162 target_row.checked_sub(1)?
1163 } else {
1164 let next = target_row + 1;
1165 if next >= mappings.len() {
1166 return None;
1167 }
1168 next
1169 };
1170 // Either the next row has real source content, or we've reached
1171 // a legitimate non-source row that the rest of the editor
1172 // already treats as a cursor stop (trailing empty line at EOF,
1173 // implicit blank final line). In either case stop walking.
1174 if navigable(target_row) {
1175 break;
1176 }
1177 let mapping = mappings.get(target_row)?;
1178 let is_plugin_virtual =
1179 mapping.visual_to_char.is_empty() || mapping.char_source_bytes.is_empty();
1180 if !is_plugin_virtual {
1181 // The row has columns but none carry a source byte — most
1182 // likely a plugin-injected decoration with padding. Keep
1183 // looking.
1184 continue;
1185 }
1186 // Empty mapping (no visual columns) is how EOF-related virtual
1187 // rows are represented; those are legitimate cursor stops so we
1188 // accept them and fall out of the loop.
1189 break;
1190 }
1191
1192 let target_mapping = mappings.get(target_row)?;
1193
1194 // Try to get byte at goal visual column. If the goal column is past
1195 // the end of visible content, land at line_end_byte (the newline or
1196 // end of buffer). If the column exists but has no source byte (e.g.
1197 // padding on a wrapped continuation line), search outward for the
1198 // nearest valid source byte at minimal visual distance.
1199 let new_pos = if goal_visual_col >= target_mapping.visual_to_char.len() {
1200 target_mapping.line_end_byte
1201 } else {
1202 target_mapping
1203 .source_byte_at_visual_col(goal_visual_col)
1204 .or_else(|| target_mapping.nearest_source_byte(goal_visual_col))
1205 .unwrap_or(target_mapping.line_end_byte)
1206 };
1207
1208 Some((new_pos, goal_visual_col))
1209 }
1210
1211 /// Get the start byte position of the visual row containing the given byte position.
1212 /// If the cursor is already at the visual row start and this is a wrapped continuation,
1213 /// moves to the previous visual row's start (within the same logical line).
1214 /// Get the start byte position of the visual row containing the given byte position.
1215 /// When `allow_advance` is true and the cursor is already at the row start,
1216 /// moves to the previous visual row's start.
1217 pub fn visual_line_start(
1218 &self,
1219 split_id: LeafId,
1220 byte_pos: usize,
1221 allow_advance: bool,
1222 ) -> Option<usize> {
1223 let mappings = self.view_line_mappings.get(&split_id)?;
1224 let row_idx = self.find_visual_row(split_id, byte_pos)?;
1225 let row = mappings.get(row_idx)?;
1226 let row_start = row.first_source_byte()?;
1227
1228 if allow_advance && byte_pos == row_start && row_idx > 0 {
1229 let prev_row = mappings.get(row_idx - 1)?;
1230 prev_row.first_source_byte()
1231 } else {
1232 Some(row_start)
1233 }
1234 }
1235
1236 /// Get the end byte position of the visual row containing the given byte position.
1237 /// If the cursor is already at the visual row end and the next row is a wrapped continuation,
1238 /// moves to the next visual row's end (within the same logical line).
1239 /// Get the end byte position of the visual row containing the given byte position.
1240 /// When `allow_advance` is true and the cursor is already at the row end,
1241 /// advances to the next visual row's end.
1242 pub fn visual_line_end(
1243 &self,
1244 split_id: LeafId,
1245 byte_pos: usize,
1246 allow_advance: bool,
1247 ) -> Option<usize> {
1248 let mappings = self.view_line_mappings.get(&split_id)?;
1249 let row_idx = self.find_visual_row(split_id, byte_pos)?;
1250 let row = mappings.get(row_idx)?;
1251
1252 if allow_advance && byte_pos == row.line_end_byte && row_idx + 1 < mappings.len() {
1253 let next_row = mappings.get(row_idx + 1)?;
1254 Some(next_row.line_end_byte)
1255 } else {
1256 Some(row.line_end_byte)
1257 }
1258 }
1259}
1260
1261/// Convert a file path to an `lsp_types::Uri`.
1262pub fn file_path_to_lsp_uri(path: &Path) -> Option<lsp_types::Uri> {
1263 fresh_core::file_uri::path_to_lsp_uri(path)
1264}
1265
1266#[cfg(test)]
1267mod uri_encoding_tests {
1268 use super::*;
1269
1270 /// Helper to get a platform-appropriate absolute path for testing.
1271 fn abs_path(suffix: &str) -> PathBuf {
1272 std::env::temp_dir().join(suffix)
1273 }
1274
1275 #[test]
1276 fn test_brackets_in_path() {
1277 let path = abs_path("MY_PROJECTS [temp]/gogame/main.go");
1278 let uri = file_path_to_lsp_uri(&path);
1279 assert!(
1280 uri.is_some(),
1281 "URI should be computed for path with brackets"
1282 );
1283 let uri = uri.unwrap();
1284 assert!(
1285 uri.as_str().contains("%5Btemp%5D"),
1286 "Brackets should be percent-encoded: {}",
1287 uri.as_str()
1288 );
1289 }
1290
1291 #[test]
1292 fn test_spaces_in_path() {
1293 let path = abs_path("My Projects/src/main.go");
1294 let uri = file_path_to_lsp_uri(&path);
1295 assert!(uri.is_some(), "URI should be computed for path with spaces");
1296 }
1297
1298 #[test]
1299 fn test_normal_path() {
1300 let path = abs_path("project/main.go");
1301 let uri = file_path_to_lsp_uri(&path);
1302 assert!(uri.is_some(), "URI should be computed for normal path");
1303 let s = uri.unwrap().as_str().to_string();
1304 assert!(s.starts_with("file:///"), "Should be a file URI: {}", s);
1305 assert!(
1306 s.ends_with("project/main.go"),
1307 "Should end with the path: {}",
1308 s
1309 );
1310 }
1311
1312 #[test]
1313 fn test_relative_path_returns_none() {
1314 let path = PathBuf::from("main.go");
1315 assert!(file_path_to_lsp_uri(&path).is_none());
1316 }
1317
1318 #[test]
1319 fn test_all_special_chars() {
1320 let path = abs_path("a[b]c{d}e^g`h/file.rs");
1321 let uri = file_path_to_lsp_uri(&path);
1322 assert!(uri.is_some(), "Should handle all special characters");
1323 let s = uri.unwrap().as_str().to_string();
1324 assert!(!s.contains('['), "[ should be encoded in {}", s);
1325 assert!(!s.contains(']'), "] should be encoded in {}", s);
1326 assert!(!s.contains('{'), "{{ should be encoded in {}", s);
1327 assert!(!s.contains('}'), "}} should be encoded in {}", s);
1328 assert!(!s.contains('^'), "^ should be encoded in {}", s);
1329 assert!(!s.contains('`'), "` should be encoded in {}", s);
1330 }
1331}