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.file_explorer_visible
72 }
73
74 pub fn file_explorer(&self) -> Option<&FileTreeView> {
75 self.file_explorer.as_ref()
76 }
77
78 pub fn toggle_file_explorer(&mut self) {
79 self.file_explorer_visible = !self.file_explorer_visible;
80
81 if self.file_explorer_visible {
82 if self.file_explorer.is_none() {
83 self.init_file_explorer();
84 }
85 self.key_context = KeyContext::FileExplorer;
86 self.set_status_message(t!("explorer.opened").to_string());
87 self.sync_file_explorer_to_active_file();
88 } else {
89 self.key_context = KeyContext::Normal;
90 self.set_status_message(t!("explorer.closed").to_string());
91 }
92
93 self.plugin_manager.run_hook(
95 "resize",
96 fresh_core::hooks::HookArgs::Resize {
97 width: self.terminal_width,
98 height: self.terminal_height,
99 },
100 );
101 }
102
103 pub fn show_file_explorer(&mut self) {
104 if !self.file_explorer_visible {
105 self.toggle_file_explorer();
106 }
107 }
108
109 pub fn sync_file_explorer_to_active_file(&mut self) {
110 if !self.file_explorer_visible {
111 return;
112 }
113
114 if self.file_explorer_sync_in_progress {
116 return;
117 }
118
119 if let Some(metadata) = self.buffer_metadata.get(&self.active_buffer()) {
120 if let Some(file_path) = metadata.file_path() {
121 let target_path = file_path.clone();
122 let working_dir = self.working_dir.clone();
123
124 if target_path.starts_with(&working_dir) {
125 if let Some(mut view) = self.file_explorer.take() {
126 tracing::trace!(
127 "sync_file_explorer_to_active_file: taking file_explorer for async expand to {:?}",
128 target_path
129 );
130 if let (Some(runtime), Some(bridge)) =
131 (&self.tokio_runtime, &self.async_bridge)
132 {
133 let sender = bridge.sender();
134 self.file_explorer_sync_in_progress = true;
136
137 runtime.spawn(async move {
138 let _success = view.expand_and_select_file(&target_path).await;
139 #[allow(clippy::let_underscore_must_use)]
141 let _ = sender.send(AsyncMessage::FileExplorerExpandedToPath(view));
142 });
143 } else {
144 self.file_explorer = Some(view);
145 }
146 }
147 }
148 }
149 }
150 }
151
152 pub fn focus_file_explorer(&mut self) {
153 if self.file_explorer_visible {
154 self.on_editor_focus_lost();
156
157 self.cancel_search_prompt_if_active();
159
160 self.key_context = KeyContext::FileExplorer;
161 self.set_status_message(t!("explorer.focused").to_string());
162 self.sync_file_explorer_to_active_file();
163 } else {
164 self.toggle_file_explorer();
165 }
166 }
167
168 pub fn focus_editor(&mut self) {
169 self.key_context = KeyContext::Normal;
170 self.set_status_message(t!("editor.focused").to_string());
171 }
172
173 pub(crate) fn init_file_explorer(&mut self) {
174 let root_path = if self.authority.filesystem.remote_connection_info().is_some()
179 && !self
180 .authority
181 .filesystem
182 .is_dir(&self.working_dir)
183 .unwrap_or(false)
184 {
185 match self.authority.filesystem.home_dir() {
186 Ok(home) => home,
187 Err(e) => {
188 tracing::error!("Failed to get remote home directory: {}", e);
189 self.set_status_message(format!("Failed to get remote home: {}", e));
190 return;
191 }
192 }
193 } else {
194 self.working_dir.clone()
195 };
196
197 if let (Some(runtime), Some(bridge)) = (&self.tokio_runtime, &self.async_bridge) {
198 let fs_manager = Arc::clone(&self.fs_manager);
199 let sender = bridge.sender();
200
201 runtime.spawn(async move {
202 match FileTree::new(root_path, fs_manager).await {
203 Ok(mut tree) => {
204 let root_id = tree.root_id();
205 if let Err(e) = tree.expand_node(root_id).await {
206 tracing::warn!("Failed to expand root directory: {}", e);
207 }
208
209 let view = FileTreeView::new(tree);
210 #[allow(clippy::let_underscore_must_use)]
212 let _ = sender.send(AsyncMessage::FileExplorerInitialized(view));
213 }
214 Err(e) => {
215 tracing::error!("Failed to initialize file explorer: {}", e);
216 }
217 }
218 });
219
220 self.set_status_message(t!("explorer.initializing").to_string());
221 }
222 }
223
224 pub fn file_explorer_navigate_up(&mut self) {
225 if let Some(explorer) = &mut self.file_explorer {
226 explorer.select_prev_match();
227 explorer.update_scroll_for_selection();
228 }
229 self.file_explorer_preview_selected();
230 }
231
232 pub fn file_explorer_navigate_down(&mut self) {
233 if let Some(explorer) = &mut self.file_explorer {
234 explorer.select_next_match();
235 explorer.update_scroll_for_selection();
236 }
237 self.file_explorer_preview_selected();
238 }
239
240 pub fn file_explorer_page_up(&mut self) {
241 if let Some(explorer) = &mut self.file_explorer {
242 explorer.select_page_up();
243 explorer.update_scroll_for_selection();
244 }
245 self.file_explorer_preview_selected();
246 }
247
248 pub fn file_explorer_page_down(&mut self) {
249 if let Some(explorer) = &mut self.file_explorer {
250 explorer.select_page_down();
251 explorer.update_scroll_for_selection();
252 }
253 self.file_explorer_preview_selected();
254 }
255
256 fn file_explorer_preview_selected(&mut self) {
264 if !self.config.file_explorer.preview_tabs {
267 return;
268 }
269
270 let path = match self
271 .file_explorer
272 .as_ref()
273 .and_then(|explorer| explorer.get_selected_entry())
274 {
275 Some(entry) if !entry.is_dir() => entry.path.clone(),
276 _ => return,
277 };
278
279 if let Err(e) = self.open_file_preview(&path) {
280 tracing::debug!(
281 "file_explorer_preview_selected: skipping preview for {:?}: {}",
282 path,
283 e
284 );
285 }
286 }
287
288 pub fn file_explorer_collapse(&mut self) {
292 let Some(explorer) = &self.file_explorer else {
293 return;
294 };
295
296 let Some(selected_id) = explorer.get_selected() else {
297 return;
298 };
299
300 let Some(node) = explorer.tree().get_node(selected_id) else {
301 return;
302 };
303
304 if node.is_dir() && node.is_expanded() {
306 self.file_explorer_toggle_expand();
307 return;
308 }
309
310 if let Some(explorer) = &mut self.file_explorer {
312 explorer.select_parent();
313 explorer.update_scroll_for_selection();
314 }
315 }
316
317 pub fn file_explorer_toggle_expand(&mut self) {
318 let selected_id = if let Some(explorer) = &self.file_explorer {
319 explorer.get_selected()
320 } else {
321 return;
322 };
323
324 let Some(selected_id) = selected_id else {
325 return;
326 };
327
328 let (is_dir, is_expanded, name) = if let Some(explorer) = &self.file_explorer {
329 let node = explorer.tree().get_node(selected_id);
330 if let Some(node) = node {
331 (node.is_dir(), node.is_expanded(), node.entry.name.clone())
332 } else {
333 return;
334 }
335 } else {
336 return;
337 };
338
339 if !is_dir {
340 return;
341 }
342
343 let status_msg = if is_expanded {
344 t!("explorer.collapsing").to_string()
345 } else {
346 t!("explorer.loading_dir", name = &name).to_string()
347 };
348 self.set_status_message(status_msg);
349
350 if let (Some(runtime), Some(explorer)) = (&self.tokio_runtime, &mut self.file_explorer) {
351 let tree = explorer.tree_mut();
352 let result = runtime.block_on(tree.toggle_node(selected_id));
353
354 let final_name = explorer
355 .tree()
356 .get_node(selected_id)
357 .map(|n| n.entry.name.clone());
358 let final_expanded = explorer
359 .tree()
360 .get_node(selected_id)
361 .map(|n| n.is_expanded())
362 .unwrap_or(false);
363
364 let mut needs_decoration_rebuild = false;
366
367 match result {
368 Ok(()) => {
369 if final_expanded {
370 let node_info = explorer
371 .tree()
372 .get_node(selected_id)
373 .map(|n| (n.entry.path.clone(), n.entry.is_symlink()));
374
375 if let Some((dir_path, is_symlink)) = node_info {
376 crate::app::file_operations::load_gitignore_via_fs(
377 self.authority.filesystem.as_ref(),
378 explorer,
379 &dir_path,
380 );
381
382 if is_symlink {
386 tracing::debug!(
387 "Symlink directory expanded, will rebuild decoration cache: {:?}",
388 dir_path
389 );
390 needs_decoration_rebuild = true;
391 }
392 }
393 }
394
395 if let Some(name) = final_name {
396 let msg = if final_expanded {
397 t!("explorer.expanded", name = &name).to_string()
398 } else {
399 t!("explorer.collapsed", name = &name).to_string()
400 };
401 self.set_status_message(msg);
402 }
403 }
404 Err(e) => {
405 self.set_status_message(
406 t!("explorer.error", error = e.to_string()).to_string(),
407 );
408 }
409 }
410
411 if needs_decoration_rebuild {
413 self.rebuild_file_explorer_decoration_cache();
414 }
415 }
416 }
417
418 pub fn file_explorer_open_file(&mut self) -> AnyhowResult<()> {
419 let entry_type = self
420 .file_explorer
421 .as_ref()
422 .and_then(|explorer| explorer.get_selected_entry())
423 .map(|entry| (entry.is_dir(), entry.path.clone(), entry.name.clone()));
424
425 if let Some((is_dir, path, name)) = entry_type {
426 if is_dir {
427 self.file_explorer_toggle_expand();
428 } else {
429 tracing::info!("[SYNTAX DEBUG] file_explorer opening file: {:?}", path);
430 match self.open_file(&path) {
431 Ok(id) => {
432 self.promote_buffer_from_preview(id);
436 self.set_status_message(
437 t!("explorer.opened_file", name = &name).to_string(),
438 );
439 self.focus_editor();
440 }
441 Err(e) => {
442 if let Some(confirmation) =
445 e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
446 {
447 self.start_large_file_encoding_confirmation(confirmation);
448 } else {
449 self.set_status_message(
450 t!("file.error_opening", error = e.to_string()).to_string(),
451 );
452 }
453 }
454 }
455 }
456 }
457 Ok(())
458 }
459
460 pub fn file_explorer_refresh(&mut self) {
461 let (selected_id, node_name) = if let Some(explorer) = &self.file_explorer {
462 if let Some(selected_id) = explorer.get_selected() {
463 let node_name = explorer
464 .tree()
465 .get_node(selected_id)
466 .map(|n| n.entry.name.clone());
467 (Some(selected_id), node_name)
468 } else {
469 (None, None)
470 }
471 } else {
472 return;
473 };
474
475 let Some(selected_id) = selected_id else {
476 return;
477 };
478
479 if let Some(name) = &node_name {
480 self.set_status_message(t!("explorer.refreshing", name = name).to_string());
481 }
482
483 if let (Some(runtime), Some(explorer)) = (&self.tokio_runtime, &mut self.file_explorer) {
484 let tree = explorer.tree_mut();
485 let result = runtime.block_on(tree.refresh_node(selected_id));
486 match result {
487 Ok(()) => {
488 if let Some(name) = node_name {
489 self.set_status_message(t!("explorer.refreshed", name = &name).to_string());
490 } else {
491 self.set_status_message(t!("explorer.refreshed_default").to_string());
492 }
493 }
494 Err(e) => {
495 self.set_status_message(
496 t!("explorer.error_refreshing", error = e.to_string()).to_string(),
497 );
498 }
499 }
500 }
501 }
502
503 pub fn file_explorer_new_file(&mut self) {
504 if let Some(explorer) = &mut self.file_explorer {
505 if let Some(selected_id) = explorer.get_selected() {
506 let node = explorer.tree().get_node(selected_id);
507 if let Some(node) = node {
508 let parent_path = get_parent_dir_path(node);
509 let filename = format!("untitled_{}.txt", timestamp_suffix());
510 let file_path = parent_path.join(&filename);
511
512 if let Some(runtime) = &self.tokio_runtime {
513 let path_clone = file_path.clone();
514 let result = self
515 .authority
516 .filesystem
517 .create_file(&path_clone)
518 .map(|_| ());
519
520 match result {
521 Ok(_) => {
522 let parent_id =
523 get_parent_node_id(explorer.tree(), selected_id, node.is_dir());
524 let tree = explorer.tree_mut();
525 if let Err(e) =
526 runtime.block_on(tree.reload_expanded_node(parent_id))
527 {
528 tracing::warn!("Failed to refresh file tree: {}", e);
529 }
530 if let Some(ref mut explorer) = self.file_explorer {
531 explorer.navigate_to_path(&path_clone);
532 }
533 self.set_status_message(
534 t!("explorer.created_file", name = &filename).to_string(),
535 );
536 self.notify_file_explorer_change(&path_clone);
537
538 if let Err(e) = self.open_file(&path_clone) {
540 tracing::warn!("Failed to open new file: {}", e);
541 }
542
543 let prompt = crate::view::prompt::Prompt::new(
544 t!("explorer.new_file_prompt").to_string(),
545 crate::view::prompt::PromptType::FileExplorerRename {
546 original_path: path_clone,
547 original_name: filename.clone(),
548 is_new_file: true,
549 },
550 );
551 self.prompt = Some(prompt);
552 }
553 Err(e) => {
554 self.set_status_message(
555 t!("explorer.error_creating_file", error = e.to_string())
556 .to_string(),
557 );
558 }
559 }
560 }
561 }
562 }
563 }
564 }
565
566 pub fn file_explorer_new_directory(&mut self) {
567 if let Some(explorer) = &mut self.file_explorer {
568 if let Some(selected_id) = explorer.get_selected() {
569 let node = explorer.tree().get_node(selected_id);
570 if let Some(node) = node {
571 let parent_path = get_parent_dir_path(node);
572 let dirname = format!("New Folder {}", timestamp_suffix());
573 let dir_path = parent_path.join(&dirname);
574
575 if let Some(runtime) = &self.tokio_runtime {
576 let path_clone = dir_path.clone();
577 let dirname_clone = dirname.clone();
578 let result = self.authority.filesystem.create_dir(&path_clone);
579
580 match result {
581 Ok(_) => {
582 let parent_id =
583 get_parent_node_id(explorer.tree(), selected_id, node.is_dir());
584 let tree = explorer.tree_mut();
585 if let Err(e) =
586 runtime.block_on(tree.reload_expanded_node(parent_id))
587 {
588 tracing::warn!("Failed to refresh file tree: {}", e);
589 }
590 if let Some(ref mut explorer) = self.file_explorer {
591 explorer.navigate_to_path(&path_clone);
592 }
593 self.set_status_message(
594 t!("explorer.created_dir", name = &dirname_clone).to_string(),
595 );
596 self.notify_file_explorer_change(&path_clone);
597
598 let prompt = crate::view::prompt::Prompt::with_initial_text(
599 t!("explorer.new_directory_prompt").to_string(),
600 crate::view::prompt::PromptType::FileExplorerRename {
601 original_path: path_clone,
602 original_name: dirname_clone,
603 is_new_file: true,
604 },
605 dirname,
606 );
607 self.prompt = Some(prompt);
608 }
609 Err(e) => {
610 self.set_status_message(
611 t!("explorer.error_creating_dir", error = e.to_string())
612 .to_string(),
613 );
614 }
615 }
616 }
617 }
618 }
619 }
620 }
621
622 pub fn file_explorer_delete(&mut self) {
623 let Some(explorer) = &self.file_explorer else {
624 return;
625 };
626 let root_id = explorer.tree().root_id();
627 let selected_ids = explorer.effective_selection();
628
629 let paths: Vec<(PathBuf, bool)> = selected_ids
630 .iter()
631 .filter(|&&id| id != root_id)
632 .filter_map(|&id| {
633 explorer
634 .tree()
635 .get_node(id)
636 .map(|n| (n.entry.path.clone(), n.is_dir()))
637 })
638 .collect();
639
640 if paths.is_empty() {
641 self.set_status_message(t!("explorer.cannot_delete_root").to_string());
642 return;
643 }
644
645 if paths.len() == 1 {
646 let (path, is_dir) = paths.into_iter().next().unwrap();
647 let name = path
648 .file_name()
649 .unwrap_or_default()
650 .to_string_lossy()
651 .to_string();
652 let type_str = if is_dir { "directory" } else { "file" };
653 self.start_prompt(
654 t!("explorer.delete_confirm", "type" = type_str, name = &name).to_string(),
655 PromptType::ConfirmDeleteFile { path, is_dir },
656 );
657 } else {
658 let count = paths.len();
659 let all_paths: Vec<PathBuf> = paths.into_iter().map(|(p, _)| p).collect();
660 let names = format_path_preview_for_prompt(&all_paths, 3);
664 self.start_prompt(
665 t!(
666 "explorer.delete_multi_confirm",
667 count = count,
668 names = &names
669 )
670 .to_string(),
671 PromptType::ConfirmMultiDelete { paths: all_paths },
672 );
673 }
674 }
675
676 pub fn perform_file_explorer_delete(&mut self, path: std::path::PathBuf, _is_dir: bool) {
680 let name = path
681 .file_name()
682 .map(|n| n.to_string_lossy().to_string())
683 .unwrap_or_default();
684
685 let delete_result = if self.authority.filesystem.remote_connection_info().is_some() {
688 self.move_to_remote_trash(&path)
689 } else {
690 trash::delete(&path).map_err(std::io::Error::other)
691 };
692
693 match delete_result {
694 Ok(_) => {
695 let to_close = self.buffer_ids_under_path(&path);
705 for id in to_close {
706 if let Err(e) = self.force_close_buffer(id) {
707 tracing::warn!(
708 "Failed to close buffer {:?} after delete of {:?}: {}",
709 id,
710 path,
711 e
712 );
713 }
714 }
715
716 if let Some(explorer) = &mut self.file_explorer {
718 if let Some(runtime) = &self.tokio_runtime {
719 if let Some(node) = explorer.tree().get_node_by_path(&path) {
721 let node_id = node.id;
722 let parent_id = get_parent_node_id(explorer.tree(), node_id, false);
723
724 let deleted_index = explorer.get_selected_index();
726
727 if let Err(e) = runtime
728 .block_on(explorer.tree_mut().reload_expanded_node(parent_id))
729 {
730 tracing::warn!("Failed to refresh file tree after delete: {}", e);
731 }
732
733 explorer.clear_multi_selection();
738
739 let count = explorer.visible_count();
742 if count > 0 {
743 let new_index = if let Some(idx) = deleted_index {
744 idx.min(count.saturating_sub(1))
745 } else {
746 0
747 };
748 if let Some(node_id) = explorer.get_node_at_index(new_index) {
749 explorer.set_selected(Some(node_id));
750 }
751 } else {
752 explorer.set_selected(Some(parent_id));
754 }
755 }
756 }
757 }
758 self.set_status_message(t!("explorer.moved_to_trash", name = &name).to_string());
759 self.notify_file_explorer_change(&path);
760
761 self.key_context = KeyContext::FileExplorer;
763 }
764 Err(e) => {
765 self.set_status_message(
766 t!("explorer.error_trash", error = e.to_string()).to_string(),
767 );
768 }
769 }
770 }
771
772 fn move_to_remote_trash(&self, path: &std::path::Path) -> std::io::Result<()> {
774 let home = self.authority.filesystem.home_dir()?;
776 let trash_dir = home.join(".local/share/fresh/trash");
777
778 if !self.authority.filesystem.exists(&trash_dir) {
780 self.authority.filesystem.create_dir_all(&trash_dir)?;
781 }
782
783 let file_name = path
785 .file_name()
786 .unwrap_or_else(|| std::ffi::OsStr::new("unnamed"));
787 let timestamp = std::time::SystemTime::now()
788 .duration_since(std::time::UNIX_EPOCH)
789 .map(|d| d.as_secs())
790 .unwrap_or(0);
791 let trash_name = format!("{}.{}", file_name.to_string_lossy(), timestamp);
792 let trash_path = trash_dir.join(trash_name);
793
794 self.authority.filesystem.rename(path, &trash_path)
796 }
797
798 pub fn file_explorer_rename(&mut self) {
799 if let Some(explorer) = &self.file_explorer {
800 if let Some(selected_id) = explorer.get_selected() {
801 if selected_id == explorer.tree().root_id() {
803 self.set_status_message(t!("explorer.cannot_rename_root").to_string());
804 return;
805 }
806
807 let node = explorer.tree().get_node(selected_id);
808 if let Some(node) = node {
809 let old_path = node.entry.path.clone();
810 let old_name = node.entry.name.clone();
811
812 let prompt = crate::view::prompt::Prompt::with_initial_text_for_edit(
817 t!("explorer.rename_prompt").to_string(),
818 crate::view::prompt::PromptType::FileExplorerRename {
819 original_path: old_path,
820 original_name: old_name.clone(),
821 is_new_file: false,
822 },
823 old_name,
824 );
825 self.prompt = Some(prompt);
826 }
827 }
828 }
829 }
830
831 pub fn perform_file_explorer_rename(
833 &mut self,
834 original_path: std::path::PathBuf,
835 original_name: String,
836 new_name: String,
837 is_new_file: bool,
838 ) {
839 if new_name.is_empty() || new_name == original_name {
840 self.set_status_message(t!("explorer.rename_cancelled").to_string());
841 return;
842 }
843
844 if new_name.chars().any(std::path::is_separator) {
849 self.set_status_message(t!("explorer.rename_invalid_separator").to_string());
850 return;
851 }
852 if new_name == "." || new_name == ".." {
853 self.set_status_message(t!("explorer.rename_invalid_dot").to_string());
854 return;
855 }
856
857 let new_path = original_path
858 .parent()
859 .map(|p| p.join(&new_name))
860 .unwrap_or_else(|| original_path.clone());
861
862 if let Some(runtime) = &self.tokio_runtime {
863 let result = self.authority.filesystem.rename(&original_path, &new_path);
864
865 match result {
866 Ok(_) => {
867 if let Some(explorer) = &mut self.file_explorer {
869 if let Some(selected_id) = explorer.get_selected() {
870 let parent_id = get_parent_node_id(explorer.tree(), selected_id, false);
871 let tree = explorer.tree_mut();
872 if let Err(e) = runtime.block_on(tree.reload_expanded_node(parent_id)) {
873 tracing::warn!("Failed to refresh file tree after rename: {}", e);
874 }
875 }
876 explorer.clear_multi_selection();
880 explorer.navigate_to_path(&new_path);
882 }
883
884 let relocated = self.relocate_buffers_for_rename(&original_path, &new_path);
892
893 if is_new_file && !relocated.is_empty() {
897 self.key_context = KeyContext::Normal;
898 }
899
900 self.set_status_message(
901 t!("explorer.renamed", old = &original_name, new = &new_name).to_string(),
902 );
903 self.notify_file_explorer_change(&new_path);
904 }
905 Err(e) => {
906 self.set_status_message(
907 t!("explorer.error_renaming", error = e.to_string()).to_string(),
908 );
909 }
910 }
911 }
912 }
913
914 pub fn file_explorer_toggle_hidden(&mut self) {
915 let show_hidden = if let Some(explorer) = &mut self.file_explorer {
916 explorer.toggle_show_hidden();
917 explorer.ignore_patterns().show_hidden()
918 } else {
919 return;
920 };
921
922 let msg = if show_hidden {
923 t!("explorer.showing_hidden")
924 } else {
925 t!("explorer.hiding_hidden")
926 };
927 self.set_status_message(msg.to_string());
928
929 self.config_mut().file_explorer.show_hidden = show_hidden;
931 self.persist_config_change(
932 "/file_explorer/show_hidden",
933 serde_json::Value::Bool(show_hidden),
934 );
935 }
936
937 pub fn file_explorer_toggle_gitignored(&mut self) {
938 let show_gitignored = if let Some(explorer) = &mut self.file_explorer {
939 explorer.toggle_show_gitignored();
940 explorer.ignore_patterns().show_gitignored()
941 } else {
942 return;
943 };
944
945 let msg = if show_gitignored {
946 t!("explorer.showing_gitignored")
947 } else {
948 t!("explorer.hiding_gitignored")
949 };
950 self.set_status_message(msg.to_string());
951
952 self.config_mut().file_explorer.show_gitignored = show_gitignored;
954 self.persist_config_change(
955 "/file_explorer/show_gitignored",
956 serde_json::Value::Bool(show_gitignored),
957 );
958 }
959
960 pub fn file_explorer_search_clear(&mut self) {
962 if matches!(
968 self.file_explorer_clipboard,
969 Some(FileExplorerClipboard { is_cut: true, .. })
970 ) {
971 self.file_explorer_clipboard = None;
972 self.set_status_message(t!("explorer.cut_cancelled").to_string());
973 return;
974 }
975 if let Some(explorer) = &mut self.file_explorer {
976 if explorer.has_multi_selection() {
977 explorer.clear_multi_selection();
978 } else if explorer.is_search_active() {
979 explorer.search_clear();
980 } else {
981 self.focus_editor();
982 }
983 }
984 }
985
986 pub fn file_explorer_extend_selection_up(&mut self) {
987 if let Some(explorer) = &mut self.file_explorer {
988 explorer.extend_selection_up();
989 }
990 }
991
992 pub fn file_explorer_extend_selection_down(&mut self) {
993 if let Some(explorer) = &mut self.file_explorer {
994 explorer.extend_selection_down();
995 }
996 }
997
998 pub fn file_explorer_toggle_select(&mut self) {
999 if let Some(explorer) = &mut self.file_explorer {
1000 explorer.toggle_select();
1001 }
1002 }
1003
1004 pub fn file_explorer_select_all(&mut self) {
1005 if let Some(explorer) = &mut self.file_explorer {
1006 explorer.select_all();
1007 }
1008 }
1009
1010 pub fn file_explorer_search_push_char(&mut self, c: char) {
1012 if let Some(explorer) = &mut self.file_explorer {
1013 explorer.search_push_char(c);
1014 explorer.update_scroll_for_selection();
1015 }
1016 }
1017
1018 pub fn file_explorer_search_pop_char(&mut self) {
1020 if let Some(explorer) = &mut self.file_explorer {
1021 explorer.search_pop_char();
1022 explorer.update_scroll_for_selection();
1023 }
1024 }
1025
1026 pub fn handle_set_file_explorer_decorations(
1027 &mut self,
1028 namespace: String,
1029 decorations: Vec<crate::view::file_tree::FileExplorerDecoration>,
1030 ) {
1031 let normalized: Vec<crate::view::file_tree::FileExplorerDecoration> = decorations
1032 .into_iter()
1033 .filter_map(|mut decoration| {
1034 let path = if decoration.path.is_absolute() {
1035 decoration.path
1036 } else {
1037 self.working_dir.join(&decoration.path)
1038 };
1039 let path = normalize_path(&path);
1040 if path.starts_with(&self.working_dir) {
1041 decoration.path = path;
1042 Some(decoration)
1043 } else {
1044 None
1045 }
1046 })
1047 .collect();
1048
1049 self.file_explorer_decorations.insert(namespace, normalized);
1050 self.rebuild_file_explorer_decoration_cache();
1051 }
1052
1053 pub fn handle_clear_file_explorer_decorations(&mut self, namespace: &str) {
1054 self.file_explorer_decorations.remove(namespace);
1055 self.rebuild_file_explorer_decoration_cache();
1056 }
1057
1058 pub(super) fn rebuild_file_explorer_decoration_cache(&mut self) {
1059 let decorations = self
1060 .file_explorer_decorations
1061 .values()
1062 .flat_map(|entries| entries.iter().cloned());
1063
1064 let symlink_mappings = self
1066 .file_explorer
1067 .as_ref()
1068 .map(|fe| fe.collect_symlink_mappings())
1069 .unwrap_or_default();
1070
1071 self.file_explorer_decoration_cache =
1072 crate::view::file_tree::FileExplorerDecorationCache::rebuild(
1073 decorations,
1074 &self.working_dir,
1075 &symlink_mappings,
1076 );
1077 }
1078
1079 pub fn file_explorer_clipboard(&self) -> Option<&FileExplorerClipboard> {
1080 self.file_explorer_clipboard.as_ref()
1081 }
1082
1083 pub fn file_explorer_copy(&mut self) {
1084 self.set_explorer_clipboard(false);
1085 }
1086
1087 pub fn file_explorer_cut(&mut self) {
1088 self.set_explorer_clipboard(true);
1089 }
1090
1091 fn set_explorer_clipboard(&mut self, is_cut: bool) {
1092 let Some(explorer) = &self.file_explorer else {
1093 return;
1094 };
1095 let root_id = explorer.tree().root_id();
1096 let selected_ids = explorer.effective_selection();
1097 let paths: Vec<PathBuf> = selected_ids
1098 .iter()
1099 .filter(|&&id| id != root_id)
1100 .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1101 .collect();
1102 if paths.is_empty() {
1103 let msg = if is_cut {
1104 t!("explorer.cannot_cut_root").to_string()
1105 } else {
1106 t!("explorer.cannot_copy_root").to_string()
1107 };
1108 self.set_status_message(msg);
1109 return;
1110 }
1111 let msg = if paths.len() == 1 {
1112 let name = paths[0]
1113 .file_name()
1114 .unwrap_or_default()
1115 .to_string_lossy()
1116 .to_string();
1117 if is_cut {
1118 t!("explorer.cut", name = &name).to_string()
1119 } else {
1120 t!("explorer.copied", name = &name).to_string()
1121 }
1122 } else {
1123 let count = paths.len();
1124 if is_cut {
1125 t!("explorer.cut_n", count = count).to_string()
1126 } else {
1127 t!("explorer.copied_n", count = count).to_string()
1128 }
1129 };
1130 self.file_explorer_clipboard = Some(FileExplorerClipboard { paths, is_cut });
1131 self.set_status_message(msg);
1132 }
1133
1134 pub fn file_explorer_paste(&mut self) {
1135 let clipboard = match self.file_explorer_clipboard.clone() {
1136 Some(c) => c,
1137 None => {
1138 self.set_status_message(t!("explorer.paste_no_source").to_string());
1139 return;
1140 }
1141 };
1142
1143 let dst_dir = if let Some(explorer) = &self.file_explorer {
1144 if let Some(selected_id) = explorer.get_selected() {
1145 if let Some(node) = explorer.tree().get_node(selected_id) {
1146 get_parent_dir_path(node)
1147 } else {
1148 return;
1149 }
1150 } else {
1151 return;
1152 }
1153 } else {
1154 return;
1155 };
1156
1157 let is_cut = clipboard.is_cut;
1158
1159 if clipboard.paths.len() == 1 {
1160 let src = clipboard.paths[0].clone();
1161 let file_name = match src.file_name() {
1162 Some(n) => n.to_os_string(),
1163 None => return,
1164 };
1165 let dst_path = dst_dir.join(&file_name);
1166
1167 if src.parent().map(|p| p == dst_dir).unwrap_or(false) {
1168 if is_cut {
1169 self.file_explorer_clipboard = None;
1174 self.set_status_message(t!("explorer.cut_cancelled").to_string());
1175 return;
1176 } else {
1177 let unique = unique_paste_name(
1178 &*self.authority.filesystem,
1179 &dst_dir,
1180 &file_name.to_string_lossy(),
1181 );
1182 self.perform_file_explorer_paste(src, unique, false);
1183 return;
1184 }
1185 }
1186
1187 if self.authority.filesystem.exists(&dst_path) {
1188 let name = truncate_name_for_prompt(&file_name.to_string_lossy(), 40);
1189 self.start_prompt(
1190 t!("explorer.paste_conflict", name = &name).to_string(),
1191 crate::view::prompt::PromptType::ConfirmPasteConflict {
1192 src,
1193 dst: dst_path,
1194 is_cut,
1195 },
1196 );
1197 } else {
1198 self.perform_file_explorer_paste(src, dst_path, is_cut);
1199 }
1200 } else {
1201 let mut safe: Vec<(PathBuf, PathBuf)> = Vec::new();
1203 let mut conflicts: Vec<(PathBuf, PathBuf)> = Vec::new();
1204
1205 for src in &clipboard.paths {
1206 let file_name = match src.file_name() {
1207 Some(n) => n.to_os_string(),
1208 None => continue,
1209 };
1210 let dst_path = dst_dir.join(&file_name);
1211 let is_same_location = src.parent().map(|p| p == dst_dir).unwrap_or(false);
1212
1213 if is_same_location {
1214 if !is_cut {
1215 let unique = unique_paste_name(
1217 &*self.authority.filesystem,
1218 &dst_dir,
1219 &file_name.to_string_lossy(),
1220 );
1221 safe.push((src.clone(), unique));
1222 }
1223 } else if self.authority.filesystem.exists(&dst_path) {
1225 conflicts.push((src.clone(), dst_path));
1226 } else {
1227 safe.push((src.clone(), dst_path));
1228 }
1229 }
1230
1231 if safe.is_empty() && conflicts.is_empty() {
1232 if is_cut {
1236 self.file_explorer_clipboard = None;
1237 self.set_status_message(t!("explorer.cut_cancelled").to_string());
1238 } else {
1239 self.set_status_message(t!("explorer.paste_same_location").to_string());
1240 }
1241 return;
1242 }
1243
1244 if conflicts.is_empty() {
1245 self.execute_resolved_multi_paste(safe, vec![], is_cut);
1246 } else {
1247 let name = truncate_name_for_prompt(
1248 &conflicts[0]
1249 .1
1250 .file_name()
1251 .unwrap_or_default()
1252 .to_string_lossy(),
1253 40,
1254 );
1255 self.start_prompt(
1256 t!("explorer.paste_conflict_multi", name = &name).to_string(),
1257 crate::view::prompt::PromptType::ConfirmMultiPasteConflict {
1258 safe,
1259 confirmed: Vec::new(),
1260 pending: conflicts,
1261 is_cut,
1262 },
1263 );
1264 }
1265 }
1266 }
1267
1268 pub(super) fn execute_resolved_multi_paste(
1276 &mut self,
1277 safe: Vec<(PathBuf, PathBuf)>,
1278 to_overwrite: Vec<(PathBuf, PathBuf)>,
1279 is_cut: bool,
1280 ) {
1281 let total = safe.len() + to_overwrite.len();
1282 if total == 0 {
1283 return;
1284 }
1285
1286 let mut succeeded: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(total);
1287 let mut clean_moves: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(total);
1294 let mut first_error: Option<std::io::Error> = None;
1295 let mut partial_moves: Vec<(PathBuf, std::io::Error)> = Vec::new();
1296 for (src, dst) in safe.into_iter().chain(to_overwrite) {
1297 match self.paste_one_fs_op(&src, &dst, is_cut) {
1298 PasteOpOutcome::Ok => {
1299 clean_moves.push((src.clone(), dst.clone()));
1300 succeeded.push((src, dst));
1301 }
1302 PasteOpOutcome::SourceRemovalFailed {
1303 dst: landed_dst,
1304 err,
1305 } => {
1306 succeeded.push((src, landed_dst.clone()));
1310 partial_moves.push((landed_dst, err));
1311 }
1312 PasteOpOutcome::Failed(e) => {
1313 if first_error.is_none() {
1314 first_error = Some(e);
1315 }
1316 }
1317 }
1318 }
1319
1320 if is_cut {
1326 for (src, dst) in &clean_moves {
1327 self.relocate_buffers_for_rename(src, dst);
1328 }
1329 }
1330
1331 if !succeeded.is_empty() {
1332 let first_dst = succeeded[0].1.clone();
1333 let any_src = succeeded[0].0.clone();
1334 self.refresh_tree_after_paste(&any_src, &first_dst, is_cut);
1335 }
1336
1337 if !partial_moves.is_empty() {
1338 let (first_dst, first_err) = &partial_moves[0];
1341 let name = first_dst
1342 .file_name()
1343 .map(|n| n.to_string_lossy().to_string())
1344 .unwrap_or_default();
1345 let msg = if partial_moves.len() == 1 {
1346 t!(
1347 "explorer.move_source_removal_failed",
1348 name = &name,
1349 error = first_err.to_string()
1350 )
1351 .to_string()
1352 } else {
1353 t!(
1354 "explorer.move_source_removal_failed_n",
1355 count = partial_moves.len()
1356 )
1357 .to_string()
1358 };
1359 self.set_status_message(msg);
1360 } else if let Some(e) = &first_error {
1361 let msg = if is_cut {
1362 t!("explorer.error_moving", error = e.to_string()).to_string()
1363 } else {
1364 t!("explorer.error_copying", error = e.to_string()).to_string()
1365 };
1366 self.set_status_message(msg);
1367 } else if total > 1 {
1368 let msg = if is_cut {
1369 t!("explorer.pasted_moved_n", count = total).to_string()
1370 } else {
1371 t!("explorer.pasted_n", count = total).to_string()
1372 };
1373 self.set_status_message(msg);
1374 } else if let Some((_, dst)) = succeeded.first() {
1375 let name = dst
1376 .file_name()
1377 .map(|n| n.to_string_lossy().to_string())
1378 .unwrap_or_default();
1379 let msg = if is_cut {
1380 t!("explorer.pasted_moved", name = &name).to_string()
1381 } else {
1382 t!("explorer.pasted", name = &name).to_string()
1383 };
1384 self.set_status_message(msg);
1385 }
1386
1387 if is_cut && first_error.is_none() && partial_moves.is_empty() {
1391 self.file_explorer_clipboard = None;
1392 }
1393 self.key_context = KeyContext::FileExplorer;
1394 }
1395
1396 fn paste_one_fs_op(&self, src: &Path, dst: &Path, is_cut: bool) -> PasteOpOutcome {
1400 let src_is_dir = self.authority.filesystem.is_dir(src).unwrap_or(false);
1401
1402 if src_is_dir && dst.starts_with(src) {
1410 return PasteOpOutcome::Failed(std::io::Error::new(
1411 std::io::ErrorKind::InvalidInput,
1412 "Cannot paste a directory into itself",
1413 ));
1414 }
1415
1416 if is_cut {
1417 match self.authority.filesystem.rename(src, dst) {
1422 Ok(()) => PasteOpOutcome::Ok,
1423 Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
1424 let copy_result = if src_is_dir {
1425 self.authority.filesystem.copy_dir_all(src, dst)
1426 } else {
1427 self.authority.filesystem.copy(src, dst).map(|_| ())
1428 };
1429 match copy_result {
1430 Ok(()) => {
1431 let remove_result = if src_is_dir {
1437 self.authority.filesystem.remove_dir_all(src)
1438 } else {
1439 self.authority.filesystem.remove_file(src)
1440 };
1441 match remove_result {
1442 Ok(()) => PasteOpOutcome::Ok,
1443 Err(remove_err) => PasteOpOutcome::SourceRemovalFailed {
1444 dst: dst.to_path_buf(),
1445 err: remove_err,
1446 },
1447 }
1448 }
1449 Err(copy_err) => {
1450 let cleanup = if src_is_dir {
1456 self.authority.filesystem.remove_dir_all(dst)
1457 } else {
1458 self.authority.filesystem.remove_file(dst)
1459 };
1460 if let Err(cleanup_err) = cleanup {
1461 tracing::warn!(
1462 "Failed to roll back partial destination {:?} after copy \
1463 fallback failed: {}",
1464 dst,
1465 cleanup_err
1466 );
1467 }
1468 PasteOpOutcome::Failed(copy_err)
1469 }
1470 }
1471 }
1472 Err(e) => PasteOpOutcome::Failed(e),
1473 }
1474 } else if src_is_dir {
1475 match self.authority.filesystem.copy_dir_all(src, dst) {
1476 Ok(()) => PasteOpOutcome::Ok,
1477 Err(e) => PasteOpOutcome::Failed(e),
1478 }
1479 } else {
1480 match self.authority.filesystem.copy(src, dst) {
1481 Ok(_) => PasteOpOutcome::Ok,
1482 Err(e) => PasteOpOutcome::Failed(e),
1483 }
1484 }
1485 }
1486
1487 fn refresh_tree_after_paste(&mut self, src: &Path, dst: &Path, is_cut: bool) {
1492 let Some(explorer) = &mut self.file_explorer else {
1493 return;
1494 };
1495 if let Some(runtime) = &self.tokio_runtime {
1496 if let Some(dst_parent) = dst.parent() {
1498 if let Some(dst_parent_node) = explorer.tree().get_node_by_path(dst_parent) {
1499 let pid = dst_parent_node.id;
1500 if let Err(e) = runtime.block_on(explorer.tree_mut().reload_expanded_node(pid))
1501 {
1502 tracing::warn!("Failed to reload destination directory after paste: {}", e);
1503 }
1504 }
1505 }
1506 if is_cut {
1516 if let Some(src_parent) = src.parent() {
1517 if let Some(src_parent_node) = explorer.tree().get_node_by_path(src_parent) {
1518 let pid = src_parent_node.id;
1519 if let Err(e) =
1520 runtime.block_on(explorer.tree_mut().reload_expanded_node(pid))
1521 {
1522 tracing::warn!("Failed to refresh source directory after move: {}", e);
1523 }
1524 }
1525 }
1526 }
1527 }
1528 explorer.clear_multi_selection();
1533 explorer.navigate_to_path(dst);
1534
1535 self.notify_file_explorer_change(dst);
1536 }
1537
1538 pub(super) fn notify_file_explorer_change(&self, path: &Path) {
1550 self.plugin_manager.run_hook(
1551 "after_file_explorer_change",
1552 crate::services::plugins::hooks::HookArgs::AfterFileExplorerChange {
1553 path: path.to_path_buf(),
1554 },
1555 );
1556 }
1557
1558 pub fn perform_file_explorer_paste(&mut self, src: PathBuf, dst: PathBuf, is_cut: bool) {
1559 let name = dst
1560 .file_name()
1561 .map(|n| n.to_string_lossy().to_string())
1562 .unwrap_or_default();
1563
1564 match self.paste_one_fs_op(&src, &dst, is_cut) {
1565 PasteOpOutcome::Ok => {
1566 if is_cut {
1573 self.relocate_buffers_for_rename(&src, &dst);
1574 }
1575 self.refresh_tree_after_paste(&src, &dst, is_cut);
1576 if is_cut {
1577 self.file_explorer_clipboard = None;
1578 self.set_status_message(t!("explorer.pasted_moved", name = &name).to_string());
1579 } else {
1580 self.set_status_message(t!("explorer.pasted", name = &name).to_string());
1581 }
1582 self.key_context = KeyContext::FileExplorer;
1583 }
1584 PasteOpOutcome::SourceRemovalFailed {
1585 dst: landed_dst,
1586 err,
1587 } => {
1588 self.refresh_tree_after_paste(&src, &landed_dst, is_cut);
1593 self.set_status_message(
1594 t!(
1595 "explorer.move_source_removal_failed",
1596 name = &name,
1597 error = err.to_string()
1598 )
1599 .to_string(),
1600 );
1601 self.key_context = KeyContext::FileExplorer;
1604 }
1605 PasteOpOutcome::Failed(e) => {
1606 let msg = if is_cut {
1607 t!("explorer.error_moving", error = e.to_string()).to_string()
1608 } else {
1609 t!("explorer.error_copying", error = e.to_string()).to_string()
1610 };
1611 self.set_status_message(msg);
1612 }
1613 }
1614 }
1615
1616 pub fn file_explorer_duplicate(&mut self) {
1622 let Some(explorer) = &self.file_explorer else {
1623 return;
1624 };
1625 let root_id = explorer.tree().root_id();
1626 let selected_ids = explorer.effective_selection();
1627 let sources: Vec<PathBuf> = selected_ids
1628 .iter()
1629 .filter(|&&id| id != root_id)
1630 .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1631 .collect();
1632
1633 if sources.is_empty() {
1634 self.set_status_message(t!("explorer.cannot_duplicate_root").to_string());
1635 return;
1636 }
1637
1638 let mut ops: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(sources.len());
1642 for src in &sources {
1643 let Some(parent) = src.parent() else {
1644 continue;
1645 };
1646 let Some(file_name) = src.file_name() else {
1647 continue;
1648 };
1649 let dst = unique_paste_name(
1650 &*self.authority.filesystem,
1651 parent,
1652 &file_name.to_string_lossy(),
1653 );
1654 ops.push((src.clone(), dst));
1655 }
1656
1657 if ops.is_empty() {
1658 return;
1659 }
1660
1661 let mut succeeded: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(ops.len());
1662 let mut first_error: Option<std::io::Error> = None;
1663 for (src, dst) in ops {
1664 match self.paste_one_fs_op(&src, &dst, false) {
1665 PasteOpOutcome::Ok => succeeded.push((src, dst)),
1666 PasteOpOutcome::SourceRemovalFailed { .. } => {
1667 unreachable!("paste_one_fs_op returned SourceRemovalFailed for a non-cut op");
1669 }
1670 PasteOpOutcome::Failed(e) => {
1671 if first_error.is_none() {
1672 first_error = Some(e);
1673 }
1674 }
1675 }
1676 }
1677
1678 if !succeeded.is_empty() {
1679 let (first_src, first_dst) = succeeded[0].clone();
1680 self.refresh_tree_after_paste(&first_src, &first_dst, false);
1681 }
1682
1683 let msg = if let Some(e) = &first_error {
1684 t!("explorer.error_copying", error = e.to_string()).to_string()
1685 } else if succeeded.len() == 1 {
1686 let name = succeeded[0]
1687 .1
1688 .file_name()
1689 .map(|n| n.to_string_lossy().to_string())
1690 .unwrap_or_default();
1691 t!("explorer.duplicated", name = &name).to_string()
1692 } else {
1693 t!("explorer.duplicated_n", count = succeeded.len()).to_string()
1694 };
1695 self.set_status_message(msg);
1696 self.key_context = KeyContext::FileExplorer;
1697 }
1698
1699 pub fn file_explorer_copy_path(&mut self, relative: bool) {
1706 let Some(explorer) = &self.file_explorer else {
1707 return;
1708 };
1709 let selected_ids = explorer.effective_selection();
1710 let paths: Vec<PathBuf> = selected_ids
1711 .iter()
1712 .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1713 .collect();
1714
1715 if paths.is_empty() {
1716 self.set_status_message(t!("clipboard.no_file_path").to_string());
1717 return;
1718 }
1719
1720 let working_dir = self.working_dir.clone();
1721 let rendered: Vec<String> = paths
1722 .iter()
1723 .map(|p| {
1724 if relative {
1725 p.strip_prefix(&working_dir)
1726 .unwrap_or(p)
1727 .to_string_lossy()
1728 .into_owned()
1729 } else {
1730 p.to_string_lossy().into_owned()
1731 }
1732 })
1733 .collect();
1734
1735 let joined = rendered.join("\n");
1736 self.clipboard.copy(joined.clone());
1737
1738 let msg = if rendered.len() == 1 {
1739 t!("clipboard.copied_path", path = &rendered[0]).to_string()
1740 } else {
1741 t!("clipboard.copied_paths_n", count = rendered.len()).to_string()
1742 };
1743 self.set_status_message(msg);
1744 }
1745}
1746
1747fn unique_paste_name(
1750 fs: &dyn crate::model::filesystem::FileSystem,
1751 dst_dir: &Path,
1752 name: &str,
1753) -> PathBuf {
1754 let (stem, ext) = split_stem_ext(name);
1755 let mut n = 1u32;
1756 loop {
1757 let candidate = if n == 1 {
1758 if ext.is_empty() {
1759 format!("{} copy", stem)
1760 } else {
1761 format!("{} copy.{}", stem, ext)
1762 }
1763 } else if ext.is_empty() {
1764 format!("{} copy {}", stem, n)
1765 } else {
1766 format!("{} copy {}.{}", stem, n, ext)
1767 };
1768 let path = dst_dir.join(&candidate);
1769 if !fs.exists(&path) {
1770 return path;
1771 }
1772 n += 1;
1773 if n > 1000 {
1774 return dst_dir.join(format!("{} copy {}", stem, timestamp_suffix()));
1776 }
1777 }
1778}
1779
1780pub(super) fn truncate_name_for_prompt(name: &str, max: usize) -> String {
1782 if name.chars().count() <= max {
1783 name.to_string()
1784 } else {
1785 let truncated: String = name.chars().take(max.saturating_sub(1)).collect();
1786 format!("{}\u{2026}", truncated)
1787 }
1788}
1789
1790pub(super) fn format_path_preview_for_prompt(paths: &[PathBuf], max_shown: usize) -> String {
1795 let names: Vec<String> = paths
1796 .iter()
1797 .map(|p| {
1798 let raw = p
1799 .file_name()
1800 .map(|n| n.to_string_lossy().to_string())
1801 .unwrap_or_default();
1802 format!("'{}'", truncate_name_for_prompt(&raw, 24))
1803 })
1804 .collect();
1805 if names.len() <= max_shown {
1806 names.join(", ")
1807 } else {
1808 let shown = names[..max_shown].join(", ");
1809 let more = names.len() - max_shown;
1810 format!("{}, \u{2026} ({} more)", shown, more)
1811 }
1812}
1813
1814fn split_stem_ext(name: &str) -> (&str, &str) {
1815 if let Some(dot_pos) = name.rfind('.') {
1817 if dot_pos > 0 {
1818 return (&name[..dot_pos], &name[dot_pos + 1..]);
1819 }
1820 }
1821 (name, "")
1822}