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