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 result = runtime.block_on(explorer.toggle_with_chain(selected_id));
314
315 let final_name = explorer
316 .tree()
317 .get_node(selected_id)
318 .map(|n| n.entry.name.clone());
319 let final_expanded = explorer
320 .tree()
321 .get_node(selected_id)
322 .map(|n| n.is_expanded())
323 .unwrap_or(false);
324
325 let mut needs_decoration_rebuild = false;
327
328 match result {
329 Ok(()) => {
330 if final_expanded {
331 let node_info = explorer
332 .tree()
333 .get_node(selected_id)
334 .map(|n| (n.entry.path.clone(), n.entry.is_symlink()));
335
336 if let Some((dir_path, is_symlink)) = node_info {
337 crate::app::file_operations::load_gitignore_via_fs(
338 self.authority.filesystem.as_ref(),
339 explorer,
340 &dir_path,
341 );
342
343 if is_symlink {
347 tracing::debug!(
348 "Symlink directory expanded, will rebuild decoration cache: {:?}",
349 dir_path
350 );
351 needs_decoration_rebuild = true;
352 }
353 }
354 }
355
356 if let Some(name) = final_name {
357 let msg = if final_expanded {
358 t!("explorer.expanded", name = &name).to_string()
359 } else {
360 t!("explorer.collapsed", name = &name).to_string()
361 };
362 self.set_status_message(msg);
363 }
364 }
365 Err(e) => {
366 self.set_status_message(
367 t!("explorer.error", error = e.to_string()).to_string(),
368 );
369 }
370 }
371
372 if needs_decoration_rebuild {
374 self.active_window_mut()
375 .rebuild_file_explorer_decoration_cache();
376 }
377 }
378 }
379
380 pub fn file_explorer_open_file(&mut self) -> AnyhowResult<()> {
381 let entry_type = self
382 .file_explorer()
383 .as_ref()
384 .and_then(|explorer| explorer.get_selected_entry())
385 .map(|entry| (entry.is_dir(), entry.path.clone(), entry.name.clone()));
386
387 if let Some((is_dir, path, name)) = entry_type {
388 if is_dir {
389 self.file_explorer_toggle_expand();
390 } else {
391 tracing::info!("[SYNTAX DEBUG] file_explorer opening file: {:?}", path);
392 match self.open_file(&path) {
393 Ok(id) => {
394 self.active_window_mut().promote_buffer_from_preview(id);
398 self.set_status_message(
399 t!("explorer.opened_file", name = &name).to_string(),
400 );
401 self.active_window_mut().focus_editor();
402 }
403 Err(e) => {
404 if let Some(confirmation) =
407 e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
408 {
409 self.start_large_file_encoding_confirmation(confirmation);
410 } else {
411 self.set_status_message(
412 t!("file.error_opening", error = e.to_string()).to_string(),
413 );
414 }
415 }
416 }
417 }
418 }
419 Ok(())
420 }
421
422 pub fn file_explorer_refresh(&mut self) {
423 let (selected_id, node_name) = if let Some(explorer) = self.file_explorer() {
424 if let Some(selected_id) = explorer.get_selected() {
425 let node_name = explorer
426 .tree()
427 .get_node(selected_id)
428 .map(|n| n.entry.name.clone());
429 (Some(selected_id), node_name)
430 } else {
431 (None, None)
432 }
433 } else {
434 return;
435 };
436
437 let Some(selected_id) = selected_id else {
438 return;
439 };
440
441 if let Some(name) = &node_name {
442 self.set_status_message(t!("explorer.refreshing", name = name).to_string());
443 }
444
445 let active_id = self.active_window;
446 if let (Some(runtime), Some(explorer)) = (
447 self.tokio_runtime.as_ref(),
448 self.windows
449 .get_mut(&active_id)
450 .and_then(|w| w.file_explorer.as_mut()),
451 ) {
452 let tree = explorer.tree_mut();
453 let result = runtime.block_on(tree.refresh_node(selected_id));
454 match result {
455 Ok(()) => {
456 if let Some(name) = node_name {
457 self.set_status_message(t!("explorer.refreshed", name = &name).to_string());
458 } else {
459 self.set_status_message(t!("explorer.refreshed_default").to_string());
460 }
461 }
462 Err(e) => {
463 self.set_status_message(
464 t!("explorer.error_refreshing", error = e.to_string()).to_string(),
465 );
466 }
467 }
468 }
469 }
470
471 pub fn file_explorer_new_file(&mut self) {
472 let active_id = self.active_window;
473 if let Some(explorer) = self
474 .windows
475 .get_mut(&active_id)
476 .and_then(|w| w.file_explorer.as_mut())
477 {
478 if let Some(selected_id) = explorer.get_selected() {
479 let node = explorer.tree().get_node(selected_id);
480 if let Some(node) = node {
481 let parent_path = get_parent_dir_path(node);
482 let filename = format!("untitled_{}.txt", timestamp_suffix());
483 let file_path = parent_path.join(&filename);
484
485 if let Some(runtime) = &self.tokio_runtime {
486 let path_clone = file_path.clone();
487 let result = self
488 .authority
489 .filesystem
490 .create_file(&path_clone)
491 .map(|_| ());
492
493 match result {
494 Ok(_) => {
495 let parent_id =
496 get_parent_node_id(explorer.tree(), selected_id, node.is_dir());
497 let tree = explorer.tree_mut();
498 if let Err(e) =
499 runtime.block_on(tree.reload_expanded_node(parent_id))
500 {
501 tracing::warn!("Failed to refresh file tree: {}", e);
502 }
503 if let Some(explorer) = self.file_explorer_mut().as_mut() {
504 explorer.navigate_to_path(&path_clone);
505 }
506 self.set_status_message(
507 t!("explorer.created_file", name = &filename).to_string(),
508 );
509 self.notify_file_explorer_change(&path_clone);
510
511 if let Err(e) = self.open_file(&path_clone) {
513 tracing::warn!("Failed to open new file: {}", e);
514 }
515
516 let prompt = crate::view::prompt::Prompt::new(
517 t!("explorer.new_file_prompt").to_string(),
518 crate::view::prompt::PromptType::FileExplorerRename {
519 original_path: path_clone,
520 original_name: filename.clone(),
521 is_new_file: true,
522 },
523 );
524 self.active_window_mut().prompt = Some(prompt);
525 }
526 Err(e) => {
527 self.set_status_message(
528 t!("explorer.error_creating_file", error = e.to_string())
529 .to_string(),
530 );
531 }
532 }
533 }
534 }
535 }
536 }
537 }
538
539 pub fn file_explorer_new_directory(&mut self) {
540 let active_id = self.active_window;
541 if let Some(explorer) = self
542 .windows
543 .get_mut(&active_id)
544 .and_then(|w| w.file_explorer.as_mut())
545 {
546 if let Some(selected_id) = explorer.get_selected() {
547 let node = explorer.tree().get_node(selected_id);
548 if let Some(node) = node {
549 let parent_path = get_parent_dir_path(node);
550 let dirname = format!("New Folder {}", timestamp_suffix());
551 let dir_path = parent_path.join(&dirname);
552
553 if let Some(runtime) = &self.tokio_runtime {
554 let path_clone = dir_path.clone();
555 let dirname_clone = dirname.clone();
556 let result = self.authority.filesystem.create_dir(&path_clone);
557
558 match result {
559 Ok(_) => {
560 let parent_id =
561 get_parent_node_id(explorer.tree(), selected_id, node.is_dir());
562 let tree = explorer.tree_mut();
563 if let Err(e) =
564 runtime.block_on(tree.reload_expanded_node(parent_id))
565 {
566 tracing::warn!("Failed to refresh file tree: {}", e);
567 }
568 if let Some(explorer) = self.file_explorer_mut().as_mut() {
569 explorer.navigate_to_path(&path_clone);
570 }
571 self.set_status_message(
572 t!("explorer.created_dir", name = &dirname_clone).to_string(),
573 );
574 self.notify_file_explorer_change(&path_clone);
575
576 let prompt = crate::view::prompt::Prompt::with_initial_text(
577 t!("explorer.new_directory_prompt").to_string(),
578 crate::view::prompt::PromptType::FileExplorerRename {
579 original_path: path_clone,
580 original_name: dirname_clone,
581 is_new_file: true,
582 },
583 dirname,
584 );
585 self.active_window_mut().prompt = Some(prompt);
586 }
587 Err(e) => {
588 self.set_status_message(
589 t!("explorer.error_creating_dir", error = e.to_string())
590 .to_string(),
591 );
592 }
593 }
594 }
595 }
596 }
597 }
598 }
599
600 pub fn file_explorer_delete(&mut self) {
601 let Some(explorer) = self.file_explorer() else {
602 return;
603 };
604 let root_id = explorer.tree().root_id();
605 let selected_ids = explorer.effective_selection();
606
607 let paths: Vec<(PathBuf, bool)> = selected_ids
608 .iter()
609 .filter(|&&id| id != root_id)
610 .filter_map(|&id| {
611 explorer
612 .tree()
613 .get_node(id)
614 .map(|n| (n.entry.path.clone(), n.is_dir()))
615 })
616 .collect();
617
618 if paths.is_empty() {
619 self.set_status_message(t!("explorer.cannot_delete_root").to_string());
620 return;
621 }
622
623 if paths.len() == 1 {
624 let (path, is_dir) = paths.into_iter().next().unwrap();
625 let name = path
626 .file_name()
627 .unwrap_or_default()
628 .to_string_lossy()
629 .to_string();
630 let type_str = if is_dir { "directory" } else { "file" };
631 self.start_prompt(
632 t!("explorer.delete_confirm", "type" = type_str, name = &name).to_string(),
633 PromptType::ConfirmDeleteFile { path, is_dir },
634 );
635 } else {
636 let count = paths.len();
637 let all_paths: Vec<PathBuf> = paths.into_iter().map(|(p, _)| p).collect();
638 let names = format_path_preview_for_prompt(&all_paths, 3);
642 self.start_prompt(
643 t!(
644 "explorer.delete_multi_confirm",
645 count = count,
646 names = &names
647 )
648 .to_string(),
649 PromptType::ConfirmMultiDelete { paths: all_paths },
650 );
651 }
652 }
653
654 pub fn perform_file_explorer_delete(&mut self, path: std::path::PathBuf, _is_dir: bool) {
658 let name = path
659 .file_name()
660 .map(|n| n.to_string_lossy().to_string())
661 .unwrap_or_default();
662
663 let delete_result = if self.authority.filesystem.remote_connection_info().is_some() {
666 self.move_to_remote_trash(&path)
667 } else {
668 trash::delete(&path).map_err(std::io::Error::other)
669 };
670
671 match delete_result {
672 Ok(_) => {
673 let to_close = self.buffer_ids_under_path(&path);
683 for id in to_close {
684 if let Err(e) = self.force_close_buffer(id) {
685 tracing::warn!(
686 "Failed to close buffer {:?} after delete of {:?}: {}",
687 id,
688 path,
689 e
690 );
691 }
692 }
693
694 let active_id = self.active_window;
696 if let Some(explorer) = self
697 .windows
698 .get_mut(&active_id)
699 .and_then(|w| w.file_explorer.as_mut())
700 {
701 if let Some(runtime) = &self.tokio_runtime {
702 if let Some(node) = explorer.tree().get_node_by_path(&path) {
704 let node_id = node.id;
705 let parent_id = get_parent_node_id(explorer.tree(), node_id, false);
706
707 let deleted_index = explorer.get_selected_index();
709
710 if let Err(e) = runtime
711 .block_on(explorer.tree_mut().reload_expanded_node(parent_id))
712 {
713 tracing::warn!("Failed to refresh file tree after delete: {}", e);
714 }
715
716 explorer.clear_multi_selection();
721
722 let count = explorer.visible_count();
725 if count > 0 {
726 let new_index = if let Some(idx) = deleted_index {
727 idx.min(count.saturating_sub(1))
728 } else {
729 0
730 };
731 if let Some(node_id) = explorer.get_node_at_index(new_index) {
732 explorer.set_selected(Some(node_id));
733 }
734 } else {
735 explorer.set_selected(Some(parent_id));
737 }
738 }
739 }
740 }
741 self.set_status_message(t!("explorer.moved_to_trash", name = &name).to_string());
742 self.notify_file_explorer_change(&path);
743
744 self.active_window_mut().key_context = KeyContext::FileExplorer;
746 }
747 Err(e) => {
748 self.set_status_message(
749 t!("explorer.error_trash", error = e.to_string()).to_string(),
750 );
751 }
752 }
753 }
754
755 fn move_to_remote_trash(&self, path: &std::path::Path) -> std::io::Result<()> {
757 let home = self.authority.filesystem.home_dir()?;
759 let trash_dir = home.join(".local/share/fresh/trash");
760
761 if !self.authority.filesystem.exists(&trash_dir) {
763 self.authority.filesystem.create_dir_all(&trash_dir)?;
764 }
765
766 let file_name = path
768 .file_name()
769 .unwrap_or_else(|| std::ffi::OsStr::new("unnamed"));
770 let timestamp = std::time::SystemTime::now()
771 .duration_since(std::time::UNIX_EPOCH)
772 .map(|d| d.as_secs())
773 .unwrap_or(0);
774 let trash_name = format!("{}.{}", file_name.to_string_lossy(), timestamp);
775 let trash_path = trash_dir.join(trash_name);
776
777 self.authority.filesystem.rename(path, &trash_path)
779 }
780
781 pub fn file_explorer_rename(&mut self) {
782 if let Some(explorer) = self.file_explorer() {
783 if let Some(selected_id) = explorer.get_selected() {
784 if selected_id == explorer.tree().root_id() {
786 self.set_status_message(t!("explorer.cannot_rename_root").to_string());
787 return;
788 }
789
790 let node = explorer.tree().get_node(selected_id);
791 if let Some(node) = node {
792 let old_path = node.entry.path.clone();
793 let old_name = node.entry.name.clone();
794
795 let prompt = crate::view::prompt::Prompt::with_initial_text_for_edit(
800 t!("explorer.rename_prompt").to_string(),
801 crate::view::prompt::PromptType::FileExplorerRename {
802 original_path: old_path,
803 original_name: old_name.clone(),
804 is_new_file: false,
805 },
806 old_name,
807 );
808 self.active_window_mut().prompt = Some(prompt);
809 }
810 }
811 }
812 }
813
814 pub fn perform_file_explorer_rename(
816 &mut self,
817 original_path: std::path::PathBuf,
818 original_name: String,
819 new_name: String,
820 is_new_file: bool,
821 ) {
822 if new_name.is_empty() || new_name == original_name {
823 self.set_status_message(t!("explorer.rename_cancelled").to_string());
824 return;
825 }
826
827 if new_name.chars().any(std::path::is_separator) {
832 self.set_status_message(t!("explorer.rename_invalid_separator").to_string());
833 return;
834 }
835 if new_name == "." || new_name == ".." {
836 self.set_status_message(t!("explorer.rename_invalid_dot").to_string());
837 return;
838 }
839
840 let new_path = original_path
841 .parent()
842 .map(|p| p.join(&new_name))
843 .unwrap_or_else(|| original_path.clone());
844
845 if self.tokio_runtime.is_some() {
846 let result = self.authority.filesystem.rename(&original_path, &new_path);
847
848 match result {
849 Ok(_) => {
850 let active_id = self.active_window;
854 if let (Some(runtime), Some(explorer)) = (
855 self.tokio_runtime.as_ref(),
856 self.windows
857 .get_mut(&active_id)
858 .and_then(|w| w.file_explorer.as_mut()),
859 ) {
860 if let Some(selected_id) = explorer.get_selected() {
861 let parent_id = get_parent_node_id(explorer.tree(), selected_id, false);
862 let tree = explorer.tree_mut();
863 if let Err(e) = runtime.block_on(tree.reload_expanded_node(parent_id)) {
864 tracing::warn!("Failed to refresh file tree after rename: {}", e);
865 }
866 }
867 explorer.clear_multi_selection();
871 explorer.navigate_to_path(&new_path);
873 }
874
875 let relocated = self.relocate_buffers_for_rename(&original_path, &new_path);
883
884 if is_new_file && !relocated.is_empty() {
888 self.active_window_mut().key_context = KeyContext::Normal;
889 }
890
891 self.set_status_message(
892 t!("explorer.renamed", old = &original_name, new = &new_name).to_string(),
893 );
894 self.notify_file_explorer_change(&new_path);
895 }
896 Err(e) => {
897 self.set_status_message(
898 t!("explorer.error_renaming", error = e.to_string()).to_string(),
899 );
900 }
901 }
902 }
903 }
904
905 pub fn file_explorer_toggle_hidden(&mut self) {
906 let show_hidden = if let Some(explorer) = self.file_explorer_mut() {
907 explorer.toggle_show_hidden();
908 explorer.ignore_patterns().show_hidden()
909 } else {
910 return;
911 };
912
913 let msg = if show_hidden {
914 t!("explorer.showing_hidden")
915 } else {
916 t!("explorer.hiding_hidden")
917 };
918 self.set_status_message(msg.to_string());
919
920 self.config_mut().file_explorer.show_hidden = show_hidden;
922 self.persist_config_change(
923 "/file_explorer/show_hidden",
924 serde_json::Value::Bool(show_hidden),
925 );
926 }
927
928 pub fn file_explorer_toggle_gitignored(&mut self) {
929 let show_gitignored = if let Some(explorer) = self.file_explorer_mut() {
930 explorer.toggle_show_gitignored();
931 explorer.ignore_patterns().show_gitignored()
932 } else {
933 return;
934 };
935
936 let msg = if show_gitignored {
937 t!("explorer.showing_gitignored")
938 } else {
939 t!("explorer.hiding_gitignored")
940 };
941 self.set_status_message(msg.to_string());
942
943 self.config_mut().file_explorer.show_gitignored = show_gitignored;
945 self.persist_config_change(
946 "/file_explorer/show_gitignored",
947 serde_json::Value::Bool(show_gitignored),
948 );
949 }
950
951 pub fn file_explorer_paste(&mut self) {
971 let clipboard = match self.active_window().file_explorer_clipboard.clone() {
972 Some(c) => c,
973 None => {
974 self.set_status_message(t!("explorer.paste_no_source").to_string());
975 return;
976 }
977 };
978
979 let dst_dir = if let Some(explorer) = self.file_explorer() {
980 if let Some(selected_id) = explorer.get_selected() {
981 if let Some(node) = explorer.tree().get_node(selected_id) {
982 get_parent_dir_path(node)
983 } else {
984 return;
985 }
986 } else {
987 return;
988 }
989 } else {
990 return;
991 };
992
993 let is_cut = clipboard.is_cut;
994
995 if clipboard.paths.len() == 1 {
996 let src = clipboard.paths[0].clone();
997 let file_name = match src.file_name() {
998 Some(n) => n.to_os_string(),
999 None => return,
1000 };
1001 let dst_path = dst_dir.join(&file_name);
1002
1003 if src.parent().map(|p| p == dst_dir).unwrap_or(false) {
1004 if is_cut {
1005 self.active_window_mut().file_explorer_clipboard = None;
1010 self.set_status_message(t!("explorer.cut_cancelled").to_string());
1011 return;
1012 } else {
1013 let unique = unique_paste_name(
1014 &*self.authority.filesystem,
1015 &dst_dir,
1016 &file_name.to_string_lossy(),
1017 );
1018 self.perform_file_explorer_paste(src, unique, false);
1019 return;
1020 }
1021 }
1022
1023 if self.authority.filesystem.exists(&dst_path) {
1024 let name = truncate_name_for_prompt(&file_name.to_string_lossy(), 40);
1025 self.start_prompt(
1026 t!("explorer.paste_conflict", name = &name).to_string(),
1027 crate::view::prompt::PromptType::ConfirmPasteConflict {
1028 src,
1029 dst: dst_path,
1030 is_cut,
1031 },
1032 );
1033 } else {
1034 self.perform_file_explorer_paste(src, dst_path, is_cut);
1035 }
1036 } else {
1037 let mut safe: Vec<(PathBuf, PathBuf)> = Vec::new();
1039 let mut conflicts: Vec<(PathBuf, PathBuf)> = Vec::new();
1040
1041 for src in &clipboard.paths {
1042 let file_name = match src.file_name() {
1043 Some(n) => n.to_os_string(),
1044 None => continue,
1045 };
1046 let dst_path = dst_dir.join(&file_name);
1047 let is_same_location = src.parent().map(|p| p == dst_dir).unwrap_or(false);
1048
1049 if is_same_location {
1050 if !is_cut {
1051 let unique = unique_paste_name(
1053 &*self.authority.filesystem,
1054 &dst_dir,
1055 &file_name.to_string_lossy(),
1056 );
1057 safe.push((src.clone(), unique));
1058 }
1059 } else if self.authority.filesystem.exists(&dst_path) {
1061 conflicts.push((src.clone(), dst_path));
1062 } else {
1063 safe.push((src.clone(), dst_path));
1064 }
1065 }
1066
1067 if safe.is_empty() && conflicts.is_empty() {
1068 if is_cut {
1072 self.active_window_mut().file_explorer_clipboard = None;
1073 self.set_status_message(t!("explorer.cut_cancelled").to_string());
1074 } else {
1075 self.set_status_message(t!("explorer.paste_same_location").to_string());
1076 }
1077 return;
1078 }
1079
1080 if conflicts.is_empty() {
1081 self.execute_resolved_multi_paste(safe, vec![], is_cut);
1082 } else {
1083 let name = truncate_name_for_prompt(
1084 &conflicts[0]
1085 .1
1086 .file_name()
1087 .unwrap_or_default()
1088 .to_string_lossy(),
1089 40,
1090 );
1091 self.start_prompt(
1092 t!("explorer.paste_conflict_multi", name = &name).to_string(),
1093 crate::view::prompt::PromptType::ConfirmMultiPasteConflict {
1094 safe,
1095 confirmed: Vec::new(),
1096 pending: conflicts,
1097 is_cut,
1098 },
1099 );
1100 }
1101 }
1102 }
1103
1104 pub(super) fn execute_resolved_multi_paste(
1112 &mut self,
1113 safe: Vec<(PathBuf, PathBuf)>,
1114 to_overwrite: Vec<(PathBuf, PathBuf)>,
1115 is_cut: bool,
1116 ) {
1117 let total = safe.len() + to_overwrite.len();
1118 if total == 0 {
1119 return;
1120 }
1121
1122 let mut succeeded: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(total);
1123 let mut clean_moves: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(total);
1130 let mut first_error: Option<std::io::Error> = None;
1131 let mut partial_moves: Vec<(PathBuf, std::io::Error)> = Vec::new();
1132 for (src, dst) in safe.into_iter().chain(to_overwrite) {
1133 match self.paste_one_fs_op(&src, &dst, is_cut) {
1134 PasteOpOutcome::Ok => {
1135 clean_moves.push((src.clone(), dst.clone()));
1136 succeeded.push((src, dst));
1137 }
1138 PasteOpOutcome::SourceRemovalFailed {
1139 dst: landed_dst,
1140 err,
1141 } => {
1142 succeeded.push((src, landed_dst.clone()));
1146 partial_moves.push((landed_dst, err));
1147 }
1148 PasteOpOutcome::Failed(e) => {
1149 if first_error.is_none() {
1150 first_error = Some(e);
1151 }
1152 }
1153 }
1154 }
1155
1156 if is_cut {
1162 for (src, dst) in &clean_moves {
1163 self.relocate_buffers_for_rename(src, dst);
1164 }
1165 }
1166
1167 if !succeeded.is_empty() {
1168 let first_dst = succeeded[0].1.clone();
1169 let any_src = succeeded[0].0.clone();
1170 self.refresh_tree_after_paste(&any_src, &first_dst, is_cut);
1171 }
1172
1173 if !partial_moves.is_empty() {
1174 let (first_dst, first_err) = &partial_moves[0];
1177 let name = first_dst
1178 .file_name()
1179 .map(|n| n.to_string_lossy().to_string())
1180 .unwrap_or_default();
1181 let msg = if partial_moves.len() == 1 {
1182 t!(
1183 "explorer.move_source_removal_failed",
1184 name = &name,
1185 error = first_err.to_string()
1186 )
1187 .to_string()
1188 } else {
1189 t!(
1190 "explorer.move_source_removal_failed_n",
1191 count = partial_moves.len()
1192 )
1193 .to_string()
1194 };
1195 self.set_status_message(msg);
1196 } else if let Some(e) = &first_error {
1197 let msg = if is_cut {
1198 t!("explorer.error_moving", error = e.to_string()).to_string()
1199 } else {
1200 t!("explorer.error_copying", error = e.to_string()).to_string()
1201 };
1202 self.set_status_message(msg);
1203 } else if total > 1 {
1204 let msg = if is_cut {
1205 t!("explorer.pasted_moved_n", count = total).to_string()
1206 } else {
1207 t!("explorer.pasted_n", count = total).to_string()
1208 };
1209 self.set_status_message(msg);
1210 } else if let Some((_, dst)) = succeeded.first() {
1211 let name = dst
1212 .file_name()
1213 .map(|n| n.to_string_lossy().to_string())
1214 .unwrap_or_default();
1215 let msg = if is_cut {
1216 t!("explorer.pasted_moved", name = &name).to_string()
1217 } else {
1218 t!("explorer.pasted", name = &name).to_string()
1219 };
1220 self.set_status_message(msg);
1221 }
1222
1223 if is_cut && first_error.is_none() && partial_moves.is_empty() {
1227 self.active_window_mut().file_explorer_clipboard = None;
1228 }
1229 self.active_window_mut().key_context = KeyContext::FileExplorer;
1230 }
1231
1232 fn paste_one_fs_op(&self, src: &Path, dst: &Path, is_cut: bool) -> PasteOpOutcome {
1236 let src_is_dir = self.authority.filesystem.is_dir(src).unwrap_or(false);
1237
1238 if src_is_dir && dst.starts_with(src) {
1246 return PasteOpOutcome::Failed(std::io::Error::new(
1247 std::io::ErrorKind::InvalidInput,
1248 "Cannot paste a directory into itself",
1249 ));
1250 }
1251
1252 if is_cut {
1253 match self.authority.filesystem.rename(src, dst) {
1258 Ok(()) => PasteOpOutcome::Ok,
1259 Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
1260 let copy_result = if src_is_dir {
1261 self.authority.filesystem.copy_dir_all(src, dst)
1262 } else {
1263 self.authority.filesystem.copy(src, dst).map(|_| ())
1264 };
1265 match copy_result {
1266 Ok(()) => {
1267 let remove_result = if src_is_dir {
1273 self.authority.filesystem.remove_dir_all(src)
1274 } else {
1275 self.authority.filesystem.remove_file(src)
1276 };
1277 match remove_result {
1278 Ok(()) => PasteOpOutcome::Ok,
1279 Err(remove_err) => PasteOpOutcome::SourceRemovalFailed {
1280 dst: dst.to_path_buf(),
1281 err: remove_err,
1282 },
1283 }
1284 }
1285 Err(copy_err) => {
1286 let cleanup = if src_is_dir {
1292 self.authority.filesystem.remove_dir_all(dst)
1293 } else {
1294 self.authority.filesystem.remove_file(dst)
1295 };
1296 if let Err(cleanup_err) = cleanup {
1297 tracing::warn!(
1298 "Failed to roll back partial destination {:?} after copy \
1299 fallback failed: {}",
1300 dst,
1301 cleanup_err
1302 );
1303 }
1304 PasteOpOutcome::Failed(copy_err)
1305 }
1306 }
1307 }
1308 Err(e) => PasteOpOutcome::Failed(e),
1309 }
1310 } else if src_is_dir {
1311 match self.authority.filesystem.copy_dir_all(src, dst) {
1312 Ok(()) => PasteOpOutcome::Ok,
1313 Err(e) => PasteOpOutcome::Failed(e),
1314 }
1315 } else {
1316 match self.authority.filesystem.copy(src, dst) {
1317 Ok(_) => PasteOpOutcome::Ok,
1318 Err(e) => PasteOpOutcome::Failed(e),
1319 }
1320 }
1321 }
1322
1323 fn refresh_tree_after_paste(&mut self, src: &Path, dst: &Path, is_cut: bool) {
1328 let active_id = self.active_window;
1329 let Some(explorer) = self
1332 .windows
1333 .get_mut(&active_id)
1334 .and_then(|w| w.file_explorer.as_mut())
1335 else {
1336 return;
1337 };
1338 if let Some(runtime) = &self.tokio_runtime {
1339 if let Some(dst_parent) = dst.parent() {
1341 if let Some(dst_parent_node) = explorer.tree().get_node_by_path(dst_parent) {
1342 let pid = dst_parent_node.id;
1343 if let Err(e) = runtime.block_on(explorer.tree_mut().reload_expanded_node(pid))
1344 {
1345 tracing::warn!("Failed to reload destination directory after paste: {}", e);
1346 }
1347 }
1348 }
1349 if is_cut {
1359 if let Some(src_parent) = src.parent() {
1360 if let Some(src_parent_node) = explorer.tree().get_node_by_path(src_parent) {
1361 let pid = src_parent_node.id;
1362 if let Err(e) =
1363 runtime.block_on(explorer.tree_mut().reload_expanded_node(pid))
1364 {
1365 tracing::warn!("Failed to refresh source directory after move: {}", e);
1366 }
1367 }
1368 }
1369 }
1370 }
1371 explorer.clear_multi_selection();
1376 explorer.navigate_to_path(dst);
1377
1378 self.notify_file_explorer_change(dst);
1379 }
1380
1381 pub(super) fn notify_file_explorer_change(&self, path: &Path) {
1393 self.plugin_manager.read().unwrap().run_hook(
1394 "after_file_explorer_change",
1395 crate::services::plugins::hooks::HookArgs::AfterFileExplorerChange {
1396 path: path.to_path_buf(),
1397 },
1398 );
1399 }
1400
1401 pub fn perform_file_explorer_paste(&mut self, src: PathBuf, dst: PathBuf, is_cut: bool) {
1402 let name = dst
1403 .file_name()
1404 .map(|n| n.to_string_lossy().to_string())
1405 .unwrap_or_default();
1406
1407 match self.paste_one_fs_op(&src, &dst, is_cut) {
1408 PasteOpOutcome::Ok => {
1409 if is_cut {
1416 self.relocate_buffers_for_rename(&src, &dst);
1417 }
1418 self.refresh_tree_after_paste(&src, &dst, is_cut);
1419 if is_cut {
1420 self.active_window_mut().file_explorer_clipboard = None;
1421 self.set_status_message(t!("explorer.pasted_moved", name = &name).to_string());
1422 } else {
1423 self.set_status_message(t!("explorer.pasted", name = &name).to_string());
1424 }
1425 self.active_window_mut().key_context = KeyContext::FileExplorer;
1426 }
1427 PasteOpOutcome::SourceRemovalFailed {
1428 dst: landed_dst,
1429 err,
1430 } => {
1431 self.refresh_tree_after_paste(&src, &landed_dst, is_cut);
1436 self.set_status_message(
1437 t!(
1438 "explorer.move_source_removal_failed",
1439 name = &name,
1440 error = err.to_string()
1441 )
1442 .to_string(),
1443 );
1444 self.active_window_mut().key_context = KeyContext::FileExplorer;
1447 }
1448 PasteOpOutcome::Failed(e) => {
1449 let msg = if is_cut {
1450 t!("explorer.error_moving", error = e.to_string()).to_string()
1451 } else {
1452 t!("explorer.error_copying", error = e.to_string()).to_string()
1453 };
1454 self.set_status_message(msg);
1455 }
1456 }
1457 }
1458
1459 pub fn file_explorer_duplicate(&mut self) {
1465 let Some(explorer) = self.file_explorer() else {
1466 return;
1467 };
1468 let root_id = explorer.tree().root_id();
1469 let selected_ids = explorer.effective_selection();
1470 let sources: Vec<PathBuf> = selected_ids
1471 .iter()
1472 .filter(|&&id| id != root_id)
1473 .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1474 .collect();
1475
1476 if sources.is_empty() {
1477 self.set_status_message(t!("explorer.cannot_duplicate_root").to_string());
1478 return;
1479 }
1480
1481 let mut ops: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(sources.len());
1485 for src in &sources {
1486 let Some(parent) = src.parent() else {
1487 continue;
1488 };
1489 let Some(file_name) = src.file_name() else {
1490 continue;
1491 };
1492 let dst = unique_paste_name(
1493 &*self.authority.filesystem,
1494 parent,
1495 &file_name.to_string_lossy(),
1496 );
1497 ops.push((src.clone(), dst));
1498 }
1499
1500 if ops.is_empty() {
1501 return;
1502 }
1503
1504 let mut succeeded: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(ops.len());
1505 let mut first_error: Option<std::io::Error> = None;
1506 for (src, dst) in ops {
1507 match self.paste_one_fs_op(&src, &dst, false) {
1508 PasteOpOutcome::Ok => succeeded.push((src, dst)),
1509 PasteOpOutcome::SourceRemovalFailed { .. } => {
1510 unreachable!("paste_one_fs_op returned SourceRemovalFailed for a non-cut op");
1512 }
1513 PasteOpOutcome::Failed(e) => {
1514 if first_error.is_none() {
1515 first_error = Some(e);
1516 }
1517 }
1518 }
1519 }
1520
1521 if !succeeded.is_empty() {
1522 let (first_src, first_dst) = succeeded[0].clone();
1523 self.refresh_tree_after_paste(&first_src, &first_dst, false);
1524 }
1525
1526 let msg = if let Some(e) = &first_error {
1527 t!("explorer.error_copying", error = e.to_string()).to_string()
1528 } else if succeeded.len() == 1 {
1529 let name = succeeded[0]
1530 .1
1531 .file_name()
1532 .map(|n| n.to_string_lossy().to_string())
1533 .unwrap_or_default();
1534 t!("explorer.duplicated", name = &name).to_string()
1535 } else {
1536 t!("explorer.duplicated_n", count = succeeded.len()).to_string()
1537 };
1538 self.set_status_message(msg);
1539 self.active_window_mut().key_context = KeyContext::FileExplorer;
1540 }
1541
1542 pub fn file_explorer_copy_path(&mut self, relative: bool) {
1549 let Some(explorer) = self.file_explorer() else {
1550 return;
1551 };
1552 let selected_ids = explorer.effective_selection();
1553 let paths: Vec<PathBuf> = selected_ids
1554 .iter()
1555 .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1556 .collect();
1557
1558 if paths.is_empty() {
1559 self.set_status_message(t!("clipboard.no_file_path").to_string());
1560 return;
1561 }
1562
1563 let working_dir = self.working_dir.clone();
1564 let rendered: Vec<String> = paths
1565 .iter()
1566 .map(|p| {
1567 if relative {
1568 p.strip_prefix(&working_dir)
1569 .unwrap_or(p)
1570 .to_string_lossy()
1571 .into_owned()
1572 } else {
1573 p.to_string_lossy().into_owned()
1574 }
1575 })
1576 .collect();
1577
1578 let joined = rendered.join("\n");
1579 self.clipboard.copy(joined.clone());
1580
1581 let msg = if rendered.len() == 1 {
1582 t!("clipboard.copied_path", path = &rendered[0]).to_string()
1583 } else {
1584 t!("clipboard.copied_paths_n", count = rendered.len()).to_string()
1585 };
1586 self.set_status_message(msg);
1587 }
1588}
1589
1590impl crate::app::window::Window {
1591 pub fn focus_editor(&mut self) {
1594 self.key_context = KeyContext::Normal;
1595 self.set_status_message(t!("editor.focused").to_string());
1596 }
1597
1598 pub fn file_explorer_search_clear(&mut self) {
1605 if matches!(
1606 self.file_explorer_clipboard,
1607 Some(FileExplorerClipboard { is_cut: true, .. })
1608 ) {
1609 self.file_explorer_clipboard = None;
1610 self.set_status_message(t!("explorer.cut_cancelled").to_string());
1611 return;
1612 }
1613 let action = self.file_explorer.as_mut().map(|explorer| {
1614 if explorer.has_multi_selection() {
1615 explorer.clear_multi_selection();
1616 None
1617 } else if explorer.is_search_active() {
1618 explorer.search_clear();
1619 None
1620 } else {
1621 Some(())
1622 }
1623 });
1624 if let Some(Some(())) = action {
1625 self.focus_editor();
1626 }
1627 }
1628
1629 pub fn handle_set_file_explorer_decorations(
1634 &mut self,
1635 namespace: String,
1636 decorations: Vec<crate::view::file_tree::FileExplorerDecoration>,
1637 ) {
1638 let root = self.root.clone();
1639 let normalized: Vec<crate::view::file_tree::FileExplorerDecoration> = decorations
1640 .into_iter()
1641 .filter_map(|mut decoration| {
1642 let path = if decoration.path.is_absolute() {
1643 decoration.path
1644 } else {
1645 root.join(&decoration.path)
1646 };
1647 let path = crate::app::normalize_path(&path);
1648 if path.starts_with(&root) {
1649 decoration.path = path;
1650 Some(decoration)
1651 } else {
1652 None
1653 }
1654 })
1655 .collect();
1656
1657 self.file_explorer_decorations.insert(namespace, normalized);
1658 self.rebuild_file_explorer_decoration_cache();
1659 }
1660
1661 pub fn handle_clear_file_explorer_decorations(&mut self, namespace: &str) {
1664 self.file_explorer_decorations.remove(namespace);
1665 self.rebuild_file_explorer_decoration_cache();
1666 }
1667
1668 pub fn rebuild_file_explorer_decoration_cache(&mut self) {
1672 let decorations: Vec<_> = self
1673 .file_explorer_decorations
1674 .values()
1675 .flat_map(|entries| entries.iter().cloned())
1676 .collect();
1677
1678 let symlink_mappings = self
1679 .file_explorer
1680 .as_ref()
1681 .map(|fe| fe.collect_symlink_mappings())
1682 .unwrap_or_default();
1683
1684 self.file_explorer_decoration_cache =
1685 crate::view::file_tree::FileExplorerDecorationCache::rebuild(
1686 decorations.into_iter(),
1687 &self.root,
1688 &symlink_mappings,
1689 );
1690 }
1691
1692 pub fn file_explorer_clipboard(&self) -> Option<&FileExplorerClipboard> {
1694 self.file_explorer_clipboard.as_ref()
1695 }
1696
1697 pub fn file_explorer_copy(&mut self) {
1699 self.set_explorer_clipboard(false);
1700 }
1701
1702 pub fn file_explorer_cut(&mut self) {
1704 self.set_explorer_clipboard(true);
1705 }
1706
1707 fn set_explorer_clipboard(&mut self, is_cut: bool) {
1712 let Some(explorer) = self.file_explorer.as_ref() else {
1713 return;
1714 };
1715 let root_id = explorer.tree().root_id();
1716 let selected_ids = explorer.effective_selection();
1717 let paths: Vec<PathBuf> = selected_ids
1718 .iter()
1719 .filter(|&&id| id != root_id)
1720 .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1721 .collect();
1722 if paths.is_empty() {
1723 let msg = if is_cut {
1724 t!("explorer.cannot_cut_root").to_string()
1725 } else {
1726 t!("explorer.cannot_copy_root").to_string()
1727 };
1728 self.set_status_message(msg);
1729 return;
1730 }
1731 let msg = if paths.len() == 1 {
1732 let name = paths[0]
1733 .file_name()
1734 .unwrap_or_default()
1735 .to_string_lossy()
1736 .to_string();
1737 if is_cut {
1738 t!("explorer.cut", name = &name).to_string()
1739 } else {
1740 t!("explorer.copied", name = &name).to_string()
1741 }
1742 } else {
1743 let count = paths.len();
1744 if is_cut {
1745 t!("explorer.cut_n", count = count).to_string()
1746 } else {
1747 t!("explorer.copied_n", count = count).to_string()
1748 }
1749 };
1750 self.file_explorer_clipboard = Some(FileExplorerClipboard { paths, is_cut });
1751 self.set_status_message(msg);
1752 }
1753
1754 pub fn sync_file_explorer_to_active_file(&mut self) {
1759 if !self.file_explorer_visible {
1760 return;
1761 }
1762
1763 if self.file_explorer_sync_in_progress {
1765 return;
1766 }
1767
1768 let active_buf = self.active_buffer();
1769 let Some(metadata) = self.buffer_metadata.get(&active_buf) else {
1770 return;
1771 };
1772 let Some(file_path) = metadata.file_path() else {
1773 return;
1774 };
1775 let target_path = file_path.clone();
1776
1777 if !target_path.starts_with(&self.root) {
1778 return;
1779 }
1780
1781 let Some(mut view) = self.file_explorer.take() else {
1782 return;
1783 };
1784 tracing::trace!(
1785 "sync_file_explorer_to_active_file: taking file_explorer for async expand to {:?}",
1786 target_path
1787 );
1788 let runtime_handle = self
1789 .resources
1790 .tokio_runtime
1791 .as_ref()
1792 .map(|r| r.handle().clone());
1793 let sender = self.resources.async_bridge.as_ref().map(|b| b.sender());
1794 if let (Some(runtime), Some(sender)) = (runtime_handle, sender) {
1795 self.file_explorer_sync_in_progress = true;
1797
1798 runtime.spawn(async move {
1799 let _success = view.expand_and_select_file(&target_path).await;
1800 #[allow(clippy::let_underscore_must_use)]
1802 let _ = sender.send(
1803 crate::services::async_bridge::AsyncMessage::FileExplorerExpandedToPath(view),
1804 );
1805 });
1806 } else {
1807 self.file_explorer = Some(view);
1808 }
1809 }
1810}
1811
1812fn unique_paste_name(
1815 fs: &dyn crate::model::filesystem::FileSystem,
1816 dst_dir: &Path,
1817 name: &str,
1818) -> PathBuf {
1819 let (stem, ext) = split_stem_ext(name);
1820 let mut n = 1u32;
1821 loop {
1822 let candidate = if n == 1 {
1823 if ext.is_empty() {
1824 format!("{} copy", stem)
1825 } else {
1826 format!("{} copy.{}", stem, ext)
1827 }
1828 } else if ext.is_empty() {
1829 format!("{} copy {}", stem, n)
1830 } else {
1831 format!("{} copy {}.{}", stem, n, ext)
1832 };
1833 let path = dst_dir.join(&candidate);
1834 if !fs.exists(&path) {
1835 return path;
1836 }
1837 n += 1;
1838 if n > 1000 {
1839 return dst_dir.join(format!("{} copy {}", stem, timestamp_suffix()));
1841 }
1842 }
1843}
1844
1845pub(super) fn truncate_name_for_prompt(name: &str, max: usize) -> String {
1847 if name.chars().count() <= max {
1848 name.to_string()
1849 } else {
1850 let truncated: String = name.chars().take(max.saturating_sub(1)).collect();
1851 format!("{}\u{2026}", truncated)
1852 }
1853}
1854
1855pub(super) fn format_path_preview_for_prompt(paths: &[PathBuf], max_shown: usize) -> String {
1860 let names: Vec<String> = paths
1861 .iter()
1862 .map(|p| {
1863 let raw = p
1864 .file_name()
1865 .map(|n| n.to_string_lossy().to_string())
1866 .unwrap_or_default();
1867 format!("'{}'", truncate_name_for_prompt(&raw, 24))
1868 })
1869 .collect();
1870 if names.len() <= max_shown {
1871 names.join(", ")
1872 } else {
1873 let shown = names[..max_shown].join(", ");
1874 let more = names.len() - max_shown;
1875 format!("{}, \u{2026} ({} more)", shown, more)
1876 }
1877}
1878
1879fn split_stem_ext(name: &str) -> (&str, &str) {
1880 if let Some(dot_pos) = name.rfind('.') {
1882 if dot_pos > 0 {
1883 return (&name[..dot_pos], &name[dot_pos + 1..]);
1884 }
1885 }
1886 (name, "")
1887}