Skip to main content

fresh/app/
file_explorer.rs

1use anyhow::Result as AnyhowResult;
2use rust_i18n::t;
3
4use super::*;
5use crate::services::async_bridge::AsyncMessage;
6use crate::view::file_tree::TreeNode;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
10pub struct FileExplorerClipboard {
11    pub paths: Vec<PathBuf>,
12    pub is_cut: bool,
13}
14
15/// Config-derived defaults handed to `Window::install_initialized_file_explorer`
16/// so the apply logic lives on `Window` without it borrowing the editor's
17/// `Config` (which would clash with the `&mut Window` borrow).
18#[derive(Debug, Clone, Copy)]
19pub(crate) struct FileExplorerViewDefaults {
20    pub show_hidden: bool,
21    pub show_gitignored: bool,
22    pub compact_directories: bool,
23}
24
25/// Outcome of a single filesystem-level paste op (`paste_one_fs_op`).
26/// The `SourceRemovalFailed` variant is a partial success: the destination
27/// exists but the original source could not be removed, so the file is
28/// effectively at both locations. Callers must surface this to the user —
29/// returning just an `Err` would hide the fact that the copy landed.
30#[derive(Debug)]
31enum PasteOpOutcome {
32    /// Move / copy completed end-to-end.
33    Ok,
34    /// Cross-filesystem cut: copy succeeded, but removing the source failed.
35    /// The file now exists at both `dst` and the original location.
36    SourceRemovalFailed { dst: PathBuf, err: std::io::Error },
37    /// Any other failure. Destination (if partially created) has already
38    /// been cleaned up by `paste_one_fs_op`.
39    Failed(std::io::Error),
40}
41
42/// Get the parent directory path from a file tree node.
43/// If the node is a directory, returns its path. If it's a file, returns the parent directory.
44fn get_parent_dir_path(node: &TreeNode) -> PathBuf {
45    if node.is_dir() {
46        node.entry.path.clone()
47    } else {
48        node.entry
49            .path
50            .parent()
51            .map(|p| p.to_path_buf())
52            .unwrap_or_else(|| node.entry.path.clone())
53    }
54}
55
56/// Generate a timestamp suffix for naming new files/directories.
57fn timestamp_suffix() -> u64 {
58    std::time::SystemTime::now()
59        .duration_since(std::time::UNIX_EPOCH)
60        .unwrap()
61        .as_secs()
62}
63
64/// Get the parent node ID for refreshing after file operations.
65/// If the node is a directory, the node itself is the parent. Otherwise, look up the actual parent.
66fn get_parent_node_id(
67    tree: &crate::view::file_tree::FileTree,
68    selected_id: crate::view::file_tree::NodeId,
69    node_is_dir: bool,
70) -> crate::view::file_tree::NodeId {
71    if node_is_dir {
72        selected_id
73    } else {
74        tree.get_node(selected_id)
75            .and_then(|n| n.parent)
76            .unwrap_or(selected_id)
77    }
78}
79
80impl Editor {
81    pub fn file_explorer_visible(&self) -> bool {
82        self.active_window().file_explorer_visible
83    }
84
85    /// Transfer keyboard focus from whatever owns it (most importantly:
86    /// a live terminal) to the file explorer.
87    ///
88    /// `dispatch_terminal_input` routes keys to the PTY whenever
89    /// `terminal_mode` is set, regardless of `key_context`. So just
90    /// writing `key_context = FileExplorer` is not enough — if the user
91    /// was in a terminal, every keystroke would still be swallowed by
92    /// the PTY and the explorer would only *look* focused (issue #2029).
93    /// Clear `terminal_mode` and remember the terminal buffer in
94    /// `terminal_mode_resume` so re-focusing the terminal later restores
95    /// live mode, mirroring the buffer-switch path in
96    /// `set_active_buffer`.
97    pub(super) fn take_focus_for_file_explorer(&mut self) {
98        let win = self.active_window_mut();
99        if win.terminal_mode {
100            let active = win.active_buffer();
101            if win.is_terminal_buffer(active) {
102                win.terminal_mode_resume.insert(active);
103            }
104            win.terminal_mode = false;
105        }
106        win.key_context = KeyContext::FileExplorer;
107    }
108
109    pub fn toggle_file_explorer(&mut self) {
110        let new_visible = !self.active_window().file_explorer_visible;
111        self.active_window_mut().file_explorer_visible = new_visible;
112
113        if new_visible {
114            if self.file_explorer().is_none() {
115                self.init_file_explorer();
116            }
117            self.take_focus_for_file_explorer();
118            self.set_status_message(t!("explorer.opened").to_string());
119            self.active_window_mut().sync_file_explorer_to_active_file();
120        } else {
121            self.active_window_mut().key_context = KeyContext::Normal;
122            self.set_status_message(t!("explorer.closed").to_string());
123        }
124
125        // Showing/hiding the sidebar changes the editor's available width.
126        // Route through the single layout funnel so split viewports and
127        // terminal PTYs reflow and plugins get the (deduped) resize hook.
128        self.relayout();
129    }
130
131    pub fn show_file_explorer(&mut self) {
132        if !self.file_explorer_visible() {
133            self.toggle_file_explorer();
134        }
135    }
136
137    pub fn focus_file_explorer(&mut self) {
138        if self.file_explorer_visible() {
139            // Dismiss transient popups and clear hover state when focusing file explorer
140            self.active_window_mut().on_editor_focus_lost();
141
142            // Cancel search/replace prompts when switching focus away from editor
143            self.active_window_mut().cancel_search_prompt_if_active();
144
145            self.take_focus_for_file_explorer();
146            self.set_status_message(t!("explorer.focused").to_string());
147            self.active_window_mut().sync_file_explorer_to_active_file();
148        } else {
149            self.toggle_file_explorer();
150        }
151    }
152
153    // `focus_editor` lives on `impl Window` — call it via
154    // `self.active_window_mut().focus_editor()`.
155
156    /// Thin delegator: building the file explorer is a per-window
157    /// operation that lives on `Window` and roots at `self.root`. The
158    /// editor just forwards to the active window (issue #2056 defect #3).
159    pub(crate) fn init_file_explorer(&mut self) {
160        self.active_window_mut().init_file_explorer();
161    }
162
163    pub fn file_explorer_navigate_up(&mut self) {
164        if let Some(explorer) = self.file_explorer_mut() {
165            explorer.select_prev_match();
166            explorer.update_scroll_for_selection();
167        }
168        self.file_explorer_preview_selected();
169    }
170
171    pub fn file_explorer_navigate_down(&mut self) {
172        if let Some(explorer) = self.file_explorer_mut() {
173            explorer.select_next_match();
174            explorer.update_scroll_for_selection();
175        }
176        self.file_explorer_preview_selected();
177    }
178
179    pub fn file_explorer_page_up(&mut self) {
180        if let Some(explorer) = self.file_explorer_mut() {
181            explorer.select_page_up();
182            explorer.update_scroll_for_selection();
183        }
184        self.file_explorer_preview_selected();
185    }
186
187    pub fn file_explorer_page_down(&mut self) {
188        if let Some(explorer) = self.file_explorer_mut() {
189            explorer.select_page_down();
190            explorer.update_scroll_for_selection();
191        }
192        self.file_explorer_preview_selected();
193    }
194
195    /// Open the currently selected file in preview mode, mirroring the
196    /// single-click flow in `handle_file_explorer_click`. No-op if the
197    /// selection is a directory, preview-tabs are disabled, or the open
198    /// would surface an interactive prompt (e.g. large-file encoding
199    /// confirmation) — the user can still commit with Enter to get the
200    /// full error flow. Keeps focus on the file explorer so further
201    /// keyboard navigation continues to update the preview.
202    fn file_explorer_preview_selected(&mut self) {
203        // Avoid turning every arrow press into a permanent tab when the
204        // user has opted out of preview tabs.
205        if !self.config.file_explorer.preview_tabs {
206            return;
207        }
208
209        let path = match self
210            .file_explorer()
211            .as_ref()
212            .and_then(|explorer| explorer.get_selected_entry())
213        {
214            Some(entry) if !entry.is_dir() => entry.path.clone(),
215            _ => return,
216        };
217
218        if let Err(e) = self.open_file_preview(&path) {
219            tracing::debug!(
220                "file_explorer_preview_selected: skipping preview for {:?}: {}",
221                path,
222                e
223            );
224        }
225    }
226
227    /// Collapse behavior for left arrow:
228    /// - If on expanded directory: collapse it
229    /// - If on file or collapsed directory: select parent directory
230    pub fn file_explorer_collapse(&mut self) {
231        let Some(explorer) = self.file_explorer() else {
232            return;
233        };
234
235        let Some(selected_id) = explorer.get_selected() else {
236            return;
237        };
238
239        let Some(node) = explorer.tree().get_node(selected_id) else {
240            return;
241        };
242
243        // If expanded directory, collapse it
244        if node.is_dir() && node.is_expanded() {
245            self.file_explorer_toggle_expand();
246            return;
247        }
248
249        // Otherwise, select parent
250        if let Some(explorer) = self.file_explorer_mut() {
251            explorer.select_parent();
252            explorer.update_scroll_for_selection();
253        }
254    }
255
256    pub fn file_explorer_toggle_expand(&mut self) {
257        let selected_id = if let Some(explorer) = self.file_explorer() {
258            explorer.get_selected()
259        } else {
260            return;
261        };
262
263        let Some(selected_id) = selected_id else {
264            return;
265        };
266
267        let (is_dir, is_expanded, name) = if let Some(explorer) = self.file_explorer() {
268            let node = explorer.tree().get_node(selected_id);
269            if let Some(node) = node {
270                (node.is_dir(), node.is_expanded(), node.entry.name.clone())
271            } else {
272                return;
273            }
274        } else {
275            return;
276        };
277
278        if !is_dir {
279            return;
280        }
281
282        let status_msg = if is_expanded {
283            t!("explorer.collapsing").to_string()
284        } else {
285            t!("explorer.loading_dir", name = &name).to_string()
286        };
287        self.set_status_message(status_msg);
288
289        let active_id = self.active_window;
290        // The active backend's filesystem, captured before the mutable
291        // window borrow below: `self.authority()` now reads the active
292        // window's owned authority, so it can't be read while
293        // `self.windows.get_mut(...)` holds a mutable borrow.
294        let fs = std::sync::Arc::clone(&self.authority().filesystem);
295        if let (Some(runtime), Some(explorer)) = (
296            self.tokio_runtime.as_ref(),
297            self.windows
298                .get_mut(&active_id)
299                .and_then(|w| w.file_explorer.as_mut()),
300        ) {
301            let result = runtime.block_on(explorer.toggle_with_chain(selected_id));
302
303            let final_name = explorer
304                .tree()
305                .get_node(selected_id)
306                .map(|n| n.entry.name.clone());
307            let final_expanded = explorer
308                .tree()
309                .get_node(selected_id)
310                .map(|n| n.is_expanded())
311                .unwrap_or(false);
312
313            // Track if we need to rebuild decoration cache (for symlink directories)
314            let mut needs_decoration_rebuild = false;
315
316            match result {
317                Ok(()) => {
318                    if final_expanded {
319                        let node_info = explorer
320                            .tree()
321                            .get_node(selected_id)
322                            .map(|n| (n.entry.path.clone(), n.entry.is_symlink()));
323
324                        if let Some((dir_path, is_symlink)) = node_info {
325                            crate::app::file_operations::load_gitignore_via_fs(
326                                fs.as_ref(),
327                                explorer,
328                                &dir_path,
329                            );
330
331                            // If a symlink directory was just expanded, we need to rebuild
332                            // the decoration cache so decorations under the canonical target
333                            // also appear under the symlink path
334                            if is_symlink {
335                                tracing::debug!(
336                                    "Symlink directory expanded, will rebuild decoration cache: {:?}",
337                                    dir_path
338                                );
339                                needs_decoration_rebuild = true;
340                            }
341                        }
342                    }
343
344                    if let Some(name) = final_name {
345                        let msg = if final_expanded {
346                            t!("explorer.expanded", name = &name).to_string()
347                        } else {
348                            t!("explorer.collapsed", name = &name).to_string()
349                        };
350                        self.set_status_message(msg);
351                    }
352                }
353                Err(e) => {
354                    self.set_status_message(
355                        t!("explorer.error", error = e.to_string()).to_string(),
356                    );
357                }
358            }
359
360            // Rebuild per-path explorer caches outside the explorer borrow so
361            // symlink-alias lookups stay in sync with the current tree shape.
362            if needs_decoration_rebuild {
363                let window = self.active_window_mut();
364                window.rebuild_file_explorer_decoration_cache();
365                window.rebuild_file_explorer_slot_override_cache();
366            }
367        }
368    }
369
370    pub fn file_explorer_open_file(&mut self) -> AnyhowResult<()> {
371        let entry_type = self
372            .file_explorer()
373            .as_ref()
374            .and_then(|explorer| explorer.get_selected_entry())
375            .map(|entry| (entry.is_dir(), entry.path.clone(), entry.name.clone()));
376
377        if let Some((is_dir, path, name)) = entry_type {
378            if is_dir {
379                self.file_explorer_toggle_expand();
380            } else {
381                tracing::info!("[SYNTAX DEBUG] file_explorer opening file: {:?}", path);
382                match self.open_file(&path) {
383                    Ok(id) => {
384                        // Double-click / Enter is the "I mean it" gesture — always
385                        // promote the tab out of preview mode so subsequent clicks
386                        // on *other* files don't replace this one.
387                        self.active_window_mut().promote_buffer_from_preview(id);
388                        self.set_status_message(
389                            t!("explorer.opened_file", name = &name).to_string(),
390                        );
391                        self.active_window_mut().focus_editor();
392                    }
393                    Err(e) => {
394                        // Check if this is a large file encoding confirmation error
395                        // These should be shown as prompts in the UI, not as fatal errors
396                        if let Some(confirmation) =
397                            e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
398                        {
399                            self.start_large_file_encoding_confirmation(confirmation);
400                        } else {
401                            self.set_status_message(
402                                t!("file.error_opening", error = e.to_string()).to_string(),
403                            );
404                        }
405                    }
406                }
407            }
408        }
409        Ok(())
410    }
411
412    pub fn file_explorer_refresh(&mut self) {
413        let (selected_id, node_name) = if let Some(explorer) = self.file_explorer() {
414            if let Some(selected_id) = explorer.get_selected() {
415                let node_name = explorer
416                    .tree()
417                    .get_node(selected_id)
418                    .map(|n| n.entry.name.clone());
419                (Some(selected_id), node_name)
420            } else {
421                (None, None)
422            }
423        } else {
424            return;
425        };
426
427        let Some(selected_id) = selected_id else {
428            return;
429        };
430
431        if let Some(name) = &node_name {
432            self.set_status_message(t!("explorer.refreshing", name = name).to_string());
433        }
434
435        let active_id = self.active_window;
436        if let (Some(runtime), Some(explorer)) = (
437            self.tokio_runtime.as_ref(),
438            self.windows
439                .get_mut(&active_id)
440                .and_then(|w| w.file_explorer.as_mut()),
441        ) {
442            let tree = explorer.tree_mut();
443            let result = runtime.block_on(tree.refresh_node(selected_id));
444            match result {
445                Ok(()) => {
446                    if let Some(name) = node_name {
447                        self.set_status_message(t!("explorer.refreshed", name = &name).to_string());
448                    } else {
449                        self.set_status_message(t!("explorer.refreshed_default").to_string());
450                    }
451                }
452                Err(e) => {
453                    self.set_status_message(
454                        t!("explorer.error_refreshing", error = e.to_string()).to_string(),
455                    );
456                }
457            }
458        }
459    }
460
461    pub fn file_explorer_new_file(&mut self) {
462        let active_id = self.active_window;
463        // Capture the active backend's filesystem before the mutable explorer
464        // borrow below (`self.authority()` reads the active window, so it
465        // can't be called while `self.windows` is borrowed mutably).
466        let fs = std::sync::Arc::clone(&self.authority().filesystem);
467        if let Some(explorer) = self
468            .windows
469            .get_mut(&active_id)
470            .and_then(|w| w.file_explorer.as_mut())
471        {
472            if let Some(selected_id) = explorer.get_selected() {
473                let node = explorer.tree().get_node(selected_id);
474                if let Some(node) = node {
475                    let parent_path = get_parent_dir_path(node);
476                    let filename = format!("untitled_{}.txt", timestamp_suffix());
477                    let file_path = parent_path.join(&filename);
478
479                    if let Some(runtime) = &self.tokio_runtime {
480                        let path_clone = file_path.clone();
481                        let result = fs.create_file(&path_clone).map(|_| ());
482
483                        match result {
484                            Ok(_) => {
485                                let parent_id =
486                                    get_parent_node_id(explorer.tree(), selected_id, node.is_dir());
487                                let tree = explorer.tree_mut();
488                                if let Err(e) =
489                                    runtime.block_on(tree.reload_expanded_node(parent_id))
490                                {
491                                    tracing::warn!("Failed to refresh file tree: {}", e);
492                                }
493                                if let Some(explorer) = self.file_explorer_mut().as_mut() {
494                                    explorer.navigate_to_path(&path_clone);
495                                }
496                                self.set_status_message(
497                                    t!("explorer.created_file", name = &filename).to_string(),
498                                );
499                                self.notify_file_explorer_change(&path_clone);
500
501                                // Open the file in the buffer
502                                if let Err(e) = self.open_file(&path_clone) {
503                                    tracing::warn!("Failed to open new file: {}", e);
504                                }
505
506                                let prompt = crate::view::prompt::Prompt::new(
507                                    t!("explorer.new_file_prompt").to_string(),
508                                    crate::view::prompt::PromptType::FileExplorerRename {
509                                        original_path: path_clone,
510                                        original_name: filename.clone(),
511                                        is_new_file: true,
512                                    },
513                                );
514                                self.active_window_mut().prompt = Some(prompt);
515                            }
516                            Err(e) => {
517                                self.set_status_message(
518                                    t!("explorer.error_creating_file", error = e.to_string())
519                                        .to_string(),
520                                );
521                            }
522                        }
523                    }
524                }
525            }
526        }
527    }
528
529    pub fn file_explorer_new_directory(&mut self) {
530        let active_id = self.active_window;
531        let fs = std::sync::Arc::clone(&self.authority().filesystem);
532        if let Some(explorer) = self
533            .windows
534            .get_mut(&active_id)
535            .and_then(|w| w.file_explorer.as_mut())
536        {
537            if let Some(selected_id) = explorer.get_selected() {
538                let node = explorer.tree().get_node(selected_id);
539                if let Some(node) = node {
540                    let parent_path = get_parent_dir_path(node);
541                    let dirname = format!("New Folder {}", timestamp_suffix());
542                    let dir_path = parent_path.join(&dirname);
543
544                    if let Some(runtime) = &self.tokio_runtime {
545                        let path_clone = dir_path.clone();
546                        let dirname_clone = dirname.clone();
547                        let result = fs.create_dir(&path_clone);
548
549                        match result {
550                            Ok(_) => {
551                                let parent_id =
552                                    get_parent_node_id(explorer.tree(), selected_id, node.is_dir());
553                                let tree = explorer.tree_mut();
554                                if let Err(e) =
555                                    runtime.block_on(tree.reload_expanded_node(parent_id))
556                                {
557                                    tracing::warn!("Failed to refresh file tree: {}", e);
558                                }
559                                if let Some(explorer) = self.file_explorer_mut().as_mut() {
560                                    explorer.navigate_to_path(&path_clone);
561                                }
562                                self.set_status_message(
563                                    t!("explorer.created_dir", name = &dirname_clone).to_string(),
564                                );
565                                self.notify_file_explorer_change(&path_clone);
566
567                                let prompt = crate::view::prompt::Prompt::with_initial_text(
568                                    t!("explorer.new_directory_prompt").to_string(),
569                                    crate::view::prompt::PromptType::FileExplorerRename {
570                                        original_path: path_clone,
571                                        original_name: dirname_clone,
572                                        is_new_file: true,
573                                    },
574                                    dirname,
575                                );
576                                self.active_window_mut().prompt = Some(prompt);
577                            }
578                            Err(e) => {
579                                self.set_status_message(
580                                    t!("explorer.error_creating_dir", error = e.to_string())
581                                        .to_string(),
582                                );
583                            }
584                        }
585                    }
586                }
587            }
588        }
589    }
590
591    pub fn file_explorer_delete(&mut self) {
592        let Some(explorer) = self.file_explorer() else {
593            return;
594        };
595        let root_id = explorer.tree().root_id();
596        let selected_ids = explorer.effective_selection();
597
598        let paths: Vec<(PathBuf, bool)> = selected_ids
599            .iter()
600            .filter(|&&id| id != root_id)
601            .filter_map(|&id| {
602                explorer
603                    .tree()
604                    .get_node(id)
605                    .map(|n| (n.entry.path.clone(), n.is_dir()))
606            })
607            .collect();
608
609        if paths.is_empty() {
610            self.set_status_message(t!("explorer.cannot_delete_root").to_string());
611            return;
612        }
613
614        if paths.len() == 1 {
615            let (path, is_dir) = paths.into_iter().next().unwrap();
616            let name = path
617                .file_name()
618                .unwrap_or_default()
619                .to_string_lossy()
620                .to_string();
621            let type_str = if is_dir { "directory" } else { "file" };
622            self.start_prompt(
623                t!("explorer.delete_confirm", "type" = type_str, name = &name).to_string(),
624                PromptType::ConfirmDeleteFile { path, is_dir },
625            );
626        } else {
627            let count = paths.len();
628            let all_paths: Vec<PathBuf> = paths.into_iter().map(|(p, _)| p).collect();
629            // Preview the first few names so the user can eyeball what's
630            // about to be deleted. Include '…' when there are more than
631            // fit in the minibuffer budget.
632            let names = format_path_preview_for_prompt(&all_paths, 3);
633            self.start_prompt(
634                t!(
635                    "explorer.delete_multi_confirm",
636                    count = count,
637                    names = &names
638                )
639                .to_string(),
640                PromptType::ConfirmMultiDelete { paths: all_paths },
641            );
642        }
643    }
644
645    /// Perform the actual file explorer delete operation (called after prompt confirmation)
646    /// For local files: moves to system trash/recycle bin
647    /// For remote files: moves to ~/.local/share/fresh/trash/ on remote
648    pub fn perform_file_explorer_delete(&mut self, path: std::path::PathBuf, _is_dir: bool) {
649        let name = path
650            .file_name()
651            .map(|n| n.to_string_lossy().to_string())
652            .unwrap_or_default();
653
654        // For remote files, move to remote trash directory
655        // For local files, use system trash
656        let delete_result = if self
657            .authority()
658            .filesystem
659            .remote_connection_info()
660            .is_some()
661        {
662            self.move_to_remote_trash(&path)
663        } else {
664            trash::delete(&path).map_err(std::io::Error::other)
665        };
666
667        match delete_result {
668            Ok(_) => {
669                // Close any open buffers backed by the deleted path (or
670                // any file that lived under it, for a directory delete).
671                // Without this, the tab keeps rendering with stale
672                // content and `Ctrl+S` would write the buffer right back
673                // to the trashed path, silently resurrecting the file
674                // the user just deleted. The user confirmed the trash
675                // action, which implies discarding unsaved edits to the
676                // doomed file too — `force_close_buffer` skips the
677                // modified-check so the buffer really goes away.
678                let to_close = self.buffer_ids_under_path(&path);
679                for id in to_close {
680                    if let Err(e) = self.force_close_buffer(id) {
681                        tracing::warn!(
682                            "Failed to close buffer {:?} after delete of {:?}: {}",
683                            id,
684                            path,
685                            e
686                        );
687                    }
688                }
689
690                // Refresh the parent directory in the file explorer
691                let active_id = self.active_window;
692                if let Some(explorer) = self
693                    .windows
694                    .get_mut(&active_id)
695                    .and_then(|w| w.file_explorer.as_mut())
696                {
697                    if let Some(runtime) = &self.tokio_runtime {
698                        // Find the node for the deleted path and get its parent
699                        if let Some(node) = explorer.tree().get_node_by_path(&path) {
700                            let node_id = node.id;
701                            let parent_id = get_parent_node_id(explorer.tree(), node_id, false);
702
703                            // Remember the index of the deleted node in the visible list
704                            let deleted_index = explorer.get_selected_index();
705
706                            if let Err(e) = runtime
707                                .block_on(explorer.tree_mut().reload_expanded_node(parent_id))
708                            {
709                                tracing::warn!("Failed to refresh file tree after delete: {}", e);
710                            }
711
712                            // The deleted node's NodeId (and any siblings
713                            // that went away with the parent refresh) can
714                            // still be in multi_selection. Drop the stale
715                            // entries so the next op targets the fresh cursor.
716                            explorer.clear_multi_selection();
717
718                            // After refresh, select the next best node:
719                            // Try to stay at the same index, or select the last visible item
720                            let count = explorer.visible_count();
721                            if count > 0 {
722                                let new_index = if let Some(idx) = deleted_index {
723                                    idx.min(count.saturating_sub(1))
724                                } else {
725                                    0
726                                };
727                                if let Some(node_id) = explorer.get_node_at_index(new_index) {
728                                    explorer.set_selected(Some(node_id));
729                                }
730                            } else {
731                                // No visible nodes, select parent
732                                explorer.set_selected(Some(parent_id));
733                            }
734                        }
735                    }
736                }
737                self.set_status_message(t!("explorer.moved_to_trash", name = &name).to_string());
738                self.notify_file_explorer_change(&path);
739
740                // Ensure focus remains on file explorer
741                self.active_window_mut().key_context = KeyContext::FileExplorer;
742            }
743            Err(e) => {
744                self.set_status_message(
745                    t!("explorer.error_trash", error = e.to_string()).to_string(),
746                );
747            }
748        }
749    }
750
751    /// Move a file/directory to the remote trash directory (~/.local/share/fresh/trash/)
752    fn move_to_remote_trash(&self, path: &std::path::Path) -> std::io::Result<()> {
753        // Get remote home directory
754        let home = self.authority().filesystem.home_dir()?;
755        let trash_dir = home.join(".local/share/fresh/trash");
756
757        // Create trash directory if it doesn't exist
758        if !self.authority().filesystem.exists(&trash_dir) {
759            self.authority().filesystem.create_dir_all(&trash_dir)?;
760        }
761
762        // Generate unique name with timestamp to avoid collisions
763        let file_name = path
764            .file_name()
765            .unwrap_or_else(|| std::ffi::OsStr::new("unnamed"));
766        let timestamp = std::time::SystemTime::now()
767            .duration_since(std::time::UNIX_EPOCH)
768            .map(|d| d.as_secs())
769            .unwrap_or(0);
770        let trash_name = format!("{}.{}", file_name.to_string_lossy(), timestamp);
771        let trash_path = trash_dir.join(trash_name);
772
773        // Move to trash
774        self.authority().filesystem.rename(path, &trash_path)
775    }
776
777    pub fn file_explorer_rename(&mut self) {
778        if let Some(explorer) = self.file_explorer() {
779            if let Some(selected_id) = explorer.get_selected() {
780                // Don't allow renaming the root directory
781                if selected_id == explorer.tree().root_id() {
782                    self.set_status_message(t!("explorer.cannot_rename_root").to_string());
783                    return;
784                }
785
786                let node = explorer.tree().get_node(selected_id);
787                if let Some(node) = node {
788                    let old_path = node.entry.path.clone();
789                    let old_name = node.entry.name.clone();
790
791                    // Create a prompt for the new name, pre-filled with the
792                    // old name and cursor at the end — the user typically
793                    // edits a suffix or extension rather than replacing the
794                    // whole name, so keep the prefill and let them type.
795                    let prompt = crate::view::prompt::Prompt::with_initial_text_for_edit(
796                        t!("explorer.rename_prompt").to_string(),
797                        crate::view::prompt::PromptType::FileExplorerRename {
798                            original_path: old_path,
799                            original_name: old_name.clone(),
800                            is_new_file: false,
801                        },
802                        old_name,
803                    );
804                    self.active_window_mut().prompt = Some(prompt);
805                }
806            }
807        }
808    }
809
810    /// Perform the actual file explorer rename operation (called after prompt confirmation)
811    pub fn perform_file_explorer_rename(
812        &mut self,
813        original_path: std::path::PathBuf,
814        original_name: String,
815        new_name: String,
816        is_new_file: bool,
817    ) {
818        if new_name.is_empty() || new_name == original_name {
819            self.set_status_message(t!("explorer.rename_cancelled").to_string());
820            return;
821        }
822
823        // Reject any platform path separator — `/` on all OSes plus `\` on
824        // Windows. `is_separator` is const-folded per platform so this keeps
825        // the same behavior on Linux (reject `/`) while also rejecting `\`
826        // when running on Windows.
827        if new_name.chars().any(std::path::is_separator) {
828            self.set_status_message(t!("explorer.rename_invalid_separator").to_string());
829            return;
830        }
831        if new_name == "." || new_name == ".." {
832            self.set_status_message(t!("explorer.rename_invalid_dot").to_string());
833            return;
834        }
835
836        let new_path = original_path
837            .parent()
838            .map(|p| p.join(&new_name))
839            .unwrap_or_else(|| original_path.clone());
840
841        if self.tokio_runtime.is_some() {
842            let result = self
843                .authority()
844                .filesystem
845                .rename(&original_path, &new_path);
846
847            match result {
848                Ok(_) => {
849                    // Refresh the parent directory and select the renamed item.
850                    // Direct `self.windows.get_mut(...)` keeps the explorer
851                    // borrow disjoint from `self.tokio_runtime`.
852                    let active_id = self.active_window;
853                    if let (Some(runtime), Some(explorer)) = (
854                        self.tokio_runtime.as_ref(),
855                        self.windows
856                            .get_mut(&active_id)
857                            .and_then(|w| w.file_explorer.as_mut()),
858                    ) {
859                        if let Some(selected_id) = explorer.get_selected() {
860                            let parent_id = get_parent_node_id(explorer.tree(), selected_id, false);
861                            let tree = explorer.tree_mut();
862                            if let Err(e) = runtime.block_on(tree.reload_expanded_node(parent_id)) {
863                                tracing::warn!("Failed to refresh file tree after rename: {}", e);
864                            }
865                        }
866                        // The renamed node has a new NodeId under the parent;
867                        // drop stale selections before navigating to the new
868                        // path so subsequent ops target the renamed item.
869                        explorer.clear_multi_selection();
870                        // Navigate to the renamed file to restore selection
871                        explorer.navigate_to_path(&new_path);
872                    }
873
874                    // Update every buffer whose path lives at or under the
875                    // renamed root — for a plain file this is the buffer for
876                    // that file itself; for a directory rename it's every
877                    // buffer backed by a file inside the renamed directory.
878                    // Without this, saving such a buffer would recreate the
879                    // old-name path, leaving behind a ghost alongside the
880                    // renamed file.
881                    let relocated = self.relocate_buffers_for_rename(&original_path, &new_path);
882
883                    // Only switch focus to the buffer if this is a new file
884                    // being created. For renames from the explorer, keep
885                    // focus in the explorer.
886                    if is_new_file && !relocated.is_empty() {
887                        self.active_window_mut().key_context = KeyContext::Normal;
888                    }
889
890                    self.set_status_message(
891                        t!("explorer.renamed", old = &original_name, new = &new_name).to_string(),
892                    );
893                    self.notify_file_explorer_change(&new_path);
894                }
895                Err(e) => {
896                    self.set_status_message(
897                        t!("explorer.error_renaming", error = e.to_string()).to_string(),
898                    );
899                }
900            }
901        }
902    }
903
904    pub fn file_explorer_toggle_hidden(&mut self) {
905        let show_hidden = if let Some(explorer) = self.file_explorer_mut() {
906            explorer.toggle_show_hidden();
907            explorer.ignore_patterns().show_hidden()
908        } else {
909            return;
910        };
911
912        let msg = if show_hidden {
913            t!("explorer.showing_hidden")
914        } else {
915            t!("explorer.hiding_hidden")
916        };
917        self.set_status_message(msg.to_string());
918
919        // Persist to config so the setting survives across sessions
920        self.config_mut().file_explorer.show_hidden = show_hidden;
921        self.persist_config_change(
922            "/file_explorer/show_hidden",
923            serde_json::Value::Bool(show_hidden),
924        );
925    }
926
927    pub fn file_explorer_toggle_gitignored(&mut self) {
928        let show_gitignored = if let Some(explorer) = self.file_explorer_mut() {
929            explorer.toggle_show_gitignored();
930            explorer.ignore_patterns().show_gitignored()
931        } else {
932            return;
933        };
934
935        let msg = if show_gitignored {
936            t!("explorer.showing_gitignored")
937        } else {
938            t!("explorer.hiding_gitignored")
939        };
940        self.set_status_message(msg.to_string());
941
942        // Persist to config so the setting survives across sessions
943        self.config_mut().file_explorer.show_gitignored = show_gitignored;
944        self.persist_config_change(
945            "/file_explorer/show_gitignored",
946            serde_json::Value::Bool(show_gitignored),
947        );
948    }
949
950    /// Clear the file explorer search (or multi-selection, pending cut, or transfer focus)
951    // `file_explorer_search_clear` lives on `impl Window` — call it via
952    // `self.active_window_mut().file_explorer_search_clear()`.
953
954    // `file_explorer_extend_selection_up/down`,
955    // `file_explorer_toggle_select`, `file_explorer_select_all`,
956    // `file_explorer_search_push_char`, `file_explorer_search_pop_char`
957    // moved to `impl Window`. Editor callers reach them via
958    // `self.active_window_mut().file_explorer_X(...)`.
959
960    // `handle_set_file_explorer_decorations`,
961    // `handle_clear_file_explorer_decorations`, and
962    // `handle_set_file_explorer_slots`,
963    // `handle_clear_file_explorer_slots`,
964    // `rebuild_file_explorer_decoration_cache`, and
965    // `rebuild_file_explorer_slot_override_cache` live on `impl Window` —
966    // call them via `self.active_window_mut()`.
967
968    // `file_explorer_clipboard`, `file_explorer_copy`, `file_explorer_cut`
969    // and the shared `set_explorer_clipboard` helper live on `impl Window`
970    // — call them via `self.active_window()` / `self.active_window_mut()`.
971
972    pub fn file_explorer_paste(&mut self) {
973        let clipboard = match self.active_window().file_explorer_clipboard.clone() {
974            Some(c) => c,
975            None => {
976                self.set_status_message(t!("explorer.paste_no_source").to_string());
977                return;
978            }
979        };
980
981        let dst_dir = if let Some(explorer) = self.file_explorer() {
982            if let Some(selected_id) = explorer.get_selected() {
983                if let Some(node) = explorer.tree().get_node(selected_id) {
984                    get_parent_dir_path(node)
985                } else {
986                    return;
987                }
988            } else {
989                return;
990            }
991        } else {
992            return;
993        };
994
995        let is_cut = clipboard.is_cut;
996
997        if clipboard.paths.len() == 1 {
998            let src = clipboard.paths[0].clone();
999            let file_name = match src.file_name() {
1000                Some(n) => n.to_os_string(),
1001                None => return,
1002            };
1003            let dst_path = dst_dir.join(&file_name);
1004
1005            if src.parent().map(|p| p == dst_dir).unwrap_or(false) {
1006                if is_cut {
1007                    // Same-dir paste of a cut is effectively "changed my
1008                    // mind": treat it as a cancel rather than surfacing a
1009                    // scary error. Must clear the clipboard, otherwise a
1010                    // later paste elsewhere would silently move the file.
1011                    self.active_window_mut().file_explorer_clipboard = None;
1012                    self.set_status_message(t!("explorer.cut_cancelled").to_string());
1013                    return;
1014                } else {
1015                    let unique = unique_paste_name(
1016                        &*self.authority().filesystem,
1017                        &dst_dir,
1018                        &file_name.to_string_lossy(),
1019                    );
1020                    self.perform_file_explorer_paste(src, unique, false);
1021                    return;
1022                }
1023            }
1024
1025            if self.authority().filesystem.exists(&dst_path) {
1026                let name = truncate_name_for_prompt(&file_name.to_string_lossy(), 40);
1027                self.start_prompt(
1028                    t!("explorer.paste_conflict", name = &name).to_string(),
1029                    crate::view::prompt::PromptType::ConfirmPasteConflict {
1030                        src,
1031                        dst: dst_path,
1032                        is_cut,
1033                    },
1034                );
1035            } else {
1036                self.perform_file_explorer_paste(src, dst_path, is_cut);
1037            }
1038        } else {
1039            // Multi-path: categorize into safe and conflicting destinations
1040            let mut safe: Vec<(PathBuf, PathBuf)> = Vec::new();
1041            let mut conflicts: Vec<(PathBuf, PathBuf)> = Vec::new();
1042
1043            for src in &clipboard.paths {
1044                let file_name = match src.file_name() {
1045                    Some(n) => n.to_os_string(),
1046                    None => continue,
1047                };
1048                let dst_path = dst_dir.join(&file_name);
1049                let is_same_location = src.parent().map(|p| p == dst_dir).unwrap_or(false);
1050
1051                if is_same_location {
1052                    if !is_cut {
1053                        // Copy to same dir: auto-rename so it lands in safe
1054                        let unique = unique_paste_name(
1055                            &*self.authority().filesystem,
1056                            &dst_dir,
1057                            &file_name.to_string_lossy(),
1058                        );
1059                        safe.push((src.clone(), unique));
1060                    }
1061                    // Cut to same dir: skip — nothing to do
1062                } else if self.authority().filesystem.exists(&dst_path) {
1063                    conflicts.push((src.clone(), dst_path));
1064                } else {
1065                    safe.push((src.clone(), dst_path));
1066                }
1067            }
1068
1069            if safe.is_empty() && conflicts.is_empty() {
1070                // For cut, an all-same-dir paste is a cancel (see the
1071                // single-path branch above). Clear the clipboard so a
1072                // later paste can't silently move the files after all.
1073                if is_cut {
1074                    self.active_window_mut().file_explorer_clipboard = None;
1075                    self.set_status_message(t!("explorer.cut_cancelled").to_string());
1076                } else {
1077                    self.set_status_message(t!("explorer.paste_same_location").to_string());
1078                }
1079                return;
1080            }
1081
1082            if conflicts.is_empty() {
1083                self.execute_resolved_multi_paste(safe, vec![], is_cut);
1084            } else {
1085                let name = truncate_name_for_prompt(
1086                    &conflicts[0]
1087                        .1
1088                        .file_name()
1089                        .unwrap_or_default()
1090                        .to_string_lossy(),
1091                    40,
1092                );
1093                self.start_prompt(
1094                    t!("explorer.paste_conflict_multi", name = &name).to_string(),
1095                    crate::view::prompt::PromptType::ConfirmMultiPasteConflict {
1096                        safe,
1097                        confirmed: Vec::new(),
1098                        pending: conflicts,
1099                        is_cut,
1100                    },
1101                );
1102            }
1103        }
1104    }
1105
1106    /// Paste all resolved items (safe + confirmed-overwrite) from a multi-conflict flow.
1107    ///
1108    /// Runs every filesystem op first, then does a single tree refresh and
1109    /// a single navigate to the first successfully pasted item. Each paste
1110    /// inside `perform_file_explorer_paste` would otherwise re-reload the
1111    /// same parent directories N times and flash N different status
1112    /// messages, with only the last one ever being visible.
1113    pub(super) fn execute_resolved_multi_paste(
1114        &mut self,
1115        safe: Vec<(PathBuf, PathBuf)>,
1116        to_overwrite: Vec<(PathBuf, PathBuf)>,
1117        is_cut: bool,
1118    ) {
1119        let total = safe.len() + to_overwrite.len();
1120        if total == 0 {
1121            return;
1122        }
1123
1124        let mut succeeded: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(total);
1125        // Clean moves are those that actually relocated the file off of
1126        // `src`. Partial moves (copy landed, source delete failed)
1127        // appear in `succeeded` so the tree refresh picks up the new
1128        // dst, but are intentionally NOT in `clean_moves`: their
1129        // sources still exist, so open buffers for them should keep
1130        // pointing at `src`, not follow the copy.
1131        let mut clean_moves: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(total);
1132        let mut first_error: Option<std::io::Error> = None;
1133        let mut partial_moves: Vec<(PathBuf, std::io::Error)> = Vec::new();
1134        for (src, dst) in safe.into_iter().chain(to_overwrite) {
1135            match self.paste_one_fs_op(&src, &dst, is_cut) {
1136                PasteOpOutcome::Ok => {
1137                    clean_moves.push((src.clone(), dst.clone()));
1138                    succeeded.push((src, dst));
1139                }
1140                PasteOpOutcome::SourceRemovalFailed {
1141                    dst: landed_dst,
1142                    err,
1143                } => {
1144                    // Copy landed; count the dst as visible in the tree
1145                    // (so the refresh below picks it up), but track the
1146                    // partial state so the status message calls it out.
1147                    succeeded.push((src, landed_dst.clone()));
1148                    partial_moves.push((landed_dst, err));
1149                }
1150                PasteOpOutcome::Failed(e) => {
1151                    if first_error.is_none() {
1152                        first_error = Some(e);
1153                    }
1154                }
1155            }
1156        }
1157
1158        // For cut (move), re-point any open buffer whose file was
1159        // among the clean moves to its new on-disk home. Without this,
1160        // saving such a buffer would recreate the file at its old
1161        // source path. Copies don't need this — they create a new
1162        // file at dst without disturbing the source buffer.
1163        if is_cut {
1164            for (src, dst) in &clean_moves {
1165                self.relocate_buffers_for_rename(src, dst);
1166            }
1167        }
1168
1169        if !succeeded.is_empty() {
1170            let first_dst = succeeded[0].1.clone();
1171            let any_src = succeeded[0].0.clone();
1172            self.refresh_tree_after_paste(&any_src, &first_dst, is_cut);
1173        }
1174
1175        if !partial_moves.is_empty() {
1176            // Partial-move always wins the status line: the user needs to
1177            // know some sources are still present.
1178            let (first_dst, first_err) = &partial_moves[0];
1179            let name = first_dst
1180                .file_name()
1181                .map(|n| n.to_string_lossy().to_string())
1182                .unwrap_or_default();
1183            let msg = if partial_moves.len() == 1 {
1184                t!(
1185                    "explorer.move_source_removal_failed",
1186                    name = &name,
1187                    error = first_err.to_string()
1188                )
1189                .to_string()
1190            } else {
1191                t!(
1192                    "explorer.move_source_removal_failed_n",
1193                    count = partial_moves.len()
1194                )
1195                .to_string()
1196            };
1197            self.set_status_message(msg);
1198        } else if let Some(e) = &first_error {
1199            let msg = if is_cut {
1200                t!("explorer.error_moving", error = e.to_string()).to_string()
1201            } else {
1202                t!("explorer.error_copying", error = e.to_string()).to_string()
1203            };
1204            self.set_status_message(msg);
1205        } else if total > 1 {
1206            let msg = if is_cut {
1207                t!("explorer.pasted_moved_n", count = total).to_string()
1208            } else {
1209                t!("explorer.pasted_n", count = total).to_string()
1210            };
1211            self.set_status_message(msg);
1212        } else if let Some((_, dst)) = succeeded.first() {
1213            let name = dst
1214                .file_name()
1215                .map(|n| n.to_string_lossy().to_string())
1216                .unwrap_or_default();
1217            let msg = if is_cut {
1218                t!("explorer.pasted_moved", name = &name).to_string()
1219            } else {
1220                t!("explorer.pasted", name = &name).to_string()
1221            };
1222            self.set_status_message(msg);
1223        }
1224
1225        // Clear the clipboard only when the move was fully clean — if a
1226        // source is still sitting at its original location the user may
1227        // want to retry, and the clipboard still contains the right path.
1228        if is_cut && first_error.is_none() && partial_moves.is_empty() {
1229            self.active_window_mut().file_explorer_clipboard = None;
1230        }
1231        self.active_window_mut().key_context = KeyContext::FileExplorer;
1232    }
1233
1234    /// Move or copy a single item at the filesystem level. No tree or UI
1235    /// state is touched — callers are responsible for refreshing the
1236    /// explorer afterwards.
1237    fn paste_one_fs_op(&self, src: &Path, dst: &Path, is_cut: bool) -> PasteOpOutcome {
1238        let src_is_dir = self.authority().filesystem.is_dir(src).unwrap_or(false);
1239
1240        // Guard against pasting a directory into itself or into one of its
1241        // own descendants. Without this, `copy_dir_all(/d, /d/d)` would
1242        // create `/d/d`, then iterate `/d` — which now contains the
1243        // just-created `/d/d` — and recurse forever until stack overflow
1244        // or disk-full. The check applies only when the source is a
1245        // directory; file-into-itself is already handled by the
1246        // same-location check in `file_explorer_paste`.
1247        if src_is_dir && dst.starts_with(src) {
1248            return PasteOpOutcome::Failed(std::io::Error::new(
1249                std::io::ErrorKind::InvalidInput,
1250                "Cannot paste a directory into itself",
1251            ));
1252        }
1253
1254        if is_cut {
1255            // Try rename first (works if same filesystem). Only fall back to
1256            // copy+delete for cross-device errors — any other rename failure
1257            // (permission denied, etc.) must surface as-is so we don't
1258            // silently succeed via a different codepath.
1259            match self.authority().filesystem.rename(src, dst) {
1260                Ok(()) => PasteOpOutcome::Ok,
1261                Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
1262                    let copy_result = if src_is_dir {
1263                        self.authority().filesystem.copy_dir_all(src, dst)
1264                    } else {
1265                        self.authority().filesystem.copy(src, dst).map(|_| ())
1266                    };
1267                    match copy_result {
1268                        Ok(()) => {
1269                            // Copy landed. Now remove the source to complete
1270                            // the move. If that fails, surface it as a
1271                            // distinct outcome — the user needs to know the
1272                            // copy is at `dst` AND the original is still at
1273                            // `src`, so they can decide what to do.
1274                            let remove_result = if src_is_dir {
1275                                self.authority().filesystem.remove_dir_all(src)
1276                            } else {
1277                                self.authority().filesystem.remove_file(src)
1278                            };
1279                            match remove_result {
1280                                Ok(()) => PasteOpOutcome::Ok,
1281                                Err(remove_err) => PasteOpOutcome::SourceRemovalFailed {
1282                                    dst: dst.to_path_buf(),
1283                                    err: remove_err,
1284                                },
1285                            }
1286                        }
1287                        Err(copy_err) => {
1288                            // Roll back the half-written destination so the
1289                            // user isn't left with a partial copy alongside
1290                            // the intact source. Cleanup errors are
1291                            // swallowed — the copy error is the interesting
1292                            // one to surface — but logged.
1293                            let cleanup = if src_is_dir {
1294                                self.authority().filesystem.remove_dir_all(dst)
1295                            } else {
1296                                self.authority().filesystem.remove_file(dst)
1297                            };
1298                            if let Err(cleanup_err) = cleanup {
1299                                tracing::warn!(
1300                                    "Failed to roll back partial destination {:?} after copy \
1301                                     fallback failed: {}",
1302                                    dst,
1303                                    cleanup_err
1304                                );
1305                            }
1306                            PasteOpOutcome::Failed(copy_err)
1307                        }
1308                    }
1309                }
1310                Err(e) => PasteOpOutcome::Failed(e),
1311            }
1312        } else if src_is_dir {
1313            match self.authority().filesystem.copy_dir_all(src, dst) {
1314                Ok(()) => PasteOpOutcome::Ok,
1315                Err(e) => PasteOpOutcome::Failed(e),
1316            }
1317        } else {
1318            match self.authority().filesystem.copy(src, dst) {
1319                Ok(_) => PasteOpOutcome::Ok,
1320                Err(e) => PasteOpOutcome::Failed(e),
1321            }
1322        }
1323    }
1324
1325    /// Refresh the destination (and source parent, if this was a cut) in
1326    /// the explorer tree after paste operations land on disk, then navigate
1327    /// the cursor to `dst`. Factored out so multi-paste can invoke it
1328    /// exactly once for a whole batch rather than N times.
1329    fn refresh_tree_after_paste(&mut self, src: &Path, dst: &Path, is_cut: bool) {
1330        let active_id = self.active_window;
1331        // Disjoint borrow on `self.windows` so the body can also read
1332        // `self.tokio_runtime`.
1333        let Some(explorer) = self
1334            .windows
1335            .get_mut(&active_id)
1336            .and_then(|w| w.file_explorer.as_mut())
1337        else {
1338            return;
1339        };
1340        if let Some(runtime) = &self.tokio_runtime {
1341            // Refresh destination parent in-place to avoid collapsing it
1342            if let Some(dst_parent) = dst.parent() {
1343                if let Some(dst_parent_node) = explorer.tree().get_node_by_path(dst_parent) {
1344                    let pid = dst_parent_node.id;
1345                    if let Err(e) = runtime.block_on(explorer.tree_mut().reload_expanded_node(pid))
1346                    {
1347                        tracing::warn!("Failed to reload destination directory after paste: {}", e);
1348                    }
1349                }
1350            }
1351            // Refresh source parent too (if cut). Using `reload_expanded_node`
1352            // here rather than `refresh_node` is important: refresh_node
1353            // collapses and re-expands the source parent, which wipes out
1354            // every descendant NodeId — including the destination directory
1355            // that was just expanded above. That in turn invalidates the
1356            // cursor (`selected_node`) and any NodeIds held elsewhere
1357            // (e.g. hover, decorations). The in-place reload keeps
1358            // unchanged siblings intact and only drops the nodes that
1359            // really went away.
1360            if is_cut {
1361                if let Some(src_parent) = src.parent() {
1362                    if let Some(src_parent_node) = explorer.tree().get_node_by_path(src_parent) {
1363                        let pid = src_parent_node.id;
1364                        if let Err(e) =
1365                            runtime.block_on(explorer.tree_mut().reload_expanded_node(pid))
1366                        {
1367                            tracing::warn!("Failed to refresh source directory after move: {}", e);
1368                        }
1369                    }
1370                }
1371            }
1372        }
1373        // Any source NodeIds that were in the multi-selection are now stale
1374        // (the tree was reloaded / source parent refreshed). Drop the
1375        // selection so subsequent actions act on the fresh cursor, not
1376        // ghost IDs.
1377        explorer.clear_multi_selection();
1378        explorer.navigate_to_path(dst);
1379
1380        self.notify_file_explorer_change(dst);
1381    }
1382
1383    /// Fire the `after_file_explorer_change` plugin hook for an
1384    /// explorer-driven on-disk mutation (create / rename / delete /
1385    /// paste / duplicate / ...). Plugins that surface filesystem-derived
1386    /// state — git status badges, etc. — subscribe to this in addition
1387    /// to `after_file_save`, since explorer-driven changes never fire
1388    /// the buffer-save hooks.
1389    ///
1390    /// `path` is one of the affected paths (destination for move/copy,
1391    /// the deleted path for delete, the new path for create/rename).
1392    /// Multi-target operations call this once per refresh, not once per
1393    /// file.
1394    pub(super) fn notify_file_explorer_change(&mut self, path: &Path) {
1395        self.plugin_manager.read().unwrap().run_hook(
1396            "after_file_explorer_change",
1397            crate::services::plugins::hooks::HookArgs::AfterFileExplorerChange {
1398                path: path.to_path_buf(),
1399            },
1400        );
1401    }
1402
1403    pub fn perform_file_explorer_paste(&mut self, src: PathBuf, dst: PathBuf, is_cut: bool) {
1404        let name = dst
1405            .file_name()
1406            .map(|n| n.to_string_lossy().to_string())
1407            .unwrap_or_default();
1408
1409        match self.paste_one_fs_op(&src, &dst, is_cut) {
1410            PasteOpOutcome::Ok => {
1411                // For cut (move), re-point any open buffer at src to
1412                // its new home at dst — before the tree refresh, since
1413                // the refresh re-resolves the cursor by path and we
1414                // want the buffer state consistent with the tree at
1415                // all observation points. A pure copy doesn't disturb
1416                // source buffers.
1417                if is_cut {
1418                    self.relocate_buffers_for_rename(&src, &dst);
1419                }
1420                self.refresh_tree_after_paste(&src, &dst, is_cut);
1421                if is_cut {
1422                    self.active_window_mut().file_explorer_clipboard = None;
1423                    self.set_status_message(t!("explorer.pasted_moved", name = &name).to_string());
1424                } else {
1425                    self.set_status_message(t!("explorer.pasted", name = &name).to_string());
1426                }
1427                self.active_window_mut().key_context = KeyContext::FileExplorer;
1428            }
1429            PasteOpOutcome::SourceRemovalFailed {
1430                dst: landed_dst,
1431                err,
1432            } => {
1433                // The copy is at landed_dst; the source is still at src.
1434                // Refresh the tree so both are visible, keep the clipboard
1435                // populated so the user can retry, and spell out both
1436                // sides of the partial state in the status line.
1437                self.refresh_tree_after_paste(&src, &landed_dst, is_cut);
1438                self.set_status_message(
1439                    t!(
1440                        "explorer.move_source_removal_failed",
1441                        name = &name,
1442                        error = err.to_string()
1443                    )
1444                    .to_string(),
1445                );
1446                // NB: don't clear the clipboard — source is still at its
1447                // original location and the user may want to retry.
1448                self.active_window_mut().key_context = KeyContext::FileExplorer;
1449            }
1450            PasteOpOutcome::Failed(e) => {
1451                let msg = if is_cut {
1452                    t!("explorer.error_moving", error = e.to_string()).to_string()
1453                } else {
1454                    t!("explorer.error_copying", error = e.to_string()).to_string()
1455                };
1456                self.set_status_message(msg);
1457            }
1458        }
1459    }
1460
1461    /// Duplicate the selected file/directory in-place, naming the new copy
1462    /// using the same `name copy[.ext]` convention as Paste's auto-rename.
1463    ///
1464    /// Multi-selection duplicates each item independently; the project
1465    /// root is skipped (you can't duplicate the project root itself).
1466    pub fn file_explorer_duplicate(&mut self) {
1467        let Some(explorer) = self.file_explorer() else {
1468            return;
1469        };
1470        let root_id = explorer.tree().root_id();
1471        let selected_ids = explorer.effective_selection();
1472        let sources: Vec<PathBuf> = selected_ids
1473            .iter()
1474            .filter(|&&id| id != root_id)
1475            .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1476            .collect();
1477
1478        if sources.is_empty() {
1479            self.set_status_message(t!("explorer.cannot_duplicate_root").to_string());
1480            return;
1481        }
1482
1483        // Resolve destination paths up front so we don't observe an
1484        // intermediate filesystem state for siblings duplicated in the
1485        // same parent directory.
1486        let mut ops: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(sources.len());
1487        for src in &sources {
1488            let Some(parent) = src.parent() else {
1489                continue;
1490            };
1491            let Some(file_name) = src.file_name() else {
1492                continue;
1493            };
1494            let dst = unique_paste_name(
1495                &*self.authority().filesystem,
1496                parent,
1497                &file_name.to_string_lossy(),
1498            );
1499            ops.push((src.clone(), dst));
1500        }
1501
1502        if ops.is_empty() {
1503            return;
1504        }
1505
1506        let mut succeeded: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(ops.len());
1507        let mut first_error: Option<std::io::Error> = None;
1508        for (src, dst) in ops {
1509            match self.paste_one_fs_op(&src, &dst, false) {
1510                PasteOpOutcome::Ok => succeeded.push((src, dst)),
1511                PasteOpOutcome::SourceRemovalFailed { .. } => {
1512                    // is_cut=false above; this variant is unreachable for copies.
1513                    unreachable!("paste_one_fs_op returned SourceRemovalFailed for a non-cut op");
1514                }
1515                PasteOpOutcome::Failed(e) => {
1516                    if first_error.is_none() {
1517                        first_error = Some(e);
1518                    }
1519                }
1520            }
1521        }
1522
1523        if !succeeded.is_empty() {
1524            let (first_src, first_dst) = succeeded[0].clone();
1525            self.refresh_tree_after_paste(&first_src, &first_dst, false);
1526        }
1527
1528        let msg = if let Some(e) = &first_error {
1529            t!("explorer.error_copying", error = e.to_string()).to_string()
1530        } else if succeeded.len() == 1 {
1531            let name = succeeded[0]
1532                .1
1533                .file_name()
1534                .map(|n| n.to_string_lossy().to_string())
1535                .unwrap_or_default();
1536            t!("explorer.duplicated", name = &name).to_string()
1537        } else {
1538            t!("explorer.duplicated_n", count = succeeded.len()).to_string()
1539        };
1540        self.set_status_message(msg);
1541        self.active_window_mut().key_context = KeyContext::FileExplorer;
1542    }
1543
1544    /// Copy the selected node's path(s) to the clipboard.
1545    ///
1546    /// `relative=true` strips `working_dir` from each path when it is a
1547    /// prefix; otherwise the absolute path is used. Multiple selections
1548    /// are joined by newlines, in the same visible order shown by the
1549    /// tree, so the result is friendly for pasting into a shell or list.
1550    pub fn file_explorer_copy_path(&mut self, relative: bool) {
1551        let Some(explorer) = self.file_explorer() else {
1552            return;
1553        };
1554        let selected_ids = explorer.effective_selection();
1555        let paths: Vec<PathBuf> = selected_ids
1556            .iter()
1557            .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1558            .collect();
1559
1560        if paths.is_empty() {
1561            self.set_status_message(t!("clipboard.no_file_path").to_string());
1562            return;
1563        }
1564
1565        let working_dir = self.working_dir().to_path_buf();
1566        let rendered: Vec<String> = paths
1567            .iter()
1568            .map(|p| {
1569                if relative {
1570                    p.strip_prefix(&working_dir)
1571                        .unwrap_or(p)
1572                        .to_string_lossy()
1573                        .into_owned()
1574                } else {
1575                    p.to_string_lossy().into_owned()
1576                }
1577            })
1578            .collect();
1579
1580        let joined = rendered.join("\n");
1581        self.clipboard.copy(joined.clone());
1582
1583        let msg = if rendered.len() == 1 {
1584            t!("clipboard.copied_path", path = &rendered[0]).to_string()
1585        } else {
1586            t!("clipboard.copied_paths_n", count = rendered.len()).to_string()
1587        };
1588        self.set_status_message(msg);
1589    }
1590}
1591
1592impl crate::app::window::Window {
1593    /// Build this window's file explorer, rooted at the window's own
1594    /// `root`. A `Window` has no access to any other project's path, so
1595    /// the explorer is correct-by-construction (issue #2056 defect #3):
1596    /// it spawns the tree build on the window's own runtime/bridge using
1597    /// `self.resources`. For remote mode, fall back to the remote home
1598    /// dir only when `root` doesn't exist on the remote filesystem.
1599    pub(crate) fn init_file_explorer(&mut self) {
1600        let is_remote = self
1601            .authority()
1602            .filesystem
1603            .remote_connection_info()
1604            .is_some();
1605        let root_exists = self
1606            .authority()
1607            .filesystem
1608            .is_dir(&self.root)
1609            .unwrap_or(false);
1610        let root_path = if is_remote && !root_exists {
1611            match self.authority().filesystem.home_dir() {
1612                Ok(home) => home,
1613                Err(e) => {
1614                    tracing::error!("Failed to get remote home directory: {}", e);
1615                    self.set_status_message(format!("Failed to get remote home: {}", e));
1616                    return;
1617                }
1618            }
1619        } else {
1620            self.root.clone()
1621        };
1622
1623        let Some(runtime) = self.resources.tokio_runtime.clone() else {
1624            return;
1625        };
1626        let fs_manager = Arc::clone(&self.resources.fs_manager);
1627        let sender = self.bridge.sender();
1628        // Tag the result with *this* window so it lands here even if another
1629        // window is active by the time the async build finishes.
1630        let window_id = self.id;
1631        runtime.spawn(async move {
1632            match FileTree::new(root_path, fs_manager).await {
1633                Ok(mut tree) => {
1634                    let root_id = tree.root_id();
1635                    if let Err(e) = tree.expand_node(root_id).await {
1636                        tracing::warn!("Failed to expand root directory: {}", e);
1637                    }
1638                    let view = FileTreeView::new(tree);
1639                    // Receiver may have been dropped during shutdown.
1640                    #[allow(clippy::let_underscore_must_use)]
1641                    let _ = sender.send(AsyncMessage::FileExplorerInitialized {
1642                        window: window_id,
1643                        view,
1644                    });
1645                }
1646                Err(e) => {
1647                    tracing::error!("Failed to initialize file explorer: {}", e);
1648                }
1649            }
1650        });
1651        self.set_status_message(t!("explorer.initializing").to_string());
1652    }
1653
1654    /// Install a freshly-built file tree (from `init_file_explorer`) onto
1655    /// *this* window. The editor's async dispatch routes the result back to the
1656    /// requesting window and calls this — so a tree built for a backgrounded
1657    /// (e.g. previewed) window can never land on whatever window happens to be
1658    /// active, which previously clobbered the active window's explorer.
1659    pub(crate) fn install_initialized_file_explorer(
1660        &mut self,
1661        mut view: FileTreeView,
1662        defaults: FileExplorerViewDefaults,
1663    ) {
1664        let root_id = view.tree().root_id();
1665        if let Some(root_path) = view.tree().get_node(root_id).map(|n| n.entry.path.clone()) {
1666            crate::app::file_operations::load_gitignore_via_fs(
1667                self.authority().filesystem.as_ref(),
1668                &mut view,
1669                &root_path,
1670            );
1671        }
1672        // Pending session-restore values win; otherwise fall back to config so
1673        // the setting survives across sessions (fixes #569).
1674        let show_hidden = self
1675            .pending_file_explorer_show_hidden
1676            .take()
1677            .unwrap_or(defaults.show_hidden);
1678        view.ignore_patterns_mut().set_show_hidden(show_hidden);
1679        let show_gitignored = self
1680            .pending_file_explorer_show_gitignored
1681            .take()
1682            .unwrap_or(defaults.show_gitignored);
1683        view.ignore_patterns_mut()
1684            .set_show_gitignored(show_gitignored);
1685        view.set_compact_directories(defaults.compact_directories);
1686        self.file_explorer = Some(view);
1687        // Auto-expand to reveal the active file on first open (issue #1569),
1688        // but only when this window is actually showing the explorer.
1689        if self.file_explorer_visible {
1690            self.sync_file_explorer_to_active_file();
1691        }
1692    }
1693
1694    /// Install an async expand-to-path result onto *this* window (routed
1695    /// per-window for the same reason as `install_initialized_file_explorer`).
1696    pub(crate) fn install_expanded_file_explorer(&mut self, mut view: FileTreeView) {
1697        view.update_scroll_for_selection();
1698        self.file_explorer = Some(view);
1699        self.file_explorer_sync_in_progress = false;
1700    }
1701
1702    /// Shift focus back to the editor pane (away from the file explorer)
1703    /// and post a per-window "Editor focused" status message.
1704    pub fn focus_editor(&mut self) {
1705        self.key_context = KeyContext::Normal;
1706        self.set_status_message(t!("editor.focused").to_string());
1707    }
1708
1709    /// Clear file-explorer state in priority order:
1710    ///   1. If a pending cut sits in the clipboard, just cancel it (so a
1711    ///      forgotten cut can't silently move a file on the next paste).
1712    ///   2. If the explorer has a multi-selection, clear it.
1713    ///   3. If the explorer's search input is active, clear the query.
1714    ///   4. Otherwise, transfer focus back to the editor.
1715    pub fn file_explorer_search_clear(&mut self) {
1716        if matches!(
1717            self.file_explorer_clipboard,
1718            Some(FileExplorerClipboard { is_cut: true, .. })
1719        ) {
1720            self.file_explorer_clipboard = None;
1721            self.set_status_message(t!("explorer.cut_cancelled").to_string());
1722            return;
1723        }
1724        let action = self.file_explorer.as_mut().map(|explorer| {
1725            if explorer.has_multi_selection() {
1726                explorer.clear_multi_selection();
1727                None
1728            } else if explorer.is_search_active() {
1729                explorer.search_clear();
1730                None
1731            } else {
1732                Some(())
1733            }
1734        });
1735        if let Some(Some(())) = action {
1736            self.focus_editor();
1737        }
1738    }
1739
1740    /// Install (or replace) a namespace of plugin-supplied file-explorer
1741    /// decorations for this window. Paths outside the window root are
1742    /// dropped silently. Triggers a rebuild of the per-path decoration
1743    /// cache the renderer reads.
1744    pub fn handle_set_file_explorer_decorations(
1745        &mut self,
1746        namespace: String,
1747        decorations: Vec<crate::view::file_tree::FileExplorerDecoration>,
1748    ) {
1749        let root = self.root.clone();
1750        let normalized: Vec<crate::view::file_tree::FileExplorerDecoration> = decorations
1751            .into_iter()
1752            .filter_map(|mut decoration| {
1753                let path = if decoration.path.is_absolute() {
1754                    decoration.path
1755                } else {
1756                    root.join(&decoration.path)
1757                };
1758                let path = crate::app::normalize_path(&path);
1759                if crate::app::explorer_path_under_root(&path, &root) {
1760                    decoration.path = crate::app::normalize_explorer_plugin_path(&path, &root);
1761                    Some(decoration)
1762                } else {
1763                    None
1764                }
1765            })
1766            .collect();
1767
1768        self.file_explorer_decorations.insert(namespace, normalized);
1769        self.rebuild_file_explorer_decoration_cache();
1770    }
1771
1772    /// Drop a namespace of plugin-supplied decorations and rebuild the
1773    /// per-path cache without it.
1774    pub fn handle_clear_file_explorer_decorations(&mut self, namespace: &str) {
1775        self.file_explorer_decorations.remove(namespace);
1776        self.rebuild_file_explorer_decoration_cache();
1777    }
1778
1779    /// Install (or replace) a namespace of plugin-supplied file-explorer slot
1780    /// overrides for this window. Any omitted slot fields continue to fall back
1781    /// to the editor's default compatibility providers.
1782    pub fn handle_set_file_explorer_slots(
1783        &mut self,
1784        namespace: String,
1785        slots: Vec<fresh_core::file_explorer::FileExplorerSlotEntry>,
1786    ) {
1787        let root = self.root.clone();
1788        let normalized: Vec<fresh_core::file_explorer::FileExplorerSlotEntry> = slots
1789            .into_iter()
1790            .filter_map(|mut slot| {
1791                let path = if slot.path.is_absolute() {
1792                    slot.path
1793                } else {
1794                    root.join(&slot.path)
1795                };
1796                let path = crate::app::normalize_path(&path);
1797                if crate::app::explorer_path_under_root(&path, &root) {
1798                    slot.path = crate::app::normalize_explorer_plugin_path(&path, &root);
1799                    Some(slot)
1800                } else {
1801                    None
1802                }
1803            })
1804            .collect();
1805
1806        self.file_explorer_slot_overrides
1807            .insert(namespace, normalized);
1808        self.rebuild_file_explorer_slot_override_cache();
1809    }
1810
1811    /// Drop a namespace of plugin-supplied file-explorer slot overrides and
1812    /// rebuild the per-path cache without it.
1813    pub fn handle_clear_file_explorer_slots(&mut self, namespace: &str) {
1814        self.file_explorer_slot_overrides.remove(namespace);
1815        self.rebuild_file_explorer_slot_override_cache();
1816    }
1817
1818    /// Recompute the `file_explorer_decoration_cache` from the current
1819    /// per-namespace decoration entries + the explorer's symlink
1820    /// mappings. Called after any decoration-mutating operation.
1821    pub fn rebuild_file_explorer_decoration_cache(&mut self) {
1822        let mut namespaces: Vec<_> = self.file_explorer_decorations.keys().cloned().collect();
1823        namespaces.sort();
1824        let decorations: Vec<_> = namespaces
1825            .into_iter()
1826            .flat_map(|namespace| {
1827                self.file_explorer_decorations
1828                    .get(&namespace)
1829                    .into_iter()
1830                    .flat_map(|entries| entries.iter().cloned())
1831            })
1832            .collect();
1833
1834        let symlink_mappings = self
1835            .file_explorer
1836            .as_ref()
1837            .map(|fe| fe.collect_symlink_mappings())
1838            .unwrap_or_default();
1839
1840        self.file_explorer_decoration_cache =
1841            crate::view::file_tree::FileExplorerDecorationCache::rebuild(
1842                decorations.into_iter(),
1843                &self.root,
1844                &symlink_mappings,
1845            );
1846    }
1847
1848    /// Recompute the `file_explorer_slot_override_cache` from the current
1849    /// per-namespace slot overrides + the explorer's symlink mappings.
1850    pub fn rebuild_file_explorer_slot_override_cache(&mut self) {
1851        let mut namespaces: Vec<_> = self.file_explorer_slot_overrides.keys().cloned().collect();
1852        namespaces.sort();
1853        let slots: Vec<_> = namespaces
1854            .into_iter()
1855            .flat_map(|namespace| {
1856                self.file_explorer_slot_overrides
1857                    .get(&namespace)
1858                    .into_iter()
1859                    .flat_map(|entries| entries.iter().cloned())
1860            })
1861            .collect();
1862
1863        let symlink_mappings = self
1864            .file_explorer
1865            .as_ref()
1866            .map(|fe| fe.collect_symlink_mappings())
1867            .unwrap_or_default();
1868
1869        self.file_explorer_slot_override_cache =
1870            crate::view::file_tree::FileExplorerSlotOverrideCache::rebuild(
1871                slots.into_iter(),
1872                &self.root,
1873                &symlink_mappings,
1874            );
1875    }
1876
1877    /// Read-only access to this window's file-explorer cut/copy clipboard.
1878    pub fn file_explorer_clipboard(&self) -> Option<&FileExplorerClipboard> {
1879        self.file_explorer_clipboard.as_ref()
1880    }
1881
1882    /// Copy the file-explorer selection to this window's clipboard.
1883    pub fn file_explorer_copy(&mut self) {
1884        self.set_explorer_clipboard(false);
1885    }
1886
1887    /// Cut the file-explorer selection to this window's clipboard.
1888    pub fn file_explorer_cut(&mut self) {
1889        self.set_explorer_clipboard(true);
1890    }
1891
1892    /// Shared body of `file_explorer_copy` and `file_explorer_cut`: read
1893    /// the explorer's selection, derive the path list, and prime the
1894    /// window's clipboard slot with a `FileExplorerClipboard`. Posts a
1895    /// status message summarising what got stashed.
1896    fn set_explorer_clipboard(&mut self, is_cut: bool) {
1897        let Some(explorer) = self.file_explorer.as_ref() else {
1898            return;
1899        };
1900        let root_id = explorer.tree().root_id();
1901        let selected_ids = explorer.effective_selection();
1902        let paths: Vec<PathBuf> = selected_ids
1903            .iter()
1904            .filter(|&&id| id != root_id)
1905            .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1906            .collect();
1907        if paths.is_empty() {
1908            let msg = if is_cut {
1909                t!("explorer.cannot_cut_root").to_string()
1910            } else {
1911                t!("explorer.cannot_copy_root").to_string()
1912            };
1913            self.set_status_message(msg);
1914            return;
1915        }
1916        let msg = if paths.len() == 1 {
1917            let name = paths[0]
1918                .file_name()
1919                .unwrap_or_default()
1920                .to_string_lossy()
1921                .to_string();
1922            if is_cut {
1923                t!("explorer.cut", name = &name).to_string()
1924            } else {
1925                t!("explorer.copied", name = &name).to_string()
1926            }
1927        } else {
1928            let count = paths.len();
1929            if is_cut {
1930                t!("explorer.cut_n", count = count).to_string()
1931            } else {
1932                t!("explorer.copied_n", count = count).to_string()
1933            }
1934        };
1935        self.file_explorer_clipboard = Some(FileExplorerClipboard { paths, is_cut });
1936        self.set_status_message(msg);
1937    }
1938
1939    /// Spawn an async expand-to-path of this window's file-explorer tree,
1940    /// targeting the active buffer's file. No-op when the explorer isn't
1941    /// visible, a sync is already running, or the target path is outside
1942    /// the window's root.
1943    pub fn sync_file_explorer_to_active_file(&mut self) {
1944        if !self.file_explorer_visible {
1945            return;
1946        }
1947
1948        // Don't start a new sync if one is already in progress
1949        if self.file_explorer_sync_in_progress {
1950            return;
1951        }
1952
1953        let active_buf = self.active_buffer();
1954        let Some(metadata) = self.buffer_metadata.get(&active_buf) else {
1955            return;
1956        };
1957        let Some(file_path) = metadata.file_path() else {
1958            return;
1959        };
1960        let target_path = file_path.clone();
1961
1962        if !target_path.starts_with(&self.root) {
1963            return;
1964        }
1965
1966        let Some(mut view) = self.file_explorer.take() else {
1967            return;
1968        };
1969        tracing::trace!(
1970            "sync_file_explorer_to_active_file: taking file_explorer for async expand to {:?}",
1971            target_path
1972        );
1973        let runtime_handle = self
1974            .resources
1975            .tokio_runtime
1976            .as_ref()
1977            .map(|r| r.handle().clone());
1978        let sender = self.resources.async_bridge.as_ref().map(|b| b.sender());
1979        let window_id = self.id;
1980        if let (Some(runtime), Some(sender)) = (runtime_handle, sender) {
1981            // Mark sync as in progress so render knows to keep the layout
1982            self.file_explorer_sync_in_progress = true;
1983
1984            runtime.spawn(async move {
1985                let _success = view.expand_and_select_file(&target_path).await;
1986                // Receiver may have been dropped during shutdown.
1987                #[allow(clippy::let_underscore_must_use)]
1988                let _ = sender.send(
1989                    crate::services::async_bridge::AsyncMessage::FileExplorerExpandedToPath {
1990                        window: window_id,
1991                        view,
1992                    },
1993                );
1994            });
1995        } else {
1996            self.file_explorer = Some(view);
1997        }
1998    }
1999}
2000
2001/// Generate a unique non-conflicting paste name in dst_dir for a file/dir named `name`.
2002/// Returns `dst_dir/name copy.ext`, `dst_dir/name copy 2.ext`, etc.
2003fn unique_paste_name(
2004    fs: &dyn crate::model::filesystem::FileSystem,
2005    dst_dir: &Path,
2006    name: &str,
2007) -> PathBuf {
2008    let (stem, ext) = split_stem_ext(name);
2009    let mut n = 1u32;
2010    loop {
2011        let candidate = if n == 1 {
2012            if ext.is_empty() {
2013                format!("{} copy", stem)
2014            } else {
2015                format!("{} copy.{}", stem, ext)
2016            }
2017        } else if ext.is_empty() {
2018            format!("{} copy {}", stem, n)
2019        } else {
2020            format!("{} copy {}.{}", stem, n, ext)
2021        };
2022        let path = dst_dir.join(&candidate);
2023        if !fs.exists(&path) {
2024            return path;
2025        }
2026        n += 1;
2027        if n > 1000 {
2028            // Fallback: use a timestamp-based name to avoid an infinite loop
2029            return dst_dir.join(format!("{} copy {}", stem, timestamp_suffix()));
2030        }
2031    }
2032}
2033
2034/// Truncate a filename to at most `max` Unicode chars for display in a minibuffer prompt.
2035pub(super) fn truncate_name_for_prompt(name: &str, max: usize) -> String {
2036    if name.chars().count() <= max {
2037        name.to_string()
2038    } else {
2039        let truncated: String = name.chars().take(max.saturating_sub(1)).collect();
2040        format!("{}\u{2026}", truncated)
2041    }
2042}
2043
2044/// Build a short, comma-separated preview of file names for a bulk-operation
2045/// prompt — e.g. `'foo.rs', 'bar.rs', 'baz.rs'` or `'a.rs', 'b.rs', … (5 more)`.
2046/// Each individual name is truncated at 24 unicode chars to keep the
2047/// preview on one minibuffer row.
2048pub(super) fn format_path_preview_for_prompt(paths: &[PathBuf], max_shown: usize) -> String {
2049    let names: Vec<String> = paths
2050        .iter()
2051        .map(|p| {
2052            let raw = p
2053                .file_name()
2054                .map(|n| n.to_string_lossy().to_string())
2055                .unwrap_or_default();
2056            format!("'{}'", truncate_name_for_prompt(&raw, 24))
2057        })
2058        .collect();
2059    if names.len() <= max_shown {
2060        names.join(", ")
2061    } else {
2062        let shown = names[..max_shown].join(", ");
2063        let more = names.len() - max_shown;
2064        format!("{}, \u{2026} ({} more)", shown, more)
2065    }
2066}
2067
2068fn split_stem_ext(name: &str) -> (&str, &str) {
2069    // Hidden files like ".gitignore" have no extension; treat the whole name as stem
2070    if let Some(dot_pos) = name.rfind('.') {
2071        if dot_pos > 0 {
2072            return (&name[..dot_pos], &name[dot_pos + 1..]);
2073        }
2074    }
2075    (name, "")
2076}