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#[derive(Debug)]
20enum PasteOpOutcome {
21 Ok,
23 SourceRemovalFailed { dst: PathBuf, err: std::io::Error },
26 Failed(std::io::Error),
29}
30
31fn 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
45fn timestamp_suffix() -> u64 {
47 std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .unwrap()
50 .as_secs()
51}
52
53fn get_parent_node_id(
56 tree: &crate::view::file_tree::FileTree,
57 selected_id: crate::view::file_tree::NodeId,
58 node_is_dir: bool,
59) -> crate::view::file_tree::NodeId {
60 if node_is_dir {
61 selected_id
62 } else {
63 tree.get_node(selected_id)
64 .and_then(|n| n.parent)
65 .unwrap_or(selected_id)
66 }
67}
68
69impl Editor {
70 pub fn file_explorer_visible(&self) -> bool {
71 self.active_window().file_explorer_visible
72 }
73
74 pub fn toggle_file_explorer(&mut self) {
75 let new_visible = !self.active_window().file_explorer_visible;
76 self.active_window_mut().file_explorer_visible = new_visible;
77
78 if new_visible {
79 if self.file_explorer().is_none() {
80 self.init_file_explorer();
81 }
82 self.active_window_mut().key_context = KeyContext::FileExplorer;
83 self.set_status_message(t!("explorer.opened").to_string());
84 self.active_window_mut().sync_file_explorer_to_active_file();
85 } else {
86 self.active_window_mut().key_context = KeyContext::Normal;
87 self.set_status_message(t!("explorer.closed").to_string());
88 }
89
90 self.plugin_manager.read().unwrap().run_hook(
92 "resize",
93 fresh_core::hooks::HookArgs::Resize {
94 width: self.terminal_width,
95 height: self.terminal_height,
96 },
97 );
98 }
99
100 pub fn show_file_explorer(&mut self) {
101 if !self.file_explorer_visible() {
102 self.toggle_file_explorer();
103 }
104 }
105
106 pub fn focus_file_explorer(&mut self) {
107 if self.file_explorer_visible() {
108 self.active_window_mut().on_editor_focus_lost();
110
111 self.active_window_mut().cancel_search_prompt_if_active();
113
114 self.active_window_mut().key_context = KeyContext::FileExplorer;
115 self.set_status_message(t!("explorer.focused").to_string());
116 self.active_window_mut().sync_file_explorer_to_active_file();
117 } else {
118 self.toggle_file_explorer();
119 }
120 }
121
122 pub(crate) fn init_file_explorer(&mut self) {
126 let root_path = if self.authority.filesystem.remote_connection_info().is_some()
131 && !self
132 .authority
133 .filesystem
134 .is_dir(&self.working_dir)
135 .unwrap_or(false)
136 {
137 match self.authority.filesystem.home_dir() {
138 Ok(home) => home,
139 Err(e) => {
140 tracing::error!("Failed to get remote home directory: {}", e);
141 self.set_status_message(format!("Failed to get remote home: {}", e));
142 return;
143 }
144 }
145 } else {
146 self.working_dir.clone()
147 };
148
149 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
150 let fs_manager = Arc::clone(&self.fs_manager);
151 let sender = bridge.sender();
152
153 runtime.spawn(async move {
154 match FileTree::new(root_path, fs_manager).await {
155 Ok(mut tree) => {
156 let root_id = tree.root_id();
157 if let Err(e) = tree.expand_node(root_id).await {
158 tracing::warn!("Failed to expand root directory: {}", e);
159 }
160
161 let view = FileTreeView::new(tree);
162 #[allow(clippy::let_underscore_must_use)]
164 let _ = sender.send(AsyncMessage::FileExplorerInitialized(view));
165 }
166 Err(e) => {
167 tracing::error!("Failed to initialize file explorer: {}", e);
168 }
169 }
170 });
171
172 self.set_status_message(t!("explorer.initializing").to_string());
173 }
174 }
175
176 pub fn file_explorer_navigate_up(&mut self) {
177 if let Some(explorer) = self.file_explorer_mut() {
178 explorer.select_prev_match();
179 explorer.update_scroll_for_selection();
180 }
181 self.file_explorer_preview_selected();
182 }
183
184 pub fn file_explorer_navigate_down(&mut self) {
185 if let Some(explorer) = self.file_explorer_mut() {
186 explorer.select_next_match();
187 explorer.update_scroll_for_selection();
188 }
189 self.file_explorer_preview_selected();
190 }
191
192 pub fn file_explorer_page_up(&mut self) {
193 if let Some(explorer) = self.file_explorer_mut() {
194 explorer.select_page_up();
195 explorer.update_scroll_for_selection();
196 }
197 self.file_explorer_preview_selected();
198 }
199
200 pub fn file_explorer_page_down(&mut self) {
201 if let Some(explorer) = self.file_explorer_mut() {
202 explorer.select_page_down();
203 explorer.update_scroll_for_selection();
204 }
205 self.file_explorer_preview_selected();
206 }
207
208 fn file_explorer_preview_selected(&mut self) {
216 if !self.config.file_explorer.preview_tabs {
219 return;
220 }
221
222 let path = match self
223 .file_explorer()
224 .as_ref()
225 .and_then(|explorer| explorer.get_selected_entry())
226 {
227 Some(entry) if !entry.is_dir() => entry.path.clone(),
228 _ => return,
229 };
230
231 if let Err(e) = self.open_file_preview(&path) {
232 tracing::debug!(
233 "file_explorer_preview_selected: skipping preview for {:?}: {}",
234 path,
235 e
236 );
237 }
238 }
239
240 pub fn file_explorer_collapse(&mut self) {
244 let Some(explorer) = self.file_explorer() else {
245 return;
246 };
247
248 let Some(selected_id) = explorer.get_selected() else {
249 return;
250 };
251
252 let Some(node) = explorer.tree().get_node(selected_id) else {
253 return;
254 };
255
256 if node.is_dir() && node.is_expanded() {
258 self.file_explorer_toggle_expand();
259 return;
260 }
261
262 if let Some(explorer) = self.file_explorer_mut() {
264 explorer.select_parent();
265 explorer.update_scroll_for_selection();
266 }
267 }
268
269 pub fn file_explorer_toggle_expand(&mut self) {
270 let selected_id = if let Some(explorer) = self.file_explorer() {
271 explorer.get_selected()
272 } else {
273 return;
274 };
275
276 let Some(selected_id) = selected_id else {
277 return;
278 };
279
280 let (is_dir, is_expanded, name) = if let Some(explorer) = self.file_explorer() {
281 let node = explorer.tree().get_node(selected_id);
282 if let Some(node) = node {
283 (node.is_dir(), node.is_expanded(), node.entry.name.clone())
284 } else {
285 return;
286 }
287 } else {
288 return;
289 };
290
291 if !is_dir {
292 return;
293 }
294
295 let status_msg = if is_expanded {
296 t!("explorer.collapsing").to_string()
297 } else {
298 t!("explorer.loading_dir", name = &name).to_string()
299 };
300 self.set_status_message(status_msg);
301
302 let active_id = self.active_window;
303 if let (Some(runtime), Some(explorer)) = (
308 self.tokio_runtime.as_ref(),
309 self.windows
310 .get_mut(&active_id)
311 .and_then(|w| w.file_explorer.as_mut()),
312 ) {
313 let tree = explorer.tree_mut();
314 let result = runtime.block_on(tree.toggle_node(selected_id));
315
316 let final_name = explorer
317 .tree()
318 .get_node(selected_id)
319 .map(|n| n.entry.name.clone());
320 let final_expanded = explorer
321 .tree()
322 .get_node(selected_id)
323 .map(|n| n.is_expanded())
324 .unwrap_or(false);
325
326 let mut needs_decoration_rebuild = false;
328
329 match result {
330 Ok(()) => {
331 if final_expanded {
332 let node_info = explorer
333 .tree()
334 .get_node(selected_id)
335 .map(|n| (n.entry.path.clone(), n.entry.is_symlink()));
336
337 if let Some((dir_path, is_symlink)) = node_info {
338 crate::app::file_operations::load_gitignore_via_fs(
339 self.authority.filesystem.as_ref(),
340 explorer,
341 &dir_path,
342 );
343
344 if is_symlink {
348 tracing::debug!(
349 "Symlink directory expanded, will rebuild decoration cache: {:?}",
350 dir_path
351 );
352 needs_decoration_rebuild = true;
353 }
354 }
355 }
356
357 if let Some(name) = final_name {
358 let msg = if final_expanded {
359 t!("explorer.expanded", name = &name).to_string()
360 } else {
361 t!("explorer.collapsed", name = &name).to_string()
362 };
363 self.set_status_message(msg);
364 }
365 }
366 Err(e) => {
367 self.set_status_message(
368 t!("explorer.error", error = e.to_string()).to_string(),
369 );
370 }
371 }
372
373 if needs_decoration_rebuild {
375 self.active_window_mut()
376 .rebuild_file_explorer_decoration_cache();
377 }
378 }
379 }
380
381 pub fn file_explorer_open_file(&mut self) -> AnyhowResult<()> {
382 let entry_type = self
383 .file_explorer()
384 .as_ref()
385 .and_then(|explorer| explorer.get_selected_entry())
386 .map(|entry| (entry.is_dir(), entry.path.clone(), entry.name.clone()));
387
388 if let Some((is_dir, path, name)) = entry_type {
389 if is_dir {
390 self.file_explorer_toggle_expand();
391 } else {
392 tracing::info!("[SYNTAX DEBUG] file_explorer opening file: {:?}", path);
393 match self.open_file(&path) {
394 Ok(id) => {
395 self.active_window_mut().promote_buffer_from_preview(id);
399 self.set_status_message(
400 t!("explorer.opened_file", name = &name).to_string(),
401 );
402 self.active_window_mut().focus_editor();
403 }
404 Err(e) => {
405 if let Some(confirmation) =
408 e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
409 {
410 self.start_large_file_encoding_confirmation(confirmation);
411 } else {
412 self.set_status_message(
413 t!("file.error_opening", error = e.to_string()).to_string(),
414 );
415 }
416 }
417 }
418 }
419 }
420 Ok(())
421 }
422
423 pub fn file_explorer_refresh(&mut self) {
424 let (selected_id, node_name) = if let Some(explorer) = self.file_explorer() {
425 if let Some(selected_id) = explorer.get_selected() {
426 let node_name = explorer
427 .tree()
428 .get_node(selected_id)
429 .map(|n| n.entry.name.clone());
430 (Some(selected_id), node_name)
431 } else {
432 (None, None)
433 }
434 } else {
435 return;
436 };
437
438 let Some(selected_id) = selected_id else {
439 return;
440 };
441
442 if let Some(name) = &node_name {
443 self.set_status_message(t!("explorer.refreshing", name = name).to_string());
444 }
445
446 let active_id = self.active_window;
447 if let (Some(runtime), Some(explorer)) = (
448 self.tokio_runtime.as_ref(),
449 self.windows
450 .get_mut(&active_id)
451 .and_then(|w| w.file_explorer.as_mut()),
452 ) {
453 let tree = explorer.tree_mut();
454 let result = runtime.block_on(tree.refresh_node(selected_id));
455 match result {
456 Ok(()) => {
457 if let Some(name) = node_name {
458 self.set_status_message(t!("explorer.refreshed", name = &name).to_string());
459 } else {
460 self.set_status_message(t!("explorer.refreshed_default").to_string());
461 }
462 }
463 Err(e) => {
464 self.set_status_message(
465 t!("explorer.error_refreshing", error = e.to_string()).to_string(),
466 );
467 }
468 }
469 }
470 }
471
472 pub fn file_explorer_new_file(&mut self) {
473 let active_id = self.active_window;
474 if let Some(explorer) = self
475 .windows
476 .get_mut(&active_id)
477 .and_then(|w| w.file_explorer.as_mut())
478 {
479 if let Some(selected_id) = explorer.get_selected() {
480 let node = explorer.tree().get_node(selected_id);
481 if let Some(node) = node {
482 let parent_path = get_parent_dir_path(node);
483 let filename = format!("untitled_{}.txt", timestamp_suffix());
484 let file_path = parent_path.join(&filename);
485
486 if let Some(runtime) = &self.tokio_runtime {
487 let path_clone = file_path.clone();
488 let result = self
489 .authority
490 .filesystem
491 .create_file(&path_clone)
492 .map(|_| ());
493
494 match result {
495 Ok(_) => {
496 let parent_id =
497 get_parent_node_id(explorer.tree(), selected_id, node.is_dir());
498 let tree = explorer.tree_mut();
499 if let Err(e) =
500 runtime.block_on(tree.reload_expanded_node(parent_id))
501 {
502 tracing::warn!("Failed to refresh file tree: {}", e);
503 }
504 if let Some(explorer) = self.file_explorer_mut().as_mut() {
505 explorer.navigate_to_path(&path_clone);
506 }
507 self.set_status_message(
508 t!("explorer.created_file", name = &filename).to_string(),
509 );
510 self.notify_file_explorer_change(&path_clone);
511
512 if let Err(e) = self.open_file(&path_clone) {
514 tracing::warn!("Failed to open new file: {}", e);
515 }
516
517 let prompt = crate::view::prompt::Prompt::new(
518 t!("explorer.new_file_prompt").to_string(),
519 crate::view::prompt::PromptType::FileExplorerRename {
520 original_path: path_clone,
521 original_name: filename.clone(),
522 is_new_file: true,
523 },
524 );
525 self.active_window_mut().prompt = Some(prompt);
526 }
527 Err(e) => {
528 self.set_status_message(
529 t!("explorer.error_creating_file", error = e.to_string())
530 .to_string(),
531 );
532 }
533 }
534 }
535 }
536 }
537 }
538 }
539
540 pub fn file_explorer_new_directory(&mut self) {
541 let active_id = self.active_window;
542 if let Some(explorer) = self
543 .windows
544 .get_mut(&active_id)
545 .and_then(|w| w.file_explorer.as_mut())
546 {
547 if let Some(selected_id) = explorer.get_selected() {
548 let node = explorer.tree().get_node(selected_id);
549 if let Some(node) = node {
550 let parent_path = get_parent_dir_path(node);
551 let dirname = format!("New Folder {}", timestamp_suffix());
552 let dir_path = parent_path.join(&dirname);
553
554 if let Some(runtime) = &self.tokio_runtime {
555 let path_clone = dir_path.clone();
556 let dirname_clone = dirname.clone();
557 let result = self.authority.filesystem.create_dir(&path_clone);
558
559 match result {
560 Ok(_) => {
561 let parent_id =
562 get_parent_node_id(explorer.tree(), selected_id, node.is_dir());
563 let tree = explorer.tree_mut();
564 if let Err(e) =
565 runtime.block_on(tree.reload_expanded_node(parent_id))
566 {
567 tracing::warn!("Failed to refresh file tree: {}", e);
568 }
569 if let Some(explorer) = self.file_explorer_mut().as_mut() {
570 explorer.navigate_to_path(&path_clone);
571 }
572 self.set_status_message(
573 t!("explorer.created_dir", name = &dirname_clone).to_string(),
574 );
575 self.notify_file_explorer_change(&path_clone);
576
577 let prompt = crate::view::prompt::Prompt::with_initial_text(
578 t!("explorer.new_directory_prompt").to_string(),
579 crate::view::prompt::PromptType::FileExplorerRename {
580 original_path: path_clone,
581 original_name: dirname_clone,
582 is_new_file: true,
583 },
584 dirname,
585 );
586 self.active_window_mut().prompt = Some(prompt);
587 }
588 Err(e) => {
589 self.set_status_message(
590 t!("explorer.error_creating_dir", error = e.to_string())
591 .to_string(),
592 );
593 }
594 }
595 }
596 }
597 }
598 }
599 }
600
601 pub fn file_explorer_delete(&mut self) {
602 let Some(explorer) = self.file_explorer() else {
603 return;
604 };
605 let root_id = explorer.tree().root_id();
606 let selected_ids = explorer.effective_selection();
607
608 let paths: Vec<(PathBuf, bool)> = selected_ids
609 .iter()
610 .filter(|&&id| id != root_id)
611 .filter_map(|&id| {
612 explorer
613 .tree()
614 .get_node(id)
615 .map(|n| (n.entry.path.clone(), n.is_dir()))
616 })
617 .collect();
618
619 if paths.is_empty() {
620 self.set_status_message(t!("explorer.cannot_delete_root").to_string());
621 return;
622 }
623
624 if paths.len() == 1 {
625 let (path, is_dir) = paths.into_iter().next().unwrap();
626 let name = path
627 .file_name()
628 .unwrap_or_default()
629 .to_string_lossy()
630 .to_string();
631 let type_str = if is_dir { "directory" } else { "file" };
632 self.start_prompt(
633 t!("explorer.delete_confirm", "type" = type_str, name = &name).to_string(),
634 PromptType::ConfirmDeleteFile { path, is_dir },
635 );
636 } else {
637 let count = paths.len();
638 let all_paths: Vec<PathBuf> = paths.into_iter().map(|(p, _)| p).collect();
639 let names = format_path_preview_for_prompt(&all_paths, 3);
643 self.start_prompt(
644 t!(
645 "explorer.delete_multi_confirm",
646 count = count,
647 names = &names
648 )
649 .to_string(),
650 PromptType::ConfirmMultiDelete { paths: all_paths },
651 );
652 }
653 }
654
655 pub fn perform_file_explorer_delete(&mut self, path: std::path::PathBuf, _is_dir: bool) {
659 let name = path
660 .file_name()
661 .map(|n| n.to_string_lossy().to_string())
662 .unwrap_or_default();
663
664 let delete_result = if self.authority.filesystem.remote_connection_info().is_some() {
667 self.move_to_remote_trash(&path)
668 } else {
669 trash::delete(&path).map_err(std::io::Error::other)
670 };
671
672 match delete_result {
673 Ok(_) => {
674 let to_close = self.buffer_ids_under_path(&path);
684 for id in to_close {
685 if let Err(e) = self.force_close_buffer(id) {
686 tracing::warn!(
687 "Failed to close buffer {:?} after delete of {:?}: {}",
688 id,
689 path,
690 e
691 );
692 }
693 }
694
695 let active_id = self.active_window;
697 if let Some(explorer) = self
698 .windows
699 .get_mut(&active_id)
700 .and_then(|w| w.file_explorer.as_mut())
701 {
702 if let Some(runtime) = &self.tokio_runtime {
703 if let Some(node) = explorer.tree().get_node_by_path(&path) {
705 let node_id = node.id;
706 let parent_id = get_parent_node_id(explorer.tree(), node_id, false);
707
708 let deleted_index = explorer.get_selected_index();
710
711 if let Err(e) = runtime
712 .block_on(explorer.tree_mut().reload_expanded_node(parent_id))
713 {
714 tracing::warn!("Failed to refresh file tree after delete: {}", e);
715 }
716
717 explorer.clear_multi_selection();
722
723 let count = explorer.visible_count();
726 if count > 0 {
727 let new_index = if let Some(idx) = deleted_index {
728 idx.min(count.saturating_sub(1))
729 } else {
730 0
731 };
732 if let Some(node_id) = explorer.get_node_at_index(new_index) {
733 explorer.set_selected(Some(node_id));
734 }
735 } else {
736 explorer.set_selected(Some(parent_id));
738 }
739 }
740 }
741 }
742 self.set_status_message(t!("explorer.moved_to_trash", name = &name).to_string());
743 self.notify_file_explorer_change(&path);
744
745 self.active_window_mut().key_context = KeyContext::FileExplorer;
747 }
748 Err(e) => {
749 self.set_status_message(
750 t!("explorer.error_trash", error = e.to_string()).to_string(),
751 );
752 }
753 }
754 }
755
756 fn move_to_remote_trash(&self, path: &std::path::Path) -> std::io::Result<()> {
758 let home = self.authority.filesystem.home_dir()?;
760 let trash_dir = home.join(".local/share/fresh/trash");
761
762 if !self.authority.filesystem.exists(&trash_dir) {
764 self.authority.filesystem.create_dir_all(&trash_dir)?;
765 }
766
767 let file_name = path
769 .file_name()
770 .unwrap_or_else(|| std::ffi::OsStr::new("unnamed"));
771 let timestamp = std::time::SystemTime::now()
772 .duration_since(std::time::UNIX_EPOCH)
773 .map(|d| d.as_secs())
774 .unwrap_or(0);
775 let trash_name = format!("{}.{}", file_name.to_string_lossy(), timestamp);
776 let trash_path = trash_dir.join(trash_name);
777
778 self.authority.filesystem.rename(path, &trash_path)
780 }
781
782 pub fn file_explorer_rename(&mut self) {
783 if let Some(explorer) = self.file_explorer() {
784 if let Some(selected_id) = explorer.get_selected() {
785 if selected_id == explorer.tree().root_id() {
787 self.set_status_message(t!("explorer.cannot_rename_root").to_string());
788 return;
789 }
790
791 let node = explorer.tree().get_node(selected_id);
792 if let Some(node) = node {
793 let old_path = node.entry.path.clone();
794 let old_name = node.entry.name.clone();
795
796 let prompt = crate::view::prompt::Prompt::with_initial_text_for_edit(
801 t!("explorer.rename_prompt").to_string(),
802 crate::view::prompt::PromptType::FileExplorerRename {
803 original_path: old_path,
804 original_name: old_name.clone(),
805 is_new_file: false,
806 },
807 old_name,
808 );
809 self.active_window_mut().prompt = Some(prompt);
810 }
811 }
812 }
813 }
814
815 pub fn perform_file_explorer_rename(
817 &mut self,
818 original_path: std::path::PathBuf,
819 original_name: String,
820 new_name: String,
821 is_new_file: bool,
822 ) {
823 if new_name.is_empty() || new_name == original_name {
824 self.set_status_message(t!("explorer.rename_cancelled").to_string());
825 return;
826 }
827
828 if new_name.chars().any(std::path::is_separator) {
833 self.set_status_message(t!("explorer.rename_invalid_separator").to_string());
834 return;
835 }
836 if new_name == "." || new_name == ".." {
837 self.set_status_message(t!("explorer.rename_invalid_dot").to_string());
838 return;
839 }
840
841 let new_path = original_path
842 .parent()
843 .map(|p| p.join(&new_name))
844 .unwrap_or_else(|| original_path.clone());
845
846 if self.tokio_runtime.is_some() {
847 let result = self.authority.filesystem.rename(&original_path, &new_path);
848
849 match result {
850 Ok(_) => {
851 let active_id = self.active_window;
855 if let (Some(runtime), Some(explorer)) = (
856 self.tokio_runtime.as_ref(),
857 self.windows
858 .get_mut(&active_id)
859 .and_then(|w| w.file_explorer.as_mut()),
860 ) {
861 if let Some(selected_id) = explorer.get_selected() {
862 let parent_id = get_parent_node_id(explorer.tree(), selected_id, false);
863 let tree = explorer.tree_mut();
864 if let Err(e) = runtime.block_on(tree.reload_expanded_node(parent_id)) {
865 tracing::warn!("Failed to refresh file tree after rename: {}", e);
866 }
867 }
868 explorer.clear_multi_selection();
872 explorer.navigate_to_path(&new_path);
874 }
875
876 let relocated = self.relocate_buffers_for_rename(&original_path, &new_path);
884
885 if is_new_file && !relocated.is_empty() {
889 self.active_window_mut().key_context = KeyContext::Normal;
890 }
891
892 self.set_status_message(
893 t!("explorer.renamed", old = &original_name, new = &new_name).to_string(),
894 );
895 self.notify_file_explorer_change(&new_path);
896 }
897 Err(e) => {
898 self.set_status_message(
899 t!("explorer.error_renaming", error = e.to_string()).to_string(),
900 );
901 }
902 }
903 }
904 }
905
906 pub fn file_explorer_toggle_hidden(&mut self) {
907 let show_hidden = if let Some(explorer) = self.file_explorer_mut() {
908 explorer.toggle_show_hidden();
909 explorer.ignore_patterns().show_hidden()
910 } else {
911 return;
912 };
913
914 let msg = if show_hidden {
915 t!("explorer.showing_hidden")
916 } else {
917 t!("explorer.hiding_hidden")
918 };
919 self.set_status_message(msg.to_string());
920
921 self.config_mut().file_explorer.show_hidden = show_hidden;
923 self.persist_config_change(
924 "/file_explorer/show_hidden",
925 serde_json::Value::Bool(show_hidden),
926 );
927 }
928
929 pub fn file_explorer_toggle_gitignored(&mut self) {
930 let show_gitignored = if let Some(explorer) = self.file_explorer_mut() {
931 explorer.toggle_show_gitignored();
932 explorer.ignore_patterns().show_gitignored()
933 } else {
934 return;
935 };
936
937 let msg = if show_gitignored {
938 t!("explorer.showing_gitignored")
939 } else {
940 t!("explorer.hiding_gitignored")
941 };
942 self.set_status_message(msg.to_string());
943
944 self.config_mut().file_explorer.show_gitignored = show_gitignored;
946 self.persist_config_change(
947 "/file_explorer/show_gitignored",
948 serde_json::Value::Bool(show_gitignored),
949 );
950 }
951
952 pub fn file_explorer_paste(&mut self) {
972 let clipboard = match self.active_window().file_explorer_clipboard.clone() {
973 Some(c) => c,
974 None => {
975 self.set_status_message(t!("explorer.paste_no_source").to_string());
976 return;
977 }
978 };
979
980 let dst_dir = if let Some(explorer) = self.file_explorer() {
981 if let Some(selected_id) = explorer.get_selected() {
982 if let Some(node) = explorer.tree().get_node(selected_id) {
983 get_parent_dir_path(node)
984 } else {
985 return;
986 }
987 } else {
988 return;
989 }
990 } else {
991 return;
992 };
993
994 let is_cut = clipboard.is_cut;
995
996 if clipboard.paths.len() == 1 {
997 let src = clipboard.paths[0].clone();
998 let file_name = match src.file_name() {
999 Some(n) => n.to_os_string(),
1000 None => return,
1001 };
1002 let dst_path = dst_dir.join(&file_name);
1003
1004 if src.parent().map(|p| p == dst_dir).unwrap_or(false) {
1005 if is_cut {
1006 self.active_window_mut().file_explorer_clipboard = None;
1011 self.set_status_message(t!("explorer.cut_cancelled").to_string());
1012 return;
1013 } else {
1014 let unique = unique_paste_name(
1015 &*self.authority.filesystem,
1016 &dst_dir,
1017 &file_name.to_string_lossy(),
1018 );
1019 self.perform_file_explorer_paste(src, unique, false);
1020 return;
1021 }
1022 }
1023
1024 if self.authority.filesystem.exists(&dst_path) {
1025 let name = truncate_name_for_prompt(&file_name.to_string_lossy(), 40);
1026 self.start_prompt(
1027 t!("explorer.paste_conflict", name = &name).to_string(),
1028 crate::view::prompt::PromptType::ConfirmPasteConflict {
1029 src,
1030 dst: dst_path,
1031 is_cut,
1032 },
1033 );
1034 } else {
1035 self.perform_file_explorer_paste(src, dst_path, is_cut);
1036 }
1037 } else {
1038 let mut safe: Vec<(PathBuf, PathBuf)> = Vec::new();
1040 let mut conflicts: Vec<(PathBuf, PathBuf)> = Vec::new();
1041
1042 for src in &clipboard.paths {
1043 let file_name = match src.file_name() {
1044 Some(n) => n.to_os_string(),
1045 None => continue,
1046 };
1047 let dst_path = dst_dir.join(&file_name);
1048 let is_same_location = src.parent().map(|p| p == dst_dir).unwrap_or(false);
1049
1050 if is_same_location {
1051 if !is_cut {
1052 let unique = unique_paste_name(
1054 &*self.authority.filesystem,
1055 &dst_dir,
1056 &file_name.to_string_lossy(),
1057 );
1058 safe.push((src.clone(), unique));
1059 }
1060 } else if self.authority.filesystem.exists(&dst_path) {
1062 conflicts.push((src.clone(), dst_path));
1063 } else {
1064 safe.push((src.clone(), dst_path));
1065 }
1066 }
1067
1068 if safe.is_empty() && conflicts.is_empty() {
1069 if is_cut {
1073 self.active_window_mut().file_explorer_clipboard = None;
1074 self.set_status_message(t!("explorer.cut_cancelled").to_string());
1075 } else {
1076 self.set_status_message(t!("explorer.paste_same_location").to_string());
1077 }
1078 return;
1079 }
1080
1081 if conflicts.is_empty() {
1082 self.execute_resolved_multi_paste(safe, vec![], is_cut);
1083 } else {
1084 let name = truncate_name_for_prompt(
1085 &conflicts[0]
1086 .1
1087 .file_name()
1088 .unwrap_or_default()
1089 .to_string_lossy(),
1090 40,
1091 );
1092 self.start_prompt(
1093 t!("explorer.paste_conflict_multi", name = &name).to_string(),
1094 crate::view::prompt::PromptType::ConfirmMultiPasteConflict {
1095 safe,
1096 confirmed: Vec::new(),
1097 pending: conflicts,
1098 is_cut,
1099 },
1100 );
1101 }
1102 }
1103 }
1104
1105 pub(super) fn execute_resolved_multi_paste(
1113 &mut self,
1114 safe: Vec<(PathBuf, PathBuf)>,
1115 to_overwrite: Vec<(PathBuf, PathBuf)>,
1116 is_cut: bool,
1117 ) {
1118 let total = safe.len() + to_overwrite.len();
1119 if total == 0 {
1120 return;
1121 }
1122
1123 let mut succeeded: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(total);
1124 let mut clean_moves: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(total);
1131 let mut first_error: Option<std::io::Error> = None;
1132 let mut partial_moves: Vec<(PathBuf, std::io::Error)> = Vec::new();
1133 for (src, dst) in safe.into_iter().chain(to_overwrite) {
1134 match self.paste_one_fs_op(&src, &dst, is_cut) {
1135 PasteOpOutcome::Ok => {
1136 clean_moves.push((src.clone(), dst.clone()));
1137 succeeded.push((src, dst));
1138 }
1139 PasteOpOutcome::SourceRemovalFailed {
1140 dst: landed_dst,
1141 err,
1142 } => {
1143 succeeded.push((src, landed_dst.clone()));
1147 partial_moves.push((landed_dst, err));
1148 }
1149 PasteOpOutcome::Failed(e) => {
1150 if first_error.is_none() {
1151 first_error = Some(e);
1152 }
1153 }
1154 }
1155 }
1156
1157 if is_cut {
1163 for (src, dst) in &clean_moves {
1164 self.relocate_buffers_for_rename(src, dst);
1165 }
1166 }
1167
1168 if !succeeded.is_empty() {
1169 let first_dst = succeeded[0].1.clone();
1170 let any_src = succeeded[0].0.clone();
1171 self.refresh_tree_after_paste(&any_src, &first_dst, is_cut);
1172 }
1173
1174 if !partial_moves.is_empty() {
1175 let (first_dst, first_err) = &partial_moves[0];
1178 let name = first_dst
1179 .file_name()
1180 .map(|n| n.to_string_lossy().to_string())
1181 .unwrap_or_default();
1182 let msg = if partial_moves.len() == 1 {
1183 t!(
1184 "explorer.move_source_removal_failed",
1185 name = &name,
1186 error = first_err.to_string()
1187 )
1188 .to_string()
1189 } else {
1190 t!(
1191 "explorer.move_source_removal_failed_n",
1192 count = partial_moves.len()
1193 )
1194 .to_string()
1195 };
1196 self.set_status_message(msg);
1197 } else if let Some(e) = &first_error {
1198 let msg = if is_cut {
1199 t!("explorer.error_moving", error = e.to_string()).to_string()
1200 } else {
1201 t!("explorer.error_copying", error = e.to_string()).to_string()
1202 };
1203 self.set_status_message(msg);
1204 } else if total > 1 {
1205 let msg = if is_cut {
1206 t!("explorer.pasted_moved_n", count = total).to_string()
1207 } else {
1208 t!("explorer.pasted_n", count = total).to_string()
1209 };
1210 self.set_status_message(msg);
1211 } else if let Some((_, dst)) = succeeded.first() {
1212 let name = dst
1213 .file_name()
1214 .map(|n| n.to_string_lossy().to_string())
1215 .unwrap_or_default();
1216 let msg = if is_cut {
1217 t!("explorer.pasted_moved", name = &name).to_string()
1218 } else {
1219 t!("explorer.pasted", name = &name).to_string()
1220 };
1221 self.set_status_message(msg);
1222 }
1223
1224 if is_cut && first_error.is_none() && partial_moves.is_empty() {
1228 self.active_window_mut().file_explorer_clipboard = None;
1229 }
1230 self.active_window_mut().key_context = KeyContext::FileExplorer;
1231 }
1232
1233 fn paste_one_fs_op(&self, src: &Path, dst: &Path, is_cut: bool) -> PasteOpOutcome {
1237 let src_is_dir = self.authority.filesystem.is_dir(src).unwrap_or(false);
1238
1239 if src_is_dir && dst.starts_with(src) {
1247 return PasteOpOutcome::Failed(std::io::Error::new(
1248 std::io::ErrorKind::InvalidInput,
1249 "Cannot paste a directory into itself",
1250 ));
1251 }
1252
1253 if is_cut {
1254 match self.authority.filesystem.rename(src, dst) {
1259 Ok(()) => PasteOpOutcome::Ok,
1260 Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
1261 let copy_result = if src_is_dir {
1262 self.authority.filesystem.copy_dir_all(src, dst)
1263 } else {
1264 self.authority.filesystem.copy(src, dst).map(|_| ())
1265 };
1266 match copy_result {
1267 Ok(()) => {
1268 let remove_result = if src_is_dir {
1274 self.authority.filesystem.remove_dir_all(src)
1275 } else {
1276 self.authority.filesystem.remove_file(src)
1277 };
1278 match remove_result {
1279 Ok(()) => PasteOpOutcome::Ok,
1280 Err(remove_err) => PasteOpOutcome::SourceRemovalFailed {
1281 dst: dst.to_path_buf(),
1282 err: remove_err,
1283 },
1284 }
1285 }
1286 Err(copy_err) => {
1287 let cleanup = if src_is_dir {
1293 self.authority.filesystem.remove_dir_all(dst)
1294 } else {
1295 self.authority.filesystem.remove_file(dst)
1296 };
1297 if let Err(cleanup_err) = cleanup {
1298 tracing::warn!(
1299 "Failed to roll back partial destination {:?} after copy \
1300 fallback failed: {}",
1301 dst,
1302 cleanup_err
1303 );
1304 }
1305 PasteOpOutcome::Failed(copy_err)
1306 }
1307 }
1308 }
1309 Err(e) => PasteOpOutcome::Failed(e),
1310 }
1311 } else if src_is_dir {
1312 match self.authority.filesystem.copy_dir_all(src, dst) {
1313 Ok(()) => PasteOpOutcome::Ok,
1314 Err(e) => PasteOpOutcome::Failed(e),
1315 }
1316 } else {
1317 match self.authority.filesystem.copy(src, dst) {
1318 Ok(_) => PasteOpOutcome::Ok,
1319 Err(e) => PasteOpOutcome::Failed(e),
1320 }
1321 }
1322 }
1323
1324 fn refresh_tree_after_paste(&mut self, src: &Path, dst: &Path, is_cut: bool) {
1329 let active_id = self.active_window;
1330 let Some(explorer) = self
1333 .windows
1334 .get_mut(&active_id)
1335 .and_then(|w| w.file_explorer.as_mut())
1336 else {
1337 return;
1338 };
1339 if let Some(runtime) = &self.tokio_runtime {
1340 if let Some(dst_parent) = dst.parent() {
1342 if let Some(dst_parent_node) = explorer.tree().get_node_by_path(dst_parent) {
1343 let pid = dst_parent_node.id;
1344 if let Err(e) = runtime.block_on(explorer.tree_mut().reload_expanded_node(pid))
1345 {
1346 tracing::warn!("Failed to reload destination directory after paste: {}", e);
1347 }
1348 }
1349 }
1350 if is_cut {
1360 if let Some(src_parent) = src.parent() {
1361 if let Some(src_parent_node) = explorer.tree().get_node_by_path(src_parent) {
1362 let pid = src_parent_node.id;
1363 if let Err(e) =
1364 runtime.block_on(explorer.tree_mut().reload_expanded_node(pid))
1365 {
1366 tracing::warn!("Failed to refresh source directory after move: {}", e);
1367 }
1368 }
1369 }
1370 }
1371 }
1372 explorer.clear_multi_selection();
1377 explorer.navigate_to_path(dst);
1378
1379 self.notify_file_explorer_change(dst);
1380 }
1381
1382 pub(super) fn notify_file_explorer_change(&self, path: &Path) {
1394 self.plugin_manager.read().unwrap().run_hook(
1395 "after_file_explorer_change",
1396 crate::services::plugins::hooks::HookArgs::AfterFileExplorerChange {
1397 path: path.to_path_buf(),
1398 },
1399 );
1400 }
1401
1402 pub fn perform_file_explorer_paste(&mut self, src: PathBuf, dst: PathBuf, is_cut: bool) {
1403 let name = dst
1404 .file_name()
1405 .map(|n| n.to_string_lossy().to_string())
1406 .unwrap_or_default();
1407
1408 match self.paste_one_fs_op(&src, &dst, is_cut) {
1409 PasteOpOutcome::Ok => {
1410 if is_cut {
1417 self.relocate_buffers_for_rename(&src, &dst);
1418 }
1419 self.refresh_tree_after_paste(&src, &dst, is_cut);
1420 if is_cut {
1421 self.active_window_mut().file_explorer_clipboard = None;
1422 self.set_status_message(t!("explorer.pasted_moved", name = &name).to_string());
1423 } else {
1424 self.set_status_message(t!("explorer.pasted", name = &name).to_string());
1425 }
1426 self.active_window_mut().key_context = KeyContext::FileExplorer;
1427 }
1428 PasteOpOutcome::SourceRemovalFailed {
1429 dst: landed_dst,
1430 err,
1431 } => {
1432 self.refresh_tree_after_paste(&src, &landed_dst, is_cut);
1437 self.set_status_message(
1438 t!(
1439 "explorer.move_source_removal_failed",
1440 name = &name,
1441 error = err.to_string()
1442 )
1443 .to_string(),
1444 );
1445 self.active_window_mut().key_context = KeyContext::FileExplorer;
1448 }
1449 PasteOpOutcome::Failed(e) => {
1450 let msg = if is_cut {
1451 t!("explorer.error_moving", error = e.to_string()).to_string()
1452 } else {
1453 t!("explorer.error_copying", error = e.to_string()).to_string()
1454 };
1455 self.set_status_message(msg);
1456 }
1457 }
1458 }
1459
1460 pub fn file_explorer_duplicate(&mut self) {
1466 let Some(explorer) = self.file_explorer() else {
1467 return;
1468 };
1469 let root_id = explorer.tree().root_id();
1470 let selected_ids = explorer.effective_selection();
1471 let sources: Vec<PathBuf> = selected_ids
1472 .iter()
1473 .filter(|&&id| id != root_id)
1474 .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1475 .collect();
1476
1477 if sources.is_empty() {
1478 self.set_status_message(t!("explorer.cannot_duplicate_root").to_string());
1479 return;
1480 }
1481
1482 let mut ops: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(sources.len());
1486 for src in &sources {
1487 let Some(parent) = src.parent() else {
1488 continue;
1489 };
1490 let Some(file_name) = src.file_name() else {
1491 continue;
1492 };
1493 let dst = unique_paste_name(
1494 &*self.authority.filesystem,
1495 parent,
1496 &file_name.to_string_lossy(),
1497 );
1498 ops.push((src.clone(), dst));
1499 }
1500
1501 if ops.is_empty() {
1502 return;
1503 }
1504
1505 let mut succeeded: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(ops.len());
1506 let mut first_error: Option<std::io::Error> = None;
1507 for (src, dst) in ops {
1508 match self.paste_one_fs_op(&src, &dst, false) {
1509 PasteOpOutcome::Ok => succeeded.push((src, dst)),
1510 PasteOpOutcome::SourceRemovalFailed { .. } => {
1511 unreachable!("paste_one_fs_op returned SourceRemovalFailed for a non-cut op");
1513 }
1514 PasteOpOutcome::Failed(e) => {
1515 if first_error.is_none() {
1516 first_error = Some(e);
1517 }
1518 }
1519 }
1520 }
1521
1522 if !succeeded.is_empty() {
1523 let (first_src, first_dst) = succeeded[0].clone();
1524 self.refresh_tree_after_paste(&first_src, &first_dst, false);
1525 }
1526
1527 let msg = if let Some(e) = &first_error {
1528 t!("explorer.error_copying", error = e.to_string()).to_string()
1529 } else if succeeded.len() == 1 {
1530 let name = succeeded[0]
1531 .1
1532 .file_name()
1533 .map(|n| n.to_string_lossy().to_string())
1534 .unwrap_or_default();
1535 t!("explorer.duplicated", name = &name).to_string()
1536 } else {
1537 t!("explorer.duplicated_n", count = succeeded.len()).to_string()
1538 };
1539 self.set_status_message(msg);
1540 self.active_window_mut().key_context = KeyContext::FileExplorer;
1541 }
1542
1543 pub fn file_explorer_copy_path(&mut self, relative: bool) {
1550 let Some(explorer) = self.file_explorer() else {
1551 return;
1552 };
1553 let selected_ids = explorer.effective_selection();
1554 let paths: Vec<PathBuf> = selected_ids
1555 .iter()
1556 .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1557 .collect();
1558
1559 if paths.is_empty() {
1560 self.set_status_message(t!("clipboard.no_file_path").to_string());
1561 return;
1562 }
1563
1564 let working_dir = self.working_dir.clone();
1565 let rendered: Vec<String> = paths
1566 .iter()
1567 .map(|p| {
1568 if relative {
1569 p.strip_prefix(&working_dir)
1570 .unwrap_or(p)
1571 .to_string_lossy()
1572 .into_owned()
1573 } else {
1574 p.to_string_lossy().into_owned()
1575 }
1576 })
1577 .collect();
1578
1579 let joined = rendered.join("\n");
1580 self.clipboard.copy(joined.clone());
1581
1582 let msg = if rendered.len() == 1 {
1583 t!("clipboard.copied_path", path = &rendered[0]).to_string()
1584 } else {
1585 t!("clipboard.copied_paths_n", count = rendered.len()).to_string()
1586 };
1587 self.set_status_message(msg);
1588 }
1589}
1590
1591impl crate::app::window::Window {
1592 pub fn focus_editor(&mut self) {
1595 self.key_context = KeyContext::Normal;
1596 self.set_status_message(t!("editor.focused").to_string());
1597 }
1598
1599 pub fn file_explorer_search_clear(&mut self) {
1606 if matches!(
1607 self.file_explorer_clipboard,
1608 Some(FileExplorerClipboard { is_cut: true, .. })
1609 ) {
1610 self.file_explorer_clipboard = None;
1611 self.set_status_message(t!("explorer.cut_cancelled").to_string());
1612 return;
1613 }
1614 let action = self.file_explorer.as_mut().map(|explorer| {
1615 if explorer.has_multi_selection() {
1616 explorer.clear_multi_selection();
1617 None
1618 } else if explorer.is_search_active() {
1619 explorer.search_clear();
1620 None
1621 } else {
1622 Some(())
1623 }
1624 });
1625 if let Some(Some(())) = action {
1626 self.focus_editor();
1627 }
1628 }
1629
1630 pub fn handle_set_file_explorer_decorations(
1635 &mut self,
1636 namespace: String,
1637 decorations: Vec<crate::view::file_tree::FileExplorerDecoration>,
1638 ) {
1639 let root = self.root.clone();
1640 let normalized: Vec<crate::view::file_tree::FileExplorerDecoration> = decorations
1641 .into_iter()
1642 .filter_map(|mut decoration| {
1643 let path = if decoration.path.is_absolute() {
1644 decoration.path
1645 } else {
1646 root.join(&decoration.path)
1647 };
1648 let path = crate::app::normalize_path(&path);
1649 if path.starts_with(&root) {
1650 decoration.path = path;
1651 Some(decoration)
1652 } else {
1653 None
1654 }
1655 })
1656 .collect();
1657
1658 self.file_explorer_decorations.insert(namespace, normalized);
1659 self.rebuild_file_explorer_decoration_cache();
1660 }
1661
1662 pub fn handle_clear_file_explorer_decorations(&mut self, namespace: &str) {
1665 self.file_explorer_decorations.remove(namespace);
1666 self.rebuild_file_explorer_decoration_cache();
1667 }
1668
1669 pub fn rebuild_file_explorer_decoration_cache(&mut self) {
1673 let decorations: Vec<_> = self
1674 .file_explorer_decorations
1675 .values()
1676 .flat_map(|entries| entries.iter().cloned())
1677 .collect();
1678
1679 let symlink_mappings = self
1680 .file_explorer
1681 .as_ref()
1682 .map(|fe| fe.collect_symlink_mappings())
1683 .unwrap_or_default();
1684
1685 self.file_explorer_decoration_cache =
1686 crate::view::file_tree::FileExplorerDecorationCache::rebuild(
1687 decorations.into_iter(),
1688 &self.root,
1689 &symlink_mappings,
1690 );
1691 }
1692
1693 pub fn file_explorer_clipboard(&self) -> Option<&FileExplorerClipboard> {
1695 self.file_explorer_clipboard.as_ref()
1696 }
1697
1698 pub fn file_explorer_copy(&mut self) {
1700 self.set_explorer_clipboard(false);
1701 }
1702
1703 pub fn file_explorer_cut(&mut self) {
1705 self.set_explorer_clipboard(true);
1706 }
1707
1708 fn set_explorer_clipboard(&mut self, is_cut: bool) {
1713 let Some(explorer) = self.file_explorer.as_ref() else {
1714 return;
1715 };
1716 let root_id = explorer.tree().root_id();
1717 let selected_ids = explorer.effective_selection();
1718 let paths: Vec<PathBuf> = selected_ids
1719 .iter()
1720 .filter(|&&id| id != root_id)
1721 .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1722 .collect();
1723 if paths.is_empty() {
1724 let msg = if is_cut {
1725 t!("explorer.cannot_cut_root").to_string()
1726 } else {
1727 t!("explorer.cannot_copy_root").to_string()
1728 };
1729 self.set_status_message(msg);
1730 return;
1731 }
1732 let msg = if paths.len() == 1 {
1733 let name = paths[0]
1734 .file_name()
1735 .unwrap_or_default()
1736 .to_string_lossy()
1737 .to_string();
1738 if is_cut {
1739 t!("explorer.cut", name = &name).to_string()
1740 } else {
1741 t!("explorer.copied", name = &name).to_string()
1742 }
1743 } else {
1744 let count = paths.len();
1745 if is_cut {
1746 t!("explorer.cut_n", count = count).to_string()
1747 } else {
1748 t!("explorer.copied_n", count = count).to_string()
1749 }
1750 };
1751 self.file_explorer_clipboard = Some(FileExplorerClipboard { paths, is_cut });
1752 self.set_status_message(msg);
1753 }
1754
1755 pub fn sync_file_explorer_to_active_file(&mut self) {
1760 if !self.file_explorer_visible {
1761 return;
1762 }
1763
1764 if self.file_explorer_sync_in_progress {
1766 return;
1767 }
1768
1769 let active_buf = self.active_buffer();
1770 let Some(metadata) = self.buffer_metadata.get(&active_buf) else {
1771 return;
1772 };
1773 let Some(file_path) = metadata.file_path() else {
1774 return;
1775 };
1776 let target_path = file_path.clone();
1777
1778 if !target_path.starts_with(&self.root) {
1779 return;
1780 }
1781
1782 let Some(mut view) = self.file_explorer.take() else {
1783 return;
1784 };
1785 tracing::trace!(
1786 "sync_file_explorer_to_active_file: taking file_explorer for async expand to {:?}",
1787 target_path
1788 );
1789 let runtime_handle = self
1790 .resources
1791 .tokio_runtime
1792 .as_ref()
1793 .map(|r| r.handle().clone());
1794 let sender = self.resources.async_bridge.as_ref().map(|b| b.sender());
1795 if let (Some(runtime), Some(sender)) = (runtime_handle, sender) {
1796 self.file_explorer_sync_in_progress = true;
1798
1799 runtime.spawn(async move {
1800 let _success = view.expand_and_select_file(&target_path).await;
1801 #[allow(clippy::let_underscore_must_use)]
1803 let _ = sender.send(
1804 crate::services::async_bridge::AsyncMessage::FileExplorerExpandedToPath(view),
1805 );
1806 });
1807 } else {
1808 self.file_explorer = Some(view);
1809 }
1810 }
1811}
1812
1813fn unique_paste_name(
1816 fs: &dyn crate::model::filesystem::FileSystem,
1817 dst_dir: &Path,
1818 name: &str,
1819) -> PathBuf {
1820 let (stem, ext) = split_stem_ext(name);
1821 let mut n = 1u32;
1822 loop {
1823 let candidate = if n == 1 {
1824 if ext.is_empty() {
1825 format!("{} copy", stem)
1826 } else {
1827 format!("{} copy.{}", stem, ext)
1828 }
1829 } else if ext.is_empty() {
1830 format!("{} copy {}", stem, n)
1831 } else {
1832 format!("{} copy {}.{}", stem, n, ext)
1833 };
1834 let path = dst_dir.join(&candidate);
1835 if !fs.exists(&path) {
1836 return path;
1837 }
1838 n += 1;
1839 if n > 1000 {
1840 return dst_dir.join(format!("{} copy {}", stem, timestamp_suffix()));
1842 }
1843 }
1844}
1845
1846pub(super) fn truncate_name_for_prompt(name: &str, max: usize) -> String {
1848 if name.chars().count() <= max {
1849 name.to_string()
1850 } else {
1851 let truncated: String = name.chars().take(max.saturating_sub(1)).collect();
1852 format!("{}\u{2026}", truncated)
1853 }
1854}
1855
1856pub(super) fn format_path_preview_for_prompt(paths: &[PathBuf], max_shown: usize) -> String {
1861 let names: Vec<String> = paths
1862 .iter()
1863 .map(|p| {
1864 let raw = p
1865 .file_name()
1866 .map(|n| n.to_string_lossy().to_string())
1867 .unwrap_or_default();
1868 format!("'{}'", truncate_name_for_prompt(&raw, 24))
1869 })
1870 .collect();
1871 if names.len() <= max_shown {
1872 names.join(", ")
1873 } else {
1874 let shown = names[..max_shown].join(", ");
1875 let more = names.len() - max_shown;
1876 format!("{}, \u{2026} ({} more)", shown, more)
1877 }
1878}
1879
1880fn split_stem_ext(name: &str) -> (&str, &str) {
1881 if let Some(dot_pos) = name.rfind('.') {
1883 if dot_pos > 0 {
1884 return (&name[..dot_pos], &name[dot_pos + 1..]);
1885 }
1886 }
1887 (name, "")
1888}