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