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