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
537 if let Err(e) = self.open_file(&path_clone) {
539 tracing::warn!("Failed to open new file: {}", e);
540 }
541
542 let prompt = crate::view::prompt::Prompt::new(
543 t!("explorer.new_file_prompt").to_string(),
544 crate::view::prompt::PromptType::FileExplorerRename {
545 original_path: path_clone,
546 original_name: filename.clone(),
547 is_new_file: true,
548 },
549 );
550 self.prompt = Some(prompt);
551 }
552 Err(e) => {
553 self.set_status_message(
554 t!("explorer.error_creating_file", error = e.to_string())
555 .to_string(),
556 );
557 }
558 }
559 }
560 }
561 }
562 }
563 }
564
565 pub fn file_explorer_new_directory(&mut self) {
566 if let Some(explorer) = &mut self.file_explorer {
567 if let Some(selected_id) = explorer.get_selected() {
568 let node = explorer.tree().get_node(selected_id);
569 if let Some(node) = node {
570 let parent_path = get_parent_dir_path(node);
571 let dirname = format!("New Folder {}", timestamp_suffix());
572 let dir_path = parent_path.join(&dirname);
573
574 if let Some(runtime) = &self.tokio_runtime {
575 let path_clone = dir_path.clone();
576 let dirname_clone = dirname.clone();
577 let result = self.authority.filesystem.create_dir(&path_clone);
578
579 match result {
580 Ok(_) => {
581 let parent_id =
582 get_parent_node_id(explorer.tree(), selected_id, node.is_dir());
583 let tree = explorer.tree_mut();
584 if let Err(e) =
585 runtime.block_on(tree.reload_expanded_node(parent_id))
586 {
587 tracing::warn!("Failed to refresh file tree: {}", e);
588 }
589 if let Some(ref mut explorer) = self.file_explorer {
590 explorer.navigate_to_path(&path_clone);
591 }
592 self.set_status_message(
593 t!("explorer.created_dir", name = &dirname_clone).to_string(),
594 );
595
596 let prompt = crate::view::prompt::Prompt::with_initial_text(
597 t!("explorer.new_directory_prompt").to_string(),
598 crate::view::prompt::PromptType::FileExplorerRename {
599 original_path: path_clone,
600 original_name: dirname_clone,
601 is_new_file: true,
602 },
603 dirname,
604 );
605 self.prompt = Some(prompt);
606 }
607 Err(e) => {
608 self.set_status_message(
609 t!("explorer.error_creating_dir", error = e.to_string())
610 .to_string(),
611 );
612 }
613 }
614 }
615 }
616 }
617 }
618 }
619
620 pub fn file_explorer_delete(&mut self) {
621 let Some(explorer) = &self.file_explorer else {
622 return;
623 };
624 let root_id = explorer.tree().root_id();
625 let selected_ids = explorer.effective_selection();
626
627 let paths: Vec<(PathBuf, bool)> = selected_ids
628 .iter()
629 .filter(|&&id| id != root_id)
630 .filter_map(|&id| {
631 explorer
632 .tree()
633 .get_node(id)
634 .map(|n| (n.entry.path.clone(), n.is_dir()))
635 })
636 .collect();
637
638 if paths.is_empty() {
639 self.set_status_message(t!("explorer.cannot_delete_root").to_string());
640 return;
641 }
642
643 if paths.len() == 1 {
644 let (path, is_dir) = paths.into_iter().next().unwrap();
645 let name = path
646 .file_name()
647 .unwrap_or_default()
648 .to_string_lossy()
649 .to_string();
650 let type_str = if is_dir { "directory" } else { "file" };
651 self.start_prompt(
652 t!("explorer.delete_confirm", "type" = type_str, name = &name).to_string(),
653 PromptType::ConfirmDeleteFile { path, is_dir },
654 );
655 } else {
656 let count = paths.len();
657 let all_paths: Vec<PathBuf> = paths.into_iter().map(|(p, _)| p).collect();
658 let names = format_path_preview_for_prompt(&all_paths, 3);
662 self.start_prompt(
663 t!(
664 "explorer.delete_multi_confirm",
665 count = count,
666 names = &names
667 )
668 .to_string(),
669 PromptType::ConfirmMultiDelete { paths: all_paths },
670 );
671 }
672 }
673
674 pub fn perform_file_explorer_delete(&mut self, path: std::path::PathBuf, _is_dir: bool) {
678 let name = path
679 .file_name()
680 .map(|n| n.to_string_lossy().to_string())
681 .unwrap_or_default();
682
683 let delete_result = if self.authority.filesystem.remote_connection_info().is_some() {
686 self.move_to_remote_trash(&path)
687 } else {
688 trash::delete(&path).map_err(std::io::Error::other)
689 };
690
691 match delete_result {
692 Ok(_) => {
693 let to_close = self.buffer_ids_under_path(&path);
703 for id in to_close {
704 if let Err(e) = self.force_close_buffer(id) {
705 tracing::warn!(
706 "Failed to close buffer {:?} after delete of {:?}: {}",
707 id,
708 path,
709 e
710 );
711 }
712 }
713
714 if let Some(explorer) = &mut self.file_explorer {
716 if let Some(runtime) = &self.tokio_runtime {
717 if let Some(node) = explorer.tree().get_node_by_path(&path) {
719 let node_id = node.id;
720 let parent_id = get_parent_node_id(explorer.tree(), node_id, false);
721
722 let deleted_index = explorer.get_selected_index();
724
725 if let Err(e) = runtime
726 .block_on(explorer.tree_mut().reload_expanded_node(parent_id))
727 {
728 tracing::warn!("Failed to refresh file tree after delete: {}", e);
729 }
730
731 explorer.clear_multi_selection();
736
737 let count = explorer.visible_count();
740 if count > 0 {
741 let new_index = if let Some(idx) = deleted_index {
742 idx.min(count.saturating_sub(1))
743 } else {
744 0
745 };
746 if let Some(node_id) = explorer.get_node_at_index(new_index) {
747 explorer.set_selected(Some(node_id));
748 }
749 } else {
750 explorer.set_selected(Some(parent_id));
752 }
753 }
754 }
755 }
756 self.set_status_message(t!("explorer.moved_to_trash", name = &name).to_string());
757
758 self.key_context = KeyContext::FileExplorer;
760 }
761 Err(e) => {
762 self.set_status_message(
763 t!("explorer.error_trash", error = e.to_string()).to_string(),
764 );
765 }
766 }
767 }
768
769 fn move_to_remote_trash(&self, path: &std::path::Path) -> std::io::Result<()> {
771 let home = self.authority.filesystem.home_dir()?;
773 let trash_dir = home.join(".local/share/fresh/trash");
774
775 if !self.authority.filesystem.exists(&trash_dir) {
777 self.authority.filesystem.create_dir_all(&trash_dir)?;
778 }
779
780 let file_name = path
782 .file_name()
783 .unwrap_or_else(|| std::ffi::OsStr::new("unnamed"));
784 let timestamp = std::time::SystemTime::now()
785 .duration_since(std::time::UNIX_EPOCH)
786 .map(|d| d.as_secs())
787 .unwrap_or(0);
788 let trash_name = format!("{}.{}", file_name.to_string_lossy(), timestamp);
789 let trash_path = trash_dir.join(trash_name);
790
791 self.authority.filesystem.rename(path, &trash_path)
793 }
794
795 pub fn file_explorer_rename(&mut self) {
796 if let Some(explorer) = &self.file_explorer {
797 if let Some(selected_id) = explorer.get_selected() {
798 if selected_id == explorer.tree().root_id() {
800 self.set_status_message(t!("explorer.cannot_rename_root").to_string());
801 return;
802 }
803
804 let node = explorer.tree().get_node(selected_id);
805 if let Some(node) = node {
806 let old_path = node.entry.path.clone();
807 let old_name = node.entry.name.clone();
808
809 let prompt = crate::view::prompt::Prompt::with_initial_text_for_edit(
814 t!("explorer.rename_prompt").to_string(),
815 crate::view::prompt::PromptType::FileExplorerRename {
816 original_path: old_path,
817 original_name: old_name.clone(),
818 is_new_file: false,
819 },
820 old_name,
821 );
822 self.prompt = Some(prompt);
823 }
824 }
825 }
826 }
827
828 pub fn perform_file_explorer_rename(
830 &mut self,
831 original_path: std::path::PathBuf,
832 original_name: String,
833 new_name: String,
834 is_new_file: bool,
835 ) {
836 if new_name.is_empty() || new_name == original_name {
837 self.set_status_message(t!("explorer.rename_cancelled").to_string());
838 return;
839 }
840
841 if new_name.chars().any(std::path::is_separator) {
846 self.set_status_message(t!("explorer.rename_invalid_separator").to_string());
847 return;
848 }
849 if new_name == "." || new_name == ".." {
850 self.set_status_message(t!("explorer.rename_invalid_dot").to_string());
851 return;
852 }
853
854 let new_path = original_path
855 .parent()
856 .map(|p| p.join(&new_name))
857 .unwrap_or_else(|| original_path.clone());
858
859 if let Some(runtime) = &self.tokio_runtime {
860 let result = self.authority.filesystem.rename(&original_path, &new_path);
861
862 match result {
863 Ok(_) => {
864 if let Some(explorer) = &mut self.file_explorer {
866 if let Some(selected_id) = explorer.get_selected() {
867 let parent_id = get_parent_node_id(explorer.tree(), selected_id, false);
868 let tree = explorer.tree_mut();
869 if let Err(e) = runtime.block_on(tree.reload_expanded_node(parent_id)) {
870 tracing::warn!("Failed to refresh file tree after rename: {}", e);
871 }
872 }
873 explorer.clear_multi_selection();
877 explorer.navigate_to_path(&new_path);
879 }
880
881 let relocated = self.relocate_buffers_for_rename(&original_path, &new_path);
889
890 if is_new_file && !relocated.is_empty() {
894 self.key_context = KeyContext::Normal;
895 }
896
897 self.set_status_message(
898 t!("explorer.renamed", old = &original_name, new = &new_name).to_string(),
899 );
900 }
901 Err(e) => {
902 self.set_status_message(
903 t!("explorer.error_renaming", error = e.to_string()).to_string(),
904 );
905 }
906 }
907 }
908 }
909
910 pub fn file_explorer_toggle_hidden(&mut self) {
911 let show_hidden = if let Some(explorer) = &mut self.file_explorer {
912 explorer.toggle_show_hidden();
913 explorer.ignore_patterns().show_hidden()
914 } else {
915 return;
916 };
917
918 let msg = if show_hidden {
919 t!("explorer.showing_hidden")
920 } else {
921 t!("explorer.hiding_hidden")
922 };
923 self.set_status_message(msg.to_string());
924
925 self.config_mut().file_explorer.show_hidden = show_hidden;
927 self.persist_config_change(
928 "/file_explorer/show_hidden",
929 serde_json::Value::Bool(show_hidden),
930 );
931 }
932
933 pub fn file_explorer_toggle_gitignored(&mut self) {
934 let show_gitignored = if let Some(explorer) = &mut self.file_explorer {
935 explorer.toggle_show_gitignored();
936 explorer.ignore_patterns().show_gitignored()
937 } else {
938 return;
939 };
940
941 let msg = if show_gitignored {
942 t!("explorer.showing_gitignored")
943 } else {
944 t!("explorer.hiding_gitignored")
945 };
946 self.set_status_message(msg.to_string());
947
948 self.config_mut().file_explorer.show_gitignored = show_gitignored;
950 self.persist_config_change(
951 "/file_explorer/show_gitignored",
952 serde_json::Value::Bool(show_gitignored),
953 );
954 }
955
956 pub fn file_explorer_search_clear(&mut self) {
958 if matches!(
964 self.file_explorer_clipboard,
965 Some(FileExplorerClipboard { is_cut: true, .. })
966 ) {
967 self.file_explorer_clipboard = None;
968 self.set_status_message(t!("explorer.cut_cancelled").to_string());
969 return;
970 }
971 if let Some(explorer) = &mut self.file_explorer {
972 if explorer.has_multi_selection() {
973 explorer.clear_multi_selection();
974 } else if explorer.is_search_active() {
975 explorer.search_clear();
976 } else {
977 self.focus_editor();
978 }
979 }
980 }
981
982 pub fn file_explorer_extend_selection_up(&mut self) {
983 if let Some(explorer) = &mut self.file_explorer {
984 explorer.extend_selection_up();
985 }
986 }
987
988 pub fn file_explorer_extend_selection_down(&mut self) {
989 if let Some(explorer) = &mut self.file_explorer {
990 explorer.extend_selection_down();
991 }
992 }
993
994 pub fn file_explorer_toggle_select(&mut self) {
995 if let Some(explorer) = &mut self.file_explorer {
996 explorer.toggle_select();
997 }
998 }
999
1000 pub fn file_explorer_select_all(&mut self) {
1001 if let Some(explorer) = &mut self.file_explorer {
1002 explorer.select_all();
1003 }
1004 }
1005
1006 pub fn file_explorer_search_push_char(&mut self, c: char) {
1008 if let Some(explorer) = &mut self.file_explorer {
1009 explorer.search_push_char(c);
1010 explorer.update_scroll_for_selection();
1011 }
1012 }
1013
1014 pub fn file_explorer_search_pop_char(&mut self) {
1016 if let Some(explorer) = &mut self.file_explorer {
1017 explorer.search_pop_char();
1018 explorer.update_scroll_for_selection();
1019 }
1020 }
1021
1022 pub fn handle_set_file_explorer_decorations(
1023 &mut self,
1024 namespace: String,
1025 decorations: Vec<crate::view::file_tree::FileExplorerDecoration>,
1026 ) {
1027 let normalized: Vec<crate::view::file_tree::FileExplorerDecoration> = decorations
1028 .into_iter()
1029 .filter_map(|mut decoration| {
1030 let path = if decoration.path.is_absolute() {
1031 decoration.path
1032 } else {
1033 self.working_dir.join(&decoration.path)
1034 };
1035 let path = normalize_path(&path);
1036 if path.starts_with(&self.working_dir) {
1037 decoration.path = path;
1038 Some(decoration)
1039 } else {
1040 None
1041 }
1042 })
1043 .collect();
1044
1045 self.file_explorer_decorations.insert(namespace, normalized);
1046 self.rebuild_file_explorer_decoration_cache();
1047 }
1048
1049 pub fn handle_clear_file_explorer_decorations(&mut self, namespace: &str) {
1050 self.file_explorer_decorations.remove(namespace);
1051 self.rebuild_file_explorer_decoration_cache();
1052 }
1053
1054 pub(super) fn rebuild_file_explorer_decoration_cache(&mut self) {
1055 let decorations = self
1056 .file_explorer_decorations
1057 .values()
1058 .flat_map(|entries| entries.iter().cloned());
1059
1060 let symlink_mappings = self
1062 .file_explorer
1063 .as_ref()
1064 .map(|fe| fe.collect_symlink_mappings())
1065 .unwrap_or_default();
1066
1067 self.file_explorer_decoration_cache =
1068 crate::view::file_tree::FileExplorerDecorationCache::rebuild(
1069 decorations,
1070 &self.working_dir,
1071 &symlink_mappings,
1072 );
1073 }
1074
1075 pub fn file_explorer_clipboard(&self) -> Option<&FileExplorerClipboard> {
1076 self.file_explorer_clipboard.as_ref()
1077 }
1078
1079 pub fn file_explorer_copy(&mut self) {
1080 self.set_explorer_clipboard(false);
1081 }
1082
1083 pub fn file_explorer_cut(&mut self) {
1084 self.set_explorer_clipboard(true);
1085 }
1086
1087 fn set_explorer_clipboard(&mut self, is_cut: bool) {
1088 let Some(explorer) = &self.file_explorer else {
1089 return;
1090 };
1091 let root_id = explorer.tree().root_id();
1092 let selected_ids = explorer.effective_selection();
1093 let paths: Vec<PathBuf> = selected_ids
1094 .iter()
1095 .filter(|&&id| id != root_id)
1096 .filter_map(|&id| explorer.tree().get_node(id).map(|n| n.entry.path.clone()))
1097 .collect();
1098 if paths.is_empty() {
1099 let msg = if is_cut {
1100 t!("explorer.cannot_cut_root").to_string()
1101 } else {
1102 t!("explorer.cannot_copy_root").to_string()
1103 };
1104 self.set_status_message(msg);
1105 return;
1106 }
1107 let msg = if paths.len() == 1 {
1108 let name = paths[0]
1109 .file_name()
1110 .unwrap_or_default()
1111 .to_string_lossy()
1112 .to_string();
1113 if is_cut {
1114 t!("explorer.cut", name = &name).to_string()
1115 } else {
1116 t!("explorer.copied", name = &name).to_string()
1117 }
1118 } else {
1119 let count = paths.len();
1120 if is_cut {
1121 t!("explorer.cut_n", count = count).to_string()
1122 } else {
1123 t!("explorer.copied_n", count = count).to_string()
1124 }
1125 };
1126 self.file_explorer_clipboard = Some(FileExplorerClipboard { paths, is_cut });
1127 self.set_status_message(msg);
1128 }
1129
1130 pub fn file_explorer_paste(&mut self) {
1131 let clipboard = match self.file_explorer_clipboard.clone() {
1132 Some(c) => c,
1133 None => {
1134 self.set_status_message(t!("explorer.paste_no_source").to_string());
1135 return;
1136 }
1137 };
1138
1139 let dst_dir = if let Some(explorer) = &self.file_explorer {
1140 if let Some(selected_id) = explorer.get_selected() {
1141 if let Some(node) = explorer.tree().get_node(selected_id) {
1142 get_parent_dir_path(node)
1143 } else {
1144 return;
1145 }
1146 } else {
1147 return;
1148 }
1149 } else {
1150 return;
1151 };
1152
1153 let is_cut = clipboard.is_cut;
1154
1155 if clipboard.paths.len() == 1 {
1156 let src = clipboard.paths[0].clone();
1157 let file_name = match src.file_name() {
1158 Some(n) => n.to_os_string(),
1159 None => return,
1160 };
1161 let dst_path = dst_dir.join(&file_name);
1162
1163 if src.parent().map(|p| p == dst_dir).unwrap_or(false) {
1164 if is_cut {
1165 self.file_explorer_clipboard = None;
1170 self.set_status_message(t!("explorer.cut_cancelled").to_string());
1171 return;
1172 } else {
1173 let unique = unique_paste_name(
1174 &*self.authority.filesystem,
1175 &dst_dir,
1176 &file_name.to_string_lossy(),
1177 );
1178 self.perform_file_explorer_paste(src, unique, false);
1179 return;
1180 }
1181 }
1182
1183 if self.authority.filesystem.exists(&dst_path) {
1184 let name = truncate_name_for_prompt(&file_name.to_string_lossy(), 40);
1185 self.start_prompt(
1186 t!("explorer.paste_conflict", name = &name).to_string(),
1187 crate::view::prompt::PromptType::ConfirmPasteConflict {
1188 src,
1189 dst: dst_path,
1190 is_cut,
1191 },
1192 );
1193 } else {
1194 self.perform_file_explorer_paste(src, dst_path, is_cut);
1195 }
1196 } else {
1197 let mut safe: Vec<(PathBuf, PathBuf)> = Vec::new();
1199 let mut conflicts: Vec<(PathBuf, PathBuf)> = Vec::new();
1200
1201 for src in &clipboard.paths {
1202 let file_name = match src.file_name() {
1203 Some(n) => n.to_os_string(),
1204 None => continue,
1205 };
1206 let dst_path = dst_dir.join(&file_name);
1207 let is_same_location = src.parent().map(|p| p == dst_dir).unwrap_or(false);
1208
1209 if is_same_location {
1210 if !is_cut {
1211 let unique = unique_paste_name(
1213 &*self.authority.filesystem,
1214 &dst_dir,
1215 &file_name.to_string_lossy(),
1216 );
1217 safe.push((src.clone(), unique));
1218 }
1219 } else if self.authority.filesystem.exists(&dst_path) {
1221 conflicts.push((src.clone(), dst_path));
1222 } else {
1223 safe.push((src.clone(), dst_path));
1224 }
1225 }
1226
1227 if safe.is_empty() && conflicts.is_empty() {
1228 if is_cut {
1232 self.file_explorer_clipboard = None;
1233 self.set_status_message(t!("explorer.cut_cancelled").to_string());
1234 } else {
1235 self.set_status_message(t!("explorer.paste_same_location").to_string());
1236 }
1237 return;
1238 }
1239
1240 if conflicts.is_empty() {
1241 self.execute_resolved_multi_paste(safe, vec![], is_cut);
1242 } else {
1243 let name = truncate_name_for_prompt(
1244 &conflicts[0]
1245 .1
1246 .file_name()
1247 .unwrap_or_default()
1248 .to_string_lossy(),
1249 40,
1250 );
1251 self.start_prompt(
1252 t!("explorer.paste_conflict_multi", name = &name).to_string(),
1253 crate::view::prompt::PromptType::ConfirmMultiPasteConflict {
1254 safe,
1255 confirmed: Vec::new(),
1256 pending: conflicts,
1257 is_cut,
1258 },
1259 );
1260 }
1261 }
1262 }
1263
1264 pub(super) fn execute_resolved_multi_paste(
1272 &mut self,
1273 safe: Vec<(PathBuf, PathBuf)>,
1274 to_overwrite: Vec<(PathBuf, PathBuf)>,
1275 is_cut: bool,
1276 ) {
1277 let total = safe.len() + to_overwrite.len();
1278 if total == 0 {
1279 return;
1280 }
1281
1282 let mut succeeded: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(total);
1283 let mut clean_moves: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(total);
1290 let mut first_error: Option<std::io::Error> = None;
1291 let mut partial_moves: Vec<(PathBuf, std::io::Error)> = Vec::new();
1292 for (src, dst) in safe.into_iter().chain(to_overwrite) {
1293 match self.paste_one_fs_op(&src, &dst, is_cut) {
1294 PasteOpOutcome::Ok => {
1295 clean_moves.push((src.clone(), dst.clone()));
1296 succeeded.push((src, dst));
1297 }
1298 PasteOpOutcome::SourceRemovalFailed {
1299 dst: landed_dst,
1300 err,
1301 } => {
1302 succeeded.push((src, landed_dst.clone()));
1306 partial_moves.push((landed_dst, err));
1307 }
1308 PasteOpOutcome::Failed(e) => {
1309 if first_error.is_none() {
1310 first_error = Some(e);
1311 }
1312 }
1313 }
1314 }
1315
1316 if is_cut {
1322 for (src, dst) in &clean_moves {
1323 self.relocate_buffers_for_rename(src, dst);
1324 }
1325 }
1326
1327 if !succeeded.is_empty() {
1328 let first_dst = succeeded[0].1.clone();
1329 let any_src = succeeded[0].0.clone();
1330 self.refresh_tree_after_paste(&any_src, &first_dst, is_cut);
1331 }
1332
1333 if !partial_moves.is_empty() {
1334 let (first_dst, first_err) = &partial_moves[0];
1337 let name = first_dst
1338 .file_name()
1339 .map(|n| n.to_string_lossy().to_string())
1340 .unwrap_or_default();
1341 let msg = if partial_moves.len() == 1 {
1342 t!(
1343 "explorer.move_source_removal_failed",
1344 name = &name,
1345 error = first_err.to_string()
1346 )
1347 .to_string()
1348 } else {
1349 t!(
1350 "explorer.move_source_removal_failed_n",
1351 count = partial_moves.len()
1352 )
1353 .to_string()
1354 };
1355 self.set_status_message(msg);
1356 } else if let Some(e) = &first_error {
1357 let msg = if is_cut {
1358 t!("explorer.error_moving", error = e.to_string()).to_string()
1359 } else {
1360 t!("explorer.error_copying", error = e.to_string()).to_string()
1361 };
1362 self.set_status_message(msg);
1363 } else if total > 1 {
1364 let msg = if is_cut {
1365 t!("explorer.pasted_moved_n", count = total).to_string()
1366 } else {
1367 t!("explorer.pasted_n", count = total).to_string()
1368 };
1369 self.set_status_message(msg);
1370 } else if let Some((_, dst)) = succeeded.first() {
1371 let name = dst
1372 .file_name()
1373 .map(|n| n.to_string_lossy().to_string())
1374 .unwrap_or_default();
1375 let msg = if is_cut {
1376 t!("explorer.pasted_moved", name = &name).to_string()
1377 } else {
1378 t!("explorer.pasted", name = &name).to_string()
1379 };
1380 self.set_status_message(msg);
1381 }
1382
1383 if is_cut && first_error.is_none() && partial_moves.is_empty() {
1387 self.file_explorer_clipboard = None;
1388 }
1389 self.key_context = KeyContext::FileExplorer;
1390 }
1391
1392 fn paste_one_fs_op(&self, src: &Path, dst: &Path, is_cut: bool) -> PasteOpOutcome {
1396 let src_is_dir = self.authority.filesystem.is_dir(src).unwrap_or(false);
1397
1398 if src_is_dir && dst.starts_with(src) {
1406 return PasteOpOutcome::Failed(std::io::Error::new(
1407 std::io::ErrorKind::InvalidInput,
1408 "Cannot paste a directory into itself",
1409 ));
1410 }
1411
1412 if is_cut {
1413 match self.authority.filesystem.rename(src, dst) {
1418 Ok(()) => PasteOpOutcome::Ok,
1419 Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
1420 let copy_result = if src_is_dir {
1421 self.authority.filesystem.copy_dir_all(src, dst)
1422 } else {
1423 self.authority.filesystem.copy(src, dst).map(|_| ())
1424 };
1425 match copy_result {
1426 Ok(()) => {
1427 let remove_result = if src_is_dir {
1433 self.authority.filesystem.remove_dir_all(src)
1434 } else {
1435 self.authority.filesystem.remove_file(src)
1436 };
1437 match remove_result {
1438 Ok(()) => PasteOpOutcome::Ok,
1439 Err(remove_err) => PasteOpOutcome::SourceRemovalFailed {
1440 dst: dst.to_path_buf(),
1441 err: remove_err,
1442 },
1443 }
1444 }
1445 Err(copy_err) => {
1446 let cleanup = if src_is_dir {
1452 self.authority.filesystem.remove_dir_all(dst)
1453 } else {
1454 self.authority.filesystem.remove_file(dst)
1455 };
1456 if let Err(cleanup_err) = cleanup {
1457 tracing::warn!(
1458 "Failed to roll back partial destination {:?} after copy \
1459 fallback failed: {}",
1460 dst,
1461 cleanup_err
1462 );
1463 }
1464 PasteOpOutcome::Failed(copy_err)
1465 }
1466 }
1467 }
1468 Err(e) => PasteOpOutcome::Failed(e),
1469 }
1470 } else if src_is_dir {
1471 match self.authority.filesystem.copy_dir_all(src, dst) {
1472 Ok(()) => PasteOpOutcome::Ok,
1473 Err(e) => PasteOpOutcome::Failed(e),
1474 }
1475 } else {
1476 match self.authority.filesystem.copy(src, dst) {
1477 Ok(_) => PasteOpOutcome::Ok,
1478 Err(e) => PasteOpOutcome::Failed(e),
1479 }
1480 }
1481 }
1482
1483 fn refresh_tree_after_paste(&mut self, src: &Path, dst: &Path, is_cut: bool) {
1488 let Some(explorer) = &mut self.file_explorer else {
1489 return;
1490 };
1491 if let Some(runtime) = &self.tokio_runtime {
1492 if let Some(dst_parent) = dst.parent() {
1494 if let Some(dst_parent_node) = explorer.tree().get_node_by_path(dst_parent) {
1495 let pid = dst_parent_node.id;
1496 if let Err(e) = runtime.block_on(explorer.tree_mut().reload_expanded_node(pid))
1497 {
1498 tracing::warn!("Failed to reload destination directory after paste: {}", e);
1499 }
1500 }
1501 }
1502 if is_cut {
1512 if let Some(src_parent) = src.parent() {
1513 if let Some(src_parent_node) = explorer.tree().get_node_by_path(src_parent) {
1514 let pid = src_parent_node.id;
1515 if let Err(e) =
1516 runtime.block_on(explorer.tree_mut().reload_expanded_node(pid))
1517 {
1518 tracing::warn!("Failed to refresh source directory after move: {}", e);
1519 }
1520 }
1521 }
1522 }
1523 }
1524 explorer.clear_multi_selection();
1529 explorer.navigate_to_path(dst);
1530 }
1531
1532 pub fn perform_file_explorer_paste(&mut self, src: PathBuf, dst: PathBuf, is_cut: bool) {
1533 let name = dst
1534 .file_name()
1535 .map(|n| n.to_string_lossy().to_string())
1536 .unwrap_or_default();
1537
1538 match self.paste_one_fs_op(&src, &dst, is_cut) {
1539 PasteOpOutcome::Ok => {
1540 if is_cut {
1547 self.relocate_buffers_for_rename(&src, &dst);
1548 }
1549 self.refresh_tree_after_paste(&src, &dst, is_cut);
1550 if is_cut {
1551 self.file_explorer_clipboard = None;
1552 self.set_status_message(t!("explorer.pasted_moved", name = &name).to_string());
1553 } else {
1554 self.set_status_message(t!("explorer.pasted", name = &name).to_string());
1555 }
1556 self.key_context = KeyContext::FileExplorer;
1557 }
1558 PasteOpOutcome::SourceRemovalFailed {
1559 dst: landed_dst,
1560 err,
1561 } => {
1562 self.refresh_tree_after_paste(&src, &landed_dst, is_cut);
1567 self.set_status_message(
1568 t!(
1569 "explorer.move_source_removal_failed",
1570 name = &name,
1571 error = err.to_string()
1572 )
1573 .to_string(),
1574 );
1575 self.key_context = KeyContext::FileExplorer;
1578 }
1579 PasteOpOutcome::Failed(e) => {
1580 let msg = if is_cut {
1581 t!("explorer.error_moving", error = e.to_string()).to_string()
1582 } else {
1583 t!("explorer.error_copying", error = e.to_string()).to_string()
1584 };
1585 self.set_status_message(msg);
1586 }
1587 }
1588 }
1589}
1590
1591fn unique_paste_name(
1594 fs: &dyn crate::model::filesystem::FileSystem,
1595 dst_dir: &Path,
1596 name: &str,
1597) -> PathBuf {
1598 let (stem, ext) = split_stem_ext(name);
1599 let mut n = 1u32;
1600 loop {
1601 let candidate = if n == 1 {
1602 if ext.is_empty() {
1603 format!("{} copy", stem)
1604 } else {
1605 format!("{} copy.{}", stem, ext)
1606 }
1607 } else if ext.is_empty() {
1608 format!("{} copy {}", stem, n)
1609 } else {
1610 format!("{} copy {}.{}", stem, n, ext)
1611 };
1612 let path = dst_dir.join(&candidate);
1613 if !fs.exists(&path) {
1614 return path;
1615 }
1616 n += 1;
1617 if n > 1000 {
1618 return dst_dir.join(format!("{} copy {}", stem, timestamp_suffix()));
1620 }
1621 }
1622}
1623
1624pub(super) fn truncate_name_for_prompt(name: &str, max: usize) -> String {
1626 if name.chars().count() <= max {
1627 name.to_string()
1628 } else {
1629 let truncated: String = name.chars().take(max.saturating_sub(1)).collect();
1630 format!("{}\u{2026}", truncated)
1631 }
1632}
1633
1634pub(super) fn format_path_preview_for_prompt(paths: &[PathBuf], max_shown: usize) -> String {
1639 let names: Vec<String> = paths
1640 .iter()
1641 .map(|p| {
1642 let raw = p
1643 .file_name()
1644 .map(|n| n.to_string_lossy().to_string())
1645 .unwrap_or_default();
1646 format!("'{}'", truncate_name_for_prompt(&raw, 24))
1647 })
1648 .collect();
1649 if names.len() <= max_shown {
1650 names.join(", ")
1651 } else {
1652 let shown = names[..max_shown].join(", ");
1653 let more = names.len() - max_shown;
1654 format!("{}, \u{2026} ({} more)", shown, more)
1655 }
1656}
1657
1658fn split_stem_ext(name: &str) -> (&str, &str) {
1659 if let Some(dot_pos) = name.rfind('.') {
1661 if dot_pos > 0 {
1662 return (&name[..dot_pos], &name[dot_pos + 1..]);
1663 }
1664 }
1665 (name, "")
1666}