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