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