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