Skip to main content

fresh/app/
file_explorer.rs

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