Skip to main content

fresh/app/
file_explorer.rs

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