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