1pub mod file_associations;
2mod project_panel_settings;
3
4use context_menu::{ContextMenu, ContextMenuItem};
5use db::kvp::KEY_VALUE_STORE;
6use drag_and_drop::{DragAndDrop, Draggable};
7use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor};
8use file_associations::FileAssociations;
9
10use futures::stream::StreamExt;
11use gpui::{
12 actions,
13 anyhow::{self, anyhow, Result},
14 elements::{
15 AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
16 ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
17 },
18 geometry::vector::Vector2F,
19 keymap_matcher::KeymapContext,
20 platform::{CursorStyle, MouseButton, PromptLevel},
21 Action, AnyElement, AppContext, AssetSource, AsyncAppContext, ClipboardItem, Element, Entity,
22 ModelHandle, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
23};
24use menu::{Confirm, SelectNext, SelectPrev};
25use project::{
26 repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
27 Worktree, WorktreeId,
28};
29use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
30use serde::{Deserialize, Serialize};
31use settings::SettingsStore;
32use std::{
33 cmp::Ordering,
34 collections::{hash_map, HashMap},
35 ffi::OsStr,
36 ops::Range,
37 path::Path,
38 sync::Arc,
39};
40use theme::ProjectPanelEntry;
41use unicase::UniCase;
42use util::{ResultExt, TryFutureExt};
43use workspace::{
44 dock::{DockPosition, Panel},
45 Workspace,
46};
47
48const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
49const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
50
51pub struct ProjectPanel {
52 project: ModelHandle<Project>,
53 fs: Arc<dyn Fs>,
54 list: UniformListState,
55 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
56 last_worktree_root_id: Option<ProjectEntryId>,
57 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
58 selection: Option<Selection>,
59 edit_state: Option<EditState>,
60 filename_editor: ViewHandle<Editor>,
61 clipboard_entry: Option<ClipboardEntry>,
62 context_menu: ViewHandle<ContextMenu>,
63 dragged_entry_destination: Option<Arc<Path>>,
64 workspace: WeakViewHandle<Workspace>,
65 has_focus: bool,
66 width: Option<f32>,
67 pending_serialization: Task<Option<()>>,
68}
69
70#[derive(Copy, Clone, Debug)]
71struct Selection {
72 worktree_id: WorktreeId,
73 entry_id: ProjectEntryId,
74}
75
76#[derive(Clone, Debug)]
77struct EditState {
78 worktree_id: WorktreeId,
79 entry_id: ProjectEntryId,
80 is_new_entry: bool,
81 is_dir: bool,
82 processing_filename: Option<String>,
83}
84
85#[derive(Copy, Clone)]
86pub enum ClipboardEntry {
87 Copied {
88 worktree_id: WorktreeId,
89 entry_id: ProjectEntryId,
90 },
91 Cut {
92 worktree_id: WorktreeId,
93 entry_id: ProjectEntryId,
94 },
95}
96
97#[derive(Debug, PartialEq, Eq)]
98pub struct EntryDetails {
99 filename: String,
100 icon: Option<Arc<str>>,
101 path: Arc<Path>,
102 depth: usize,
103 kind: EntryKind,
104 is_ignored: bool,
105 is_expanded: bool,
106 is_selected: bool,
107 is_editing: bool,
108 is_processing: bool,
109 is_cut: bool,
110 git_status: Option<GitFileStatus>,
111}
112
113actions!(
114 project_panel,
115 [
116 ExpandSelectedEntry,
117 CollapseSelectedEntry,
118 CollapseAllEntries,
119 NewDirectory,
120 NewFile,
121 Copy,
122 CopyPath,
123 CopyRelativePath,
124 RevealInFinder,
125 OpenInTerminal,
126 Cut,
127 Paste,
128 Delete,
129 Rename,
130 Open,
131 ToggleFocus,
132 NewSearchInDirectory,
133 ]
134);
135
136pub fn init_settings(cx: &mut AppContext) {
137 settings::register::<ProjectPanelSettings>(cx);
138}
139
140pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
141 init_settings(cx);
142 file_associations::init(assets, cx);
143 cx.add_action(ProjectPanel::expand_selected_entry);
144 cx.add_action(ProjectPanel::collapse_selected_entry);
145 cx.add_action(ProjectPanel::collapse_all_entries);
146 cx.add_action(ProjectPanel::select_prev);
147 cx.add_action(ProjectPanel::select_next);
148 cx.add_action(ProjectPanel::new_file);
149 cx.add_action(ProjectPanel::new_directory);
150 cx.add_action(ProjectPanel::rename);
151 cx.add_async_action(ProjectPanel::delete);
152 cx.add_async_action(ProjectPanel::confirm);
153 cx.add_async_action(ProjectPanel::open_file);
154 cx.add_action(ProjectPanel::cancel);
155 cx.add_action(ProjectPanel::cut);
156 cx.add_action(ProjectPanel::copy);
157 cx.add_action(ProjectPanel::copy_path);
158 cx.add_action(ProjectPanel::copy_relative_path);
159 cx.add_action(ProjectPanel::reveal_in_finder);
160 cx.add_action(ProjectPanel::open_in_terminal);
161 cx.add_action(ProjectPanel::new_search_in_directory);
162 cx.add_action(
163 |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
164 this.paste(action, cx);
165 },
166 );
167}
168
169#[derive(Debug)]
170pub enum Event {
171 OpenedEntry {
172 entry_id: ProjectEntryId,
173 focus_opened_item: bool,
174 },
175 SplitEntry {
176 entry_id: ProjectEntryId,
177 },
178 DockPositionChanged,
179 Focus,
180 NewSearchInDirectory {
181 dir_entry: Entry,
182 },
183 ActivatePanel,
184}
185
186#[derive(Serialize, Deserialize)]
187struct SerializedProjectPanel {
188 width: Option<f32>,
189}
190
191impl ProjectPanel {
192 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
193 let project = workspace.project().clone();
194 let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
195 cx.observe(&project, |this, _, cx| {
196 this.update_visible_entries(None, cx);
197 cx.notify();
198 })
199 .detach();
200 cx.subscribe(&project, |this, project, event, cx| match event {
201 project::Event::ActiveEntryChanged(Some(entry_id)) => {
202 if settings::get::<ProjectPanelSettings>(cx).auto_reveal_entries {
203 this.reveal_entry(project, *entry_id, true, cx);
204 }
205 }
206 project::Event::RevealInProjectPanel(entry_id) => {
207 this.reveal_entry(project, *entry_id, false, cx);
208 cx.emit(Event::ActivatePanel);
209 }
210 project::Event::ActivateProjectPanel => {
211 cx.emit(Event::ActivatePanel);
212 }
213 project::Event::WorktreeRemoved(id) => {
214 this.expanded_dir_ids.remove(id);
215 this.update_visible_entries(None, cx);
216 cx.notify();
217 }
218 _ => {}
219 })
220 .detach();
221
222 let filename_editor = cx.add_view(|cx| {
223 Editor::single_line(
224 Some(Arc::new(|theme| {
225 let mut style = theme.project_panel.filename_editor.clone();
226 style.container.background_color.take();
227 style
228 })),
229 cx,
230 )
231 });
232
233 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
234 editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
235 this.autoscroll(cx);
236 }
237 _ => {}
238 })
239 .detach();
240 cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
241 if !is_focused
242 && this
243 .edit_state
244 .as_ref()
245 .map_or(false, |state| state.processing_filename.is_none())
246 {
247 this.edit_state = None;
248 this.update_visible_entries(None, cx);
249 }
250 })
251 .detach();
252
253 cx.observe_global::<FileAssociations, _>(|_, cx| {
254 cx.notify();
255 })
256 .detach();
257
258 let view_id = cx.view_id();
259 let mut this = Self {
260 project: project.clone(),
261 fs: workspace.app_state().fs.clone(),
262 list: Default::default(),
263 visible_entries: Default::default(),
264 last_worktree_root_id: Default::default(),
265 expanded_dir_ids: Default::default(),
266 selection: None,
267 edit_state: None,
268 filename_editor,
269 clipboard_entry: None,
270 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
271 dragged_entry_destination: None,
272 workspace: workspace.weak_handle(),
273 has_focus: false,
274 width: None,
275 pending_serialization: Task::ready(None),
276 };
277 this.update_visible_entries(None, cx);
278
279 // Update the dock position when the setting changes.
280 let mut old_dock_position = this.position(cx);
281 cx.observe_global::<SettingsStore, _>(move |this, cx| {
282 let new_dock_position = this.position(cx);
283 if new_dock_position != old_dock_position {
284 old_dock_position = new_dock_position;
285 cx.emit(Event::DockPositionChanged);
286 }
287 })
288 .detach();
289
290 this
291 });
292
293 cx.subscribe(&project_panel, {
294 let project_panel = project_panel.downgrade();
295 move |workspace, _, event, cx| match event {
296 &Event::OpenedEntry {
297 entry_id,
298 focus_opened_item,
299 } => {
300 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
301 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
302 workspace
303 .open_path(
304 ProjectPath {
305 worktree_id: worktree.read(cx).id(),
306 path: entry.path.clone(),
307 },
308 None,
309 focus_opened_item,
310 cx,
311 )
312 .detach_and_log_err(cx);
313 if !focus_opened_item {
314 if let Some(project_panel) = project_panel.upgrade(cx) {
315 cx.focus(&project_panel);
316 }
317 }
318 }
319 }
320 }
321 &Event::SplitEntry { entry_id } => {
322 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
323 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
324 workspace
325 .split_path(
326 ProjectPath {
327 worktree_id: worktree.read(cx).id(),
328 path: entry.path.clone(),
329 },
330 cx,
331 )
332 .detach_and_log_err(cx);
333 }
334 }
335 }
336 _ => {}
337 }
338 })
339 .detach();
340
341 project_panel
342 }
343
344 pub fn load(
345 workspace: WeakViewHandle<Workspace>,
346 cx: AsyncAppContext,
347 ) -> Task<Result<ViewHandle<Self>>> {
348 cx.spawn(|mut cx| async move {
349 let serialized_panel = if let Some(panel) = cx
350 .background()
351 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
352 .await
353 .log_err()
354 .flatten()
355 {
356 Some(serde_json::from_str::<SerializedProjectPanel>(&panel)?)
357 } else {
358 None
359 };
360 workspace.update(&mut cx, |workspace, cx| {
361 let panel = ProjectPanel::new(workspace, cx);
362 if let Some(serialized_panel) = serialized_panel {
363 panel.update(cx, |panel, cx| {
364 panel.width = serialized_panel.width;
365 cx.notify();
366 });
367 }
368 panel
369 })
370 })
371 }
372
373 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
374 let width = self.width;
375 self.pending_serialization = cx.background().spawn(
376 async move {
377 KEY_VALUE_STORE
378 .write_kvp(
379 PROJECT_PANEL_KEY.into(),
380 serde_json::to_string(&SerializedProjectPanel { width })?,
381 )
382 .await?;
383 anyhow::Ok(())
384 }
385 .log_err(),
386 );
387 }
388
389 fn deploy_context_menu(
390 &mut self,
391 position: Vector2F,
392 entry_id: ProjectEntryId,
393 cx: &mut ViewContext<Self>,
394 ) {
395 let project = self.project.read(cx);
396
397 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
398 id
399 } else {
400 return;
401 };
402
403 self.selection = Some(Selection {
404 worktree_id,
405 entry_id,
406 });
407
408 let mut menu_entries = Vec::new();
409 if let Some((worktree, entry)) = self.selected_entry(cx) {
410 let is_root = Some(entry) == worktree.root_entry();
411 if !project.is_remote() {
412 menu_entries.push(ContextMenuItem::action(
413 "Add Folder to Project",
414 workspace::AddFolderToProject,
415 ));
416 if is_root {
417 let project = self.project.clone();
418 menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
419 project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
420 }));
421 }
422 }
423 menu_entries.push(ContextMenuItem::action("New File", NewFile));
424 menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
425 menu_entries.push(ContextMenuItem::Separator);
426 menu_entries.push(ContextMenuItem::action("Cut", Cut));
427 menu_entries.push(ContextMenuItem::action("Copy", Copy));
428 if let Some(clipboard_entry) = self.clipboard_entry {
429 if clipboard_entry.worktree_id() == worktree.id() {
430 menu_entries.push(ContextMenuItem::action("Paste", Paste));
431 }
432 }
433 menu_entries.push(ContextMenuItem::Separator);
434 menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
435 menu_entries.push(ContextMenuItem::action(
436 "Copy Relative Path",
437 CopyRelativePath,
438 ));
439
440 if entry.is_dir() {
441 menu_entries.push(ContextMenuItem::Separator);
442 }
443 menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
444 if entry.is_dir() {
445 menu_entries.push(ContextMenuItem::action("Open in Terminal", OpenInTerminal));
446 menu_entries.push(ContextMenuItem::action(
447 "Search Inside",
448 NewSearchInDirectory,
449 ));
450 }
451
452 menu_entries.push(ContextMenuItem::Separator);
453 menu_entries.push(ContextMenuItem::action("Rename", Rename));
454 if !is_root {
455 menu_entries.push(ContextMenuItem::action("Delete", Delete));
456 }
457 }
458
459 self.context_menu.update(cx, |menu, cx| {
460 menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
461 });
462
463 cx.notify();
464 }
465
466 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
467 if let Some((worktree, entry)) = self.selected_entry(cx) {
468 if entry.is_dir() {
469 let worktree_id = worktree.id();
470 let entry_id = entry.id;
471 let expanded_dir_ids =
472 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
473 expanded_dir_ids
474 } else {
475 return;
476 };
477
478 match expanded_dir_ids.binary_search(&entry_id) {
479 Ok(_) => self.select_next(&SelectNext, cx),
480 Err(ix) => {
481 self.project.update(cx, |project, cx| {
482 project.expand_entry(worktree_id, entry_id, cx);
483 });
484
485 expanded_dir_ids.insert(ix, entry_id);
486 self.update_visible_entries(None, cx);
487 cx.notify();
488 }
489 }
490 }
491 }
492 }
493
494 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
495 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
496 let worktree_id = worktree.id();
497 let expanded_dir_ids =
498 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
499 expanded_dir_ids
500 } else {
501 return;
502 };
503
504 loop {
505 let entry_id = entry.id;
506 match expanded_dir_ids.binary_search(&entry_id) {
507 Ok(ix) => {
508 expanded_dir_ids.remove(ix);
509 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
510 cx.notify();
511 break;
512 }
513 Err(_) => {
514 if let Some(parent_entry) =
515 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
516 {
517 entry = parent_entry;
518 } else {
519 break;
520 }
521 }
522 }
523 }
524 }
525 }
526
527 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
528 self.expanded_dir_ids.clear();
529 self.update_visible_entries(None, cx);
530 cx.notify();
531 }
532
533 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
534 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
535 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
536 self.project.update(cx, |project, cx| {
537 match expanded_dir_ids.binary_search(&entry_id) {
538 Ok(ix) => {
539 expanded_dir_ids.remove(ix);
540 }
541 Err(ix) => {
542 project.expand_entry(worktree_id, entry_id, cx);
543 expanded_dir_ids.insert(ix, entry_id);
544 }
545 }
546 });
547 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
548 cx.focus_self();
549 cx.notify();
550 }
551 }
552 }
553
554 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
555 if let Some(selection) = self.selection {
556 let (mut worktree_ix, mut entry_ix, _) =
557 self.index_for_selection(selection).unwrap_or_default();
558 if entry_ix > 0 {
559 entry_ix -= 1;
560 } else if worktree_ix > 0 {
561 worktree_ix -= 1;
562 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
563 } else {
564 return;
565 }
566
567 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
568 self.selection = Some(Selection {
569 worktree_id: *worktree_id,
570 entry_id: worktree_entries[entry_ix].id,
571 });
572 self.autoscroll(cx);
573 cx.notify();
574 } else {
575 self.select_first(cx);
576 }
577 }
578
579 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
580 if let Some(task) = self.confirm_edit(cx) {
581 return Some(task);
582 }
583
584 None
585 }
586
587 fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
588 if let Some((_, entry)) = self.selected_entry(cx) {
589 if entry.is_file() {
590 self.open_entry(entry.id, true, cx);
591 }
592 }
593
594 None
595 }
596
597 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
598 let edit_state = self.edit_state.as_mut()?;
599 cx.focus_self();
600
601 let worktree_id = edit_state.worktree_id;
602 let is_new_entry = edit_state.is_new_entry;
603 let is_dir = edit_state.is_dir;
604 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
605 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
606 let filename = self.filename_editor.read(cx).text(cx);
607
608 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
609 let edit_task;
610 let edited_entry_id;
611 if is_new_entry {
612 self.selection = Some(Selection {
613 worktree_id,
614 entry_id: NEW_ENTRY_ID,
615 });
616 let new_path = entry.path.join(&filename.trim_start_matches("/"));
617 if path_already_exists(new_path.as_path()) {
618 return None;
619 }
620
621 edited_entry_id = NEW_ENTRY_ID;
622 edit_task = self.project.update(cx, |project, cx| {
623 project.create_entry((worktree_id, &new_path), is_dir, cx)
624 });
625 } else {
626 let new_path = if let Some(parent) = entry.path.clone().parent() {
627 parent.join(&filename)
628 } else {
629 filename.clone().into()
630 };
631 if path_already_exists(new_path.as_path()) {
632 return None;
633 }
634
635 edited_entry_id = entry.id;
636 edit_task = self.project.update(cx, |project, cx| {
637 project.rename_entry(entry.id, new_path.as_path(), cx)
638 });
639 };
640
641 edit_state.processing_filename = Some(filename);
642 cx.notify();
643
644 Some(cx.spawn(|this, mut cx| async move {
645 let new_entry = edit_task.await;
646 this.update(&mut cx, |this, cx| {
647 this.edit_state.take();
648 cx.notify();
649 })?;
650
651 if let Some(new_entry) = new_entry? {
652 this.update(&mut cx, |this, cx| {
653 if let Some(selection) = &mut this.selection {
654 if selection.entry_id == edited_entry_id {
655 selection.worktree_id = worktree_id;
656 selection.entry_id = new_entry.id;
657 this.expand_to_selection(cx);
658 }
659 }
660 this.update_visible_entries(None, cx);
661 if is_new_entry && !is_dir {
662 this.open_entry(new_entry.id, true, cx);
663 }
664 cx.notify();
665 })?;
666 }
667 Ok(())
668 }))
669 }
670
671 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
672 self.edit_state = None;
673 self.update_visible_entries(None, cx);
674 cx.focus_self();
675 cx.notify();
676 }
677
678 fn open_entry(
679 &mut self,
680 entry_id: ProjectEntryId,
681 focus_opened_item: bool,
682 cx: &mut ViewContext<Self>,
683 ) {
684 cx.emit(Event::OpenedEntry {
685 entry_id,
686 focus_opened_item,
687 });
688 }
689
690 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
691 cx.emit(Event::SplitEntry { entry_id });
692 }
693
694 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
695 self.add_entry(false, cx)
696 }
697
698 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
699 self.add_entry(true, cx)
700 }
701
702 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
703 if let Some(Selection {
704 worktree_id,
705 entry_id,
706 }) = self.selection
707 {
708 let directory_id;
709 if let Some((worktree, expanded_dir_ids)) = self
710 .project
711 .read(cx)
712 .worktree_for_id(worktree_id, cx)
713 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
714 {
715 let worktree = worktree.read(cx);
716 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
717 loop {
718 if entry.is_dir() {
719 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
720 expanded_dir_ids.insert(ix, entry.id);
721 }
722 directory_id = entry.id;
723 break;
724 } else {
725 if let Some(parent_path) = entry.path.parent() {
726 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
727 entry = parent_entry;
728 continue;
729 }
730 }
731 return;
732 }
733 }
734 } else {
735 return;
736 };
737 } else {
738 return;
739 };
740
741 self.edit_state = Some(EditState {
742 worktree_id,
743 entry_id: directory_id,
744 is_new_entry: true,
745 is_dir,
746 processing_filename: None,
747 });
748 self.filename_editor
749 .update(cx, |editor, cx| editor.clear(cx));
750 cx.focus(&self.filename_editor);
751 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
752 self.autoscroll(cx);
753 cx.notify();
754 }
755 }
756
757 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
758 if let Some(Selection {
759 worktree_id,
760 entry_id,
761 }) = self.selection
762 {
763 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
764 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
765 self.edit_state = Some(EditState {
766 worktree_id,
767 entry_id,
768 is_new_entry: false,
769 is_dir: entry.is_dir(),
770 processing_filename: None,
771 });
772 let file_name = entry
773 .path
774 .file_name()
775 .map(|s| s.to_string_lossy())
776 .unwrap_or_default()
777 .to_string();
778 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
779 let selection_end =
780 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
781 self.filename_editor.update(cx, |editor, cx| {
782 editor.set_text(file_name, cx);
783 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
784 s.select_ranges([0..selection_end])
785 })
786 });
787 cx.focus(&self.filename_editor);
788 self.update_visible_entries(None, cx);
789 self.autoscroll(cx);
790 cx.notify();
791 }
792 }
793
794 cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
795 drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
796 })
797 }
798 }
799
800 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
801 let Selection { entry_id, .. } = self.selection?;
802 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
803 let file_name = path.file_name()?;
804
805 let mut answer = cx.prompt(
806 PromptLevel::Info,
807 &format!("Delete {file_name:?}?"),
808 &["Delete", "Cancel"],
809 );
810 Some(cx.spawn(|this, mut cx| async move {
811 if answer.next().await != Some(0) {
812 return Ok(());
813 }
814 this.update(&mut cx, |this, cx| {
815 this.project
816 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
817 .ok_or_else(|| anyhow!("no such entry"))
818 })??
819 .await
820 }))
821 }
822
823 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
824 if let Some(selection) = self.selection {
825 let (mut worktree_ix, mut entry_ix, _) =
826 self.index_for_selection(selection).unwrap_or_default();
827 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
828 if entry_ix + 1 < worktree_entries.len() {
829 entry_ix += 1;
830 } else {
831 worktree_ix += 1;
832 entry_ix = 0;
833 }
834 }
835
836 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
837 if let Some(entry) = worktree_entries.get(entry_ix) {
838 self.selection = Some(Selection {
839 worktree_id: *worktree_id,
840 entry_id: entry.id,
841 });
842 self.autoscroll(cx);
843 cx.notify();
844 }
845 }
846 } else {
847 self.select_first(cx);
848 }
849 }
850
851 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
852 let worktree = self
853 .visible_entries
854 .first()
855 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
856 if let Some(worktree) = worktree {
857 let worktree = worktree.read(cx);
858 let worktree_id = worktree.id();
859 if let Some(root_entry) = worktree.root_entry() {
860 self.selection = Some(Selection {
861 worktree_id,
862 entry_id: root_entry.id,
863 });
864 self.autoscroll(cx);
865 cx.notify();
866 }
867 }
868 }
869
870 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
871 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
872 self.list.scroll_to(ScrollTarget::Show(index));
873 cx.notify();
874 }
875 }
876
877 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
878 if let Some((worktree, entry)) = self.selected_entry(cx) {
879 self.clipboard_entry = Some(ClipboardEntry::Cut {
880 worktree_id: worktree.id(),
881 entry_id: entry.id,
882 });
883 cx.notify();
884 }
885 }
886
887 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
888 if let Some((worktree, entry)) = self.selected_entry(cx) {
889 self.clipboard_entry = Some(ClipboardEntry::Copied {
890 worktree_id: worktree.id(),
891 entry_id: entry.id,
892 });
893 cx.notify();
894 }
895 }
896
897 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
898 if let Some((worktree, entry)) = self.selected_entry(cx) {
899 let clipboard_entry = self.clipboard_entry?;
900 if clipboard_entry.worktree_id() != worktree.id() {
901 return None;
902 }
903
904 let clipboard_entry_file_name = self
905 .project
906 .read(cx)
907 .path_for_entry(clipboard_entry.entry_id(), cx)?
908 .path
909 .file_name()?
910 .to_os_string();
911
912 let mut new_path = entry.path.to_path_buf();
913 if entry.is_file() {
914 new_path.pop();
915 }
916
917 new_path.push(&clipboard_entry_file_name);
918 let extension = new_path.extension().map(|e| e.to_os_string());
919 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
920 let mut ix = 0;
921 while worktree.entry_for_path(&new_path).is_some() {
922 new_path.pop();
923
924 let mut new_file_name = file_name_without_extension.to_os_string();
925 new_file_name.push(" copy");
926 if ix > 0 {
927 new_file_name.push(format!(" {}", ix));
928 }
929 if let Some(extension) = extension.as_ref() {
930 new_file_name.push(".");
931 new_file_name.push(extension);
932 }
933
934 new_path.push(new_file_name);
935 ix += 1;
936 }
937
938 if clipboard_entry.is_cut() {
939 self.project
940 .update(cx, |project, cx| {
941 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
942 })
943 .detach_and_log_err(cx)
944 } else {
945 self.project
946 .update(cx, |project, cx| {
947 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
948 })
949 .detach_and_log_err(cx)
950 }
951 }
952 None
953 }
954
955 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
956 if let Some((worktree, entry)) = self.selected_entry(cx) {
957 cx.write_to_clipboard(ClipboardItem::new(
958 worktree
959 .abs_path()
960 .join(&entry.path)
961 .to_string_lossy()
962 .to_string(),
963 ));
964 }
965 }
966
967 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
968 if let Some((_, entry)) = self.selected_entry(cx) {
969 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
970 }
971 }
972
973 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
974 if let Some((worktree, entry)) = self.selected_entry(cx) {
975 cx.reveal_path(&worktree.abs_path().join(&entry.path));
976 }
977 }
978
979 fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
980 if let Some((worktree, entry)) = self.selected_entry(cx) {
981 let window = cx.window();
982 let view_id = cx.view_id();
983 let path = worktree.abs_path().join(&entry.path);
984
985 cx.app_context()
986 .spawn(|mut cx| async move {
987 window.dispatch_action(
988 view_id,
989 &workspace::OpenTerminal {
990 working_directory: path,
991 },
992 &mut cx,
993 );
994 })
995 .detach();
996 }
997 }
998
999 pub fn new_search_in_directory(
1000 &mut self,
1001 _: &NewSearchInDirectory,
1002 cx: &mut ViewContext<Self>,
1003 ) {
1004 if let Some((_, entry)) = self.selected_entry(cx) {
1005 if entry.is_dir() {
1006 cx.emit(Event::NewSearchInDirectory {
1007 dir_entry: entry.clone(),
1008 });
1009 }
1010 }
1011 }
1012
1013 fn move_entry(
1014 &mut self,
1015 entry_to_move: ProjectEntryId,
1016 destination: ProjectEntryId,
1017 destination_is_file: bool,
1018 cx: &mut ViewContext<Self>,
1019 ) {
1020 let destination_worktree = self.project.update(cx, |project, cx| {
1021 let entry_path = project.path_for_entry(entry_to_move, cx)?;
1022 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1023
1024 let mut destination_path = destination_entry_path.as_ref();
1025 if destination_is_file {
1026 destination_path = destination_path.parent()?;
1027 }
1028
1029 let mut new_path = destination_path.to_path_buf();
1030 new_path.push(entry_path.path.file_name()?);
1031 if new_path != entry_path.path.as_ref() {
1032 let task = project.rename_entry(entry_to_move, new_path, cx);
1033 cx.foreground().spawn(task).detach_and_log_err(cx);
1034 }
1035
1036 Some(project.worktree_id_for_entry(destination, cx)?)
1037 });
1038
1039 if let Some(destination_worktree) = destination_worktree {
1040 self.expand_entry(destination_worktree, destination, cx);
1041 }
1042 }
1043
1044 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1045 let mut entry_index = 0;
1046 let mut visible_entries_index = 0;
1047 for (worktree_index, (worktree_id, worktree_entries)) in
1048 self.visible_entries.iter().enumerate()
1049 {
1050 if *worktree_id == selection.worktree_id {
1051 for entry in worktree_entries {
1052 if entry.id == selection.entry_id {
1053 return Some((worktree_index, entry_index, visible_entries_index));
1054 } else {
1055 visible_entries_index += 1;
1056 entry_index += 1;
1057 }
1058 }
1059 break;
1060 } else {
1061 visible_entries_index += worktree_entries.len();
1062 }
1063 }
1064 None
1065 }
1066
1067 pub fn selected_entry<'a>(
1068 &self,
1069 cx: &'a AppContext,
1070 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1071 let (worktree, entry) = self.selected_entry_handle(cx)?;
1072 Some((worktree.read(cx), entry))
1073 }
1074
1075 fn selected_entry_handle<'a>(
1076 &self,
1077 cx: &'a AppContext,
1078 ) -> Option<(ModelHandle<Worktree>, &'a project::Entry)> {
1079 let selection = self.selection?;
1080 let project = self.project.read(cx);
1081 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1082 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1083 Some((worktree, entry))
1084 }
1085
1086 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1087 let (worktree, entry) = self.selected_entry(cx)?;
1088 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1089
1090 for path in entry.path.ancestors() {
1091 let Some(entry) = worktree.entry_for_path(path) else {
1092 continue;
1093 };
1094 if entry.is_dir() {
1095 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1096 expanded_dir_ids.insert(idx, entry.id);
1097 }
1098 }
1099 }
1100
1101 Some(())
1102 }
1103
1104 fn update_visible_entries(
1105 &mut self,
1106 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1107 cx: &mut ViewContext<Self>,
1108 ) {
1109 let project = self.project.read(cx);
1110 self.last_worktree_root_id = project
1111 .visible_worktrees(cx)
1112 .rev()
1113 .next()
1114 .and_then(|worktree| worktree.read(cx).root_entry())
1115 .map(|entry| entry.id);
1116
1117 self.visible_entries.clear();
1118 for worktree in project.visible_worktrees(cx) {
1119 let snapshot = worktree.read(cx).snapshot();
1120 let worktree_id = snapshot.id();
1121
1122 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1123 hash_map::Entry::Occupied(e) => e.into_mut(),
1124 hash_map::Entry::Vacant(e) => {
1125 // The first time a worktree's root entry becomes available,
1126 // mark that root entry as expanded.
1127 if let Some(entry) = snapshot.root_entry() {
1128 e.insert(vec![entry.id]).as_slice()
1129 } else {
1130 &[]
1131 }
1132 }
1133 };
1134
1135 let mut new_entry_parent_id = None;
1136 let mut new_entry_kind = EntryKind::Dir;
1137 if let Some(edit_state) = &self.edit_state {
1138 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1139 new_entry_parent_id = Some(edit_state.entry_id);
1140 new_entry_kind = if edit_state.is_dir {
1141 EntryKind::Dir
1142 } else {
1143 EntryKind::File(Default::default())
1144 };
1145 }
1146 }
1147
1148 let mut visible_worktree_entries = Vec::new();
1149 let mut entry_iter = snapshot.entries(true);
1150
1151 while let Some(entry) = entry_iter.entry() {
1152 visible_worktree_entries.push(entry.clone());
1153 if Some(entry.id) == new_entry_parent_id {
1154 visible_worktree_entries.push(Entry {
1155 id: NEW_ENTRY_ID,
1156 kind: new_entry_kind,
1157 path: entry.path.join("\0").into(),
1158 inode: 0,
1159 mtime: entry.mtime,
1160 is_symlink: false,
1161 is_ignored: false,
1162 is_external: false,
1163 git_status: entry.git_status,
1164 });
1165 }
1166 if expanded_dir_ids.binary_search(&entry.id).is_err()
1167 && entry_iter.advance_to_sibling()
1168 {
1169 continue;
1170 }
1171 entry_iter.advance();
1172 }
1173
1174 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1175
1176 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1177 let mut components_a = entry_a.path.components().peekable();
1178 let mut components_b = entry_b.path.components().peekable();
1179 loop {
1180 match (components_a.next(), components_b.next()) {
1181 (Some(component_a), Some(component_b)) => {
1182 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1183 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1184 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1185 let name_a =
1186 UniCase::new(component_a.as_os_str().to_string_lossy());
1187 let name_b =
1188 UniCase::new(component_b.as_os_str().to_string_lossy());
1189 name_a.cmp(&name_b)
1190 });
1191 if !ordering.is_eq() {
1192 return ordering;
1193 }
1194 }
1195 (Some(_), None) => break Ordering::Greater,
1196 (None, Some(_)) => break Ordering::Less,
1197 (None, None) => break Ordering::Equal,
1198 }
1199 }
1200 });
1201 self.visible_entries
1202 .push((worktree_id, visible_worktree_entries));
1203 }
1204
1205 if let Some((worktree_id, entry_id)) = new_selected_entry {
1206 self.selection = Some(Selection {
1207 worktree_id,
1208 entry_id,
1209 });
1210 }
1211 }
1212
1213 fn expand_entry(
1214 &mut self,
1215 worktree_id: WorktreeId,
1216 entry_id: ProjectEntryId,
1217 cx: &mut ViewContext<Self>,
1218 ) {
1219 self.project.update(cx, |project, cx| {
1220 if let Some((worktree, expanded_dir_ids)) = project
1221 .worktree_for_id(worktree_id, cx)
1222 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1223 {
1224 project.expand_entry(worktree_id, entry_id, cx);
1225 let worktree = worktree.read(cx);
1226
1227 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1228 loop {
1229 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1230 expanded_dir_ids.insert(ix, entry.id);
1231 }
1232
1233 if let Some(parent_entry) =
1234 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1235 {
1236 entry = parent_entry;
1237 } else {
1238 break;
1239 }
1240 }
1241 }
1242 }
1243 });
1244 }
1245
1246 fn for_each_visible_entry(
1247 &self,
1248 range: Range<usize>,
1249 cx: &mut ViewContext<ProjectPanel>,
1250 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1251 ) {
1252 let mut ix = 0;
1253 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1254 if ix >= range.end {
1255 return;
1256 }
1257
1258 if ix + visible_worktree_entries.len() <= range.start {
1259 ix += visible_worktree_entries.len();
1260 continue;
1261 }
1262
1263 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1264 let (git_status_setting, show_file_icons, show_folder_icons) = {
1265 let settings = settings::get::<ProjectPanelSettings>(cx);
1266 (
1267 settings.git_status,
1268 settings.file_icons,
1269 settings.folder_icons,
1270 )
1271 };
1272 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1273 let snapshot = worktree.read(cx).snapshot();
1274 let root_name = OsStr::new(snapshot.root_name());
1275 let expanded_entry_ids = self
1276 .expanded_dir_ids
1277 .get(&snapshot.id())
1278 .map(Vec::as_slice)
1279 .unwrap_or(&[]);
1280
1281 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1282 for entry in visible_worktree_entries[entry_range].iter() {
1283 let status = git_status_setting.then(|| entry.git_status).flatten();
1284 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1285 let icon = match entry.kind {
1286 EntryKind::File(_) => {
1287 if show_file_icons {
1288 Some(FileAssociations::get_icon(&entry.path, cx))
1289 } else {
1290 None
1291 }
1292 }
1293 _ => {
1294 if show_folder_icons {
1295 Some(FileAssociations::get_folder_icon(is_expanded, cx))
1296 } else {
1297 Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1298 }
1299 }
1300 };
1301
1302 let mut details = EntryDetails {
1303 filename: entry
1304 .path
1305 .file_name()
1306 .unwrap_or(root_name)
1307 .to_string_lossy()
1308 .to_string(),
1309 icon,
1310 path: entry.path.clone(),
1311 depth: entry.path.components().count(),
1312 kind: entry.kind,
1313 is_ignored: entry.is_ignored,
1314 is_expanded,
1315 is_selected: self.selection.map_or(false, |e| {
1316 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1317 }),
1318 is_editing: false,
1319 is_processing: false,
1320 is_cut: self
1321 .clipboard_entry
1322 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1323 git_status: status,
1324 };
1325
1326 if let Some(edit_state) = &self.edit_state {
1327 let is_edited_entry = if edit_state.is_new_entry {
1328 entry.id == NEW_ENTRY_ID
1329 } else {
1330 entry.id == edit_state.entry_id
1331 };
1332
1333 if is_edited_entry {
1334 if let Some(processing_filename) = &edit_state.processing_filename {
1335 details.is_processing = true;
1336 details.filename.clear();
1337 details.filename.push_str(processing_filename);
1338 } else {
1339 if edit_state.is_new_entry {
1340 details.filename.clear();
1341 }
1342 details.is_editing = true;
1343 }
1344 }
1345 }
1346
1347 callback(entry.id, details, cx);
1348 }
1349 }
1350 ix = end_ix;
1351 }
1352 }
1353
1354 fn render_entry_visual_element<V: 'static>(
1355 details: &EntryDetails,
1356 editor: Option<&ViewHandle<Editor>>,
1357 padding: f32,
1358 row_container_style: ContainerStyle,
1359 style: &ProjectPanelEntry,
1360 cx: &mut ViewContext<V>,
1361 ) -> AnyElement<V> {
1362 let show_editor = details.is_editing && !details.is_processing;
1363
1364 let mut filename_text_style = style.text.clone();
1365 filename_text_style.color = details
1366 .git_status
1367 .as_ref()
1368 .map(|status| match status {
1369 GitFileStatus::Added => style.status.git.inserted,
1370 GitFileStatus::Modified => style.status.git.modified,
1371 GitFileStatus::Conflict => style.status.git.conflict,
1372 })
1373 .unwrap_or(style.text.color);
1374
1375 Flex::row()
1376 .with_child(if let Some(icon) = &details.icon {
1377 Svg::new(icon.to_string())
1378 .with_color(style.icon_color)
1379 .constrained()
1380 .with_max_width(style.icon_size)
1381 .with_max_height(style.icon_size)
1382 .aligned()
1383 .constrained()
1384 .with_width(style.icon_size)
1385 } else {
1386 Empty::new()
1387 .constrained()
1388 .with_max_width(style.icon_size)
1389 .with_max_height(style.icon_size)
1390 .aligned()
1391 .constrained()
1392 .with_width(style.icon_size)
1393 })
1394 .with_child(if show_editor && editor.is_some() {
1395 ChildView::new(editor.as_ref().unwrap(), cx)
1396 .contained()
1397 .with_margin_left(style.icon_spacing)
1398 .aligned()
1399 .left()
1400 .flex(1.0, true)
1401 .into_any()
1402 } else {
1403 Label::new(details.filename.clone(), filename_text_style)
1404 .contained()
1405 .with_margin_left(style.icon_spacing)
1406 .aligned()
1407 .left()
1408 .into_any()
1409 })
1410 .constrained()
1411 .with_height(style.height)
1412 .contained()
1413 .with_style(row_container_style)
1414 .with_padding_left(padding)
1415 .into_any_named("project panel entry visual element")
1416 }
1417
1418 fn render_entry(
1419 entry_id: ProjectEntryId,
1420 details: EntryDetails,
1421 editor: &ViewHandle<Editor>,
1422 dragged_entry_destination: &mut Option<Arc<Path>>,
1423 theme: &theme::ProjectPanel,
1424 cx: &mut ViewContext<Self>,
1425 ) -> AnyElement<Self> {
1426 let kind = details.kind;
1427 let path = details.path.clone();
1428 let settings = settings::get::<ProjectPanelSettings>(cx);
1429 let padding = theme.container.padding.left + details.depth as f32 * settings.indent_size;
1430
1431 let entry_style = if details.is_cut {
1432 &theme.cut_entry
1433 } else if details.is_ignored {
1434 &theme.ignored_entry
1435 } else {
1436 &theme.entry
1437 };
1438
1439 let show_editor = details.is_editing && !details.is_processing;
1440
1441 MouseEventHandler::new::<Self, _>(entry_id.to_usize(), cx, |state, cx| {
1442 let mut style = entry_style
1443 .in_state(details.is_selected)
1444 .style_for(state)
1445 .clone();
1446
1447 if cx
1448 .global::<DragAndDrop<Workspace>>()
1449 .currently_dragged::<ProjectEntryId>(cx.window())
1450 .is_some()
1451 && dragged_entry_destination
1452 .as_ref()
1453 .filter(|destination| details.path.starts_with(destination))
1454 .is_some()
1455 {
1456 style = entry_style.active_state().default.clone();
1457 }
1458
1459 let row_container_style = if show_editor {
1460 theme.filename_editor.container
1461 } else {
1462 style.container
1463 };
1464
1465 Self::render_entry_visual_element(
1466 &details,
1467 Some(editor),
1468 padding,
1469 row_container_style,
1470 &style,
1471 cx,
1472 )
1473 })
1474 .on_click(MouseButton::Left, move |event, this, cx| {
1475 if !show_editor {
1476 if kind.is_dir() {
1477 this.toggle_expanded(entry_id, cx);
1478 } else {
1479 if event.cmd {
1480 this.split_entry(entry_id, cx);
1481 } else if !event.cmd {
1482 this.open_entry(entry_id, event.click_count > 1, cx);
1483 }
1484 }
1485 }
1486 })
1487 .on_down(MouseButton::Right, move |event, this, cx| {
1488 this.deploy_context_menu(event.position, entry_id, cx);
1489 })
1490 .on_up(MouseButton::Left, move |_, this, cx| {
1491 if let Some((_, dragged_entry)) = cx
1492 .global::<DragAndDrop<Workspace>>()
1493 .currently_dragged::<ProjectEntryId>(cx.window())
1494 {
1495 this.move_entry(
1496 *dragged_entry,
1497 entry_id,
1498 matches!(details.kind, EntryKind::File(_)),
1499 cx,
1500 );
1501 }
1502 })
1503 .on_move(move |_, this, cx| {
1504 if cx
1505 .global::<DragAndDrop<Workspace>>()
1506 .currently_dragged::<ProjectEntryId>(cx.window())
1507 .is_some()
1508 {
1509 this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1510 path.parent().map(|parent| Arc::from(parent))
1511 } else {
1512 Some(path.clone())
1513 };
1514 }
1515 })
1516 .as_draggable(entry_id, {
1517 let row_container_style = theme.dragged_entry.container;
1518
1519 move |_, _, cx: &mut ViewContext<Workspace>| {
1520 let theme = theme::current(cx).clone();
1521 Self::render_entry_visual_element(
1522 &details,
1523 None,
1524 padding,
1525 row_container_style,
1526 &theme.project_panel.dragged_entry,
1527 cx,
1528 )
1529 }
1530 })
1531 .with_cursor_style(CursorStyle::PointingHand)
1532 .into_any_named("project panel entry")
1533 }
1534
1535 fn reveal_entry(
1536 &mut self,
1537 project: ModelHandle<Project>,
1538 entry_id: ProjectEntryId,
1539 skip_ignored: bool,
1540 cx: &mut ViewContext<'_, '_, ProjectPanel>,
1541 ) {
1542 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1543 let worktree = worktree.read(cx);
1544 if skip_ignored
1545 && worktree
1546 .entry_for_id(entry_id)
1547 .map_or(true, |entry| entry.is_ignored)
1548 {
1549 return;
1550 }
1551
1552 let worktree_id = worktree.id();
1553 self.expand_entry(worktree_id, entry_id, cx);
1554 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1555 self.autoscroll(cx);
1556 cx.notify();
1557 }
1558 }
1559}
1560
1561impl View for ProjectPanel {
1562 fn ui_name() -> &'static str {
1563 "ProjectPanel"
1564 }
1565
1566 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1567 enum ProjectPanel {}
1568 let theme = &theme::current(cx).project_panel;
1569 let mut container_style = theme.container;
1570 let padding = std::mem::take(&mut container_style.padding);
1571 let last_worktree_root_id = self.last_worktree_root_id;
1572
1573 let has_worktree = self.visible_entries.len() != 0;
1574
1575 if has_worktree {
1576 Stack::new()
1577 .with_child(
1578 MouseEventHandler::new::<ProjectPanel, _>(0, cx, |_, cx| {
1579 UniformList::new(
1580 self.list.clone(),
1581 self.visible_entries
1582 .iter()
1583 .map(|(_, worktree_entries)| worktree_entries.len())
1584 .sum(),
1585 cx,
1586 move |this, range, items, cx| {
1587 let theme = theme::current(cx).clone();
1588 let mut dragged_entry_destination =
1589 this.dragged_entry_destination.clone();
1590 this.for_each_visible_entry(range, cx, |id, details, cx| {
1591 items.push(Self::render_entry(
1592 id,
1593 details,
1594 &this.filename_editor,
1595 &mut dragged_entry_destination,
1596 &theme.project_panel,
1597 cx,
1598 ));
1599 });
1600 this.dragged_entry_destination = dragged_entry_destination;
1601 },
1602 )
1603 .with_padding_top(padding.top)
1604 .with_padding_bottom(padding.bottom)
1605 .contained()
1606 .with_style(container_style)
1607 .expanded()
1608 })
1609 .on_down(MouseButton::Right, move |event, this, cx| {
1610 // When deploying the context menu anywhere below the last project entry,
1611 // act as if the user clicked the root of the last worktree.
1612 if let Some(entry_id) = last_worktree_root_id {
1613 this.deploy_context_menu(event.position, entry_id, cx);
1614 }
1615 }),
1616 )
1617 .with_child(ChildView::new(&self.context_menu, cx))
1618 .into_any_named("project panel")
1619 } else {
1620 Flex::column()
1621 .with_child(
1622 MouseEventHandler::new::<Self, _>(2, cx, {
1623 let button_style = theme.open_project_button.clone();
1624 let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1625 move |state, cx| {
1626 let button_style = button_style.style_for(state).clone();
1627 let context_menu_item = context_menu_item_style
1628 .active_state()
1629 .style_for(state)
1630 .clone();
1631
1632 theme::ui::keystroke_label(
1633 "Open a project",
1634 &button_style,
1635 &context_menu_item.keystroke,
1636 Box::new(workspace::Open),
1637 cx,
1638 )
1639 }
1640 })
1641 .on_click(MouseButton::Left, move |_, this, cx| {
1642 if let Some(workspace) = this.workspace.upgrade(cx) {
1643 workspace.update(cx, |workspace, cx| {
1644 if let Some(task) = workspace.open(&Default::default(), cx) {
1645 task.detach_and_log_err(cx);
1646 }
1647 })
1648 }
1649 })
1650 .with_cursor_style(CursorStyle::PointingHand),
1651 )
1652 .contained()
1653 .with_style(container_style)
1654 .into_any_named("empty project panel")
1655 }
1656 }
1657
1658 fn update_keymap_context(&self, keymap: &mut KeymapContext, cx: &AppContext) {
1659 Self::reset_to_default_keymap_context(keymap);
1660 keymap.add_identifier("menu");
1661
1662 if let Some(window) = cx.active_window() {
1663 window.read_with(cx, |cx| {
1664 let identifier = if self.filename_editor.is_focused(cx) {
1665 "editing"
1666 } else {
1667 "not_editing"
1668 };
1669
1670 keymap.add_identifier(identifier);
1671 });
1672 }
1673 }
1674
1675 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1676 if !self.has_focus {
1677 self.has_focus = true;
1678 cx.emit(Event::Focus);
1679 }
1680 }
1681
1682 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1683 self.has_focus = false;
1684 }
1685}
1686
1687impl Entity for ProjectPanel {
1688 type Event = Event;
1689}
1690
1691impl workspace::dock::Panel for ProjectPanel {
1692 fn position(&self, cx: &WindowContext) -> DockPosition {
1693 match settings::get::<ProjectPanelSettings>(cx).dock {
1694 ProjectPanelDockPosition::Left => DockPosition::Left,
1695 ProjectPanelDockPosition::Right => DockPosition::Right,
1696 }
1697 }
1698
1699 fn position_is_valid(&self, position: DockPosition) -> bool {
1700 matches!(position, DockPosition::Left | DockPosition::Right)
1701 }
1702
1703 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1704 settings::update_settings_file::<ProjectPanelSettings>(
1705 self.fs.clone(),
1706 cx,
1707 move |settings| {
1708 let dock = match position {
1709 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1710 DockPosition::Right => ProjectPanelDockPosition::Right,
1711 };
1712 settings.dock = Some(dock);
1713 },
1714 );
1715 }
1716
1717 fn size(&self, cx: &WindowContext) -> f32 {
1718 self.width
1719 .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1720 }
1721
1722 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1723 self.width = size;
1724 self.serialize(cx);
1725 cx.notify();
1726 }
1727
1728 fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
1729 Some("icons/project.svg")
1730 }
1731
1732 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1733 ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1734 }
1735
1736 fn should_change_position_on_event(event: &Self::Event) -> bool {
1737 matches!(event, Event::DockPositionChanged)
1738 }
1739
1740 fn has_focus(&self, _: &WindowContext) -> bool {
1741 self.has_focus
1742 }
1743
1744 fn is_focus_event(event: &Self::Event) -> bool {
1745 matches!(event, Event::Focus)
1746 }
1747}
1748
1749impl ClipboardEntry {
1750 fn is_cut(&self) -> bool {
1751 matches!(self, Self::Cut { .. })
1752 }
1753
1754 fn entry_id(&self) -> ProjectEntryId {
1755 match self {
1756 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1757 *entry_id
1758 }
1759 }
1760 }
1761
1762 fn worktree_id(&self) -> WorktreeId {
1763 match self {
1764 ClipboardEntry::Copied { worktree_id, .. }
1765 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1766 }
1767 }
1768}
1769
1770#[cfg(test)]
1771mod tests {
1772 use super::*;
1773 use gpui::{AnyWindowHandle, TestAppContext, ViewHandle, WindowHandle};
1774 use pretty_assertions::assert_eq;
1775 use project::{project_settings::ProjectSettings, FakeFs};
1776 use serde_json::json;
1777 use settings::SettingsStore;
1778 use std::{
1779 collections::HashSet,
1780 path::{Path, PathBuf},
1781 sync::atomic::{self, AtomicUsize},
1782 };
1783 use workspace::{pane, AppState};
1784
1785 #[gpui::test]
1786 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1787 init_test(cx);
1788
1789 let fs = FakeFs::new(cx.background());
1790 fs.insert_tree(
1791 "/root1",
1792 json!({
1793 ".dockerignore": "",
1794 ".git": {
1795 "HEAD": "",
1796 },
1797 "a": {
1798 "0": { "q": "", "r": "", "s": "" },
1799 "1": { "t": "", "u": "" },
1800 "2": { "v": "", "w": "", "x": "", "y": "" },
1801 },
1802 "b": {
1803 "3": { "Q": "" },
1804 "4": { "R": "", "S": "", "T": "", "U": "" },
1805 },
1806 "C": {
1807 "5": {},
1808 "6": { "V": "", "W": "" },
1809 "7": { "X": "" },
1810 "8": { "Y": {}, "Z": "" }
1811 }
1812 }),
1813 )
1814 .await;
1815 fs.insert_tree(
1816 "/root2",
1817 json!({
1818 "d": {
1819 "9": ""
1820 },
1821 "e": {}
1822 }),
1823 )
1824 .await;
1825
1826 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1827 let workspace = cx
1828 .add_window(|cx| Workspace::test_new(project.clone(), cx))
1829 .root(cx);
1830 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1831 assert_eq!(
1832 visible_entries_as_strings(&panel, 0..50, cx),
1833 &[
1834 "v root1",
1835 " > .git",
1836 " > a",
1837 " > b",
1838 " > C",
1839 " .dockerignore",
1840 "v root2",
1841 " > d",
1842 " > e",
1843 ]
1844 );
1845
1846 toggle_expand_dir(&panel, "root1/b", cx);
1847 assert_eq!(
1848 visible_entries_as_strings(&panel, 0..50, cx),
1849 &[
1850 "v root1",
1851 " > .git",
1852 " > a",
1853 " v b <== selected",
1854 " > 3",
1855 " > 4",
1856 " > C",
1857 " .dockerignore",
1858 "v root2",
1859 " > d",
1860 " > e",
1861 ]
1862 );
1863
1864 assert_eq!(
1865 visible_entries_as_strings(&panel, 6..9, cx),
1866 &[
1867 //
1868 " > C",
1869 " .dockerignore",
1870 "v root2",
1871 ]
1872 );
1873 }
1874
1875 #[gpui::test]
1876 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1877 init_test(cx);
1878 cx.update(|cx| {
1879 cx.update_global::<SettingsStore, _, _>(|store, cx| {
1880 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1881 project_settings.file_scan_exclusions =
1882 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1883 });
1884 });
1885 });
1886
1887 let fs = FakeFs::new(cx.background());
1888 fs.insert_tree(
1889 "/root1",
1890 json!({
1891 ".dockerignore": "",
1892 ".git": {
1893 "HEAD": "",
1894 },
1895 "a": {
1896 "0": { "q": "", "r": "", "s": "" },
1897 "1": { "t": "", "u": "" },
1898 "2": { "v": "", "w": "", "x": "", "y": "" },
1899 },
1900 "b": {
1901 "3": { "Q": "" },
1902 "4": { "R": "", "S": "", "T": "", "U": "" },
1903 },
1904 "C": {
1905 "5": {},
1906 "6": { "V": "", "W": "" },
1907 "7": { "X": "" },
1908 "8": { "Y": {}, "Z": "" }
1909 }
1910 }),
1911 )
1912 .await;
1913 fs.insert_tree(
1914 "/root2",
1915 json!({
1916 "d": {
1917 "4": ""
1918 },
1919 "e": {}
1920 }),
1921 )
1922 .await;
1923
1924 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1925 let workspace = cx
1926 .add_window(|cx| Workspace::test_new(project.clone(), cx))
1927 .root(cx);
1928 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1929 assert_eq!(
1930 visible_entries_as_strings(&panel, 0..50, cx),
1931 &[
1932 "v root1",
1933 " > a",
1934 " > b",
1935 " > C",
1936 " .dockerignore",
1937 "v root2",
1938 " > d",
1939 " > e",
1940 ]
1941 );
1942
1943 toggle_expand_dir(&panel, "root1/b", cx);
1944 assert_eq!(
1945 visible_entries_as_strings(&panel, 0..50, cx),
1946 &[
1947 "v root1",
1948 " > a",
1949 " v b <== selected",
1950 " > 3",
1951 " > C",
1952 " .dockerignore",
1953 "v root2",
1954 " > d",
1955 " > e",
1956 ]
1957 );
1958
1959 toggle_expand_dir(&panel, "root2/d", cx);
1960 assert_eq!(
1961 visible_entries_as_strings(&panel, 0..50, cx),
1962 &[
1963 "v root1",
1964 " > a",
1965 " v b",
1966 " > 3",
1967 " > C",
1968 " .dockerignore",
1969 "v root2",
1970 " v d <== selected",
1971 " > e",
1972 ]
1973 );
1974
1975 toggle_expand_dir(&panel, "root2/e", cx);
1976 assert_eq!(
1977 visible_entries_as_strings(&panel, 0..50, cx),
1978 &[
1979 "v root1",
1980 " > a",
1981 " v b",
1982 " > 3",
1983 " > C",
1984 " .dockerignore",
1985 "v root2",
1986 " v d",
1987 " v e <== selected",
1988 ]
1989 );
1990 }
1991
1992 #[gpui::test(iterations = 30)]
1993 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1994 init_test(cx);
1995
1996 let fs = FakeFs::new(cx.background());
1997 fs.insert_tree(
1998 "/root1",
1999 json!({
2000 ".dockerignore": "",
2001 ".git": {
2002 "HEAD": "",
2003 },
2004 "a": {
2005 "0": { "q": "", "r": "", "s": "" },
2006 "1": { "t": "", "u": "" },
2007 "2": { "v": "", "w": "", "x": "", "y": "" },
2008 },
2009 "b": {
2010 "3": { "Q": "" },
2011 "4": { "R": "", "S": "", "T": "", "U": "" },
2012 },
2013 "C": {
2014 "5": {},
2015 "6": { "V": "", "W": "" },
2016 "7": { "X": "" },
2017 "8": { "Y": {}, "Z": "" }
2018 }
2019 }),
2020 )
2021 .await;
2022 fs.insert_tree(
2023 "/root2",
2024 json!({
2025 "d": {
2026 "9": ""
2027 },
2028 "e": {}
2029 }),
2030 )
2031 .await;
2032
2033 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2034 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2035 let workspace = window.root(cx);
2036 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2037
2038 select_path(&panel, "root1", cx);
2039 assert_eq!(
2040 visible_entries_as_strings(&panel, 0..10, cx),
2041 &[
2042 "v root1 <== selected",
2043 " > .git",
2044 " > a",
2045 " > b",
2046 " > C",
2047 " .dockerignore",
2048 "v root2",
2049 " > d",
2050 " > e",
2051 ]
2052 );
2053
2054 // Add a file with the root folder selected. The filename editor is placed
2055 // before the first file in the root folder.
2056 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2057 window.read_with(cx, |cx| {
2058 let panel = panel.read(cx);
2059 assert!(panel.filename_editor.is_focused(cx));
2060 });
2061 assert_eq!(
2062 visible_entries_as_strings(&panel, 0..10, cx),
2063 &[
2064 "v root1",
2065 " > .git",
2066 " > a",
2067 " > b",
2068 " > C",
2069 " [EDITOR: ''] <== selected",
2070 " .dockerignore",
2071 "v root2",
2072 " > d",
2073 " > e",
2074 ]
2075 );
2076
2077 let confirm = panel.update(cx, |panel, cx| {
2078 panel
2079 .filename_editor
2080 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2081 panel.confirm(&Confirm, cx).unwrap()
2082 });
2083 assert_eq!(
2084 visible_entries_as_strings(&panel, 0..10, cx),
2085 &[
2086 "v root1",
2087 " > .git",
2088 " > a",
2089 " > b",
2090 " > C",
2091 " [PROCESSING: 'the-new-filename'] <== selected",
2092 " .dockerignore",
2093 "v root2",
2094 " > d",
2095 " > e",
2096 ]
2097 );
2098
2099 confirm.await.unwrap();
2100 assert_eq!(
2101 visible_entries_as_strings(&panel, 0..10, cx),
2102 &[
2103 "v root1",
2104 " > .git",
2105 " > a",
2106 " > b",
2107 " > C",
2108 " .dockerignore",
2109 " the-new-filename <== selected",
2110 "v root2",
2111 " > d",
2112 " > e",
2113 ]
2114 );
2115
2116 select_path(&panel, "root1/b", cx);
2117 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2118 assert_eq!(
2119 visible_entries_as_strings(&panel, 0..10, cx),
2120 &[
2121 "v root1",
2122 " > .git",
2123 " > a",
2124 " v b",
2125 " > 3",
2126 " > 4",
2127 " [EDITOR: ''] <== selected",
2128 " > C",
2129 " .dockerignore",
2130 " the-new-filename",
2131 ]
2132 );
2133
2134 panel
2135 .update(cx, |panel, cx| {
2136 panel
2137 .filename_editor
2138 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2139 panel.confirm(&Confirm, cx).unwrap()
2140 })
2141 .await
2142 .unwrap();
2143 assert_eq!(
2144 visible_entries_as_strings(&panel, 0..10, cx),
2145 &[
2146 "v root1",
2147 " > .git",
2148 " > a",
2149 " v b",
2150 " > 3",
2151 " > 4",
2152 " another-filename.txt <== selected",
2153 " > C",
2154 " .dockerignore",
2155 " the-new-filename",
2156 ]
2157 );
2158
2159 select_path(&panel, "root1/b/another-filename.txt", cx);
2160 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2161 assert_eq!(
2162 visible_entries_as_strings(&panel, 0..10, cx),
2163 &[
2164 "v root1",
2165 " > .git",
2166 " > a",
2167 " v b",
2168 " > 3",
2169 " > 4",
2170 " [EDITOR: 'another-filename.txt'] <== selected",
2171 " > C",
2172 " .dockerignore",
2173 " the-new-filename",
2174 ]
2175 );
2176
2177 let confirm = panel.update(cx, |panel, cx| {
2178 panel.filename_editor.update(cx, |editor, cx| {
2179 let file_name_selections = editor.selections.all::<usize>(cx);
2180 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2181 let file_name_selection = &file_name_selections[0];
2182 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2183 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2184
2185 editor.set_text("a-different-filename.tar.gz", cx)
2186 });
2187 panel.confirm(&Confirm, cx).unwrap()
2188 });
2189 assert_eq!(
2190 visible_entries_as_strings(&panel, 0..10, cx),
2191 &[
2192 "v root1",
2193 " > .git",
2194 " > a",
2195 " v b",
2196 " > 3",
2197 " > 4",
2198 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2199 " > C",
2200 " .dockerignore",
2201 " the-new-filename",
2202 ]
2203 );
2204
2205 confirm.await.unwrap();
2206 assert_eq!(
2207 visible_entries_as_strings(&panel, 0..10, cx),
2208 &[
2209 "v root1",
2210 " > .git",
2211 " > a",
2212 " v b",
2213 " > 3",
2214 " > 4",
2215 " a-different-filename.tar.gz <== selected",
2216 " > C",
2217 " .dockerignore",
2218 " the-new-filename",
2219 ]
2220 );
2221
2222 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2223 assert_eq!(
2224 visible_entries_as_strings(&panel, 0..10, cx),
2225 &[
2226 "v root1",
2227 " > .git",
2228 " > a",
2229 " v b",
2230 " > 3",
2231 " > 4",
2232 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2233 " > C",
2234 " .dockerignore",
2235 " the-new-filename",
2236 ]
2237 );
2238
2239 panel.update(cx, |panel, cx| {
2240 panel.filename_editor.update(cx, |editor, cx| {
2241 let file_name_selections = editor.selections.all::<usize>(cx);
2242 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2243 let file_name_selection = &file_name_selections[0];
2244 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2245 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");
2246
2247 });
2248 panel.cancel(&Cancel, cx)
2249 });
2250
2251 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2252 assert_eq!(
2253 visible_entries_as_strings(&panel, 0..10, cx),
2254 &[
2255 "v root1",
2256 " > .git",
2257 " > a",
2258 " v b",
2259 " > [EDITOR: ''] <== selected",
2260 " > 3",
2261 " > 4",
2262 " a-different-filename.tar.gz",
2263 " > C",
2264 " .dockerignore",
2265 ]
2266 );
2267
2268 let confirm = panel.update(cx, |panel, cx| {
2269 panel
2270 .filename_editor
2271 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2272 panel.confirm(&Confirm, cx).unwrap()
2273 });
2274 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2275 assert_eq!(
2276 visible_entries_as_strings(&panel, 0..10, cx),
2277 &[
2278 "v root1",
2279 " > .git",
2280 " > a",
2281 " v b",
2282 " > [PROCESSING: 'new-dir']",
2283 " > 3 <== selected",
2284 " > 4",
2285 " a-different-filename.tar.gz",
2286 " > C",
2287 " .dockerignore",
2288 ]
2289 );
2290
2291 confirm.await.unwrap();
2292 assert_eq!(
2293 visible_entries_as_strings(&panel, 0..10, cx),
2294 &[
2295 "v root1",
2296 " > .git",
2297 " > a",
2298 " v b",
2299 " > 3 <== selected",
2300 " > 4",
2301 " > new-dir",
2302 " a-different-filename.tar.gz",
2303 " > C",
2304 " .dockerignore",
2305 ]
2306 );
2307
2308 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2309 assert_eq!(
2310 visible_entries_as_strings(&panel, 0..10, cx),
2311 &[
2312 "v root1",
2313 " > .git",
2314 " > a",
2315 " v b",
2316 " > [EDITOR: '3'] <== selected",
2317 " > 4",
2318 " > new-dir",
2319 " a-different-filename.tar.gz",
2320 " > C",
2321 " .dockerignore",
2322 ]
2323 );
2324
2325 // Dismiss the rename editor when it loses focus.
2326 workspace.update(cx, |_, cx| cx.focus_self());
2327 assert_eq!(
2328 visible_entries_as_strings(&panel, 0..10, cx),
2329 &[
2330 "v root1",
2331 " > .git",
2332 " > a",
2333 " v b",
2334 " > 3 <== selected",
2335 " > 4",
2336 " > new-dir",
2337 " a-different-filename.tar.gz",
2338 " > C",
2339 " .dockerignore",
2340 ]
2341 );
2342 }
2343
2344 #[gpui::test(iterations = 30)]
2345 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2346 init_test(cx);
2347
2348 let fs = FakeFs::new(cx.background());
2349 fs.insert_tree(
2350 "/root1",
2351 json!({
2352 ".dockerignore": "",
2353 ".git": {
2354 "HEAD": "",
2355 },
2356 "a": {
2357 "0": { "q": "", "r": "", "s": "" },
2358 "1": { "t": "", "u": "" },
2359 "2": { "v": "", "w": "", "x": "", "y": "" },
2360 },
2361 "b": {
2362 "3": { "Q": "" },
2363 "4": { "R": "", "S": "", "T": "", "U": "" },
2364 },
2365 "C": {
2366 "5": {},
2367 "6": { "V": "", "W": "" },
2368 "7": { "X": "" },
2369 "8": { "Y": {}, "Z": "" }
2370 }
2371 }),
2372 )
2373 .await;
2374 fs.insert_tree(
2375 "/root2",
2376 json!({
2377 "d": {
2378 "9": ""
2379 },
2380 "e": {}
2381 }),
2382 )
2383 .await;
2384
2385 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2386 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2387 let workspace = window.root(cx);
2388 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2389
2390 select_path(&panel, "root1", cx);
2391 assert_eq!(
2392 visible_entries_as_strings(&panel, 0..10, cx),
2393 &[
2394 "v root1 <== selected",
2395 " > .git",
2396 " > a",
2397 " > b",
2398 " > C",
2399 " .dockerignore",
2400 "v root2",
2401 " > d",
2402 " > e",
2403 ]
2404 );
2405
2406 // Add a file with the root folder selected. The filename editor is placed
2407 // before the first file in the root folder.
2408 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2409 window.read_with(cx, |cx| {
2410 let panel = panel.read(cx);
2411 assert!(panel.filename_editor.is_focused(cx));
2412 });
2413 assert_eq!(
2414 visible_entries_as_strings(&panel, 0..10, cx),
2415 &[
2416 "v root1",
2417 " > .git",
2418 " > a",
2419 " > b",
2420 " > C",
2421 " [EDITOR: ''] <== selected",
2422 " .dockerignore",
2423 "v root2",
2424 " > d",
2425 " > e",
2426 ]
2427 );
2428
2429 let confirm = panel.update(cx, |panel, cx| {
2430 panel.filename_editor.update(cx, |editor, cx| {
2431 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2432 });
2433 panel.confirm(&Confirm, cx).unwrap()
2434 });
2435
2436 assert_eq!(
2437 visible_entries_as_strings(&panel, 0..10, cx),
2438 &[
2439 "v root1",
2440 " > .git",
2441 " > a",
2442 " > b",
2443 " > C",
2444 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2445 " .dockerignore",
2446 "v root2",
2447 " > d",
2448 " > e",
2449 ]
2450 );
2451
2452 confirm.await.unwrap();
2453 assert_eq!(
2454 visible_entries_as_strings(&panel, 0..13, cx),
2455 &[
2456 "v root1",
2457 " > .git",
2458 " > a",
2459 " > b",
2460 " v bdir1",
2461 " v dir2",
2462 " the-new-filename <== selected",
2463 " > C",
2464 " .dockerignore",
2465 "v root2",
2466 " > d",
2467 " > e",
2468 ]
2469 );
2470 }
2471
2472 #[gpui::test]
2473 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2474 init_test(cx);
2475
2476 let fs = FakeFs::new(cx.background());
2477 fs.insert_tree(
2478 "/root1",
2479 json!({
2480 "one.two.txt": "",
2481 "one.txt": ""
2482 }),
2483 )
2484 .await;
2485
2486 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2487 let workspace = cx
2488 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2489 .root(cx);
2490 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2491
2492 panel.update(cx, |panel, cx| {
2493 panel.select_next(&Default::default(), cx);
2494 panel.select_next(&Default::default(), cx);
2495 });
2496
2497 assert_eq!(
2498 visible_entries_as_strings(&panel, 0..50, cx),
2499 &[
2500 //
2501 "v root1",
2502 " one.two.txt <== selected",
2503 " one.txt",
2504 ]
2505 );
2506
2507 // Regression test - file name is created correctly when
2508 // the copied file's name contains multiple dots.
2509 panel.update(cx, |panel, cx| {
2510 panel.copy(&Default::default(), cx);
2511 panel.paste(&Default::default(), cx);
2512 });
2513 cx.foreground().run_until_parked();
2514
2515 assert_eq!(
2516 visible_entries_as_strings(&panel, 0..50, cx),
2517 &[
2518 //
2519 "v root1",
2520 " one.two copy.txt",
2521 " one.two.txt <== selected",
2522 " one.txt",
2523 ]
2524 );
2525
2526 panel.update(cx, |panel, cx| {
2527 panel.paste(&Default::default(), cx);
2528 });
2529 cx.foreground().run_until_parked();
2530
2531 assert_eq!(
2532 visible_entries_as_strings(&panel, 0..50, cx),
2533 &[
2534 //
2535 "v root1",
2536 " one.two copy 1.txt",
2537 " one.two copy.txt",
2538 " one.two.txt <== selected",
2539 " one.txt",
2540 ]
2541 );
2542 }
2543
2544 #[gpui::test]
2545 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2546 init_test_with_editor(cx);
2547
2548 let fs = FakeFs::new(cx.background());
2549 fs.insert_tree(
2550 "/src",
2551 json!({
2552 "test": {
2553 "first.rs": "// First Rust file",
2554 "second.rs": "// Second Rust file",
2555 "third.rs": "// Third Rust file",
2556 }
2557 }),
2558 )
2559 .await;
2560
2561 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2562 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2563 let workspace = window.root(cx);
2564 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2565
2566 toggle_expand_dir(&panel, "src/test", cx);
2567 select_path(&panel, "src/test/first.rs", cx);
2568 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2569 cx.foreground().run_until_parked();
2570 assert_eq!(
2571 visible_entries_as_strings(&panel, 0..10, cx),
2572 &[
2573 "v src",
2574 " v test",
2575 " first.rs <== selected",
2576 " second.rs",
2577 " third.rs"
2578 ]
2579 );
2580 ensure_single_file_is_opened(window, "test/first.rs", cx);
2581
2582 submit_deletion(window.into(), &panel, cx);
2583 assert_eq!(
2584 visible_entries_as_strings(&panel, 0..10, cx),
2585 &[
2586 "v src",
2587 " v test",
2588 " second.rs",
2589 " third.rs"
2590 ],
2591 "Project panel should have no deleted file, no other file is selected in it"
2592 );
2593 ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2594
2595 select_path(&panel, "src/test/second.rs", cx);
2596 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2597 cx.foreground().run_until_parked();
2598 assert_eq!(
2599 visible_entries_as_strings(&panel, 0..10, cx),
2600 &[
2601 "v src",
2602 " v test",
2603 " second.rs <== selected",
2604 " third.rs"
2605 ]
2606 );
2607 ensure_single_file_is_opened(window, "test/second.rs", cx);
2608
2609 window.update(cx, |cx| {
2610 let active_items = workspace
2611 .read(cx)
2612 .panes()
2613 .iter()
2614 .filter_map(|pane| pane.read(cx).active_item())
2615 .collect::<Vec<_>>();
2616 assert_eq!(active_items.len(), 1);
2617 let open_editor = active_items
2618 .into_iter()
2619 .next()
2620 .unwrap()
2621 .downcast::<Editor>()
2622 .expect("Open item should be an editor");
2623 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2624 });
2625 submit_deletion(window.into(), &panel, cx);
2626 assert_eq!(
2627 visible_entries_as_strings(&panel, 0..10, cx),
2628 &["v src", " v test", " third.rs"],
2629 "Project panel should have no deleted file, with one last file remaining"
2630 );
2631 ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2632 }
2633
2634 #[gpui::test]
2635 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2636 init_test_with_editor(cx);
2637
2638 let fs = FakeFs::new(cx.background());
2639 fs.insert_tree(
2640 "/src",
2641 json!({
2642 "test": {
2643 "first.rs": "// First Rust file",
2644 "second.rs": "// Second Rust file",
2645 "third.rs": "// Third Rust file",
2646 }
2647 }),
2648 )
2649 .await;
2650
2651 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2652 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2653 let workspace = window.root(cx);
2654 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2655
2656 select_path(&panel, "src/", cx);
2657 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2658 cx.foreground().run_until_parked();
2659 assert_eq!(
2660 visible_entries_as_strings(&panel, 0..10, cx),
2661 &["v src <== selected", " > test"]
2662 );
2663 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2664 window.read_with(cx, |cx| {
2665 let panel = panel.read(cx);
2666 assert!(panel.filename_editor.is_focused(cx));
2667 });
2668 assert_eq!(
2669 visible_entries_as_strings(&panel, 0..10, cx),
2670 &["v src", " > [EDITOR: ''] <== selected", " > test"]
2671 );
2672 panel.update(cx, |panel, cx| {
2673 panel
2674 .filename_editor
2675 .update(cx, |editor, cx| editor.set_text("test", cx));
2676 assert!(
2677 panel.confirm(&Confirm, cx).is_none(),
2678 "Should not allow to confirm on conflicting new directory name"
2679 )
2680 });
2681 assert_eq!(
2682 visible_entries_as_strings(&panel, 0..10, cx),
2683 &["v src", " > test"],
2684 "File list should be unchanged after failed folder create confirmation"
2685 );
2686
2687 select_path(&panel, "src/test/", cx);
2688 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2689 cx.foreground().run_until_parked();
2690 assert_eq!(
2691 visible_entries_as_strings(&panel, 0..10, cx),
2692 &["v src", " > test <== selected"]
2693 );
2694 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2695 window.read_with(cx, |cx| {
2696 let panel = panel.read(cx);
2697 assert!(panel.filename_editor.is_focused(cx));
2698 });
2699 assert_eq!(
2700 visible_entries_as_strings(&panel, 0..10, cx),
2701 &[
2702 "v src",
2703 " v test",
2704 " [EDITOR: ''] <== selected",
2705 " first.rs",
2706 " second.rs",
2707 " third.rs"
2708 ]
2709 );
2710 panel.update(cx, |panel, cx| {
2711 panel
2712 .filename_editor
2713 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2714 assert!(
2715 panel.confirm(&Confirm, cx).is_none(),
2716 "Should not allow to confirm on conflicting new file name"
2717 )
2718 });
2719 assert_eq!(
2720 visible_entries_as_strings(&panel, 0..10, cx),
2721 &[
2722 "v src",
2723 " v test",
2724 " first.rs",
2725 " second.rs",
2726 " third.rs"
2727 ],
2728 "File list should be unchanged after failed file create confirmation"
2729 );
2730
2731 select_path(&panel, "src/test/first.rs", cx);
2732 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2733 cx.foreground().run_until_parked();
2734 assert_eq!(
2735 visible_entries_as_strings(&panel, 0..10, cx),
2736 &[
2737 "v src",
2738 " v test",
2739 " first.rs <== selected",
2740 " second.rs",
2741 " third.rs"
2742 ],
2743 );
2744 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2745 window.read_with(cx, |cx| {
2746 let panel = panel.read(cx);
2747 assert!(panel.filename_editor.is_focused(cx));
2748 });
2749 assert_eq!(
2750 visible_entries_as_strings(&panel, 0..10, cx),
2751 &[
2752 "v src",
2753 " v test",
2754 " [EDITOR: 'first.rs'] <== selected",
2755 " second.rs",
2756 " third.rs"
2757 ]
2758 );
2759 panel.update(cx, |panel, cx| {
2760 panel
2761 .filename_editor
2762 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2763 assert!(
2764 panel.confirm(&Confirm, cx).is_none(),
2765 "Should not allow to confirm on conflicting file rename"
2766 )
2767 });
2768 assert_eq!(
2769 visible_entries_as_strings(&panel, 0..10, cx),
2770 &[
2771 "v src",
2772 " v test",
2773 " first.rs <== selected",
2774 " second.rs",
2775 " third.rs"
2776 ],
2777 "File list should be unchanged after failed rename confirmation"
2778 );
2779 }
2780
2781 #[gpui::test]
2782 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2783 init_test_with_editor(cx);
2784
2785 let fs = FakeFs::new(cx.background());
2786 fs.insert_tree(
2787 "/src",
2788 json!({
2789 "test": {
2790 "first.rs": "// First Rust file",
2791 "second.rs": "// Second Rust file",
2792 "third.rs": "// Third Rust file",
2793 }
2794 }),
2795 )
2796 .await;
2797
2798 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2799 let workspace = cx
2800 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2801 .root(cx);
2802 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2803
2804 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2805 let _subscription = panel.update(cx, |_, cx| {
2806 let subcription_count = Arc::clone(&new_search_events_count);
2807 cx.subscribe(&cx.handle(), move |_, _, event, _| {
2808 if matches!(event, Event::NewSearchInDirectory { .. }) {
2809 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2810 }
2811 })
2812 });
2813
2814 toggle_expand_dir(&panel, "src/test", cx);
2815 select_path(&panel, "src/test/first.rs", cx);
2816 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2817 cx.foreground().run_until_parked();
2818 assert_eq!(
2819 visible_entries_as_strings(&panel, 0..10, cx),
2820 &[
2821 "v src",
2822 " v test",
2823 " first.rs <== selected",
2824 " second.rs",
2825 " third.rs"
2826 ]
2827 );
2828 panel.update(cx, |panel, cx| {
2829 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2830 });
2831 assert_eq!(
2832 new_search_events_count.load(atomic::Ordering::SeqCst),
2833 0,
2834 "Should not trigger new search in directory when called on a file"
2835 );
2836
2837 select_path(&panel, "src/test", cx);
2838 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2839 cx.foreground().run_until_parked();
2840 assert_eq!(
2841 visible_entries_as_strings(&panel, 0..10, cx),
2842 &[
2843 "v src",
2844 " v test <== selected",
2845 " first.rs",
2846 " second.rs",
2847 " third.rs"
2848 ]
2849 );
2850 panel.update(cx, |panel, cx| {
2851 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2852 });
2853 assert_eq!(
2854 new_search_events_count.load(atomic::Ordering::SeqCst),
2855 1,
2856 "Should trigger new search in directory when called on a directory"
2857 );
2858 }
2859
2860 #[gpui::test]
2861 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2862 init_test_with_editor(cx);
2863
2864 let fs = FakeFs::new(cx.background());
2865 fs.insert_tree(
2866 "/project_root",
2867 json!({
2868 "dir_1": {
2869 "nested_dir": {
2870 "file_a.py": "# File contents",
2871 "file_b.py": "# File contents",
2872 "file_c.py": "# File contents",
2873 },
2874 "file_1.py": "# File contents",
2875 "file_2.py": "# File contents",
2876 "file_3.py": "# File contents",
2877 },
2878 "dir_2": {
2879 "file_1.py": "# File contents",
2880 "file_2.py": "# File contents",
2881 "file_3.py": "# File contents",
2882 }
2883 }),
2884 )
2885 .await;
2886
2887 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2888 let workspace = cx
2889 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2890 .root(cx);
2891 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2892
2893 panel.update(cx, |panel, cx| {
2894 panel.collapse_all_entries(&CollapseAllEntries, cx)
2895 });
2896 cx.foreground().run_until_parked();
2897 assert_eq!(
2898 visible_entries_as_strings(&panel, 0..10, cx),
2899 &["v project_root", " > dir_1", " > dir_2",]
2900 );
2901
2902 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2903 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2904 cx.foreground().run_until_parked();
2905 assert_eq!(
2906 visible_entries_as_strings(&panel, 0..10, cx),
2907 &[
2908 "v project_root",
2909 " v dir_1 <== selected",
2910 " > nested_dir",
2911 " file_1.py",
2912 " file_2.py",
2913 " file_3.py",
2914 " > dir_2",
2915 ]
2916 );
2917 }
2918
2919 #[gpui::test]
2920 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2921 init_test(cx);
2922
2923 let fs = FakeFs::new(cx.background());
2924 fs.as_fake().insert_tree("/root", json!({})).await;
2925 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2926 let workspace = cx
2927 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2928 .root(cx);
2929 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2930
2931 // Make a new buffer with no backing file
2932 workspace.update(cx, |workspace, cx| {
2933 Editor::new_file(workspace, &Default::default(), cx)
2934 });
2935
2936 // "Save as"" the buffer, creating a new backing file for it
2937 let task = workspace.update(cx, |workspace, cx| {
2938 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2939 });
2940
2941 cx.foreground().run_until_parked();
2942 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2943 task.await.unwrap();
2944
2945 // Rename the file
2946 select_path(&panel, "root/new", cx);
2947 assert_eq!(
2948 visible_entries_as_strings(&panel, 0..10, cx),
2949 &["v root", " new <== selected"]
2950 );
2951 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2952 panel.update(cx, |panel, cx| {
2953 panel
2954 .filename_editor
2955 .update(cx, |editor, cx| editor.set_text("newer", cx));
2956 });
2957 panel
2958 .update(cx, |panel, cx| panel.confirm(&Confirm, cx))
2959 .unwrap()
2960 .await
2961 .unwrap();
2962
2963 cx.foreground().run_until_parked();
2964 assert_eq!(
2965 visible_entries_as_strings(&panel, 0..10, cx),
2966 &["v root", " newer <== selected"]
2967 );
2968
2969 workspace
2970 .update(cx, |workspace, cx| {
2971 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2972 })
2973 .await
2974 .unwrap();
2975
2976 cx.foreground().run_until_parked();
2977 // assert that saving the file doesn't restore "new"
2978 assert_eq!(
2979 visible_entries_as_strings(&panel, 0..10, cx),
2980 &["v root", " newer <== selected"]
2981 );
2982 }
2983
2984 #[gpui::test]
2985 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
2986 init_test_with_editor(cx);
2987 cx.update(|cx| {
2988 cx.update_global::<SettingsStore, _, _>(|store, cx| {
2989 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2990 project_settings.file_scan_exclusions = Some(Vec::new());
2991 });
2992 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2993 project_panel_settings.auto_reveal_entries = Some(false)
2994 });
2995 })
2996 });
2997
2998 let fs = FakeFs::new(cx.background());
2999 fs.insert_tree(
3000 "/project_root",
3001 json!({
3002 ".git": {},
3003 ".gitignore": "**/gitignored_dir",
3004 "dir_1": {
3005 "file_1.py": "# File 1_1 contents",
3006 "file_2.py": "# File 1_2 contents",
3007 "file_3.py": "# File 1_3 contents",
3008 "gitignored_dir": {
3009 "file_a.py": "# File contents",
3010 "file_b.py": "# File contents",
3011 "file_c.py": "# File contents",
3012 },
3013 },
3014 "dir_2": {
3015 "file_1.py": "# File 2_1 contents",
3016 "file_2.py": "# File 2_2 contents",
3017 "file_3.py": "# File 2_3 contents",
3018 }
3019 }),
3020 )
3021 .await;
3022
3023 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3024 let workspace = cx
3025 .add_window(|cx| Workspace::test_new(project.clone(), cx))
3026 .root(cx);
3027 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
3028
3029 assert_eq!(
3030 visible_entries_as_strings(&panel, 0..20, cx),
3031 &[
3032 "v project_root",
3033 " > .git",
3034 " > dir_1",
3035 " > dir_2",
3036 " .gitignore",
3037 ]
3038 );
3039
3040 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3041 .expect("dir 1 file is not ignored and should have an entry");
3042 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3043 .expect("dir 2 file is not ignored and should have an entry");
3044 let gitignored_dir_file =
3045 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3046 assert_eq!(
3047 gitignored_dir_file, None,
3048 "File in the gitignored dir should not have an entry before its dir is toggled"
3049 );
3050
3051 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3052 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3053 cx.foreground().run_until_parked();
3054 assert_eq!(
3055 visible_entries_as_strings(&panel, 0..20, cx),
3056 &[
3057 "v project_root",
3058 " > .git",
3059 " v dir_1",
3060 " v gitignored_dir <== selected",
3061 " file_a.py",
3062 " file_b.py",
3063 " file_c.py",
3064 " file_1.py",
3065 " file_2.py",
3066 " file_3.py",
3067 " > dir_2",
3068 " .gitignore",
3069 ],
3070 "Should show gitignored dir file list in the project panel"
3071 );
3072 let gitignored_dir_file =
3073 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3074 .expect("after gitignored dir got opened, a file entry should be present");
3075
3076 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3077 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3078 assert_eq!(
3079 visible_entries_as_strings(&panel, 0..20, cx),
3080 &[
3081 "v project_root",
3082 " > .git",
3083 " > dir_1 <== selected",
3084 " > dir_2",
3085 " .gitignore",
3086 ],
3087 "Should hide all dir contents again and prepare for the auto reveal test"
3088 );
3089
3090 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3091 panel.update(cx, |panel, cx| {
3092 panel.project.update(cx, |_, cx| {
3093 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3094 })
3095 });
3096 cx.foreground().run_until_parked();
3097 assert_eq!(
3098 visible_entries_as_strings(&panel, 0..20, cx),
3099 &[
3100 "v project_root",
3101 " > .git",
3102 " > dir_1 <== selected",
3103 " > dir_2",
3104 " .gitignore",
3105 ],
3106 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3107 );
3108 }
3109
3110 cx.update(|cx| {
3111 cx.update_global::<SettingsStore, _, _>(|store, cx| {
3112 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3113 project_panel_settings.auto_reveal_entries = Some(true)
3114 });
3115 })
3116 });
3117
3118 panel.update(cx, |panel, cx| {
3119 panel.project.update(cx, |_, cx| {
3120 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3121 })
3122 });
3123 cx.foreground().run_until_parked();
3124 assert_eq!(
3125 visible_entries_as_strings(&panel, 0..20, cx),
3126 &[
3127 "v project_root",
3128 " > .git",
3129 " v dir_1",
3130 " > gitignored_dir",
3131 " file_1.py <== selected",
3132 " file_2.py",
3133 " file_3.py",
3134 " > dir_2",
3135 " .gitignore",
3136 ],
3137 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3138 );
3139
3140 panel.update(cx, |panel, cx| {
3141 panel.project.update(cx, |_, cx| {
3142 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3143 })
3144 });
3145 cx.foreground().run_until_parked();
3146 assert_eq!(
3147 visible_entries_as_strings(&panel, 0..20, cx),
3148 &[
3149 "v project_root",
3150 " > .git",
3151 " v dir_1",
3152 " > gitignored_dir",
3153 " file_1.py",
3154 " file_2.py",
3155 " file_3.py",
3156 " v dir_2",
3157 " file_1.py <== selected",
3158 " file_2.py",
3159 " file_3.py",
3160 " .gitignore",
3161 ],
3162 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3163 );
3164
3165 panel.update(cx, |panel, cx| {
3166 panel.project.update(cx, |_, cx| {
3167 cx.emit(project::Event::ActiveEntryChanged(Some(
3168 gitignored_dir_file,
3169 )))
3170 })
3171 });
3172 cx.foreground().run_until_parked();
3173 assert_eq!(
3174 visible_entries_as_strings(&panel, 0..20, cx),
3175 &[
3176 "v project_root",
3177 " > .git",
3178 " v dir_1",
3179 " > gitignored_dir",
3180 " file_1.py",
3181 " file_2.py",
3182 " file_3.py",
3183 " v dir_2",
3184 " file_1.py <== selected",
3185 " file_2.py",
3186 " file_3.py",
3187 " .gitignore",
3188 ],
3189 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3190 );
3191
3192 panel.update(cx, |panel, cx| {
3193 panel.project.update(cx, |_, cx| {
3194 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3195 })
3196 });
3197 cx.foreground().run_until_parked();
3198 assert_eq!(
3199 visible_entries_as_strings(&panel, 0..20, cx),
3200 &[
3201 "v project_root",
3202 " > .git",
3203 " v dir_1",
3204 " v gitignored_dir",
3205 " file_a.py <== selected",
3206 " file_b.py",
3207 " file_c.py",
3208 " file_1.py",
3209 " file_2.py",
3210 " file_3.py",
3211 " v dir_2",
3212 " file_1.py",
3213 " file_2.py",
3214 " file_3.py",
3215 " .gitignore",
3216 ],
3217 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3218 );
3219 }
3220
3221 #[gpui::test]
3222 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3223 init_test_with_editor(cx);
3224 cx.update(|cx| {
3225 cx.update_global::<SettingsStore, _, _>(|store, cx| {
3226 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3227 project_settings.file_scan_exclusions = Some(Vec::new());
3228 });
3229 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3230 project_panel_settings.auto_reveal_entries = Some(false)
3231 });
3232 })
3233 });
3234
3235 let fs = FakeFs::new(cx.background());
3236 fs.insert_tree(
3237 "/project_root",
3238 json!({
3239 ".git": {},
3240 ".gitignore": "**/gitignored_dir",
3241 "dir_1": {
3242 "file_1.py": "# File 1_1 contents",
3243 "file_2.py": "# File 1_2 contents",
3244 "file_3.py": "# File 1_3 contents",
3245 "gitignored_dir": {
3246 "file_a.py": "# File contents",
3247 "file_b.py": "# File contents",
3248 "file_c.py": "# File contents",
3249 },
3250 },
3251 "dir_2": {
3252 "file_1.py": "# File 2_1 contents",
3253 "file_2.py": "# File 2_2 contents",
3254 "file_3.py": "# File 2_3 contents",
3255 }
3256 }),
3257 )
3258 .await;
3259
3260 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3261 let workspace = cx
3262 .add_window(|cx| Workspace::test_new(project.clone(), cx))
3263 .root(cx);
3264 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
3265
3266 assert_eq!(
3267 visible_entries_as_strings(&panel, 0..20, cx),
3268 &[
3269 "v project_root",
3270 " > .git",
3271 " > dir_1",
3272 " > dir_2",
3273 " .gitignore",
3274 ]
3275 );
3276
3277 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3278 .expect("dir 1 file is not ignored and should have an entry");
3279 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3280 .expect("dir 2 file is not ignored and should have an entry");
3281 let gitignored_dir_file =
3282 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3283 assert_eq!(
3284 gitignored_dir_file, None,
3285 "File in the gitignored dir should not have an entry before its dir is toggled"
3286 );
3287
3288 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3289 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3290 cx.foreground().run_until_parked();
3291 assert_eq!(
3292 visible_entries_as_strings(&panel, 0..20, cx),
3293 &[
3294 "v project_root",
3295 " > .git",
3296 " v dir_1",
3297 " v gitignored_dir <== selected",
3298 " file_a.py",
3299 " file_b.py",
3300 " file_c.py",
3301 " file_1.py",
3302 " file_2.py",
3303 " file_3.py",
3304 " > dir_2",
3305 " .gitignore",
3306 ],
3307 "Should show gitignored dir file list in the project panel"
3308 );
3309 let gitignored_dir_file =
3310 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3311 .expect("after gitignored dir got opened, a file entry should be present");
3312
3313 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3314 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3315 assert_eq!(
3316 visible_entries_as_strings(&panel, 0..20, cx),
3317 &[
3318 "v project_root",
3319 " > .git",
3320 " > dir_1 <== selected",
3321 " > dir_2",
3322 " .gitignore",
3323 ],
3324 "Should hide all dir contents again and prepare for the explicit reveal test"
3325 );
3326
3327 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3328 panel.update(cx, |panel, cx| {
3329 panel.project.update(cx, |_, cx| {
3330 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3331 })
3332 });
3333 cx.foreground().run_until_parked();
3334 assert_eq!(
3335 visible_entries_as_strings(&panel, 0..20, cx),
3336 &[
3337 "v project_root",
3338 " > .git",
3339 " > dir_1 <== selected",
3340 " > dir_2",
3341 " .gitignore",
3342 ],
3343 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3344 );
3345 }
3346
3347 panel.update(cx, |panel, cx| {
3348 panel.project.update(cx, |_, cx| {
3349 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3350 })
3351 });
3352 cx.foreground().run_until_parked();
3353 assert_eq!(
3354 visible_entries_as_strings(&panel, 0..20, cx),
3355 &[
3356 "v project_root",
3357 " > .git",
3358 " v dir_1",
3359 " > gitignored_dir",
3360 " file_1.py <== selected",
3361 " file_2.py",
3362 " file_3.py",
3363 " > dir_2",
3364 " .gitignore",
3365 ],
3366 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3367 );
3368
3369 panel.update(cx, |panel, cx| {
3370 panel.project.update(cx, |_, cx| {
3371 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3372 })
3373 });
3374 cx.foreground().run_until_parked();
3375 assert_eq!(
3376 visible_entries_as_strings(&panel, 0..20, cx),
3377 &[
3378 "v project_root",
3379 " > .git",
3380 " v dir_1",
3381 " > gitignored_dir",
3382 " file_1.py",
3383 " file_2.py",
3384 " file_3.py",
3385 " v dir_2",
3386 " file_1.py <== selected",
3387 " file_2.py",
3388 " file_3.py",
3389 " .gitignore",
3390 ],
3391 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3392 );
3393
3394 panel.update(cx, |panel, cx| {
3395 panel.project.update(cx, |_, cx| {
3396 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3397 })
3398 });
3399 cx.foreground().run_until_parked();
3400 assert_eq!(
3401 visible_entries_as_strings(&panel, 0..20, cx),
3402 &[
3403 "v project_root",
3404 " > .git",
3405 " v dir_1",
3406 " v gitignored_dir",
3407 " file_a.py <== selected",
3408 " file_b.py",
3409 " file_c.py",
3410 " file_1.py",
3411 " file_2.py",
3412 " file_3.py",
3413 " v dir_2",
3414 " file_1.py",
3415 " file_2.py",
3416 " file_3.py",
3417 " .gitignore",
3418 ],
3419 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3420 );
3421 }
3422
3423 fn toggle_expand_dir(
3424 panel: &ViewHandle<ProjectPanel>,
3425 path: impl AsRef<Path>,
3426 cx: &mut TestAppContext,
3427 ) {
3428 let path = path.as_ref();
3429 panel.update(cx, |panel, cx| {
3430 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
3431 let worktree = worktree.read(cx);
3432 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3433 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3434 panel.toggle_expanded(entry_id, cx);
3435 return;
3436 }
3437 }
3438 panic!("no worktree for path {:?}", path);
3439 });
3440 }
3441
3442 fn select_path(
3443 panel: &ViewHandle<ProjectPanel>,
3444 path: impl AsRef<Path>,
3445 cx: &mut TestAppContext,
3446 ) {
3447 let path = path.as_ref();
3448 panel.update(cx, |panel, cx| {
3449 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
3450 let worktree = worktree.read(cx);
3451 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3452 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3453 panel.selection = Some(Selection {
3454 worktree_id: worktree.id(),
3455 entry_id,
3456 });
3457 return;
3458 }
3459 }
3460 panic!("no worktree for path {path:?}");
3461 });
3462 }
3463
3464 fn find_project_entry(
3465 panel: &ViewHandle<ProjectPanel>,
3466 path: impl AsRef<Path>,
3467 cx: &mut TestAppContext,
3468 ) -> Option<ProjectEntryId> {
3469 let path = path.as_ref();
3470 panel.update(cx, |panel, cx| {
3471 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
3472 let worktree = worktree.read(cx);
3473 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3474 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3475 }
3476 }
3477 panic!("no worktree for path {path:?}");
3478 })
3479 }
3480
3481 fn visible_entries_as_strings(
3482 panel: &ViewHandle<ProjectPanel>,
3483 range: Range<usize>,
3484 cx: &mut TestAppContext,
3485 ) -> Vec<String> {
3486 let mut result = Vec::new();
3487 let mut project_entries = HashSet::new();
3488 let mut has_editor = false;
3489
3490 panel.update(cx, |panel, cx| {
3491 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3492 if details.is_editing {
3493 assert!(!has_editor, "duplicate editor entry");
3494 has_editor = true;
3495 } else {
3496 assert!(
3497 project_entries.insert(project_entry),
3498 "duplicate project entry {:?} {:?}",
3499 project_entry,
3500 details
3501 );
3502 }
3503
3504 let indent = " ".repeat(details.depth);
3505 let icon = if details.kind.is_dir() {
3506 if details.is_expanded {
3507 "v "
3508 } else {
3509 "> "
3510 }
3511 } else {
3512 " "
3513 };
3514 let name = if details.is_editing {
3515 format!("[EDITOR: '{}']", details.filename)
3516 } else if details.is_processing {
3517 format!("[PROCESSING: '{}']", details.filename)
3518 } else {
3519 details.filename.clone()
3520 };
3521 let selected = if details.is_selected {
3522 " <== selected"
3523 } else {
3524 ""
3525 };
3526 result.push(format!("{indent}{icon}{name}{selected}"));
3527 });
3528 });
3529
3530 result
3531 }
3532
3533 fn init_test(cx: &mut TestAppContext) {
3534 cx.foreground().forbid_parking();
3535 cx.update(|cx| {
3536 cx.set_global(SettingsStore::test(cx));
3537 init_settings(cx);
3538 theme::init((), cx);
3539 language::init(cx);
3540 editor::init_settings(cx);
3541 crate::init((), cx);
3542 workspace::init_settings(cx);
3543 client::init_settings(cx);
3544 Project::init_settings(cx);
3545
3546 cx.update_global::<SettingsStore, _, _>(|store, cx| {
3547 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3548 project_settings.file_scan_exclusions = Some(Vec::new());
3549 });
3550 });
3551 });
3552 }
3553
3554 fn init_test_with_editor(cx: &mut TestAppContext) {
3555 cx.foreground().forbid_parking();
3556 cx.update(|cx| {
3557 let app_state = AppState::test(cx);
3558 theme::init((), cx);
3559 init_settings(cx);
3560 language::init(cx);
3561 editor::init(cx);
3562 pane::init(cx);
3563 crate::init((), cx);
3564 workspace::init(app_state.clone(), cx);
3565 Project::init_settings(cx);
3566 });
3567 }
3568
3569 fn ensure_single_file_is_opened(
3570 window: WindowHandle<Workspace>,
3571 expected_path: &str,
3572 cx: &mut TestAppContext,
3573 ) {
3574 window.update_root(cx, |workspace, cx| {
3575 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3576 assert_eq!(worktrees.len(), 1);
3577 let worktree_id = WorktreeId::from_usize(worktrees[0].id());
3578
3579 let open_project_paths = workspace
3580 .panes()
3581 .iter()
3582 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3583 .collect::<Vec<_>>();
3584 assert_eq!(
3585 open_project_paths,
3586 vec![ProjectPath {
3587 worktree_id,
3588 path: Arc::from(Path::new(expected_path))
3589 }],
3590 "Should have opened file, selected in project panel"
3591 );
3592 });
3593 }
3594
3595 fn submit_deletion(
3596 window: AnyWindowHandle,
3597 panel: &ViewHandle<ProjectPanel>,
3598 cx: &mut TestAppContext,
3599 ) {
3600 assert!(
3601 !window.has_pending_prompt(cx),
3602 "Should have no prompts before the deletion"
3603 );
3604 panel.update(cx, |panel, cx| {
3605 panel
3606 .delete(&Delete, cx)
3607 .expect("Deletion start")
3608 .detach_and_log_err(cx);
3609 });
3610 assert!(
3611 window.has_pending_prompt(cx),
3612 "Should have a prompt after the deletion"
3613 );
3614 window.simulate_prompt_answer(0, cx);
3615 assert!(
3616 !window.has_pending_prompt(cx),
3617 "Should have no prompts after prompt was replied to"
3618 );
3619 cx.foreground().run_until_parked();
3620 }
3621
3622 fn ensure_no_open_items_and_panes(
3623 window: AnyWindowHandle,
3624 workspace: &ViewHandle<Workspace>,
3625 cx: &mut TestAppContext,
3626 ) {
3627 assert!(
3628 !window.has_pending_prompt(cx),
3629 "Should have no prompts after deletion operation closes the file"
3630 );
3631 window.read_with(cx, |cx| {
3632 let open_project_paths = workspace
3633 .read(cx)
3634 .panes()
3635 .iter()
3636 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3637 .collect::<Vec<_>>();
3638 assert!(
3639 open_project_paths.is_empty(),
3640 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3641 );
3642 });
3643 }
3644}
3645// TODO - a workspace command?