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