1mod project_panel_settings;
2use client::{ErrorCode, ErrorExt};
3use settings::{Settings, SettingsStore};
4
5use db::kvp::KEY_VALUE_STORE;
6use editor::{items::entry_git_aware_label_color, scroll::Autoscroll, Editor};
7use file_icons::FileIcons;
8
9use anyhow::{anyhow, Result};
10use collections::{hash_map, BTreeSet, HashMap};
11use git::repository::GitFileStatus;
12use gpui::{
13 actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
14 AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent,
15 EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext,
16 ListSizingBehavior, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
17 PromptLevel, Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View,
18 ViewContext, VisualContext as _, WeakView, WindowContext,
19};
20use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
21use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
22use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
23use serde::{Deserialize, Serialize};
24use std::{
25 collections::HashSet,
26 ffi::OsStr,
27 ops::Range,
28 path::{Path, PathBuf},
29 sync::Arc,
30};
31use theme::ThemeSettings;
32use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem, Tooltip};
33use util::{maybe, ResultExt, TryFutureExt};
34use workspace::{
35 dock::{DockPosition, Panel, PanelEvent},
36 notifications::{DetachAndPromptErr, NotifyTaskExt},
37 DraggedSelection, OpenInTerminal, SelectedEntry, Workspace,
38};
39use worktree::CreatedEntry;
40
41const PROJECT_PANEL_KEY: &str = "ProjectPanel";
42const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
43
44pub struct ProjectPanel {
45 project: Model<Project>,
46 fs: Arc<dyn Fs>,
47 scroll_handle: UniformListScrollHandle,
48 focus_handle: FocusHandle,
49 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
50 last_worktree_root_id: Option<ProjectEntryId>,
51 last_external_paths_drag_over_entry: Option<ProjectEntryId>,
52 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
53 unfolded_dir_ids: HashSet<ProjectEntryId>,
54 // Currently selected entry in a file tree
55 selection: Option<SelectedEntry>,
56 marked_entries: BTreeSet<SelectedEntry>,
57 context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
58 edit_state: Option<EditState>,
59 filename_editor: View<Editor>,
60 clipboard: Option<ClipboardEntry>,
61 _dragged_entry_destination: Option<Arc<Path>>,
62 workspace: WeakView<Workspace>,
63 width: Option<Pixels>,
64 pending_serialization: Task<Option<()>>,
65}
66
67#[derive(Clone, Debug)]
68struct EditState {
69 worktree_id: WorktreeId,
70 entry_id: ProjectEntryId,
71 is_new_entry: bool,
72 is_dir: bool,
73 processing_filename: Option<String>,
74}
75
76#[derive(Clone, Debug)]
77enum ClipboardEntry {
78 Copied(BTreeSet<SelectedEntry>),
79 Cut(BTreeSet<SelectedEntry>),
80}
81
82#[derive(Debug, PartialEq, Eq, Clone)]
83pub struct EntryDetails {
84 filename: String,
85 icon: Option<Arc<str>>,
86 path: Arc<Path>,
87 depth: usize,
88 kind: EntryKind,
89 is_ignored: bool,
90 is_expanded: bool,
91 is_selected: bool,
92 is_marked: bool,
93 is_editing: bool,
94 is_processing: bool,
95 is_cut: bool,
96 git_status: Option<GitFileStatus>,
97 is_private: bool,
98 worktree_id: WorktreeId,
99 canonical_path: Option<PathBuf>,
100}
101
102#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
103pub struct Delete {
104 #[serde(default)]
105 pub skip_prompt: bool,
106}
107
108#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
109pub struct Trash {
110 #[serde(default)]
111 pub skip_prompt: bool,
112}
113
114impl_actions!(project_panel, [Delete, Trash]);
115
116actions!(
117 project_panel,
118 [
119 ExpandSelectedEntry,
120 CollapseSelectedEntry,
121 CollapseAllEntries,
122 NewDirectory,
123 NewFile,
124 Copy,
125 CopyPath,
126 CopyRelativePath,
127 Duplicate,
128 RevealInFinder,
129 Cut,
130 Paste,
131 Rename,
132 Open,
133 OpenPermanent,
134 ToggleFocus,
135 NewSearchInDirectory,
136 UnfoldDirectory,
137 FoldDirectory,
138 SelectParent,
139 ]
140);
141
142pub fn init_settings(cx: &mut AppContext) {
143 ProjectPanelSettings::register(cx);
144}
145
146pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
147 init_settings(cx);
148 file_icons::init(assets, cx);
149
150 cx.observe_new_views(|workspace: &mut Workspace, _| {
151 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
152 workspace.toggle_panel_focus::<ProjectPanel>(cx);
153 });
154 })
155 .detach();
156}
157
158#[derive(Debug)]
159pub enum Event {
160 OpenedEntry {
161 entry_id: ProjectEntryId,
162 focus_opened_item: bool,
163 allow_preview: bool,
164 mark_selected: bool,
165 },
166 SplitEntry {
167 entry_id: ProjectEntryId,
168 },
169 Focus,
170}
171
172#[derive(Serialize, Deserialize)]
173struct SerializedProjectPanel {
174 width: Option<Pixels>,
175}
176
177struct DraggedProjectEntryView {
178 selection: SelectedEntry,
179 details: EntryDetails,
180 width: Pixels,
181 selections: Arc<BTreeSet<SelectedEntry>>,
182}
183
184impl ProjectPanel {
185 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
186 let project = workspace.project().clone();
187 let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
188 let focus_handle = cx.focus_handle();
189 cx.on_focus(&focus_handle, Self::focus_in).detach();
190
191 cx.subscribe(&project, |this, project, event, cx| match event {
192 project::Event::ActiveEntryChanged(Some(entry_id)) => {
193 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
194 this.reveal_entry(project, *entry_id, true, cx);
195 }
196 }
197 project::Event::RevealInProjectPanel(entry_id) => {
198 this.reveal_entry(project, *entry_id, false, cx);
199 cx.emit(PanelEvent::Activate);
200 }
201 project::Event::ActivateProjectPanel => {
202 cx.emit(PanelEvent::Activate);
203 }
204 project::Event::WorktreeRemoved(id) => {
205 this.expanded_dir_ids.remove(id);
206 this.update_visible_entries(None, cx);
207 cx.notify();
208 }
209 project::Event::WorktreeUpdatedEntries(_, _)
210 | project::Event::WorktreeAdded
211 | project::Event::WorktreeOrderChanged => {
212 this.update_visible_entries(None, cx);
213 cx.notify();
214 }
215 _ => {}
216 })
217 .detach();
218
219 let filename_editor = cx.new_view(|cx| Editor::single_line(cx));
220
221 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
222 editor::EditorEvent::BufferEdited
223 | editor::EditorEvent::SelectionsChanged { .. } => {
224 this.autoscroll(cx);
225 }
226 editor::EditorEvent::Blurred => {
227 if this
228 .edit_state
229 .as_ref()
230 .map_or(false, |state| state.processing_filename.is_none())
231 {
232 this.edit_state = None;
233 this.update_visible_entries(None, cx);
234 }
235 }
236 _ => {}
237 })
238 .detach();
239
240 cx.observe_global::<FileIcons>(|_, cx| {
241 cx.notify();
242 })
243 .detach();
244
245 let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
246 cx.observe_global::<SettingsStore>(move |_, cx| {
247 let new_settings = *ProjectPanelSettings::get_global(cx);
248 if project_panel_settings != new_settings {
249 project_panel_settings = new_settings;
250 cx.notify();
251 }
252 })
253 .detach();
254
255 let mut this = Self {
256 project: project.clone(),
257 fs: workspace.app_state().fs.clone(),
258 scroll_handle: UniformListScrollHandle::new(),
259 focus_handle,
260 visible_entries: Default::default(),
261 last_worktree_root_id: Default::default(),
262 last_external_paths_drag_over_entry: None,
263 expanded_dir_ids: Default::default(),
264 unfolded_dir_ids: Default::default(),
265 selection: None,
266 marked_entries: Default::default(),
267 edit_state: None,
268 context_menu: None,
269 filename_editor,
270 clipboard: None,
271 _dragged_entry_destination: None,
272 workspace: workspace.weak_handle(),
273 width: None,
274 pending_serialization: Task::ready(None),
275 };
276 this.update_visible_entries(None, cx);
277
278 this
279 });
280
281 cx.subscribe(&project_panel, {
282 let project_panel = project_panel.downgrade();
283 move |workspace, _, event, cx| match event {
284 &Event::OpenedEntry {
285 entry_id,
286 focus_opened_item,
287 allow_preview,
288 mark_selected
289 } => {
290 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
291 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
292 let file_path = entry.path.clone();
293 let worktree_id = worktree.read(cx).id();
294 let entry_id = entry.id;
295
296 project_panel.update(cx, |this, _| {
297 if !mark_selected {
298 this.marked_entries.clear();
299 }
300 this.marked_entries.insert(SelectedEntry {
301 worktree_id,
302 entry_id
303 });
304 }).ok();
305
306
307 workspace
308 .open_path_preview(
309 ProjectPath {
310 worktree_id,
311 path: file_path.clone(),
312 },
313 None,
314 focus_opened_item,
315 allow_preview,
316 cx,
317 )
318 .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
319 match e.error_code() {
320 ErrorCode::Disconnected => Some("Disconnected from remote project".to_string()),
321 ErrorCode::UnsharedItem => Some(format!(
322 "{} is not shared by the host. This could be because it has been marked as `private`",
323 file_path.display()
324 )),
325 _ => None,
326 }
327 });
328
329 if let Some(project_panel) = project_panel.upgrade() {
330 // Always select the entry, regardless of whether it is opened or not.
331 project_panel.update(cx, |project_panel, _| {
332 project_panel.selection = Some(SelectedEntry {
333 worktree_id,
334 entry_id
335 });
336 });
337 if !focus_opened_item {
338 let focus_handle = project_panel.read(cx).focus_handle.clone();
339 cx.focus(&focus_handle);
340 }
341 }
342 }
343 }
344 }
345 &Event::SplitEntry { entry_id } => {
346 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
347 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
348 workspace
349 .split_path(
350 ProjectPath {
351 worktree_id: worktree.read(cx).id(),
352 path: entry.path.clone(),
353 },
354 cx,
355 )
356 .detach_and_log_err(cx);
357 }
358 }
359 }
360 _ => {}
361 }
362 })
363 .detach();
364
365 project_panel
366 }
367
368 pub async fn load(
369 workspace: WeakView<Workspace>,
370 mut cx: AsyncWindowContext,
371 ) -> Result<View<Self>> {
372 let serialized_panel = cx
373 .background_executor()
374 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
375 .await
376 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
377 .log_err()
378 .flatten()
379 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
380 .transpose()
381 .log_err()
382 .flatten();
383
384 workspace.update(&mut cx, |workspace, cx| {
385 let panel = ProjectPanel::new(workspace, cx);
386 if let Some(serialized_panel) = serialized_panel {
387 panel.update(cx, |panel, cx| {
388 panel.width = serialized_panel.width.map(|px| px.round());
389 cx.notify();
390 });
391 }
392 panel
393 })
394 }
395
396 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
397 let width = self.width;
398 self.pending_serialization = cx.background_executor().spawn(
399 async move {
400 KEY_VALUE_STORE
401 .write_kvp(
402 PROJECT_PANEL_KEY.into(),
403 serde_json::to_string(&SerializedProjectPanel { width })?,
404 )
405 .await?;
406 anyhow::Ok(())
407 }
408 .log_err(),
409 );
410 }
411
412 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
413 if !self.focus_handle.contains_focused(cx) {
414 cx.emit(Event::Focus);
415 }
416 }
417
418 fn deploy_context_menu(
419 &mut self,
420 position: Point<Pixels>,
421 entry_id: ProjectEntryId,
422 cx: &mut ViewContext<Self>,
423 ) {
424 let this = cx.view().clone();
425 let project = self.project.read(cx);
426
427 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
428 id
429 } else {
430 return;
431 };
432
433 self.selection = Some(SelectedEntry {
434 worktree_id,
435 entry_id,
436 });
437
438 if let Some((worktree, entry)) = self.selected_entry(cx) {
439 let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
440 let is_root = Some(entry) == worktree.root_entry();
441 let is_dir = entry.is_dir();
442 let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
443 let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
444 let worktree_id = worktree.id();
445 let is_local = project.is_local();
446 let is_read_only = project.is_read_only();
447 let is_remote = project.is_remote();
448
449 let context_menu = ContextMenu::build(cx, |menu, cx| {
450 menu.context(self.focus_handle.clone()).when_else(
451 is_read_only,
452 |menu| {
453 menu.action("Copy Relative Path", Box::new(CopyRelativePath))
454 .when(is_dir, |menu| {
455 menu.action("Search Inside", Box::new(NewSearchInDirectory))
456 })
457 },
458 |menu| {
459 menu.action("New File", Box::new(NewFile))
460 .action("New Folder", Box::new(NewDirectory))
461 .separator()
462 .action("Reveal in Finder", Box::new(RevealInFinder))
463 .action("Open in Terminal", Box::new(OpenInTerminal))
464 .when(is_dir, |menu| {
465 menu.separator()
466 .action("Find in Folder…", Box::new(NewSearchInDirectory))
467 })
468 .when(is_unfoldable, |menu| {
469 menu.action("Unfold Directory", Box::new(UnfoldDirectory))
470 })
471 .when(is_foldable, |menu| {
472 menu.action("Fold Directory", Box::new(FoldDirectory))
473 })
474 .separator()
475 .action("Cut", Box::new(Cut))
476 .action("Copy", Box::new(Copy))
477 .action("Duplicate", Box::new(Duplicate))
478 // TODO: Paste should always be visible, cbut disabled when clipboard is empty
479 .when_some(self.clipboard.as_ref(), |menu, entry| {
480 let entries_for_worktree_id = (SelectedEntry {
481 worktree_id,
482 entry_id: ProjectEntryId::MIN,
483 })
484 ..(SelectedEntry {
485 worktree_id,
486 entry_id: ProjectEntryId::MAX,
487 });
488 menu.when(
489 entry
490 .items()
491 .range(entries_for_worktree_id)
492 .next()
493 .is_some(),
494 |menu| menu.action("Paste", Box::new(Paste)),
495 )
496 })
497 .separator()
498 .action("Copy Path", Box::new(CopyPath))
499 .action("Copy Relative Path", Box::new(CopyRelativePath))
500 .separator()
501 .action("Rename", Box::new(Rename))
502 .when(!is_root, |menu| {
503 menu.action("Trash", Box::new(Trash { skip_prompt: false }))
504 .action("Delete", Box::new(Delete { skip_prompt: false }))
505 })
506 .when(is_local & is_root, |menu| {
507 menu.separator()
508 .when(!is_remote, |menu| {
509 menu.action(
510 "Add Folder to Project…",
511 Box::new(workspace::AddFolderToProject),
512 )
513 })
514 .entry(
515 "Remove from Project",
516 None,
517 cx.handler_for(&this, move |this, cx| {
518 this.project.update(cx, |project, cx| {
519 project.remove_worktree(worktree_id, cx)
520 });
521 }),
522 )
523 })
524 .when(is_local & is_root, |menu| {
525 menu.separator()
526 .action("Collapse All", Box::new(CollapseAllEntries))
527 })
528 },
529 )
530 });
531
532 cx.focus_view(&context_menu);
533 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
534 this.context_menu.take();
535 cx.notify();
536 });
537 self.context_menu = Some((context_menu, position, subscription));
538 }
539
540 cx.notify();
541 }
542
543 fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
544 if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
545 return false;
546 }
547
548 if let Some(parent_path) = entry.path.parent() {
549 let snapshot = worktree.snapshot();
550 let mut child_entries = snapshot.child_entries(&parent_path);
551 if let Some(child) = child_entries.next() {
552 if child_entries.next().is_none() {
553 return child.kind.is_dir();
554 }
555 }
556 };
557 false
558 }
559
560 fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
561 if entry.is_dir() {
562 let snapshot = worktree.snapshot();
563
564 let mut child_entries = snapshot.child_entries(&entry.path);
565 if let Some(child) = child_entries.next() {
566 if child_entries.next().is_none() {
567 return child.kind.is_dir();
568 }
569 }
570 }
571 false
572 }
573
574 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
575 if let Some((worktree, entry)) = self.selected_entry(cx) {
576 if entry.is_dir() {
577 let worktree_id = worktree.id();
578 let entry_id = entry.id;
579 let expanded_dir_ids =
580 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
581 expanded_dir_ids
582 } else {
583 return;
584 };
585
586 match expanded_dir_ids.binary_search(&entry_id) {
587 Ok(_) => self.select_next(&SelectNext, cx),
588 Err(ix) => {
589 self.project.update(cx, |project, cx| {
590 project.expand_entry(worktree_id, entry_id, cx);
591 });
592
593 expanded_dir_ids.insert(ix, entry_id);
594 self.update_visible_entries(None, cx);
595 cx.notify();
596 }
597 }
598 }
599 }
600 }
601
602 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
603 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
604 let worktree_id = worktree.id();
605 let expanded_dir_ids =
606 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
607 expanded_dir_ids
608 } else {
609 return;
610 };
611
612 loop {
613 let entry_id = entry.id;
614 match expanded_dir_ids.binary_search(&entry_id) {
615 Ok(ix) => {
616 expanded_dir_ids.remove(ix);
617 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
618 cx.notify();
619 break;
620 }
621 Err(_) => {
622 if let Some(parent_entry) =
623 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
624 {
625 entry = parent_entry;
626 } else {
627 break;
628 }
629 }
630 }
631 }
632 }
633 }
634
635 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
636 // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
637 // (which is it's default behaviour when there's no entry for a worktree in expanded_dir_ids).
638 self.expanded_dir_ids
639 .retain(|_, expanded_entries| expanded_entries.is_empty());
640 self.update_visible_entries(None, cx);
641 cx.notify();
642 }
643
644 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
645 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
646 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
647 self.project.update(cx, |project, cx| {
648 match expanded_dir_ids.binary_search(&entry_id) {
649 Ok(ix) => {
650 expanded_dir_ids.remove(ix);
651 }
652 Err(ix) => {
653 project.expand_entry(worktree_id, entry_id, cx);
654 expanded_dir_ids.insert(ix, entry_id);
655 }
656 }
657 });
658 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
659 cx.focus(&self.focus_handle);
660 cx.notify();
661 }
662 }
663 }
664
665 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
666 if let Some(selection) = self.selection {
667 let (mut worktree_ix, mut entry_ix, _) =
668 self.index_for_selection(selection).unwrap_or_default();
669 if entry_ix > 0 {
670 entry_ix -= 1;
671 } else if worktree_ix > 0 {
672 worktree_ix -= 1;
673 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
674 } else {
675 return;
676 }
677
678 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
679 let selection = SelectedEntry {
680 worktree_id: *worktree_id,
681 entry_id: worktree_entries[entry_ix].id,
682 };
683 self.selection = Some(selection);
684 if cx.modifiers().shift {
685 self.marked_entries.insert(selection);
686 }
687 self.autoscroll(cx);
688 cx.notify();
689 } else {
690 self.select_first(&SelectFirst {}, cx);
691 }
692 }
693
694 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
695 if let Some(task) = self.confirm_edit(cx) {
696 task.detach_and_notify_err(cx);
697 }
698 }
699
700 fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
701 self.open_internal(false, true, false, cx);
702 }
703
704 fn open_permanent(&mut self, _: &OpenPermanent, cx: &mut ViewContext<Self>) {
705 self.open_internal(true, false, true, cx);
706 }
707
708 fn open_internal(
709 &mut self,
710 mark_selected: bool,
711 allow_preview: bool,
712 focus_opened_item: bool,
713 cx: &mut ViewContext<Self>,
714 ) {
715 if let Some((_, entry)) = self.selected_entry(cx) {
716 if entry.is_file() {
717 self.open_entry(
718 entry.id,
719 mark_selected,
720 focus_opened_item,
721 allow_preview,
722 cx,
723 );
724 } else {
725 self.toggle_expanded(entry.id, cx);
726 }
727 }
728 }
729
730 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
731 let edit_state = self.edit_state.as_mut()?;
732 cx.focus(&self.focus_handle);
733
734 let worktree_id = edit_state.worktree_id;
735 let is_new_entry = edit_state.is_new_entry;
736 let filename = self.filename_editor.read(cx).text(cx);
737 edit_state.is_dir = edit_state.is_dir
738 || (edit_state.is_new_entry && filename.ends_with(std::path::MAIN_SEPARATOR));
739 let is_dir = edit_state.is_dir;
740 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
741 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
742
743 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
744 let edit_task;
745 let edited_entry_id;
746 if is_new_entry {
747 self.selection = Some(SelectedEntry {
748 worktree_id,
749 entry_id: NEW_ENTRY_ID,
750 });
751 let new_path = entry.path.join(&filename.trim_start_matches('/'));
752 if path_already_exists(new_path.as_path()) {
753 return None;
754 }
755
756 edited_entry_id = NEW_ENTRY_ID;
757 edit_task = self.project.update(cx, |project, cx| {
758 project.create_entry((worktree_id, &new_path), is_dir, cx)
759 });
760 } else {
761 let new_path = if let Some(parent) = entry.path.clone().parent() {
762 parent.join(&filename)
763 } else {
764 filename.clone().into()
765 };
766 if path_already_exists(new_path.as_path()) {
767 return None;
768 }
769
770 edited_entry_id = entry.id;
771 edit_task = self.project.update(cx, |project, cx| {
772 project.rename_entry(entry.id, new_path.as_path(), cx)
773 });
774 };
775
776 edit_state.processing_filename = Some(filename);
777 cx.notify();
778
779 Some(cx.spawn(|project_panel, mut cx| async move {
780 let new_entry = edit_task.await;
781 project_panel.update(&mut cx, |project_panel, cx| {
782 project_panel.edit_state.take();
783 cx.notify();
784 })?;
785
786 match new_entry {
787 Err(e) => {
788 project_panel.update(&mut cx, |project_panel, cx| {
789 project_panel.marked_entries.clear();
790 project_panel.update_visible_entries(None, cx);
791 }).ok();
792 Err(e)?;
793 }
794 Ok(CreatedEntry::Included(new_entry)) => {
795 project_panel.update(&mut cx, |project_panel, cx| {
796 if let Some(selection) = &mut project_panel.selection {
797 if selection.entry_id == edited_entry_id {
798 selection.worktree_id = worktree_id;
799 selection.entry_id = new_entry.id;
800 project_panel.marked_entries.clear();
801 project_panel.expand_to_selection(cx);
802 }
803 }
804 project_panel.update_visible_entries(None, cx);
805 if is_new_entry && !is_dir {
806 project_panel.open_entry(new_entry.id, false, true, false, cx);
807 }
808 cx.notify();
809 })?;
810 }
811 Ok(CreatedEntry::Excluded { abs_path }) => {
812 if let Some(open_task) = project_panel
813 .update(&mut cx, |project_panel, cx| {
814 project_panel.marked_entries.clear();
815 project_panel.update_visible_entries(None, cx);
816
817 if is_dir {
818 project_panel.project.update(cx, |_, cx| {
819 cx.emit(project::Event::Notification(format!(
820 "Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel"
821 )))
822 });
823 None
824 } else {
825 project_panel
826 .workspace
827 .update(cx, |workspace, cx| {
828 workspace.open_abs_path(abs_path, true, cx)
829 })
830 .ok()
831 }
832 })
833 .ok()
834 .flatten()
835 {
836 let _ = open_task.await?;
837 }
838 }
839 }
840 Ok(())
841 }))
842 }
843
844 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
845 self.edit_state = None;
846 self.update_visible_entries(None, cx);
847 self.marked_entries.clear();
848 cx.focus(&self.focus_handle);
849 cx.notify();
850 }
851
852 fn open_entry(
853 &mut self,
854 entry_id: ProjectEntryId,
855 mark_selected: bool,
856 focus_opened_item: bool,
857 allow_preview: bool,
858 cx: &mut ViewContext<Self>,
859 ) {
860 cx.emit(Event::OpenedEntry {
861 entry_id,
862 focus_opened_item,
863 allow_preview,
864 mark_selected,
865 });
866 }
867
868 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
869 cx.emit(Event::SplitEntry { entry_id });
870 }
871
872 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
873 self.add_entry(false, cx)
874 }
875
876 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
877 self.add_entry(true, cx)
878 }
879
880 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
881 if let Some(SelectedEntry {
882 worktree_id,
883 entry_id,
884 }) = self.selection
885 {
886 let directory_id;
887 if let Some((worktree, expanded_dir_ids)) = self
888 .project
889 .read(cx)
890 .worktree_for_id(worktree_id, cx)
891 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
892 {
893 let worktree = worktree.read(cx);
894 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
895 loop {
896 if entry.is_dir() {
897 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
898 expanded_dir_ids.insert(ix, entry.id);
899 }
900 directory_id = entry.id;
901 break;
902 } else {
903 if let Some(parent_path) = entry.path.parent() {
904 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
905 entry = parent_entry;
906 continue;
907 }
908 }
909 return;
910 }
911 }
912 } else {
913 return;
914 };
915 } else {
916 return;
917 };
918 self.marked_entries.clear();
919 self.edit_state = Some(EditState {
920 worktree_id,
921 entry_id: directory_id,
922 is_new_entry: true,
923 is_dir,
924 processing_filename: None,
925 });
926 self.filename_editor.update(cx, |editor, cx| {
927 editor.clear(cx);
928 editor.focus(cx);
929 });
930 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
931 self.autoscroll(cx);
932 cx.notify();
933 }
934 }
935
936 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
937 if let Some(SelectedEntry {
938 worktree_id,
939 entry_id,
940 }) = self.selection
941 {
942 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
943 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
944 self.edit_state = Some(EditState {
945 worktree_id,
946 entry_id,
947 is_new_entry: false,
948 is_dir: entry.is_dir(),
949 processing_filename: None,
950 });
951 let file_name = entry
952 .path
953 .file_name()
954 .map(|s| s.to_string_lossy())
955 .unwrap_or_default()
956 .to_string();
957 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
958 let selection_end =
959 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
960 self.filename_editor.update(cx, |editor, cx| {
961 editor.set_text(file_name, cx);
962 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
963 s.select_ranges([0..selection_end])
964 });
965 editor.focus(cx);
966 });
967 self.update_visible_entries(None, cx);
968 self.autoscroll(cx);
969 cx.notify();
970 }
971 }
972 }
973 }
974
975 fn trash(&mut self, action: &Trash, cx: &mut ViewContext<Self>) {
976 self.remove(true, action.skip_prompt, cx);
977 }
978
979 fn delete(&mut self, action: &Delete, cx: &mut ViewContext<Self>) {
980 self.remove(false, action.skip_prompt, cx);
981 }
982
983 fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) {
984 maybe!({
985 if self.marked_entries.is_empty() && self.selection.is_none() {
986 return None;
987 }
988 let project = self.project.read(cx);
989 let items_to_delete = self.marked_entries();
990 let file_paths = items_to_delete
991 .into_iter()
992 .filter_map(|selection| {
993 Some((
994 selection.entry_id,
995 project
996 .path_for_entry(selection.entry_id, cx)?
997 .path
998 .file_name()?
999 .to_string_lossy()
1000 .into_owned(),
1001 ))
1002 })
1003 .collect::<Vec<_>>();
1004 if file_paths.is_empty() {
1005 return None;
1006 }
1007 let answer = if !skip_prompt {
1008 let operation = if trash { "Trash" } else { "Delete" };
1009
1010 let prompt =
1011 if let Some((_, path)) = file_paths.first().filter(|_| file_paths.len() == 1) {
1012 format!("{operation} {path}?")
1013 } else {
1014 const CUTOFF_POINT: usize = 10;
1015 let names = if file_paths.len() > CUTOFF_POINT {
1016 let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
1017 let mut paths = file_paths
1018 .iter()
1019 .map(|(_, path)| path.clone())
1020 .take(CUTOFF_POINT)
1021 .collect::<Vec<_>>();
1022 paths.truncate(CUTOFF_POINT);
1023 if truncated_path_counts == 1 {
1024 paths.push(".. 1 file not shown".into());
1025 } else {
1026 paths.push(format!(".. {} files not shown", truncated_path_counts));
1027 }
1028 paths
1029 } else {
1030 file_paths.iter().map(|(_, path)| path.clone()).collect()
1031 };
1032
1033 format!(
1034 "Do you want to {} the following {} files?\n{}",
1035 operation.to_lowercase(),
1036 file_paths.len(),
1037 names.join("\n")
1038 )
1039 };
1040 Some(cx.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"]))
1041 } else {
1042 None
1043 };
1044
1045 cx.spawn(|this, mut cx| async move {
1046 if let Some(answer) = answer {
1047 if answer.await != Ok(0) {
1048 return Result::<(), anyhow::Error>::Ok(());
1049 }
1050 }
1051 for (entry_id, _) in file_paths {
1052 this.update(&mut cx, |this, cx| {
1053 this.project
1054 .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
1055 .ok_or_else(|| anyhow!("no such entry"))
1056 })??
1057 .await?;
1058 }
1059 Result::<(), anyhow::Error>::Ok(())
1060 })
1061 .detach_and_log_err(cx);
1062 Some(())
1063 });
1064 }
1065
1066 fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
1067 if let Some((worktree, entry)) = self.selected_entry(cx) {
1068 self.unfolded_dir_ids.insert(entry.id);
1069
1070 let snapshot = worktree.snapshot();
1071 let mut parent_path = entry.path.parent();
1072 while let Some(path) = parent_path {
1073 if let Some(parent_entry) = worktree.entry_for_path(path) {
1074 let mut children_iter = snapshot.child_entries(path);
1075
1076 if children_iter.by_ref().take(2).count() > 1 {
1077 break;
1078 }
1079
1080 self.unfolded_dir_ids.insert(parent_entry.id);
1081 parent_path = path.parent();
1082 } else {
1083 break;
1084 }
1085 }
1086
1087 self.update_visible_entries(None, cx);
1088 self.autoscroll(cx);
1089 cx.notify();
1090 }
1091 }
1092
1093 fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext<Self>) {
1094 if let Some((worktree, entry)) = self.selected_entry(cx) {
1095 self.unfolded_dir_ids.remove(&entry.id);
1096
1097 let snapshot = worktree.snapshot();
1098 let mut path = &*entry.path;
1099 loop {
1100 let mut child_entries_iter = snapshot.child_entries(path);
1101 if let Some(child) = child_entries_iter.next() {
1102 if child_entries_iter.next().is_none() && child.is_dir() {
1103 self.unfolded_dir_ids.remove(&child.id);
1104 path = &*child.path;
1105 } else {
1106 break;
1107 }
1108 } else {
1109 break;
1110 }
1111 }
1112
1113 self.update_visible_entries(None, cx);
1114 self.autoscroll(cx);
1115 cx.notify();
1116 }
1117 }
1118
1119 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1120 if let Some(selection) = self.selection {
1121 let (mut worktree_ix, mut entry_ix, _) =
1122 self.index_for_selection(selection).unwrap_or_default();
1123 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
1124 if entry_ix + 1 < worktree_entries.len() {
1125 entry_ix += 1;
1126 } else {
1127 worktree_ix += 1;
1128 entry_ix = 0;
1129 }
1130 }
1131
1132 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
1133 if let Some(entry) = worktree_entries.get(entry_ix) {
1134 let selection = SelectedEntry {
1135 worktree_id: *worktree_id,
1136 entry_id: entry.id,
1137 };
1138 self.selection = Some(selection);
1139 if cx.modifiers().shift {
1140 self.marked_entries.insert(selection);
1141 }
1142
1143 self.autoscroll(cx);
1144 cx.notify();
1145 }
1146 }
1147 } else {
1148 self.select_first(&SelectFirst {}, cx);
1149 }
1150 }
1151
1152 fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
1153 if let Some((worktree, entry)) = self.selected_entry(cx) {
1154 if let Some(parent) = entry.path.parent() {
1155 if let Some(parent_entry) = worktree.entry_for_path(parent) {
1156 self.selection = Some(SelectedEntry {
1157 worktree_id: worktree.id(),
1158 entry_id: parent_entry.id,
1159 });
1160 self.autoscroll(cx);
1161 cx.notify();
1162 }
1163 }
1164 } else {
1165 self.select_first(&SelectFirst {}, cx);
1166 }
1167 }
1168
1169 fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
1170 let worktree = self
1171 .visible_entries
1172 .first()
1173 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
1174 if let Some(worktree) = worktree {
1175 let worktree = worktree.read(cx);
1176 let worktree_id = worktree.id();
1177 if let Some(root_entry) = worktree.root_entry() {
1178 let selection = SelectedEntry {
1179 worktree_id,
1180 entry_id: root_entry.id,
1181 };
1182 self.selection = Some(selection);
1183 if cx.modifiers().shift {
1184 self.marked_entries.insert(selection);
1185 }
1186 self.autoscroll(cx);
1187 cx.notify();
1188 }
1189 }
1190 }
1191
1192 fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
1193 let worktree = self
1194 .visible_entries
1195 .last()
1196 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
1197 if let Some(worktree) = worktree {
1198 let worktree = worktree.read(cx);
1199 let worktree_id = worktree.id();
1200 if let Some(last_entry) = worktree.entries(true, 0).last() {
1201 self.selection = Some(SelectedEntry {
1202 worktree_id,
1203 entry_id: last_entry.id,
1204 });
1205 self.autoscroll(cx);
1206 cx.notify();
1207 }
1208 }
1209 }
1210
1211 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
1212 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
1213 self.scroll_handle.scroll_to_item(index);
1214 cx.notify();
1215 }
1216 }
1217
1218 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
1219 let entries = self.marked_entries();
1220 if !entries.is_empty() {
1221 self.clipboard = Some(ClipboardEntry::Cut(entries));
1222 cx.notify();
1223 }
1224 }
1225
1226 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
1227 let entries = self.marked_entries();
1228 if !entries.is_empty() {
1229 self.clipboard = Some(ClipboardEntry::Copied(entries));
1230 cx.notify();
1231 }
1232 }
1233
1234 fn create_paste_path(
1235 &self,
1236 source: &SelectedEntry,
1237 (worktree, target_entry): (Model<Worktree>, &Entry),
1238 cx: &AppContext,
1239 ) -> Option<PathBuf> {
1240 let mut new_path = target_entry.path.to_path_buf();
1241 // If we're pasting into a file, or a directory into itself, go up one level.
1242 if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
1243 new_path.pop();
1244 }
1245 let clipboard_entry_file_name = self
1246 .project
1247 .read(cx)
1248 .path_for_entry(source.entry_id, cx)?
1249 .path
1250 .file_name()?
1251 .to_os_string();
1252 new_path.push(&clipboard_entry_file_name);
1253 let extension = new_path.extension().map(|e| e.to_os_string());
1254 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
1255 let mut ix = 0;
1256 {
1257 let worktree = worktree.read(cx);
1258 while worktree.entry_for_path(&new_path).is_some() {
1259 new_path.pop();
1260
1261 let mut new_file_name = file_name_without_extension.to_os_string();
1262 new_file_name.push(" copy");
1263 if ix > 0 {
1264 new_file_name.push(format!(" {}", ix));
1265 }
1266 if let Some(extension) = extension.as_ref() {
1267 new_file_name.push(".");
1268 new_file_name.push(extension);
1269 }
1270
1271 new_path.push(new_file_name);
1272 ix += 1;
1273 }
1274 }
1275 Some(new_path)
1276 }
1277
1278 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
1279 maybe!({
1280 let (worktree, entry) = self.selected_entry_handle(cx)?;
1281 let entry = entry.clone();
1282 let worktree_id = worktree.read(cx).id();
1283 let clipboard_entries = self
1284 .clipboard
1285 .as_ref()
1286 .filter(|clipboard| !clipboard.items().is_empty())?;
1287
1288 for clipboard_entry in clipboard_entries.items() {
1289 if clipboard_entry.worktree_id != worktree_id {
1290 return None;
1291 }
1292 let new_path =
1293 self.create_paste_path(clipboard_entry, self.selected_entry_handle(cx)?, cx)?;
1294 if clipboard_entries.is_cut() {
1295 self.project
1296 .update(cx, |project, cx| {
1297 project.rename_entry(clipboard_entry.entry_id, new_path, cx)
1298 })
1299 .detach_and_log_err(cx)
1300 } else {
1301 self.project
1302 .update(cx, |project, cx| {
1303 project.copy_entry(clipboard_entry.entry_id, new_path, cx)
1304 })
1305 .detach_and_log_err(cx)
1306 }
1307 }
1308 self.expand_entry(worktree_id, entry.id, cx);
1309 Some(())
1310 });
1311 }
1312
1313 fn duplicate(&mut self, _: &Duplicate, cx: &mut ViewContext<Self>) {
1314 self.copy(&Copy {}, cx);
1315 self.paste(&Paste {}, cx);
1316 }
1317
1318 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
1319 if let Some((worktree, entry)) = self.selected_entry(cx) {
1320 cx.write_to_clipboard(ClipboardItem::new(
1321 worktree
1322 .abs_path()
1323 .join(&entry.path)
1324 .to_string_lossy()
1325 .to_string(),
1326 ));
1327 }
1328 }
1329
1330 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
1331 if let Some((_, entry)) = self.selected_entry(cx) {
1332 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
1333 }
1334 }
1335
1336 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
1337 if let Some((worktree, entry)) = self.selected_entry(cx) {
1338 cx.reveal_path(&worktree.abs_path().join(&entry.path));
1339 }
1340 }
1341
1342 fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
1343 if let Some((worktree, entry)) = self.selected_entry(cx) {
1344 let abs_path = worktree.abs_path().join(&entry.path);
1345 let working_directory = if entry.is_dir() {
1346 Some(abs_path)
1347 } else {
1348 if entry.is_symlink {
1349 abs_path.canonicalize().ok()
1350 } else {
1351 Some(abs_path)
1352 }
1353 .and_then(|path| Some(path.parent()?.to_path_buf()))
1354 };
1355 if let Some(working_directory) = working_directory {
1356 cx.dispatch_action(workspace::OpenTerminal { working_directory }.boxed_clone())
1357 }
1358 }
1359 }
1360
1361 pub fn new_search_in_directory(
1362 &mut self,
1363 _: &NewSearchInDirectory,
1364 cx: &mut ViewContext<Self>,
1365 ) {
1366 if let Some((worktree, entry)) = self.selected_entry(cx) {
1367 if entry.is_dir() {
1368 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
1369 let dir_path = if include_root {
1370 let mut full_path = PathBuf::from(worktree.root_name());
1371 full_path.push(&entry.path);
1372 Arc::from(full_path)
1373 } else {
1374 entry.path.clone()
1375 };
1376
1377 self.workspace
1378 .update(cx, |workspace, cx| {
1379 search::ProjectSearchView::new_search_in_directory(
1380 workspace, &dir_path, cx,
1381 );
1382 })
1383 .ok();
1384 }
1385 }
1386 }
1387
1388 fn move_entry(
1389 &mut self,
1390 entry_to_move: ProjectEntryId,
1391 destination: ProjectEntryId,
1392 destination_is_file: bool,
1393 cx: &mut ViewContext<Self>,
1394 ) {
1395 if self
1396 .project
1397 .read(cx)
1398 .entry_is_worktree_root(entry_to_move, cx)
1399 {
1400 self.move_worktree_root(entry_to_move, destination, cx)
1401 } else {
1402 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
1403 }
1404 }
1405
1406 fn move_worktree_root(
1407 &mut self,
1408 entry_to_move: ProjectEntryId,
1409 destination: ProjectEntryId,
1410 cx: &mut ViewContext<Self>,
1411 ) {
1412 self.project.update(cx, |project, cx| {
1413 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
1414 return;
1415 };
1416 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
1417 return;
1418 };
1419
1420 let worktree_id = worktree_to_move.read(cx).id();
1421 let destination_id = destination_worktree.read(cx).id();
1422
1423 project
1424 .move_worktree(worktree_id, destination_id, cx)
1425 .log_err();
1426 });
1427 return;
1428 }
1429
1430 fn move_worktree_entry(
1431 &mut self,
1432 entry_to_move: ProjectEntryId,
1433 destination: ProjectEntryId,
1434 destination_is_file: bool,
1435 cx: &mut ViewContext<Self>,
1436 ) {
1437 let destination_worktree = self.project.update(cx, |project, cx| {
1438 let entry_path = project.path_for_entry(entry_to_move, cx)?;
1439 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1440
1441 let mut destination_path = destination_entry_path.as_ref();
1442 if destination_is_file {
1443 destination_path = destination_path.parent()?;
1444 }
1445
1446 let mut new_path = destination_path.to_path_buf();
1447 new_path.push(entry_path.path.file_name()?);
1448 if new_path != entry_path.path.as_ref() {
1449 let task = project.rename_entry(entry_to_move, new_path, cx);
1450 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1451 }
1452
1453 project.worktree_id_for_entry(destination, cx)
1454 });
1455
1456 if let Some(destination_worktree) = destination_worktree {
1457 self.expand_entry(destination_worktree, destination, cx);
1458 }
1459 }
1460
1461 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
1462 let mut entry_index = 0;
1463 let mut visible_entries_index = 0;
1464 for (worktree_index, (worktree_id, worktree_entries)) in
1465 self.visible_entries.iter().enumerate()
1466 {
1467 if *worktree_id == selection.worktree_id {
1468 for entry in worktree_entries {
1469 if entry.id == selection.entry_id {
1470 return Some((worktree_index, entry_index, visible_entries_index));
1471 } else {
1472 visible_entries_index += 1;
1473 entry_index += 1;
1474 }
1475 }
1476 break;
1477 } else {
1478 visible_entries_index += worktree_entries.len();
1479 }
1480 }
1481 None
1482 }
1483
1484 // Returns list of entries that should be affected by an operation.
1485 // When currently selected entry is not marked, it's treated as the only marked entry.
1486 fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
1487 let Some(selection) = self.selection else {
1488 return Default::default();
1489 };
1490 if self.marked_entries.contains(&selection) {
1491 self.marked_entries.clone()
1492 } else {
1493 BTreeSet::from_iter([selection])
1494 }
1495 }
1496 pub fn selected_entry<'a>(
1497 &self,
1498 cx: &'a AppContext,
1499 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1500 let (worktree, entry) = self.selected_entry_handle(cx)?;
1501 Some((worktree.read(cx), entry))
1502 }
1503
1504 fn selected_entry_handle<'a>(
1505 &self,
1506 cx: &'a AppContext,
1507 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1508 let selection = self.selection?;
1509 let project = self.project.read(cx);
1510 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1511 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1512 Some((worktree, entry))
1513 }
1514
1515 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1516 let (worktree, entry) = self.selected_entry(cx)?;
1517 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1518
1519 for path in entry.path.ancestors() {
1520 let Some(entry) = worktree.entry_for_path(path) else {
1521 continue;
1522 };
1523 if entry.is_dir() {
1524 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1525 expanded_dir_ids.insert(idx, entry.id);
1526 }
1527 }
1528 }
1529
1530 Some(())
1531 }
1532
1533 fn update_visible_entries(
1534 &mut self,
1535 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1536 cx: &mut ViewContext<Self>,
1537 ) {
1538 let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1539 let project = self.project.read(cx);
1540 self.last_worktree_root_id = project
1541 .visible_worktrees(cx)
1542 .rev()
1543 .next()
1544 .and_then(|worktree| worktree.read(cx).root_entry())
1545 .map(|entry| entry.id);
1546
1547 self.visible_entries.clear();
1548 for worktree in project.visible_worktrees(cx) {
1549 let snapshot = worktree.read(cx).snapshot();
1550 let worktree_id = snapshot.id();
1551
1552 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1553 hash_map::Entry::Occupied(e) => e.into_mut(),
1554 hash_map::Entry::Vacant(e) => {
1555 // The first time a worktree's root entry becomes available,
1556 // mark that root entry as expanded.
1557 if let Some(entry) = snapshot.root_entry() {
1558 e.insert(vec![entry.id]).as_slice()
1559 } else {
1560 &[]
1561 }
1562 }
1563 };
1564
1565 let mut new_entry_parent_id = None;
1566 let mut new_entry_kind = EntryKind::Dir;
1567 if let Some(edit_state) = &self.edit_state {
1568 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1569 new_entry_parent_id = Some(edit_state.entry_id);
1570 new_entry_kind = if edit_state.is_dir {
1571 EntryKind::Dir
1572 } else {
1573 EntryKind::File(Default::default())
1574 };
1575 }
1576 }
1577
1578 let mut visible_worktree_entries = Vec::new();
1579 let mut entry_iter = snapshot.entries(true, 0);
1580 while let Some(entry) = entry_iter.entry() {
1581 if auto_collapse_dirs
1582 && entry.kind.is_dir()
1583 && !self.unfolded_dir_ids.contains(&entry.id)
1584 {
1585 if let Some(root_path) = snapshot.root_entry() {
1586 let mut child_entries = snapshot.child_entries(&entry.path);
1587 if let Some(child) = child_entries.next() {
1588 if entry.path != root_path.path
1589 && child_entries.next().is_none()
1590 && child.kind.is_dir()
1591 {
1592 entry_iter.advance();
1593 continue;
1594 }
1595 }
1596 }
1597 }
1598
1599 visible_worktree_entries.push(entry.clone());
1600 if Some(entry.id) == new_entry_parent_id {
1601 visible_worktree_entries.push(Entry {
1602 id: NEW_ENTRY_ID,
1603 kind: new_entry_kind,
1604 path: entry.path.join("\0").into(),
1605 inode: 0,
1606 mtime: entry.mtime,
1607 is_ignored: entry.is_ignored,
1608 is_external: false,
1609 is_private: false,
1610 git_status: entry.git_status,
1611 canonical_path: entry.canonical_path.clone(),
1612 is_symlink: entry.is_symlink,
1613 });
1614 }
1615 if expanded_dir_ids.binary_search(&entry.id).is_err()
1616 && entry_iter.advance_to_sibling()
1617 {
1618 continue;
1619 }
1620 entry_iter.advance();
1621 }
1622
1623 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1624 project::sort_worktree_entries(&mut visible_worktree_entries);
1625 self.visible_entries
1626 .push((worktree_id, visible_worktree_entries));
1627 }
1628
1629 if let Some((worktree_id, entry_id)) = new_selected_entry {
1630 self.selection = Some(SelectedEntry {
1631 worktree_id,
1632 entry_id,
1633 });
1634 if cx.modifiers().shift {
1635 self.marked_entries.insert(SelectedEntry {
1636 worktree_id,
1637 entry_id,
1638 });
1639 }
1640 }
1641 }
1642
1643 fn expand_entry(
1644 &mut self,
1645 worktree_id: WorktreeId,
1646 entry_id: ProjectEntryId,
1647 cx: &mut ViewContext<Self>,
1648 ) {
1649 self.project.update(cx, |project, cx| {
1650 if let Some((worktree, expanded_dir_ids)) = project
1651 .worktree_for_id(worktree_id, cx)
1652 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1653 {
1654 project.expand_entry(worktree_id, entry_id, cx);
1655 let worktree = worktree.read(cx);
1656
1657 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1658 loop {
1659 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1660 expanded_dir_ids.insert(ix, entry.id);
1661 }
1662
1663 if let Some(parent_entry) =
1664 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1665 {
1666 entry = parent_entry;
1667 } else {
1668 break;
1669 }
1670 }
1671 }
1672 }
1673 });
1674 }
1675
1676 fn drop_external_files(
1677 &mut self,
1678 paths: &[PathBuf],
1679 entry_id: ProjectEntryId,
1680 cx: &mut ViewContext<Self>,
1681 ) {
1682 let mut paths: Vec<Arc<Path>> = paths
1683 .into_iter()
1684 .map(|path| Arc::from(path.clone()))
1685 .collect();
1686
1687 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
1688
1689 let Some((target_directory, worktree)) = maybe!({
1690 let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
1691 let entry = worktree.read(cx).entry_for_id(entry_id)?;
1692 let path = worktree.read(cx).absolutize(&entry.path).ok()?;
1693 let target_directory = if path.is_dir() {
1694 path
1695 } else {
1696 path.parent()?.to_path_buf()
1697 };
1698 Some((target_directory, worktree))
1699 }) else {
1700 return;
1701 };
1702
1703 let mut paths_to_replace = Vec::new();
1704 for path in &paths {
1705 if let Some(name) = path.file_name() {
1706 let mut target_path = target_directory.clone();
1707 target_path.push(name);
1708 if target_path.exists() {
1709 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
1710 }
1711 }
1712 }
1713
1714 cx.spawn(|this, mut cx| {
1715 async move {
1716 for (filename, original_path) in &paths_to_replace {
1717 let answer = cx
1718 .prompt(
1719 PromptLevel::Info,
1720 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
1721 None,
1722 &["Replace", "Cancel"],
1723 )
1724 .await?;
1725 if answer == 1 {
1726 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
1727 paths.remove(item_idx);
1728 }
1729 }
1730 }
1731
1732 if paths.is_empty() {
1733 return Ok(());
1734 }
1735
1736 let task = worktree.update(&mut cx, |worktree, cx| {
1737 worktree.copy_external_entries(target_directory, paths, true, cx)
1738 })?;
1739
1740 let opened_entries = task.await?;
1741 this.update(&mut cx, |this, cx| {
1742 if open_file_after_drop && !opened_entries.is_empty() {
1743 this.open_entry(opened_entries[0], true, true, false, cx);
1744 }
1745 })
1746 }
1747 .log_err()
1748 })
1749 .detach();
1750 }
1751
1752 fn drag_onto(
1753 &mut self,
1754 selections: &DraggedSelection,
1755 target_entry_id: ProjectEntryId,
1756 is_file: bool,
1757 cx: &mut ViewContext<Self>,
1758 ) {
1759 let should_copy = cx.modifiers().alt;
1760 if should_copy {
1761 let _ = maybe!({
1762 let project = self.project.read(cx);
1763 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
1764 let target_entry = target_worktree
1765 .read(cx)
1766 .entry_for_id(target_entry_id)?
1767 .clone();
1768 for selection in selections.items() {
1769 let new_path = self.create_paste_path(
1770 &selection,
1771 (target_worktree.clone(), &target_entry),
1772 cx,
1773 )?;
1774 self.project
1775 .update(cx, |project, cx| {
1776 project.copy_entry(selection.entry_id, new_path, cx)
1777 })
1778 .detach_and_log_err(cx)
1779 }
1780
1781 Some(())
1782 });
1783 } else {
1784 for selection in selections.items() {
1785 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
1786 }
1787 }
1788 }
1789
1790 fn for_each_visible_entry(
1791 &self,
1792 range: Range<usize>,
1793 cx: &mut ViewContext<ProjectPanel>,
1794 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1795 ) {
1796 let mut ix = 0;
1797 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1798 if ix >= range.end {
1799 return;
1800 }
1801
1802 if ix + visible_worktree_entries.len() <= range.start {
1803 ix += visible_worktree_entries.len();
1804 continue;
1805 }
1806
1807 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1808 let (git_status_setting, show_file_icons, show_folder_icons) = {
1809 let settings = ProjectPanelSettings::get_global(cx);
1810 (
1811 settings.git_status,
1812 settings.file_icons,
1813 settings.folder_icons,
1814 )
1815 };
1816 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1817 let snapshot = worktree.read(cx).snapshot();
1818 let root_name = OsStr::new(snapshot.root_name());
1819 let expanded_entry_ids = self
1820 .expanded_dir_ids
1821 .get(&snapshot.id())
1822 .map(Vec::as_slice)
1823 .unwrap_or(&[]);
1824
1825 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1826 let entries = visible_worktree_entries
1827 .iter()
1828 .map(|e| (e.path.clone()))
1829 .collect();
1830 for entry in visible_worktree_entries[entry_range].iter() {
1831 let status = git_status_setting.then(|| entry.git_status).flatten();
1832 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1833 let icon = match entry.kind {
1834 EntryKind::File(_) => {
1835 if show_file_icons {
1836 FileIcons::get_icon(&entry.path, cx)
1837 } else {
1838 None
1839 }
1840 }
1841 _ => {
1842 if show_folder_icons {
1843 FileIcons::get_folder_icon(is_expanded, cx)
1844 } else {
1845 FileIcons::get_chevron_icon(is_expanded, cx)
1846 }
1847 }
1848 };
1849
1850 let (depth, difference) =
1851 ProjectPanel::calculate_depth_and_difference(entry, &entries);
1852
1853 let filename = match difference {
1854 diff if diff > 1 => entry
1855 .path
1856 .iter()
1857 .skip(entry.path.components().count() - diff)
1858 .collect::<PathBuf>()
1859 .to_str()
1860 .unwrap_or_default()
1861 .to_string(),
1862 _ => entry
1863 .path
1864 .file_name()
1865 .map(|name| name.to_string_lossy().into_owned())
1866 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
1867 };
1868 let selection = SelectedEntry {
1869 worktree_id: snapshot.id(),
1870 entry_id: entry.id,
1871 };
1872 let mut details = EntryDetails {
1873 filename,
1874 icon,
1875 path: entry.path.clone(),
1876 depth,
1877 kind: entry.kind,
1878 is_ignored: entry.is_ignored,
1879 is_expanded,
1880 is_selected: self.selection == Some(selection),
1881 is_marked: self.marked_entries.contains(&selection),
1882 is_editing: false,
1883 is_processing: false,
1884 is_cut: self
1885 .clipboard
1886 .as_ref()
1887 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
1888 git_status: status,
1889 is_private: entry.is_private,
1890 worktree_id: *worktree_id,
1891 canonical_path: entry.canonical_path.clone(),
1892 };
1893
1894 if let Some(edit_state) = &self.edit_state {
1895 let is_edited_entry = if edit_state.is_new_entry {
1896 entry.id == NEW_ENTRY_ID
1897 } else {
1898 entry.id == edit_state.entry_id
1899 };
1900
1901 if is_edited_entry {
1902 if let Some(processing_filename) = &edit_state.processing_filename {
1903 details.is_processing = true;
1904 details.filename.clear();
1905 details.filename.push_str(processing_filename);
1906 } else {
1907 if edit_state.is_new_entry {
1908 details.filename.clear();
1909 }
1910 details.is_editing = true;
1911 }
1912 }
1913 }
1914
1915 callback(entry.id, details, cx);
1916 }
1917 }
1918 ix = end_ix;
1919 }
1920 }
1921
1922 fn calculate_depth_and_difference(
1923 entry: &Entry,
1924 visible_worktree_entries: &HashSet<Arc<Path>>,
1925 ) -> (usize, usize) {
1926 let (depth, difference) = entry
1927 .path
1928 .ancestors()
1929 .skip(1) // Skip the entry itself
1930 .find_map(|ancestor| {
1931 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
1932 let entry_path_components_count = entry.path.components().count();
1933 let parent_path_components_count = parent_entry.components().count();
1934 let difference = entry_path_components_count - parent_path_components_count;
1935 let depth = parent_entry
1936 .ancestors()
1937 .skip(1)
1938 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
1939 .count();
1940 Some((depth + 1, difference))
1941 } else {
1942 None
1943 }
1944 })
1945 .unwrap_or((0, 0));
1946
1947 (depth, difference)
1948 }
1949
1950 fn render_entry(
1951 &self,
1952 entry_id: ProjectEntryId,
1953 details: EntryDetails,
1954 cx: &mut ViewContext<Self>,
1955 ) -> Stateful<Div> {
1956 let kind = details.kind;
1957 let settings = ProjectPanelSettings::get_global(cx);
1958 let show_editor = details.is_editing && !details.is_processing;
1959 let selection = SelectedEntry {
1960 worktree_id: details.worktree_id,
1961 entry_id,
1962 };
1963 let is_marked = self.marked_entries.contains(&selection);
1964 let is_active = self
1965 .selection
1966 .map_or(false, |selection| selection.entry_id == entry_id);
1967 let width = self.size(cx);
1968 let filename_text_color =
1969 entry_git_aware_label_color(details.git_status, details.is_ignored, is_marked);
1970 let file_name = details.filename.clone();
1971 let mut icon = details.icon.clone();
1972 if settings.file_icons && show_editor && details.kind.is_file() {
1973 let filename = self.filename_editor.read(cx).text(cx);
1974 if filename.len() > 2 {
1975 icon = FileIcons::get_icon(Path::new(&filename), cx);
1976 }
1977 }
1978
1979 let canonical_path = details
1980 .canonical_path
1981 .as_ref()
1982 .map(|f| f.to_string_lossy().to_string());
1983 let path = details.path.clone();
1984
1985 let depth = details.depth;
1986 let worktree_id = details.worktree_id;
1987 let selections = Arc::new(self.marked_entries.clone());
1988
1989 let dragged_selection = DraggedSelection {
1990 active_selection: selection,
1991 marked_selections: selections,
1992 };
1993 div()
1994 .id(entry_id.to_proto() as usize)
1995 .on_drag_move::<ExternalPaths>(cx.listener(
1996 move |this, event: &DragMoveEvent<ExternalPaths>, cx| {
1997 if event.bounds.contains(&event.event.position) {
1998 if this.last_external_paths_drag_over_entry == Some(entry_id) {
1999 return;
2000 }
2001 this.last_external_paths_drag_over_entry = Some(entry_id);
2002 this.marked_entries.clear();
2003
2004 let Some((worktree, path, entry)) = maybe!({
2005 let worktree = this
2006 .project
2007 .read(cx)
2008 .worktree_for_id(selection.worktree_id, cx)?;
2009 let worktree = worktree.read(cx);
2010 let abs_path = worktree.absolutize(&path).log_err()?;
2011 let path = if abs_path.is_dir() {
2012 path.as_ref()
2013 } else {
2014 path.parent()?
2015 };
2016 let entry = worktree.entry_for_path(path)?;
2017 Some((worktree, path, entry))
2018 }) else {
2019 return;
2020 };
2021
2022 this.marked_entries.insert(SelectedEntry {
2023 entry_id: entry.id,
2024 worktree_id: worktree.id(),
2025 });
2026
2027 for entry in worktree.child_entries(path) {
2028 this.marked_entries.insert(SelectedEntry {
2029 entry_id: entry.id,
2030 worktree_id: worktree.id(),
2031 });
2032 }
2033
2034 cx.notify();
2035 }
2036 },
2037 ))
2038 .on_drop(
2039 cx.listener(move |this, external_paths: &ExternalPaths, cx| {
2040 this.last_external_paths_drag_over_entry = None;
2041 this.marked_entries.clear();
2042 this.drop_external_files(external_paths.paths(), entry_id, cx);
2043 cx.stop_propagation();
2044 }),
2045 )
2046 .on_drag(dragged_selection, move |selection, cx| {
2047 cx.new_view(|_| DraggedProjectEntryView {
2048 details: details.clone(),
2049 width,
2050 selection: selection.active_selection,
2051 selections: selection.marked_selections.clone(),
2052 })
2053 })
2054 .drag_over::<DraggedSelection>(|style, _, cx| {
2055 style.bg(cx.theme().colors().drop_target_background)
2056 })
2057 .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
2058 this.drag_onto(selections, entry_id, kind.is_file(), cx);
2059 }))
2060 .child(
2061 ListItem::new(entry_id.to_proto() as usize)
2062 .indent_level(depth)
2063 .indent_step_size(px(settings.indent_size))
2064 .selected(is_marked || is_active)
2065 .when_some(canonical_path, |this, path| {
2066 this.end_slot::<AnyElement>(
2067 div()
2068 .id("symlink_icon")
2069 .tooltip(move |cx| {
2070 Tooltip::text(format!("{path} • Symbolic Link"), cx)
2071 })
2072 .child(
2073 Icon::new(IconName::ArrowUpRight)
2074 .size(IconSize::Indicator)
2075 .color(filename_text_color),
2076 )
2077 .into_any_element(),
2078 )
2079 })
2080 .child(if let Some(icon) = &icon {
2081 h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
2082 } else {
2083 h_flex()
2084 .size(IconSize::default().rems())
2085 .invisible()
2086 .flex_none()
2087 })
2088 .child(
2089 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
2090 h_flex().h_6().w_full().child(editor.clone())
2091 } else {
2092 h_flex().h_6().child(
2093 Label::new(file_name)
2094 .single_line()
2095 .color(filename_text_color),
2096 )
2097 }
2098 .ml_1(),
2099 )
2100 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
2101 if event.down.button == MouseButton::Right || event.down.first_mouse {
2102 return;
2103 }
2104 if !show_editor {
2105 if let Some(selection) =
2106 this.selection.filter(|_| event.down.modifiers.shift)
2107 {
2108 let current_selection = this.index_for_selection(selection);
2109 let target_selection = this.index_for_selection(SelectedEntry {
2110 entry_id,
2111 worktree_id,
2112 });
2113 if let Some(((_, _, source_index), (_, _, target_index))) =
2114 current_selection.zip(target_selection)
2115 {
2116 let range_start = source_index.min(target_index);
2117 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
2118 let mut new_selections = BTreeSet::new();
2119 this.for_each_visible_entry(
2120 range_start..range_end,
2121 cx,
2122 |entry_id, details, _| {
2123 new_selections.insert(SelectedEntry {
2124 entry_id,
2125 worktree_id: details.worktree_id,
2126 });
2127 },
2128 );
2129
2130 this.marked_entries = this
2131 .marked_entries
2132 .union(&new_selections)
2133 .cloned()
2134 .collect();
2135
2136 this.selection = Some(SelectedEntry {
2137 entry_id,
2138 worktree_id,
2139 });
2140 // Ensure that the current entry is selected.
2141 this.marked_entries.insert(SelectedEntry {
2142 entry_id,
2143 worktree_id,
2144 });
2145 }
2146 } else if event.down.modifiers.secondary() {
2147 if !this.marked_entries.insert(selection) {
2148 this.marked_entries.remove(&selection);
2149 }
2150 } else if kind.is_dir() {
2151 this.toggle_expanded(entry_id, cx);
2152 } else {
2153 let click_count = event.up.click_count;
2154 if click_count > 1 && event.down.modifiers.secondary() {
2155 this.split_entry(entry_id, cx);
2156 } else {
2157 this.open_entry(
2158 entry_id,
2159 cx.modifiers().secondary(),
2160 click_count > 1,
2161 click_count == 1,
2162 cx,
2163 );
2164 }
2165 }
2166 }
2167 }))
2168 .on_secondary_mouse_down(cx.listener(
2169 move |this, event: &MouseDownEvent, cx| {
2170 // Stop propagation to prevent the catch-all context menu for the project
2171 // panel from being deployed.
2172 cx.stop_propagation();
2173 this.deploy_context_menu(event.position, entry_id, cx);
2174 },
2175 )),
2176 )
2177 .border_1()
2178 .border_r_2()
2179 .rounded_none()
2180 .hover(|style| {
2181 if is_active {
2182 style
2183 } else {
2184 let hover_color = cx.theme().colors().ghost_element_hover;
2185 style.bg(hover_color).border_color(hover_color)
2186 }
2187 })
2188 .when(is_marked || is_active, |this| {
2189 let colors = cx.theme().colors();
2190 this.when(is_marked, |this| this.bg(colors.ghost_element_selected))
2191 .border_color(colors.ghost_element_selected)
2192 })
2193 .when(
2194 is_active && self.focus_handle.contains_focused(cx),
2195 |this| this.border_color(Color::Selected.color(cx)),
2196 )
2197 }
2198
2199 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
2200 let mut dispatch_context = KeyContext::new_with_defaults();
2201 dispatch_context.add("ProjectPanel");
2202 dispatch_context.add("menu");
2203
2204 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
2205 "editing"
2206 } else {
2207 "not_editing"
2208 };
2209
2210 dispatch_context.add(identifier);
2211 dispatch_context
2212 }
2213
2214 fn reveal_entry(
2215 &mut self,
2216 project: Model<Project>,
2217 entry_id: ProjectEntryId,
2218 skip_ignored: bool,
2219 cx: &mut ViewContext<'_, ProjectPanel>,
2220 ) {
2221 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
2222 let worktree = worktree.read(cx);
2223 if skip_ignored
2224 && worktree
2225 .entry_for_id(entry_id)
2226 .map_or(true, |entry| entry.is_ignored)
2227 {
2228 return;
2229 }
2230
2231 let worktree_id = worktree.id();
2232 self.marked_entries.clear();
2233 self.expand_entry(worktree_id, entry_id, cx);
2234 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
2235 self.autoscroll(cx);
2236 cx.notify();
2237 }
2238 }
2239}
2240
2241impl Render for ProjectPanel {
2242 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
2243 let has_worktree = self.visible_entries.len() != 0;
2244 let project = self.project.read(cx);
2245
2246 if has_worktree {
2247 h_flex()
2248 .id("project-panel")
2249 .size_full()
2250 .relative()
2251 .key_context(self.dispatch_context(cx))
2252 .on_action(cx.listener(Self::select_next))
2253 .on_action(cx.listener(Self::select_prev))
2254 .on_action(cx.listener(Self::select_first))
2255 .on_action(cx.listener(Self::select_last))
2256 .on_action(cx.listener(Self::select_parent))
2257 .on_action(cx.listener(Self::expand_selected_entry))
2258 .on_action(cx.listener(Self::collapse_selected_entry))
2259 .on_action(cx.listener(Self::collapse_all_entries))
2260 .on_action(cx.listener(Self::open))
2261 .on_action(cx.listener(Self::open_permanent))
2262 .on_action(cx.listener(Self::confirm))
2263 .on_action(cx.listener(Self::cancel))
2264 .on_action(cx.listener(Self::copy_path))
2265 .on_action(cx.listener(Self::copy_relative_path))
2266 .on_action(cx.listener(Self::new_search_in_directory))
2267 .on_action(cx.listener(Self::unfold_directory))
2268 .on_action(cx.listener(Self::fold_directory))
2269 .when(!project.is_read_only(), |el| {
2270 el.on_action(cx.listener(Self::new_file))
2271 .on_action(cx.listener(Self::new_directory))
2272 .on_action(cx.listener(Self::rename))
2273 .on_action(cx.listener(Self::delete))
2274 .on_action(cx.listener(Self::trash))
2275 .on_action(cx.listener(Self::cut))
2276 .on_action(cx.listener(Self::copy))
2277 .on_action(cx.listener(Self::paste))
2278 .on_action(cx.listener(Self::duplicate))
2279 })
2280 .when(project.is_local(), |el| {
2281 el.on_action(cx.listener(Self::reveal_in_finder))
2282 .on_action(cx.listener(Self::open_in_terminal))
2283 })
2284 .on_mouse_down(
2285 MouseButton::Right,
2286 cx.listener(move |this, event: &MouseDownEvent, cx| {
2287 // When deploying the context menu anywhere below the last project entry,
2288 // act as if the user clicked the root of the last worktree.
2289 if let Some(entry_id) = this.last_worktree_root_id {
2290 this.deploy_context_menu(event.position, entry_id, cx);
2291 }
2292 }),
2293 )
2294 .track_focus(&self.focus_handle)
2295 .child(
2296 uniform_list(
2297 cx.view().clone(),
2298 "entries",
2299 self.visible_entries
2300 .iter()
2301 .map(|(_, worktree_entries)| worktree_entries.len())
2302 .sum(),
2303 {
2304 |this, range, cx| {
2305 let mut items = Vec::new();
2306 this.for_each_visible_entry(range, cx, |id, details, cx| {
2307 items.push(this.render_entry(id, details, cx));
2308 });
2309 items
2310 }
2311 },
2312 )
2313 .size_full()
2314 .with_sizing_behavior(ListSizingBehavior::Infer)
2315 .track_scroll(self.scroll_handle.clone()),
2316 )
2317 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2318 deferred(
2319 anchored()
2320 .position(*position)
2321 .anchor(gpui::AnchorCorner::TopLeft)
2322 .child(menu.clone()),
2323 )
2324 .with_priority(1)
2325 }))
2326 } else {
2327 v_flex()
2328 .id("empty-project_panel")
2329 .size_full()
2330 .p_4()
2331 .track_focus(&self.focus_handle)
2332 .child(
2333 Button::new("open_project", "Open a project")
2334 .style(ButtonStyle::Filled)
2335 .full_width()
2336 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
2337 .on_click(cx.listener(|this, _, cx| {
2338 this.workspace
2339 .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
2340 .log_err();
2341 })),
2342 )
2343 .drag_over::<ExternalPaths>(|style, _, cx| {
2344 style.bg(cx.theme().colors().drop_target_background)
2345 })
2346 .on_drop(
2347 cx.listener(move |this, external_paths: &ExternalPaths, cx| {
2348 this.last_external_paths_drag_over_entry = None;
2349 this.marked_entries.clear();
2350 if let Some(task) = this
2351 .workspace
2352 .update(cx, |workspace, cx| {
2353 workspace.open_workspace_for_paths(
2354 true,
2355 external_paths.paths().to_owned(),
2356 cx,
2357 )
2358 })
2359 .log_err()
2360 {
2361 task.detach_and_log_err(cx);
2362 }
2363 cx.stop_propagation();
2364 }),
2365 )
2366 }
2367 }
2368}
2369
2370impl Render for DraggedProjectEntryView {
2371 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2372 let settings = ProjectPanelSettings::get_global(cx);
2373 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
2374 h_flex().font(ui_font).map(|this| {
2375 if self.selections.contains(&self.selection) {
2376 this.flex_shrink()
2377 .p_1()
2378 .items_end()
2379 .rounded_md()
2380 .child(self.selections.len().to_string())
2381 } else {
2382 this.bg(cx.theme().colors().background).w(self.width).child(
2383 ListItem::new(self.selection.entry_id.to_proto() as usize)
2384 .indent_level(self.details.depth)
2385 .indent_step_size(px(settings.indent_size))
2386 .child(if let Some(icon) = &self.details.icon {
2387 div().child(Icon::from_path(icon.to_string()))
2388 } else {
2389 div()
2390 })
2391 .child(Label::new(self.details.filename.clone())),
2392 )
2393 }
2394 })
2395 }
2396}
2397
2398impl EventEmitter<Event> for ProjectPanel {}
2399
2400impl EventEmitter<PanelEvent> for ProjectPanel {}
2401
2402impl Panel for ProjectPanel {
2403 fn position(&self, cx: &WindowContext) -> DockPosition {
2404 match ProjectPanelSettings::get_global(cx).dock {
2405 ProjectPanelDockPosition::Left => DockPosition::Left,
2406 ProjectPanelDockPosition::Right => DockPosition::Right,
2407 }
2408 }
2409
2410 fn position_is_valid(&self, position: DockPosition) -> bool {
2411 matches!(position, DockPosition::Left | DockPosition::Right)
2412 }
2413
2414 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2415 settings::update_settings_file::<ProjectPanelSettings>(
2416 self.fs.clone(),
2417 cx,
2418 move |settings| {
2419 let dock = match position {
2420 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
2421 DockPosition::Right => ProjectPanelDockPosition::Right,
2422 };
2423 settings.dock = Some(dock);
2424 },
2425 );
2426 }
2427
2428 fn size(&self, cx: &WindowContext) -> Pixels {
2429 self.width
2430 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
2431 }
2432
2433 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2434 self.width = size;
2435 self.serialize(cx);
2436 cx.notify();
2437 }
2438
2439 fn icon(&self, cx: &WindowContext) -> Option<IconName> {
2440 ProjectPanelSettings::get_global(cx)
2441 .button
2442 .then(|| IconName::FileTree)
2443 }
2444
2445 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2446 Some("Project Panel")
2447 }
2448
2449 fn toggle_action(&self) -> Box<dyn Action> {
2450 Box::new(ToggleFocus)
2451 }
2452
2453 fn persistent_name() -> &'static str {
2454 "Project Panel"
2455 }
2456
2457 fn starts_open(&self, cx: &WindowContext) -> bool {
2458 let project = &self.project.read(cx);
2459 project.dev_server_project_id().is_some()
2460 || project.visible_worktrees(cx).any(|tree| {
2461 tree.read(cx)
2462 .root_entry()
2463 .map_or(false, |entry| entry.is_dir())
2464 })
2465 }
2466}
2467
2468impl FocusableView for ProjectPanel {
2469 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
2470 self.focus_handle.clone()
2471 }
2472}
2473
2474impl ClipboardEntry {
2475 fn is_cut(&self) -> bool {
2476 matches!(self, Self::Cut { .. })
2477 }
2478
2479 fn items(&self) -> &BTreeSet<SelectedEntry> {
2480 match self {
2481 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
2482 }
2483 }
2484}
2485
2486#[cfg(test)]
2487mod tests {
2488 use super::*;
2489 use collections::HashSet;
2490 use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
2491 use pretty_assertions::assert_eq;
2492 use project::{FakeFs, WorktreeSettings};
2493 use serde_json::json;
2494 use settings::SettingsStore;
2495 use std::path::{Path, PathBuf};
2496 use workspace::{
2497 item::{Item, ProjectItem},
2498 register_project_item, AppState,
2499 };
2500
2501 #[gpui::test]
2502 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
2503 init_test(cx);
2504
2505 let fs = FakeFs::new(cx.executor().clone());
2506 fs.insert_tree(
2507 "/root1",
2508 json!({
2509 ".dockerignore": "",
2510 ".git": {
2511 "HEAD": "",
2512 },
2513 "a": {
2514 "0": { "q": "", "r": "", "s": "" },
2515 "1": { "t": "", "u": "" },
2516 "2": { "v": "", "w": "", "x": "", "y": "" },
2517 },
2518 "b": {
2519 "3": { "Q": "" },
2520 "4": { "R": "", "S": "", "T": "", "U": "" },
2521 },
2522 "C": {
2523 "5": {},
2524 "6": { "V": "", "W": "" },
2525 "7": { "X": "" },
2526 "8": { "Y": {}, "Z": "" }
2527 }
2528 }),
2529 )
2530 .await;
2531 fs.insert_tree(
2532 "/root2",
2533 json!({
2534 "d": {
2535 "9": ""
2536 },
2537 "e": {}
2538 }),
2539 )
2540 .await;
2541
2542 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2543 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2544 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2545 let panel = workspace
2546 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2547 .unwrap();
2548 assert_eq!(
2549 visible_entries_as_strings(&panel, 0..50, cx),
2550 &[
2551 "v root1",
2552 " > .git",
2553 " > a",
2554 " > b",
2555 " > C",
2556 " .dockerignore",
2557 "v root2",
2558 " > d",
2559 " > e",
2560 ]
2561 );
2562
2563 toggle_expand_dir(&panel, "root1/b", cx);
2564 assert_eq!(
2565 visible_entries_as_strings(&panel, 0..50, cx),
2566 &[
2567 "v root1",
2568 " > .git",
2569 " > a",
2570 " v b <== selected",
2571 " > 3",
2572 " > 4",
2573 " > C",
2574 " .dockerignore",
2575 "v root2",
2576 " > d",
2577 " > e",
2578 ]
2579 );
2580
2581 assert_eq!(
2582 visible_entries_as_strings(&panel, 6..9, cx),
2583 &[
2584 //
2585 " > C",
2586 " .dockerignore",
2587 "v root2",
2588 ]
2589 );
2590 }
2591
2592 #[gpui::test]
2593 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
2594 init_test(cx);
2595 cx.update(|cx| {
2596 cx.update_global::<SettingsStore, _>(|store, cx| {
2597 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
2598 worktree_settings.file_scan_exclusions =
2599 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
2600 });
2601 });
2602 });
2603
2604 let fs = FakeFs::new(cx.background_executor.clone());
2605 fs.insert_tree(
2606 "/root1",
2607 json!({
2608 ".dockerignore": "",
2609 ".git": {
2610 "HEAD": "",
2611 },
2612 "a": {
2613 "0": { "q": "", "r": "", "s": "" },
2614 "1": { "t": "", "u": "" },
2615 "2": { "v": "", "w": "", "x": "", "y": "" },
2616 },
2617 "b": {
2618 "3": { "Q": "" },
2619 "4": { "R": "", "S": "", "T": "", "U": "" },
2620 },
2621 "C": {
2622 "5": {},
2623 "6": { "V": "", "W": "" },
2624 "7": { "X": "" },
2625 "8": { "Y": {}, "Z": "" }
2626 }
2627 }),
2628 )
2629 .await;
2630 fs.insert_tree(
2631 "/root2",
2632 json!({
2633 "d": {
2634 "4": ""
2635 },
2636 "e": {}
2637 }),
2638 )
2639 .await;
2640
2641 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2642 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2643 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2644 let panel = workspace
2645 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2646 .unwrap();
2647 assert_eq!(
2648 visible_entries_as_strings(&panel, 0..50, cx),
2649 &[
2650 "v root1",
2651 " > a",
2652 " > b",
2653 " > C",
2654 " .dockerignore",
2655 "v root2",
2656 " > d",
2657 " > e",
2658 ]
2659 );
2660
2661 toggle_expand_dir(&panel, "root1/b", cx);
2662 assert_eq!(
2663 visible_entries_as_strings(&panel, 0..50, cx),
2664 &[
2665 "v root1",
2666 " > a",
2667 " v b <== selected",
2668 " > 3",
2669 " > C",
2670 " .dockerignore",
2671 "v root2",
2672 " > d",
2673 " > e",
2674 ]
2675 );
2676
2677 toggle_expand_dir(&panel, "root2/d", cx);
2678 assert_eq!(
2679 visible_entries_as_strings(&panel, 0..50, cx),
2680 &[
2681 "v root1",
2682 " > a",
2683 " v b",
2684 " > 3",
2685 " > C",
2686 " .dockerignore",
2687 "v root2",
2688 " v d <== selected",
2689 " > e",
2690 ]
2691 );
2692
2693 toggle_expand_dir(&panel, "root2/e", cx);
2694 assert_eq!(
2695 visible_entries_as_strings(&panel, 0..50, cx),
2696 &[
2697 "v root1",
2698 " > a",
2699 " v b",
2700 " > 3",
2701 " > C",
2702 " .dockerignore",
2703 "v root2",
2704 " v d",
2705 " v e <== selected",
2706 ]
2707 );
2708 }
2709
2710 #[gpui::test]
2711 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
2712 init_test(cx);
2713
2714 let fs = FakeFs::new(cx.executor().clone());
2715 fs.insert_tree(
2716 "/root1",
2717 json!({
2718 "dir_1": {
2719 "nested_dir_1": {
2720 "nested_dir_2": {
2721 "nested_dir_3": {
2722 "file_a.java": "// File contents",
2723 "file_b.java": "// File contents",
2724 "file_c.java": "// File contents",
2725 "nested_dir_4": {
2726 "nested_dir_5": {
2727 "file_d.java": "// File contents",
2728 }
2729 }
2730 }
2731 }
2732 }
2733 }
2734 }),
2735 )
2736 .await;
2737 fs.insert_tree(
2738 "/root2",
2739 json!({
2740 "dir_2": {
2741 "file_1.java": "// File contents",
2742 }
2743 }),
2744 )
2745 .await;
2746
2747 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2748 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2749 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2750 cx.update(|cx| {
2751 let settings = *ProjectPanelSettings::get_global(cx);
2752 ProjectPanelSettings::override_global(
2753 ProjectPanelSettings {
2754 auto_fold_dirs: true,
2755 ..settings
2756 },
2757 cx,
2758 );
2759 });
2760 let panel = workspace
2761 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2762 .unwrap();
2763 assert_eq!(
2764 visible_entries_as_strings(&panel, 0..10, cx),
2765 &[
2766 "v root1",
2767 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2768 "v root2",
2769 " > dir_2",
2770 ]
2771 );
2772
2773 toggle_expand_dir(
2774 &panel,
2775 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2776 cx,
2777 );
2778 assert_eq!(
2779 visible_entries_as_strings(&panel, 0..10, cx),
2780 &[
2781 "v root1",
2782 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
2783 " > nested_dir_4/nested_dir_5",
2784 " file_a.java",
2785 " file_b.java",
2786 " file_c.java",
2787 "v root2",
2788 " > dir_2",
2789 ]
2790 );
2791
2792 toggle_expand_dir(
2793 &panel,
2794 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
2795 cx,
2796 );
2797 assert_eq!(
2798 visible_entries_as_strings(&panel, 0..10, cx),
2799 &[
2800 "v root1",
2801 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2802 " v nested_dir_4/nested_dir_5 <== selected",
2803 " file_d.java",
2804 " file_a.java",
2805 " file_b.java",
2806 " file_c.java",
2807 "v root2",
2808 " > dir_2",
2809 ]
2810 );
2811 toggle_expand_dir(&panel, "root2/dir_2", cx);
2812 assert_eq!(
2813 visible_entries_as_strings(&panel, 0..10, cx),
2814 &[
2815 "v root1",
2816 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2817 " v nested_dir_4/nested_dir_5",
2818 " file_d.java",
2819 " file_a.java",
2820 " file_b.java",
2821 " file_c.java",
2822 "v root2",
2823 " v dir_2 <== selected",
2824 " file_1.java",
2825 ]
2826 );
2827 }
2828
2829 #[gpui::test(iterations = 30)]
2830 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
2831 init_test(cx);
2832
2833 let fs = FakeFs::new(cx.executor().clone());
2834 fs.insert_tree(
2835 "/root1",
2836 json!({
2837 ".dockerignore": "",
2838 ".git": {
2839 "HEAD": "",
2840 },
2841 "a": {
2842 "0": { "q": "", "r": "", "s": "" },
2843 "1": { "t": "", "u": "" },
2844 "2": { "v": "", "w": "", "x": "", "y": "" },
2845 },
2846 "b": {
2847 "3": { "Q": "" },
2848 "4": { "R": "", "S": "", "T": "", "U": "" },
2849 },
2850 "C": {
2851 "5": {},
2852 "6": { "V": "", "W": "" },
2853 "7": { "X": "" },
2854 "8": { "Y": {}, "Z": "" }
2855 }
2856 }),
2857 )
2858 .await;
2859 fs.insert_tree(
2860 "/root2",
2861 json!({
2862 "d": {
2863 "9": ""
2864 },
2865 "e": {}
2866 }),
2867 )
2868 .await;
2869
2870 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2871 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2872 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2873 let panel = workspace
2874 .update(cx, |workspace, cx| {
2875 let panel = ProjectPanel::new(workspace, cx);
2876 workspace.add_panel(panel.clone(), cx);
2877 panel
2878 })
2879 .unwrap();
2880
2881 select_path(&panel, "root1", cx);
2882 assert_eq!(
2883 visible_entries_as_strings(&panel, 0..10, cx),
2884 &[
2885 "v root1 <== selected",
2886 " > .git",
2887 " > a",
2888 " > b",
2889 " > C",
2890 " .dockerignore",
2891 "v root2",
2892 " > d",
2893 " > e",
2894 ]
2895 );
2896
2897 // Add a file with the root folder selected. The filename editor is placed
2898 // before the first file in the root folder.
2899 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2900 panel.update(cx, |panel, cx| {
2901 assert!(panel.filename_editor.read(cx).is_focused(cx));
2902 });
2903 assert_eq!(
2904 visible_entries_as_strings(&panel, 0..10, cx),
2905 &[
2906 "v root1",
2907 " > .git",
2908 " > a",
2909 " > b",
2910 " > C",
2911 " [EDITOR: ''] <== selected",
2912 " .dockerignore",
2913 "v root2",
2914 " > d",
2915 " > e",
2916 ]
2917 );
2918
2919 let confirm = panel.update(cx, |panel, cx| {
2920 panel
2921 .filename_editor
2922 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2923 panel.confirm_edit(cx).unwrap()
2924 });
2925 assert_eq!(
2926 visible_entries_as_strings(&panel, 0..10, cx),
2927 &[
2928 "v root1",
2929 " > .git",
2930 " > a",
2931 " > b",
2932 " > C",
2933 " [PROCESSING: 'the-new-filename'] <== selected",
2934 " .dockerignore",
2935 "v root2",
2936 " > d",
2937 " > e",
2938 ]
2939 );
2940
2941 confirm.await.unwrap();
2942 assert_eq!(
2943 visible_entries_as_strings(&panel, 0..10, cx),
2944 &[
2945 "v root1",
2946 " > .git",
2947 " > a",
2948 " > b",
2949 " > C",
2950 " .dockerignore",
2951 " the-new-filename <== selected <== marked",
2952 "v root2",
2953 " > d",
2954 " > e",
2955 ]
2956 );
2957
2958 select_path(&panel, "root1/b", cx);
2959 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2960 assert_eq!(
2961 visible_entries_as_strings(&panel, 0..10, cx),
2962 &[
2963 "v root1",
2964 " > .git",
2965 " > a",
2966 " v b",
2967 " > 3",
2968 " > 4",
2969 " [EDITOR: ''] <== selected",
2970 " > C",
2971 " .dockerignore",
2972 " the-new-filename",
2973 ]
2974 );
2975
2976 panel
2977 .update(cx, |panel, cx| {
2978 panel
2979 .filename_editor
2980 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2981 panel.confirm_edit(cx).unwrap()
2982 })
2983 .await
2984 .unwrap();
2985 assert_eq!(
2986 visible_entries_as_strings(&panel, 0..10, cx),
2987 &[
2988 "v root1",
2989 " > .git",
2990 " > a",
2991 " v b",
2992 " > 3",
2993 " > 4",
2994 " another-filename.txt <== selected <== marked",
2995 " > C",
2996 " .dockerignore",
2997 " the-new-filename",
2998 ]
2999 );
3000
3001 select_path(&panel, "root1/b/another-filename.txt", cx);
3002 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3003 assert_eq!(
3004 visible_entries_as_strings(&panel, 0..10, cx),
3005 &[
3006 "v root1",
3007 " > .git",
3008 " > a",
3009 " v b",
3010 " > 3",
3011 " > 4",
3012 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
3013 " > C",
3014 " .dockerignore",
3015 " the-new-filename",
3016 ]
3017 );
3018
3019 let confirm = panel.update(cx, |panel, cx| {
3020 panel.filename_editor.update(cx, |editor, cx| {
3021 let file_name_selections = editor.selections.all::<usize>(cx);
3022 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3023 let file_name_selection = &file_name_selections[0];
3024 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3025 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
3026
3027 editor.set_text("a-different-filename.tar.gz", cx)
3028 });
3029 panel.confirm_edit(cx).unwrap()
3030 });
3031 assert_eq!(
3032 visible_entries_as_strings(&panel, 0..10, cx),
3033 &[
3034 "v root1",
3035 " > .git",
3036 " > a",
3037 " v b",
3038 " > 3",
3039 " > 4",
3040 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
3041 " > C",
3042 " .dockerignore",
3043 " the-new-filename",
3044 ]
3045 );
3046
3047 confirm.await.unwrap();
3048 assert_eq!(
3049 visible_entries_as_strings(&panel, 0..10, cx),
3050 &[
3051 "v root1",
3052 " > .git",
3053 " > a",
3054 " v b",
3055 " > 3",
3056 " > 4",
3057 " a-different-filename.tar.gz <== selected",
3058 " > C",
3059 " .dockerignore",
3060 " the-new-filename",
3061 ]
3062 );
3063
3064 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3065 assert_eq!(
3066 visible_entries_as_strings(&panel, 0..10, cx),
3067 &[
3068 "v root1",
3069 " > .git",
3070 " > a",
3071 " v b",
3072 " > 3",
3073 " > 4",
3074 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
3075 " > C",
3076 " .dockerignore",
3077 " the-new-filename",
3078 ]
3079 );
3080
3081 panel.update(cx, |panel, cx| {
3082 panel.filename_editor.update(cx, |editor, cx| {
3083 let file_name_selections = editor.selections.all::<usize>(cx);
3084 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3085 let file_name_selection = &file_name_selections[0];
3086 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3087 assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
3088
3089 });
3090 panel.cancel(&menu::Cancel, cx)
3091 });
3092
3093 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3094 assert_eq!(
3095 visible_entries_as_strings(&panel, 0..10, cx),
3096 &[
3097 "v root1",
3098 " > .git",
3099 " > a",
3100 " v b",
3101 " > [EDITOR: ''] <== selected",
3102 " > 3",
3103 " > 4",
3104 " a-different-filename.tar.gz",
3105 " > C",
3106 " .dockerignore",
3107 ]
3108 );
3109
3110 let confirm = panel.update(cx, |panel, cx| {
3111 panel
3112 .filename_editor
3113 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
3114 panel.confirm_edit(cx).unwrap()
3115 });
3116 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
3117 assert_eq!(
3118 visible_entries_as_strings(&panel, 0..10, cx),
3119 &[
3120 "v root1",
3121 " > .git",
3122 " > a",
3123 " v b",
3124 " > [PROCESSING: 'new-dir']",
3125 " > 3 <== selected",
3126 " > 4",
3127 " a-different-filename.tar.gz",
3128 " > C",
3129 " .dockerignore",
3130 ]
3131 );
3132
3133 confirm.await.unwrap();
3134 assert_eq!(
3135 visible_entries_as_strings(&panel, 0..10, cx),
3136 &[
3137 "v root1",
3138 " > .git",
3139 " > a",
3140 " v b",
3141 " > 3 <== selected",
3142 " > 4",
3143 " > new-dir",
3144 " a-different-filename.tar.gz",
3145 " > C",
3146 " .dockerignore",
3147 ]
3148 );
3149
3150 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
3151 assert_eq!(
3152 visible_entries_as_strings(&panel, 0..10, cx),
3153 &[
3154 "v root1",
3155 " > .git",
3156 " > a",
3157 " v b",
3158 " > [EDITOR: '3'] <== selected",
3159 " > 4",
3160 " > new-dir",
3161 " a-different-filename.tar.gz",
3162 " > C",
3163 " .dockerignore",
3164 ]
3165 );
3166
3167 // Dismiss the rename editor when it loses focus.
3168 workspace.update(cx, |_, cx| cx.blur()).unwrap();
3169 assert_eq!(
3170 visible_entries_as_strings(&panel, 0..10, cx),
3171 &[
3172 "v root1",
3173 " > .git",
3174 " > a",
3175 " v b",
3176 " > 3 <== selected",
3177 " > 4",
3178 " > new-dir",
3179 " a-different-filename.tar.gz",
3180 " > C",
3181 " .dockerignore",
3182 ]
3183 );
3184 }
3185
3186 #[gpui::test(iterations = 10)]
3187 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
3188 init_test(cx);
3189
3190 let fs = FakeFs::new(cx.executor().clone());
3191 fs.insert_tree(
3192 "/root1",
3193 json!({
3194 ".dockerignore": "",
3195 ".git": {
3196 "HEAD": "",
3197 },
3198 "a": {
3199 "0": { "q": "", "r": "", "s": "" },
3200 "1": { "t": "", "u": "" },
3201 "2": { "v": "", "w": "", "x": "", "y": "" },
3202 },
3203 "b": {
3204 "3": { "Q": "" },
3205 "4": { "R": "", "S": "", "T": "", "U": "" },
3206 },
3207 "C": {
3208 "5": {},
3209 "6": { "V": "", "W": "" },
3210 "7": { "X": "" },
3211 "8": { "Y": {}, "Z": "" }
3212 }
3213 }),
3214 )
3215 .await;
3216 fs.insert_tree(
3217 "/root2",
3218 json!({
3219 "d": {
3220 "9": ""
3221 },
3222 "e": {}
3223 }),
3224 )
3225 .await;
3226
3227 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3228 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3229 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3230 let panel = workspace
3231 .update(cx, |workspace, cx| {
3232 let panel = ProjectPanel::new(workspace, cx);
3233 workspace.add_panel(panel.clone(), cx);
3234 panel
3235 })
3236 .unwrap();
3237
3238 select_path(&panel, "root1", cx);
3239 assert_eq!(
3240 visible_entries_as_strings(&panel, 0..10, cx),
3241 &[
3242 "v root1 <== selected",
3243 " > .git",
3244 " > a",
3245 " > b",
3246 " > C",
3247 " .dockerignore",
3248 "v root2",
3249 " > d",
3250 " > e",
3251 ]
3252 );
3253
3254 // Add a file with the root folder selected. The filename editor is placed
3255 // before the first file in the root folder.
3256 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3257 panel.update(cx, |panel, cx| {
3258 assert!(panel.filename_editor.read(cx).is_focused(cx));
3259 });
3260 assert_eq!(
3261 visible_entries_as_strings(&panel, 0..10, cx),
3262 &[
3263 "v root1",
3264 " > .git",
3265 " > a",
3266 " > b",
3267 " > C",
3268 " [EDITOR: ''] <== selected",
3269 " .dockerignore",
3270 "v root2",
3271 " > d",
3272 " > e",
3273 ]
3274 );
3275
3276 let confirm = panel.update(cx, |panel, cx| {
3277 panel.filename_editor.update(cx, |editor, cx| {
3278 editor.set_text("/bdir1/dir2/the-new-filename", cx)
3279 });
3280 panel.confirm_edit(cx).unwrap()
3281 });
3282
3283 assert_eq!(
3284 visible_entries_as_strings(&panel, 0..10, cx),
3285 &[
3286 "v root1",
3287 " > .git",
3288 " > a",
3289 " > b",
3290 " > C",
3291 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
3292 " .dockerignore",
3293 "v root2",
3294 " > d",
3295 " > e",
3296 ]
3297 );
3298
3299 confirm.await.unwrap();
3300 assert_eq!(
3301 visible_entries_as_strings(&panel, 0..13, cx),
3302 &[
3303 "v root1",
3304 " > .git",
3305 " > a",
3306 " > b",
3307 " v bdir1",
3308 " v dir2",
3309 " the-new-filename <== selected <== marked",
3310 " > C",
3311 " .dockerignore",
3312 "v root2",
3313 " > d",
3314 " > e",
3315 ]
3316 );
3317 }
3318
3319 #[gpui::test]
3320 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
3321 init_test(cx);
3322
3323 let fs = FakeFs::new(cx.executor().clone());
3324 fs.insert_tree(
3325 "/root1",
3326 json!({
3327 ".dockerignore": "",
3328 ".git": {
3329 "HEAD": "",
3330 },
3331 }),
3332 )
3333 .await;
3334
3335 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3336 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3337 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3338 let panel = workspace
3339 .update(cx, |workspace, cx| {
3340 let panel = ProjectPanel::new(workspace, cx);
3341 workspace.add_panel(panel.clone(), cx);
3342 panel
3343 })
3344 .unwrap();
3345
3346 select_path(&panel, "root1", cx);
3347 assert_eq!(
3348 visible_entries_as_strings(&panel, 0..10, cx),
3349 &["v root1 <== selected", " > .git", " .dockerignore",]
3350 );
3351
3352 // Add a file with the root folder selected. The filename editor is placed
3353 // before the first file in the root folder.
3354 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3355 panel.update(cx, |panel, cx| {
3356 assert!(panel.filename_editor.read(cx).is_focused(cx));
3357 });
3358 assert_eq!(
3359 visible_entries_as_strings(&panel, 0..10, cx),
3360 &[
3361 "v root1",
3362 " > .git",
3363 " [EDITOR: ''] <== selected",
3364 " .dockerignore",
3365 ]
3366 );
3367
3368 let confirm = panel.update(cx, |panel, cx| {
3369 panel
3370 .filename_editor
3371 .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
3372 panel.confirm_edit(cx).unwrap()
3373 });
3374
3375 assert_eq!(
3376 visible_entries_as_strings(&panel, 0..10, cx),
3377 &[
3378 "v root1",
3379 " > .git",
3380 " [PROCESSING: '/new_dir/'] <== selected",
3381 " .dockerignore",
3382 ]
3383 );
3384
3385 confirm.await.unwrap();
3386 assert_eq!(
3387 visible_entries_as_strings(&panel, 0..13, cx),
3388 &[
3389 "v root1",
3390 " > .git",
3391 " v new_dir <== selected",
3392 " .dockerignore",
3393 ]
3394 );
3395 }
3396
3397 #[gpui::test]
3398 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
3399 init_test(cx);
3400
3401 let fs = FakeFs::new(cx.executor().clone());
3402 fs.insert_tree(
3403 "/root1",
3404 json!({
3405 "one.two.txt": "",
3406 "one.txt": ""
3407 }),
3408 )
3409 .await;
3410
3411 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3412 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3413 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3414 let panel = workspace
3415 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3416 .unwrap();
3417
3418 panel.update(cx, |panel, cx| {
3419 panel.select_next(&Default::default(), cx);
3420 panel.select_next(&Default::default(), cx);
3421 });
3422
3423 assert_eq!(
3424 visible_entries_as_strings(&panel, 0..50, cx),
3425 &[
3426 //
3427 "v root1",
3428 " one.two.txt <== selected",
3429 " one.txt",
3430 ]
3431 );
3432
3433 // Regression test - file name is created correctly when
3434 // the copied file's name contains multiple dots.
3435 panel.update(cx, |panel, cx| {
3436 panel.copy(&Default::default(), cx);
3437 panel.paste(&Default::default(), cx);
3438 });
3439 cx.executor().run_until_parked();
3440
3441 assert_eq!(
3442 visible_entries_as_strings(&panel, 0..50, cx),
3443 &[
3444 //
3445 "v root1",
3446 " one.two copy.txt",
3447 " one.two.txt <== selected",
3448 " one.txt",
3449 ]
3450 );
3451
3452 panel.update(cx, |panel, cx| {
3453 panel.paste(&Default::default(), cx);
3454 });
3455 cx.executor().run_until_parked();
3456
3457 assert_eq!(
3458 visible_entries_as_strings(&panel, 0..50, cx),
3459 &[
3460 //
3461 "v root1",
3462 " one.two copy 1.txt",
3463 " one.two copy.txt",
3464 " one.two.txt <== selected",
3465 " one.txt",
3466 ]
3467 );
3468 }
3469
3470 #[gpui::test]
3471 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
3472 init_test(cx);
3473
3474 let fs = FakeFs::new(cx.executor().clone());
3475 fs.insert_tree(
3476 "/root",
3477 json!({
3478 "a": {
3479 "one.txt": "",
3480 "two.txt": "",
3481 "inner_dir": {
3482 "three.txt": "",
3483 "four.txt": "",
3484 }
3485 },
3486 "b": {}
3487 }),
3488 )
3489 .await;
3490
3491 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3492 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3493 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3494 let panel = workspace
3495 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3496 .unwrap();
3497
3498 select_path(&panel, "root/a", cx);
3499 panel.update(cx, |panel, cx| {
3500 panel.copy(&Default::default(), cx);
3501 panel.select_next(&Default::default(), cx);
3502 panel.paste(&Default::default(), cx);
3503 });
3504 cx.executor().run_until_parked();
3505
3506 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
3507 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
3508
3509 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
3510 assert_ne!(
3511 pasted_dir_file, None,
3512 "Pasted directory file should have an entry"
3513 );
3514
3515 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
3516 assert_ne!(
3517 pasted_dir_inner_dir, None,
3518 "Directories inside pasted directory should have an entry"
3519 );
3520
3521 toggle_expand_dir(&panel, "root/b/a", cx);
3522 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
3523
3524 assert_eq!(
3525 visible_entries_as_strings(&panel, 0..50, cx),
3526 &[
3527 //
3528 "v root",
3529 " > a",
3530 " v b",
3531 " v a",
3532 " v inner_dir <== selected",
3533 " four.txt",
3534 " three.txt",
3535 " one.txt",
3536 " two.txt",
3537 ]
3538 );
3539
3540 select_path(&panel, "root", cx);
3541 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3542 cx.executor().run_until_parked();
3543 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3544 cx.executor().run_until_parked();
3545 assert_eq!(
3546 visible_entries_as_strings(&panel, 0..50, cx),
3547 &[
3548 //
3549 "v root <== selected",
3550 " > a",
3551 " > a copy",
3552 " > a copy 1",
3553 " v b",
3554 " v a",
3555 " v inner_dir",
3556 " four.txt",
3557 " three.txt",
3558 " one.txt",
3559 " two.txt"
3560 ]
3561 );
3562 }
3563
3564 #[gpui::test]
3565 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
3566 init_test_with_editor(cx);
3567
3568 let fs = FakeFs::new(cx.executor().clone());
3569 fs.insert_tree(
3570 "/src",
3571 json!({
3572 "test": {
3573 "first.rs": "// First Rust file",
3574 "second.rs": "// Second Rust file",
3575 "third.rs": "// Third Rust file",
3576 }
3577 }),
3578 )
3579 .await;
3580
3581 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3582 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3583 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3584 let panel = workspace
3585 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3586 .unwrap();
3587
3588 toggle_expand_dir(&panel, "src/test", cx);
3589 select_path(&panel, "src/test/first.rs", cx);
3590 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3591 cx.executor().run_until_parked();
3592 assert_eq!(
3593 visible_entries_as_strings(&panel, 0..10, cx),
3594 &[
3595 "v src",
3596 " v test",
3597 " first.rs <== selected",
3598 " second.rs",
3599 " third.rs"
3600 ]
3601 );
3602 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
3603
3604 submit_deletion(&panel, cx);
3605 assert_eq!(
3606 visible_entries_as_strings(&panel, 0..10, cx),
3607 &[
3608 "v src",
3609 " v test",
3610 " second.rs",
3611 " third.rs"
3612 ],
3613 "Project panel should have no deleted file, no other file is selected in it"
3614 );
3615 ensure_no_open_items_and_panes(&workspace, cx);
3616
3617 select_path(&panel, "src/test/second.rs", cx);
3618 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3619 cx.executor().run_until_parked();
3620 assert_eq!(
3621 visible_entries_as_strings(&panel, 0..10, cx),
3622 &[
3623 "v src",
3624 " v test",
3625 " second.rs <== selected",
3626 " third.rs"
3627 ]
3628 );
3629 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
3630
3631 workspace
3632 .update(cx, |workspace, cx| {
3633 let active_items = workspace
3634 .panes()
3635 .iter()
3636 .filter_map(|pane| pane.read(cx).active_item())
3637 .collect::<Vec<_>>();
3638 assert_eq!(active_items.len(), 1);
3639 let open_editor = active_items
3640 .into_iter()
3641 .next()
3642 .unwrap()
3643 .downcast::<Editor>()
3644 .expect("Open item should be an editor");
3645 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
3646 })
3647 .unwrap();
3648 submit_deletion_skipping_prompt(&panel, cx);
3649 assert_eq!(
3650 visible_entries_as_strings(&panel, 0..10, cx),
3651 &["v src", " v test", " third.rs"],
3652 "Project panel should have no deleted file, with one last file remaining"
3653 );
3654 ensure_no_open_items_and_panes(&workspace, cx);
3655 }
3656
3657 #[gpui::test]
3658 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
3659 init_test_with_editor(cx);
3660
3661 let fs = FakeFs::new(cx.executor().clone());
3662 fs.insert_tree(
3663 "/src",
3664 json!({
3665 "test": {
3666 "first.rs": "// First Rust file",
3667 "second.rs": "// Second Rust file",
3668 "third.rs": "// Third Rust file",
3669 }
3670 }),
3671 )
3672 .await;
3673
3674 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3675 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3676 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3677 let panel = workspace
3678 .update(cx, |workspace, cx| {
3679 let panel = ProjectPanel::new(workspace, cx);
3680 workspace.add_panel(panel.clone(), cx);
3681 panel
3682 })
3683 .unwrap();
3684
3685 select_path(&panel, "src/", cx);
3686 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3687 cx.executor().run_until_parked();
3688 assert_eq!(
3689 visible_entries_as_strings(&panel, 0..10, cx),
3690 &[
3691 //
3692 "v src <== selected",
3693 " > test"
3694 ]
3695 );
3696 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3697 panel.update(cx, |panel, cx| {
3698 assert!(panel.filename_editor.read(cx).is_focused(cx));
3699 });
3700 assert_eq!(
3701 visible_entries_as_strings(&panel, 0..10, cx),
3702 &[
3703 //
3704 "v src",
3705 " > [EDITOR: ''] <== selected",
3706 " > test"
3707 ]
3708 );
3709 panel.update(cx, |panel, cx| {
3710 panel
3711 .filename_editor
3712 .update(cx, |editor, cx| editor.set_text("test", cx));
3713 assert!(
3714 panel.confirm_edit(cx).is_none(),
3715 "Should not allow to confirm on conflicting new directory name"
3716 )
3717 });
3718 assert_eq!(
3719 visible_entries_as_strings(&panel, 0..10, cx),
3720 &[
3721 //
3722 "v src",
3723 " > test"
3724 ],
3725 "File list should be unchanged after failed folder create confirmation"
3726 );
3727
3728 select_path(&panel, "src/test/", cx);
3729 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3730 cx.executor().run_until_parked();
3731 assert_eq!(
3732 visible_entries_as_strings(&panel, 0..10, cx),
3733 &[
3734 //
3735 "v src",
3736 " > test <== selected"
3737 ]
3738 );
3739 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3740 panel.update(cx, |panel, cx| {
3741 assert!(panel.filename_editor.read(cx).is_focused(cx));
3742 });
3743 assert_eq!(
3744 visible_entries_as_strings(&panel, 0..10, cx),
3745 &[
3746 "v src",
3747 " v test",
3748 " [EDITOR: ''] <== selected",
3749 " first.rs",
3750 " second.rs",
3751 " third.rs"
3752 ]
3753 );
3754 panel.update(cx, |panel, cx| {
3755 panel
3756 .filename_editor
3757 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
3758 assert!(
3759 panel.confirm_edit(cx).is_none(),
3760 "Should not allow to confirm on conflicting new file name"
3761 )
3762 });
3763 assert_eq!(
3764 visible_entries_as_strings(&panel, 0..10, cx),
3765 &[
3766 "v src",
3767 " v test",
3768 " first.rs",
3769 " second.rs",
3770 " third.rs"
3771 ],
3772 "File list should be unchanged after failed file create confirmation"
3773 );
3774
3775 select_path(&panel, "src/test/first.rs", cx);
3776 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3777 cx.executor().run_until_parked();
3778 assert_eq!(
3779 visible_entries_as_strings(&panel, 0..10, cx),
3780 &[
3781 "v src",
3782 " v test",
3783 " first.rs <== selected",
3784 " second.rs",
3785 " third.rs"
3786 ],
3787 );
3788 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3789 panel.update(cx, |panel, cx| {
3790 assert!(panel.filename_editor.read(cx).is_focused(cx));
3791 });
3792 assert_eq!(
3793 visible_entries_as_strings(&panel, 0..10, cx),
3794 &[
3795 "v src",
3796 " v test",
3797 " [EDITOR: 'first.rs'] <== selected",
3798 " second.rs",
3799 " third.rs"
3800 ]
3801 );
3802 panel.update(cx, |panel, cx| {
3803 panel
3804 .filename_editor
3805 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
3806 assert!(
3807 panel.confirm_edit(cx).is_none(),
3808 "Should not allow to confirm on conflicting file rename"
3809 )
3810 });
3811 assert_eq!(
3812 visible_entries_as_strings(&panel, 0..10, cx),
3813 &[
3814 "v src",
3815 " v test",
3816 " first.rs <== selected",
3817 " second.rs",
3818 " third.rs"
3819 ],
3820 "File list should be unchanged after failed rename confirmation"
3821 );
3822 }
3823
3824 #[gpui::test]
3825 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3826 init_test_with_editor(cx);
3827
3828 let fs = FakeFs::new(cx.executor().clone());
3829 fs.insert_tree(
3830 "/project_root",
3831 json!({
3832 "dir_1": {
3833 "nested_dir": {
3834 "file_a.py": "# File contents",
3835 }
3836 },
3837 "file_1.py": "# File contents",
3838 }),
3839 )
3840 .await;
3841
3842 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3843 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3844 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3845 let panel = workspace
3846 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3847 .unwrap();
3848
3849 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3850 cx.executor().run_until_parked();
3851 select_path(&panel, "project_root/dir_1", cx);
3852 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3853 select_path(&panel, "project_root/dir_1/nested_dir", cx);
3854 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3855 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3856 cx.executor().run_until_parked();
3857 assert_eq!(
3858 visible_entries_as_strings(&panel, 0..10, cx),
3859 &[
3860 "v project_root",
3861 " v dir_1",
3862 " > nested_dir <== selected",
3863 " file_1.py",
3864 ]
3865 );
3866 }
3867
3868 #[gpui::test]
3869 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3870 init_test_with_editor(cx);
3871
3872 let fs = FakeFs::new(cx.executor().clone());
3873 fs.insert_tree(
3874 "/project_root",
3875 json!({
3876 "dir_1": {
3877 "nested_dir": {
3878 "file_a.py": "# File contents",
3879 "file_b.py": "# File contents",
3880 "file_c.py": "# File contents",
3881 },
3882 "file_1.py": "# File contents",
3883 "file_2.py": "# File contents",
3884 "file_3.py": "# File contents",
3885 },
3886 "dir_2": {
3887 "file_1.py": "# File contents",
3888 "file_2.py": "# File contents",
3889 "file_3.py": "# File contents",
3890 }
3891 }),
3892 )
3893 .await;
3894
3895 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3896 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3897 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3898 let panel = workspace
3899 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3900 .unwrap();
3901
3902 panel.update(cx, |panel, cx| {
3903 panel.collapse_all_entries(&CollapseAllEntries, cx)
3904 });
3905 cx.executor().run_until_parked();
3906 assert_eq!(
3907 visible_entries_as_strings(&panel, 0..10, cx),
3908 &["v project_root", " > dir_1", " > dir_2",]
3909 );
3910
3911 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3912 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3913 cx.executor().run_until_parked();
3914 assert_eq!(
3915 visible_entries_as_strings(&panel, 0..10, cx),
3916 &[
3917 "v project_root",
3918 " v dir_1 <== selected",
3919 " > nested_dir",
3920 " file_1.py",
3921 " file_2.py",
3922 " file_3.py",
3923 " > dir_2",
3924 ]
3925 );
3926 }
3927
3928 #[gpui::test]
3929 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
3930 init_test(cx);
3931
3932 let fs = FakeFs::new(cx.executor().clone());
3933 fs.as_fake().insert_tree("/root", json!({})).await;
3934 let project = Project::test(fs, ["/root".as_ref()], cx).await;
3935 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3936 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3937 let panel = workspace
3938 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3939 .unwrap();
3940
3941 // Make a new buffer with no backing file
3942 workspace
3943 .update(cx, |workspace, cx| {
3944 Editor::new_file(workspace, &Default::default(), cx)
3945 })
3946 .unwrap();
3947
3948 cx.executor().run_until_parked();
3949
3950 // "Save as" the buffer, creating a new backing file for it
3951 let save_task = workspace
3952 .update(cx, |workspace, cx| {
3953 workspace.save_active_item(workspace::SaveIntent::Save, cx)
3954 })
3955 .unwrap();
3956
3957 cx.executor().run_until_parked();
3958 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3959 save_task.await.unwrap();
3960
3961 // Rename the file
3962 select_path(&panel, "root/new", cx);
3963 assert_eq!(
3964 visible_entries_as_strings(&panel, 0..10, cx),
3965 &["v root", " new <== selected"]
3966 );
3967 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3968 panel.update(cx, |panel, cx| {
3969 panel
3970 .filename_editor
3971 .update(cx, |editor, cx| editor.set_text("newer", cx));
3972 });
3973 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3974
3975 cx.executor().run_until_parked();
3976 assert_eq!(
3977 visible_entries_as_strings(&panel, 0..10, cx),
3978 &["v root", " newer <== selected"]
3979 );
3980
3981 workspace
3982 .update(cx, |workspace, cx| {
3983 workspace.save_active_item(workspace::SaveIntent::Save, cx)
3984 })
3985 .unwrap()
3986 .await
3987 .unwrap();
3988
3989 cx.executor().run_until_parked();
3990 // assert that saving the file doesn't restore "new"
3991 assert_eq!(
3992 visible_entries_as_strings(&panel, 0..10, cx),
3993 &["v root", " newer <== selected"]
3994 );
3995 }
3996
3997 #[gpui::test]
3998 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
3999 init_test_with_editor(cx);
4000 let fs = FakeFs::new(cx.executor().clone());
4001 fs.insert_tree(
4002 "/project_root",
4003 json!({
4004 "dir_1": {
4005 "nested_dir": {
4006 "file_a.py": "# File contents",
4007 }
4008 },
4009 "file_1.py": "# File contents",
4010 }),
4011 )
4012 .await;
4013
4014 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4015 let worktree_id =
4016 cx.update(|cx| project.read(cx).worktrees().next().unwrap().read(cx).id());
4017 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4018 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4019 let panel = workspace
4020 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4021 .unwrap();
4022 cx.update(|cx| {
4023 panel.update(cx, |this, cx| {
4024 this.select_next(&Default::default(), cx);
4025 this.expand_selected_entry(&Default::default(), cx);
4026 this.expand_selected_entry(&Default::default(), cx);
4027 this.select_next(&Default::default(), cx);
4028 this.expand_selected_entry(&Default::default(), cx);
4029 this.select_next(&Default::default(), cx);
4030 })
4031 });
4032 assert_eq!(
4033 visible_entries_as_strings(&panel, 0..10, cx),
4034 &[
4035 "v project_root",
4036 " v dir_1",
4037 " v nested_dir",
4038 " file_a.py <== selected",
4039 " file_1.py",
4040 ]
4041 );
4042 let modifiers_with_shift = gpui::Modifiers {
4043 shift: true,
4044 ..Default::default()
4045 };
4046 cx.simulate_modifiers_change(modifiers_with_shift);
4047 cx.update(|cx| {
4048 panel.update(cx, |this, cx| {
4049 this.select_next(&Default::default(), cx);
4050 })
4051 });
4052 assert_eq!(
4053 visible_entries_as_strings(&panel, 0..10, cx),
4054 &[
4055 "v project_root",
4056 " v dir_1",
4057 " v nested_dir",
4058 " file_a.py",
4059 " file_1.py <== selected <== marked",
4060 ]
4061 );
4062 cx.update(|cx| {
4063 panel.update(cx, |this, cx| {
4064 this.select_prev(&Default::default(), cx);
4065 })
4066 });
4067 assert_eq!(
4068 visible_entries_as_strings(&panel, 0..10, cx),
4069 &[
4070 "v project_root",
4071 " v dir_1",
4072 " v nested_dir",
4073 " file_a.py <== selected <== marked",
4074 " file_1.py <== marked",
4075 ]
4076 );
4077 cx.update(|cx| {
4078 panel.update(cx, |this, cx| {
4079 let drag = DraggedSelection {
4080 active_selection: this.selection.unwrap(),
4081 marked_selections: Arc::new(this.marked_entries.clone()),
4082 };
4083 let target_entry = this
4084 .project
4085 .read(cx)
4086 .entry_for_path(&(worktree_id, "").into(), cx)
4087 .unwrap();
4088 this.drag_onto(&drag, target_entry.id, false, cx);
4089 });
4090 });
4091 cx.run_until_parked();
4092 assert_eq!(
4093 visible_entries_as_strings(&panel, 0..10, cx),
4094 &[
4095 "v project_root",
4096 " v dir_1",
4097 " v nested_dir",
4098 " file_1.py <== marked",
4099 " file_a.py <== selected <== marked",
4100 ]
4101 );
4102 // ESC clears out all marks
4103 cx.update(|cx| {
4104 panel.update(cx, |this, cx| {
4105 this.cancel(&menu::Cancel, cx);
4106 })
4107 });
4108 assert_eq!(
4109 visible_entries_as_strings(&panel, 0..10, cx),
4110 &[
4111 "v project_root",
4112 " v dir_1",
4113 " v nested_dir",
4114 " file_1.py",
4115 " file_a.py <== selected",
4116 ]
4117 );
4118 // ESC clears out all marks
4119 cx.update(|cx| {
4120 panel.update(cx, |this, cx| {
4121 this.select_prev(&SelectPrev, cx);
4122 this.select_next(&SelectNext, cx);
4123 })
4124 });
4125 assert_eq!(
4126 visible_entries_as_strings(&panel, 0..10, cx),
4127 &[
4128 "v project_root",
4129 " v dir_1",
4130 " v nested_dir",
4131 " file_1.py <== marked",
4132 " file_a.py <== selected <== marked",
4133 ]
4134 );
4135 cx.simulate_modifiers_change(Default::default());
4136 cx.update(|cx| {
4137 panel.update(cx, |this, cx| {
4138 this.cut(&Cut, cx);
4139 this.select_prev(&SelectPrev, cx);
4140 this.select_prev(&SelectPrev, cx);
4141
4142 this.paste(&Paste, cx);
4143 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
4144 })
4145 });
4146 cx.run_until_parked();
4147 assert_eq!(
4148 visible_entries_as_strings(&panel, 0..10, cx),
4149 &[
4150 "v project_root",
4151 " v dir_1",
4152 " v nested_dir <== selected",
4153 " file_1.py <== marked",
4154 " file_a.py <== marked",
4155 ]
4156 );
4157 cx.simulate_modifiers_change(modifiers_with_shift);
4158 cx.update(|cx| {
4159 panel.update(cx, |this, cx| {
4160 this.expand_selected_entry(&Default::default(), cx);
4161 this.select_next(&SelectNext, cx);
4162 this.select_next(&SelectNext, cx);
4163 })
4164 });
4165 submit_deletion(&panel, cx);
4166 assert_eq!(
4167 visible_entries_as_strings(&panel, 0..10, cx),
4168 &["v project_root", " v dir_1", " v nested_dir",]
4169 );
4170 }
4171 #[gpui::test]
4172 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4173 init_test_with_editor(cx);
4174 cx.update(|cx| {
4175 cx.update_global::<SettingsStore, _>(|store, cx| {
4176 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4177 worktree_settings.file_scan_exclusions = Some(Vec::new());
4178 });
4179 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4180 project_panel_settings.auto_reveal_entries = Some(false)
4181 });
4182 })
4183 });
4184
4185 let fs = FakeFs::new(cx.background_executor.clone());
4186 fs.insert_tree(
4187 "/project_root",
4188 json!({
4189 ".git": {},
4190 ".gitignore": "**/gitignored_dir",
4191 "dir_1": {
4192 "file_1.py": "# File 1_1 contents",
4193 "file_2.py": "# File 1_2 contents",
4194 "file_3.py": "# File 1_3 contents",
4195 "gitignored_dir": {
4196 "file_a.py": "# File contents",
4197 "file_b.py": "# File contents",
4198 "file_c.py": "# File contents",
4199 },
4200 },
4201 "dir_2": {
4202 "file_1.py": "# File 2_1 contents",
4203 "file_2.py": "# File 2_2 contents",
4204 "file_3.py": "# File 2_3 contents",
4205 }
4206 }),
4207 )
4208 .await;
4209
4210 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4211 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4212 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4213 let panel = workspace
4214 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4215 .unwrap();
4216
4217 assert_eq!(
4218 visible_entries_as_strings(&panel, 0..20, cx),
4219 &[
4220 "v project_root",
4221 " > .git",
4222 " > dir_1",
4223 " > dir_2",
4224 " .gitignore",
4225 ]
4226 );
4227
4228 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4229 .expect("dir 1 file is not ignored and should have an entry");
4230 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4231 .expect("dir 2 file is not ignored and should have an entry");
4232 let gitignored_dir_file =
4233 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4234 assert_eq!(
4235 gitignored_dir_file, None,
4236 "File in the gitignored dir should not have an entry before its dir is toggled"
4237 );
4238
4239 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4240 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4241 cx.executor().run_until_parked();
4242 assert_eq!(
4243 visible_entries_as_strings(&panel, 0..20, cx),
4244 &[
4245 "v project_root",
4246 " > .git",
4247 " v dir_1",
4248 " v gitignored_dir <== selected",
4249 " file_a.py",
4250 " file_b.py",
4251 " file_c.py",
4252 " file_1.py",
4253 " file_2.py",
4254 " file_3.py",
4255 " > dir_2",
4256 " .gitignore",
4257 ],
4258 "Should show gitignored dir file list in the project panel"
4259 );
4260 let gitignored_dir_file =
4261 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4262 .expect("after gitignored dir got opened, a file entry should be present");
4263
4264 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4265 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4266 assert_eq!(
4267 visible_entries_as_strings(&panel, 0..20, cx),
4268 &[
4269 "v project_root",
4270 " > .git",
4271 " > dir_1 <== selected",
4272 " > dir_2",
4273 " .gitignore",
4274 ],
4275 "Should hide all dir contents again and prepare for the auto reveal test"
4276 );
4277
4278 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4279 panel.update(cx, |panel, cx| {
4280 panel.project.update(cx, |_, cx| {
4281 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4282 })
4283 });
4284 cx.run_until_parked();
4285 assert_eq!(
4286 visible_entries_as_strings(&panel, 0..20, cx),
4287 &[
4288 "v project_root",
4289 " > .git",
4290 " > dir_1 <== selected",
4291 " > dir_2",
4292 " .gitignore",
4293 ],
4294 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4295 );
4296 }
4297
4298 cx.update(|cx| {
4299 cx.update_global::<SettingsStore, _>(|store, cx| {
4300 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4301 project_panel_settings.auto_reveal_entries = Some(true)
4302 });
4303 })
4304 });
4305
4306 panel.update(cx, |panel, cx| {
4307 panel.project.update(cx, |_, cx| {
4308 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4309 })
4310 });
4311 cx.run_until_parked();
4312 assert_eq!(
4313 visible_entries_as_strings(&panel, 0..20, cx),
4314 &[
4315 "v project_root",
4316 " > .git",
4317 " v dir_1",
4318 " > gitignored_dir",
4319 " file_1.py <== selected",
4320 " file_2.py",
4321 " file_3.py",
4322 " > dir_2",
4323 " .gitignore",
4324 ],
4325 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4326 );
4327
4328 panel.update(cx, |panel, cx| {
4329 panel.project.update(cx, |_, cx| {
4330 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4331 })
4332 });
4333 cx.run_until_parked();
4334 assert_eq!(
4335 visible_entries_as_strings(&panel, 0..20, cx),
4336 &[
4337 "v project_root",
4338 " > .git",
4339 " v dir_1",
4340 " > gitignored_dir",
4341 " file_1.py",
4342 " file_2.py",
4343 " file_3.py",
4344 " v dir_2",
4345 " file_1.py <== selected",
4346 " file_2.py",
4347 " file_3.py",
4348 " .gitignore",
4349 ],
4350 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4351 );
4352
4353 panel.update(cx, |panel, cx| {
4354 panel.project.update(cx, |_, cx| {
4355 cx.emit(project::Event::ActiveEntryChanged(Some(
4356 gitignored_dir_file,
4357 )))
4358 })
4359 });
4360 cx.run_until_parked();
4361 assert_eq!(
4362 visible_entries_as_strings(&panel, 0..20, cx),
4363 &[
4364 "v project_root",
4365 " > .git",
4366 " v dir_1",
4367 " > gitignored_dir",
4368 " file_1.py",
4369 " file_2.py",
4370 " file_3.py",
4371 " v dir_2",
4372 " file_1.py <== selected",
4373 " file_2.py",
4374 " file_3.py",
4375 " .gitignore",
4376 ],
4377 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4378 );
4379
4380 panel.update(cx, |panel, cx| {
4381 panel.project.update(cx, |_, cx| {
4382 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4383 })
4384 });
4385 cx.run_until_parked();
4386 assert_eq!(
4387 visible_entries_as_strings(&panel, 0..20, cx),
4388 &[
4389 "v project_root",
4390 " > .git",
4391 " v dir_1",
4392 " v gitignored_dir",
4393 " file_a.py <== selected",
4394 " file_b.py",
4395 " file_c.py",
4396 " file_1.py",
4397 " file_2.py",
4398 " file_3.py",
4399 " v dir_2",
4400 " file_1.py",
4401 " file_2.py",
4402 " file_3.py",
4403 " .gitignore",
4404 ],
4405 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4406 );
4407 }
4408
4409 #[gpui::test]
4410 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
4411 init_test_with_editor(cx);
4412 cx.update(|cx| {
4413 cx.update_global::<SettingsStore, _>(|store, cx| {
4414 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4415 worktree_settings.file_scan_exclusions = Some(Vec::new());
4416 });
4417 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4418 project_panel_settings.auto_reveal_entries = Some(false)
4419 });
4420 })
4421 });
4422
4423 let fs = FakeFs::new(cx.background_executor.clone());
4424 fs.insert_tree(
4425 "/project_root",
4426 json!({
4427 ".git": {},
4428 ".gitignore": "**/gitignored_dir",
4429 "dir_1": {
4430 "file_1.py": "# File 1_1 contents",
4431 "file_2.py": "# File 1_2 contents",
4432 "file_3.py": "# File 1_3 contents",
4433 "gitignored_dir": {
4434 "file_a.py": "# File contents",
4435 "file_b.py": "# File contents",
4436 "file_c.py": "# File contents",
4437 },
4438 },
4439 "dir_2": {
4440 "file_1.py": "# File 2_1 contents",
4441 "file_2.py": "# File 2_2 contents",
4442 "file_3.py": "# File 2_3 contents",
4443 }
4444 }),
4445 )
4446 .await;
4447
4448 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4449 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4450 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4451 let panel = workspace
4452 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4453 .unwrap();
4454
4455 assert_eq!(
4456 visible_entries_as_strings(&panel, 0..20, cx),
4457 &[
4458 "v project_root",
4459 " > .git",
4460 " > dir_1",
4461 " > dir_2",
4462 " .gitignore",
4463 ]
4464 );
4465
4466 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4467 .expect("dir 1 file is not ignored and should have an entry");
4468 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4469 .expect("dir 2 file is not ignored and should have an entry");
4470 let gitignored_dir_file =
4471 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4472 assert_eq!(
4473 gitignored_dir_file, None,
4474 "File in the gitignored dir should not have an entry before its dir is toggled"
4475 );
4476
4477 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4478 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4479 cx.run_until_parked();
4480 assert_eq!(
4481 visible_entries_as_strings(&panel, 0..20, cx),
4482 &[
4483 "v project_root",
4484 " > .git",
4485 " v dir_1",
4486 " v gitignored_dir <== selected",
4487 " file_a.py",
4488 " file_b.py",
4489 " file_c.py",
4490 " file_1.py",
4491 " file_2.py",
4492 " file_3.py",
4493 " > dir_2",
4494 " .gitignore",
4495 ],
4496 "Should show gitignored dir file list in the project panel"
4497 );
4498 let gitignored_dir_file =
4499 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4500 .expect("after gitignored dir got opened, a file entry should be present");
4501
4502 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4503 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4504 assert_eq!(
4505 visible_entries_as_strings(&panel, 0..20, cx),
4506 &[
4507 "v project_root",
4508 " > .git",
4509 " > dir_1 <== selected",
4510 " > dir_2",
4511 " .gitignore",
4512 ],
4513 "Should hide all dir contents again and prepare for the explicit reveal test"
4514 );
4515
4516 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4517 panel.update(cx, |panel, cx| {
4518 panel.project.update(cx, |_, cx| {
4519 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4520 })
4521 });
4522 cx.run_until_parked();
4523 assert_eq!(
4524 visible_entries_as_strings(&panel, 0..20, cx),
4525 &[
4526 "v project_root",
4527 " > .git",
4528 " > dir_1 <== selected",
4529 " > dir_2",
4530 " .gitignore",
4531 ],
4532 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4533 );
4534 }
4535
4536 panel.update(cx, |panel, cx| {
4537 panel.project.update(cx, |_, cx| {
4538 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
4539 })
4540 });
4541 cx.run_until_parked();
4542 assert_eq!(
4543 visible_entries_as_strings(&panel, 0..20, cx),
4544 &[
4545 "v project_root",
4546 " > .git",
4547 " v dir_1",
4548 " > gitignored_dir",
4549 " file_1.py <== selected",
4550 " file_2.py",
4551 " file_3.py",
4552 " > dir_2",
4553 " .gitignore",
4554 ],
4555 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
4556 );
4557
4558 panel.update(cx, |panel, cx| {
4559 panel.project.update(cx, |_, cx| {
4560 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
4561 })
4562 });
4563 cx.run_until_parked();
4564 assert_eq!(
4565 visible_entries_as_strings(&panel, 0..20, cx),
4566 &[
4567 "v project_root",
4568 " > .git",
4569 " v dir_1",
4570 " > gitignored_dir",
4571 " file_1.py",
4572 " file_2.py",
4573 " file_3.py",
4574 " v dir_2",
4575 " file_1.py <== selected",
4576 " file_2.py",
4577 " file_3.py",
4578 " .gitignore",
4579 ],
4580 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
4581 );
4582
4583 panel.update(cx, |panel, cx| {
4584 panel.project.update(cx, |_, cx| {
4585 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4586 })
4587 });
4588 cx.run_until_parked();
4589 assert_eq!(
4590 visible_entries_as_strings(&panel, 0..20, cx),
4591 &[
4592 "v project_root",
4593 " > .git",
4594 " v dir_1",
4595 " v gitignored_dir",
4596 " file_a.py <== selected",
4597 " file_b.py",
4598 " file_c.py",
4599 " file_1.py",
4600 " file_2.py",
4601 " file_3.py",
4602 " v dir_2",
4603 " file_1.py",
4604 " file_2.py",
4605 " file_3.py",
4606 " .gitignore",
4607 ],
4608 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
4609 );
4610 }
4611
4612 #[gpui::test]
4613 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
4614 init_test(cx);
4615 cx.update(|cx| {
4616 cx.update_global::<SettingsStore, _>(|store, cx| {
4617 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
4618 project_settings.file_scan_exclusions =
4619 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
4620 });
4621 });
4622 });
4623
4624 cx.update(|cx| {
4625 register_project_item::<TestProjectItemView>(cx);
4626 });
4627
4628 let fs = FakeFs::new(cx.executor().clone());
4629 fs.insert_tree(
4630 "/root1",
4631 json!({
4632 ".dockerignore": "",
4633 ".git": {
4634 "HEAD": "",
4635 },
4636 }),
4637 )
4638 .await;
4639
4640 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4641 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4642 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4643 let panel = workspace
4644 .update(cx, |workspace, cx| {
4645 let panel = ProjectPanel::new(workspace, cx);
4646 workspace.add_panel(panel.clone(), cx);
4647 panel
4648 })
4649 .unwrap();
4650
4651 select_path(&panel, "root1", cx);
4652 assert_eq!(
4653 visible_entries_as_strings(&panel, 0..10, cx),
4654 &["v root1 <== selected", " .dockerignore",]
4655 );
4656 workspace
4657 .update(cx, |workspace, cx| {
4658 assert!(
4659 workspace.active_item(cx).is_none(),
4660 "Should have no active items in the beginning"
4661 );
4662 })
4663 .unwrap();
4664
4665 let excluded_file_path = ".git/COMMIT_EDITMSG";
4666 let excluded_dir_path = "excluded_dir";
4667
4668 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4669 panel.update(cx, |panel, cx| {
4670 assert!(panel.filename_editor.read(cx).is_focused(cx));
4671 });
4672 panel
4673 .update(cx, |panel, cx| {
4674 panel
4675 .filename_editor
4676 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4677 panel.confirm_edit(cx).unwrap()
4678 })
4679 .await
4680 .unwrap();
4681
4682 assert_eq!(
4683 visible_entries_as_strings(&panel, 0..13, cx),
4684 &["v root1", " .dockerignore"],
4685 "Excluded dir should not be shown after opening a file in it"
4686 );
4687 panel.update(cx, |panel, cx| {
4688 assert!(
4689 !panel.filename_editor.read(cx).is_focused(cx),
4690 "Should have closed the file name editor"
4691 );
4692 });
4693 workspace
4694 .update(cx, |workspace, cx| {
4695 let active_entry_path = workspace
4696 .active_item(cx)
4697 .expect("should have opened and activated the excluded item")
4698 .act_as::<TestProjectItemView>(cx)
4699 .expect(
4700 "should have opened the corresponding project item for the excluded item",
4701 )
4702 .read(cx)
4703 .path
4704 .clone();
4705 assert_eq!(
4706 active_entry_path.path.as_ref(),
4707 Path::new(excluded_file_path),
4708 "Should open the excluded file"
4709 );
4710
4711 assert!(
4712 workspace.notification_ids().is_empty(),
4713 "Should have no notifications after opening an excluded file"
4714 );
4715 })
4716 .unwrap();
4717 assert!(
4718 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
4719 "Should have created the excluded file"
4720 );
4721
4722 select_path(&panel, "root1", cx);
4723 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4724 panel.update(cx, |panel, cx| {
4725 assert!(panel.filename_editor.read(cx).is_focused(cx));
4726 });
4727 panel
4728 .update(cx, |panel, cx| {
4729 panel
4730 .filename_editor
4731 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4732 panel.confirm_edit(cx).unwrap()
4733 })
4734 .await
4735 .unwrap();
4736
4737 assert_eq!(
4738 visible_entries_as_strings(&panel, 0..13, cx),
4739 &["v root1", " .dockerignore"],
4740 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
4741 );
4742 panel.update(cx, |panel, cx| {
4743 assert!(
4744 !panel.filename_editor.read(cx).is_focused(cx),
4745 "Should have closed the file name editor"
4746 );
4747 });
4748 workspace
4749 .update(cx, |workspace, cx| {
4750 let notifications = workspace.notification_ids();
4751 assert_eq!(
4752 notifications.len(),
4753 1,
4754 "Should receive one notification with the error message"
4755 );
4756 workspace.dismiss_notification(notifications.first().unwrap(), cx);
4757 assert!(workspace.notification_ids().is_empty());
4758 })
4759 .unwrap();
4760
4761 select_path(&panel, "root1", cx);
4762 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4763 panel.update(cx, |panel, cx| {
4764 assert!(panel.filename_editor.read(cx).is_focused(cx));
4765 });
4766 panel
4767 .update(cx, |panel, cx| {
4768 panel
4769 .filename_editor
4770 .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
4771 panel.confirm_edit(cx).unwrap()
4772 })
4773 .await
4774 .unwrap();
4775
4776 assert_eq!(
4777 visible_entries_as_strings(&panel, 0..13, cx),
4778 &["v root1", " .dockerignore"],
4779 "Should not change the project panel after trying to create an excluded directory"
4780 );
4781 panel.update(cx, |panel, cx| {
4782 assert!(
4783 !panel.filename_editor.read(cx).is_focused(cx),
4784 "Should have closed the file name editor"
4785 );
4786 });
4787 workspace
4788 .update(cx, |workspace, cx| {
4789 let notifications = workspace.notification_ids();
4790 assert_eq!(
4791 notifications.len(),
4792 1,
4793 "Should receive one notification explaining that no directory is actually shown"
4794 );
4795 workspace.dismiss_notification(notifications.first().unwrap(), cx);
4796 assert!(workspace.notification_ids().is_empty());
4797 })
4798 .unwrap();
4799 assert!(
4800 fs.is_dir(Path::new("/root1/excluded_dir")).await,
4801 "Should have created the excluded directory"
4802 );
4803 }
4804
4805 fn toggle_expand_dir(
4806 panel: &View<ProjectPanel>,
4807 path: impl AsRef<Path>,
4808 cx: &mut VisualTestContext,
4809 ) {
4810 let path = path.as_ref();
4811 panel.update(cx, |panel, cx| {
4812 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4813 let worktree = worktree.read(cx);
4814 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4815 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4816 panel.toggle_expanded(entry_id, cx);
4817 return;
4818 }
4819 }
4820 panic!("no worktree for path {:?}", path);
4821 });
4822 }
4823
4824 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
4825 let path = path.as_ref();
4826 panel.update(cx, |panel, cx| {
4827 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4828 let worktree = worktree.read(cx);
4829 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4830 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4831 panel.selection = Some(crate::SelectedEntry {
4832 worktree_id: worktree.id(),
4833 entry_id,
4834 });
4835 return;
4836 }
4837 }
4838 panic!("no worktree for path {:?}", path);
4839 });
4840 }
4841
4842 fn find_project_entry(
4843 panel: &View<ProjectPanel>,
4844 path: impl AsRef<Path>,
4845 cx: &mut VisualTestContext,
4846 ) -> Option<ProjectEntryId> {
4847 let path = path.as_ref();
4848 panel.update(cx, |panel, cx| {
4849 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4850 let worktree = worktree.read(cx);
4851 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4852 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
4853 }
4854 }
4855 panic!("no worktree for path {path:?}");
4856 })
4857 }
4858
4859 fn visible_entries_as_strings(
4860 panel: &View<ProjectPanel>,
4861 range: Range<usize>,
4862 cx: &mut VisualTestContext,
4863 ) -> Vec<String> {
4864 let mut result = Vec::new();
4865 let mut project_entries = HashSet::default();
4866 let mut has_editor = false;
4867
4868 panel.update(cx, |panel, cx| {
4869 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
4870 if details.is_editing {
4871 assert!(!has_editor, "duplicate editor entry");
4872 has_editor = true;
4873 } else {
4874 assert!(
4875 project_entries.insert(project_entry),
4876 "duplicate project entry {:?} {:?}",
4877 project_entry,
4878 details
4879 );
4880 }
4881
4882 let indent = " ".repeat(details.depth);
4883 let icon = if details.kind.is_dir() {
4884 if details.is_expanded {
4885 "v "
4886 } else {
4887 "> "
4888 }
4889 } else {
4890 " "
4891 };
4892 let name = if details.is_editing {
4893 format!("[EDITOR: '{}']", details.filename)
4894 } else if details.is_processing {
4895 format!("[PROCESSING: '{}']", details.filename)
4896 } else {
4897 details.filename.clone()
4898 };
4899 let selected = if details.is_selected {
4900 " <== selected"
4901 } else {
4902 ""
4903 };
4904 let marked = if details.is_marked {
4905 " <== marked"
4906 } else {
4907 ""
4908 };
4909
4910 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
4911 });
4912 });
4913
4914 result
4915 }
4916
4917 fn init_test(cx: &mut TestAppContext) {
4918 cx.update(|cx| {
4919 let settings_store = SettingsStore::test(cx);
4920 cx.set_global(settings_store);
4921 init_settings(cx);
4922 theme::init(theme::LoadThemes::JustBase, cx);
4923 language::init(cx);
4924 editor::init_settings(cx);
4925 crate::init((), cx);
4926 workspace::init_settings(cx);
4927 client::init_settings(cx);
4928 Project::init_settings(cx);
4929
4930 cx.update_global::<SettingsStore, _>(|store, cx| {
4931 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4932 worktree_settings.file_scan_exclusions = Some(Vec::new());
4933 });
4934 });
4935 });
4936 }
4937
4938 fn init_test_with_editor(cx: &mut TestAppContext) {
4939 cx.update(|cx| {
4940 let app_state = AppState::test(cx);
4941 theme::init(theme::LoadThemes::JustBase, cx);
4942 init_settings(cx);
4943 language::init(cx);
4944 editor::init(cx);
4945 crate::init((), cx);
4946 workspace::init(app_state.clone(), cx);
4947 Project::init_settings(cx);
4948 });
4949 }
4950
4951 fn ensure_single_file_is_opened(
4952 window: &WindowHandle<Workspace>,
4953 expected_path: &str,
4954 cx: &mut TestAppContext,
4955 ) {
4956 window
4957 .update(cx, |workspace, cx| {
4958 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
4959 assert_eq!(worktrees.len(), 1);
4960 let worktree_id = worktrees[0].read(cx).id();
4961
4962 let open_project_paths = workspace
4963 .panes()
4964 .iter()
4965 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4966 .collect::<Vec<_>>();
4967 assert_eq!(
4968 open_project_paths,
4969 vec![ProjectPath {
4970 worktree_id,
4971 path: Arc::from(Path::new(expected_path))
4972 }],
4973 "Should have opened file, selected in project panel"
4974 );
4975 })
4976 .unwrap();
4977 }
4978
4979 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4980 assert!(
4981 !cx.has_pending_prompt(),
4982 "Should have no prompts before the deletion"
4983 );
4984 panel.update(cx, |panel, cx| {
4985 panel.delete(&Delete { skip_prompt: false }, cx)
4986 });
4987 assert!(
4988 cx.has_pending_prompt(),
4989 "Should have a prompt after the deletion"
4990 );
4991 cx.simulate_prompt_answer(0);
4992 assert!(
4993 !cx.has_pending_prompt(),
4994 "Should have no prompts after prompt was replied to"
4995 );
4996 cx.executor().run_until_parked();
4997 }
4998
4999 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
5000 assert!(
5001 !cx.has_pending_prompt(),
5002 "Should have no prompts before the deletion"
5003 );
5004 panel.update(cx, |panel, cx| {
5005 panel.delete(&Delete { skip_prompt: true }, cx)
5006 });
5007 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
5008 cx.executor().run_until_parked();
5009 }
5010
5011 fn ensure_no_open_items_and_panes(
5012 workspace: &WindowHandle<Workspace>,
5013 cx: &mut VisualTestContext,
5014 ) {
5015 assert!(
5016 !cx.has_pending_prompt(),
5017 "Should have no prompts after deletion operation closes the file"
5018 );
5019 workspace
5020 .read_with(cx, |workspace, cx| {
5021 let open_project_paths = workspace
5022 .panes()
5023 .iter()
5024 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5025 .collect::<Vec<_>>();
5026 assert!(
5027 open_project_paths.is_empty(),
5028 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
5029 );
5030 })
5031 .unwrap();
5032 }
5033
5034 struct TestProjectItemView {
5035 focus_handle: FocusHandle,
5036 path: ProjectPath,
5037 }
5038
5039 struct TestProjectItem {
5040 path: ProjectPath,
5041 }
5042
5043 impl project::Item for TestProjectItem {
5044 fn try_open(
5045 _project: &Model<Project>,
5046 path: &ProjectPath,
5047 cx: &mut AppContext,
5048 ) -> Option<Task<gpui::Result<Model<Self>>>> {
5049 let path = path.clone();
5050 Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
5051 }
5052
5053 fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
5054 None
5055 }
5056
5057 fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
5058 Some(self.path.clone())
5059 }
5060 }
5061
5062 impl ProjectItem for TestProjectItemView {
5063 type Item = TestProjectItem;
5064
5065 fn for_project_item(
5066 _: Model<Project>,
5067 project_item: Model<Self::Item>,
5068 cx: &mut ViewContext<Self>,
5069 ) -> Self
5070 where
5071 Self: Sized,
5072 {
5073 Self {
5074 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
5075 focus_handle: cx.focus_handle(),
5076 }
5077 }
5078 }
5079
5080 impl Item for TestProjectItemView {
5081 type Event = ();
5082 }
5083
5084 impl EventEmitter<()> for TestProjectItemView {}
5085
5086 impl FocusableView for TestProjectItemView {
5087 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
5088 self.focus_handle.clone()
5089 }
5090 }
5091
5092 impl Render for TestProjectItemView {
5093 fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
5094 Empty
5095 }
5096 }
5097}