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 let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
203 {
204 this.expand_entry(worktree_id, *entry_id, cx);
205 this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
206 this.autoscroll(cx);
207 cx.notify();
208 }
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 let 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 Ok(())
667 }))
668 }
669
670 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
671 self.edit_state = None;
672 self.update_visible_entries(None, cx);
673 cx.focus_self();
674 cx.notify();
675 }
676
677 fn open_entry(
678 &mut self,
679 entry_id: ProjectEntryId,
680 focus_opened_item: bool,
681 cx: &mut ViewContext<Self>,
682 ) {
683 cx.emit(Event::OpenedEntry {
684 entry_id,
685 focus_opened_item,
686 });
687 }
688
689 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
690 cx.emit(Event::SplitEntry { entry_id });
691 }
692
693 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
694 self.add_entry(false, cx)
695 }
696
697 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
698 self.add_entry(true, cx)
699 }
700
701 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
702 if let Some(Selection {
703 worktree_id,
704 entry_id,
705 }) = self.selection
706 {
707 let directory_id;
708 if let Some((worktree, expanded_dir_ids)) = self
709 .project
710 .read(cx)
711 .worktree_for_id(worktree_id, cx)
712 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
713 {
714 let worktree = worktree.read(cx);
715 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
716 loop {
717 if entry.is_dir() {
718 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
719 expanded_dir_ids.insert(ix, entry.id);
720 }
721 directory_id = entry.id;
722 break;
723 } else {
724 if let Some(parent_path) = entry.path.parent() {
725 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
726 entry = parent_entry;
727 continue;
728 }
729 }
730 return;
731 }
732 }
733 } else {
734 return;
735 };
736 } else {
737 return;
738 };
739
740 self.edit_state = Some(EditState {
741 worktree_id,
742 entry_id: directory_id,
743 is_new_entry: true,
744 is_dir,
745 processing_filename: None,
746 });
747 self.filename_editor
748 .update(cx, |editor, cx| editor.clear(cx));
749 cx.focus(&self.filename_editor);
750 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
751 self.autoscroll(cx);
752 cx.notify();
753 }
754 }
755
756 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
757 if let Some(Selection {
758 worktree_id,
759 entry_id,
760 }) = self.selection
761 {
762 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
763 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
764 self.edit_state = Some(EditState {
765 worktree_id,
766 entry_id,
767 is_new_entry: false,
768 is_dir: entry.is_dir(),
769 processing_filename: None,
770 });
771 let file_name = entry
772 .path
773 .file_name()
774 .map(|s| s.to_string_lossy())
775 .unwrap_or_default()
776 .to_string();
777 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
778 let selection_end =
779 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
780 self.filename_editor.update(cx, |editor, cx| {
781 editor.set_text(file_name, cx);
782 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
783 s.select_ranges([0..selection_end])
784 })
785 });
786 cx.focus(&self.filename_editor);
787 self.update_visible_entries(None, cx);
788 self.autoscroll(cx);
789 cx.notify();
790 }
791 }
792
793 cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
794 drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
795 })
796 }
797 }
798
799 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
800 let Selection { entry_id, .. } = self.selection?;
801 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
802 let file_name = path.file_name()?;
803
804 let mut answer = cx.prompt(
805 PromptLevel::Info,
806 &format!("Delete {file_name:?}?"),
807 &["Delete", "Cancel"],
808 );
809 Some(cx.spawn(|this, mut cx| async move {
810 if answer.next().await != Some(0) {
811 return Ok(());
812 }
813 this.update(&mut cx, |this, cx| {
814 this.project
815 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
816 .ok_or_else(|| anyhow!("no such entry"))
817 })??
818 .await
819 }))
820 }
821
822 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
823 if let Some(selection) = self.selection {
824 let (mut worktree_ix, mut entry_ix, _) =
825 self.index_for_selection(selection).unwrap_or_default();
826 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
827 if entry_ix + 1 < worktree_entries.len() {
828 entry_ix += 1;
829 } else {
830 worktree_ix += 1;
831 entry_ix = 0;
832 }
833 }
834
835 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
836 if let Some(entry) = worktree_entries.get(entry_ix) {
837 self.selection = Some(Selection {
838 worktree_id: *worktree_id,
839 entry_id: entry.id,
840 });
841 self.autoscroll(cx);
842 cx.notify();
843 }
844 }
845 } else {
846 self.select_first(cx);
847 }
848 }
849
850 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
851 let worktree = self
852 .visible_entries
853 .first()
854 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
855 if let Some(worktree) = worktree {
856 let worktree = worktree.read(cx);
857 let worktree_id = worktree.id();
858 if let Some(root_entry) = worktree.root_entry() {
859 self.selection = Some(Selection {
860 worktree_id,
861 entry_id: root_entry.id,
862 });
863 self.autoscroll(cx);
864 cx.notify();
865 }
866 }
867 }
868
869 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
870 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
871 self.list.scroll_to(ScrollTarget::Show(index));
872 cx.notify();
873 }
874 }
875
876 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
877 if let Some((worktree, entry)) = self.selected_entry(cx) {
878 self.clipboard_entry = Some(ClipboardEntry::Cut {
879 worktree_id: worktree.id(),
880 entry_id: entry.id,
881 });
882 cx.notify();
883 }
884 }
885
886 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
887 if let Some((worktree, entry)) = self.selected_entry(cx) {
888 self.clipboard_entry = Some(ClipboardEntry::Copied {
889 worktree_id: worktree.id(),
890 entry_id: entry.id,
891 });
892 cx.notify();
893 }
894 }
895
896 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
897 if let Some((worktree, entry)) = self.selected_entry(cx) {
898 let clipboard_entry = self.clipboard_entry?;
899 if clipboard_entry.worktree_id() != worktree.id() {
900 return None;
901 }
902
903 let clipboard_entry_file_name = self
904 .project
905 .read(cx)
906 .path_for_entry(clipboard_entry.entry_id(), cx)?
907 .path
908 .file_name()?
909 .to_os_string();
910
911 let mut new_path = entry.path.to_path_buf();
912 if entry.is_file() {
913 new_path.pop();
914 }
915
916 new_path.push(&clipboard_entry_file_name);
917 let extension = new_path.extension().map(|e| e.to_os_string());
918 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
919 let mut ix = 0;
920 while worktree.entry_for_path(&new_path).is_some() {
921 new_path.pop();
922
923 let mut new_file_name = file_name_without_extension.to_os_string();
924 new_file_name.push(" copy");
925 if ix > 0 {
926 new_file_name.push(format!(" {}", ix));
927 }
928 if let Some(extension) = extension.as_ref() {
929 new_file_name.push(".");
930 new_file_name.push(extension);
931 }
932
933 new_path.push(new_file_name);
934 ix += 1;
935 }
936
937 if clipboard_entry.is_cut() {
938 if let Some(task) = self.project.update(cx, |project, cx| {
939 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
940 }) {
941 task.detach_and_log_err(cx)
942 }
943 } else if let Some(task) = self.project.update(cx, |project, cx| {
944 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
945 }) {
946 task.detach_and_log_err(cx)
947 }
948 }
949 None
950 }
951
952 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
953 if let Some((worktree, entry)) = self.selected_entry(cx) {
954 cx.write_to_clipboard(ClipboardItem::new(
955 worktree
956 .abs_path()
957 .join(&entry.path)
958 .to_string_lossy()
959 .to_string(),
960 ));
961 }
962 }
963
964 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
965 if let Some((_, entry)) = self.selected_entry(cx) {
966 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
967 }
968 }
969
970 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
971 if let Some((worktree, entry)) = self.selected_entry(cx) {
972 cx.reveal_path(&worktree.abs_path().join(&entry.path));
973 }
974 }
975
976 fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
977 if let Some((worktree, entry)) = self.selected_entry(cx) {
978 let window = cx.window();
979 let view_id = cx.view_id();
980 let path = worktree.abs_path().join(&entry.path);
981
982 cx.app_context()
983 .spawn(|mut cx| async move {
984 window.dispatch_action(
985 view_id,
986 &workspace::OpenTerminal {
987 working_directory: path,
988 },
989 &mut cx,
990 );
991 })
992 .detach();
993 }
994 }
995
996 pub fn new_search_in_directory(
997 &mut self,
998 _: &NewSearchInDirectory,
999 cx: &mut ViewContext<Self>,
1000 ) {
1001 if let Some((_, entry)) = self.selected_entry(cx) {
1002 if entry.is_dir() {
1003 cx.emit(Event::NewSearchInDirectory {
1004 dir_entry: entry.clone(),
1005 });
1006 }
1007 }
1008 }
1009
1010 fn move_entry(
1011 &mut self,
1012 entry_to_move: ProjectEntryId,
1013 destination: ProjectEntryId,
1014 destination_is_file: bool,
1015 cx: &mut ViewContext<Self>,
1016 ) {
1017 let destination_worktree = self.project.update(cx, |project, cx| {
1018 let entry_path = project.path_for_entry(entry_to_move, cx)?;
1019 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1020
1021 let mut destination_path = destination_entry_path.as_ref();
1022 if destination_is_file {
1023 destination_path = destination_path.parent()?;
1024 }
1025
1026 let mut new_path = destination_path.to_path_buf();
1027 new_path.push(entry_path.path.file_name()?);
1028 if new_path != entry_path.path.as_ref() {
1029 let task = project.rename_entry(entry_to_move, new_path, cx)?;
1030 cx.foreground().spawn(task).detach_and_log_err(cx);
1031 }
1032
1033 Some(project.worktree_id_for_entry(destination, cx)?)
1034 });
1035
1036 if let Some(destination_worktree) = destination_worktree {
1037 self.expand_entry(destination_worktree, destination, cx);
1038 }
1039 }
1040
1041 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1042 let mut entry_index = 0;
1043 let mut visible_entries_index = 0;
1044 for (worktree_index, (worktree_id, worktree_entries)) in
1045 self.visible_entries.iter().enumerate()
1046 {
1047 if *worktree_id == selection.worktree_id {
1048 for entry in worktree_entries {
1049 if entry.id == selection.entry_id {
1050 return Some((worktree_index, entry_index, visible_entries_index));
1051 } else {
1052 visible_entries_index += 1;
1053 entry_index += 1;
1054 }
1055 }
1056 break;
1057 } else {
1058 visible_entries_index += worktree_entries.len();
1059 }
1060 }
1061 None
1062 }
1063
1064 pub fn selected_entry<'a>(
1065 &self,
1066 cx: &'a AppContext,
1067 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1068 let (worktree, entry) = self.selected_entry_handle(cx)?;
1069 Some((worktree.read(cx), entry))
1070 }
1071
1072 fn selected_entry_handle<'a>(
1073 &self,
1074 cx: &'a AppContext,
1075 ) -> Option<(ModelHandle<Worktree>, &'a project::Entry)> {
1076 let selection = self.selection?;
1077 let project = self.project.read(cx);
1078 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1079 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1080 Some((worktree, entry))
1081 }
1082
1083 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1084 let (worktree, entry) = self.selected_entry(cx)?;
1085 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1086
1087 for path in entry.path.ancestors() {
1088 let Some(entry) = worktree.entry_for_path(path) else {
1089 continue;
1090 };
1091 if entry.is_dir() {
1092 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1093 expanded_dir_ids.insert(idx, entry.id);
1094 }
1095 }
1096 }
1097
1098 Some(())
1099 }
1100
1101 fn update_visible_entries(
1102 &mut self,
1103 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1104 cx: &mut ViewContext<Self>,
1105 ) {
1106 let project = self.project.read(cx);
1107 self.last_worktree_root_id = project
1108 .visible_worktrees(cx)
1109 .rev()
1110 .next()
1111 .and_then(|worktree| worktree.read(cx).root_entry())
1112 .map(|entry| entry.id);
1113
1114 self.visible_entries.clear();
1115 for worktree in project.visible_worktrees(cx) {
1116 let snapshot = worktree.read(cx).snapshot();
1117 let worktree_id = snapshot.id();
1118
1119 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1120 hash_map::Entry::Occupied(e) => e.into_mut(),
1121 hash_map::Entry::Vacant(e) => {
1122 // The first time a worktree's root entry becomes available,
1123 // mark that root entry as expanded.
1124 if let Some(entry) = snapshot.root_entry() {
1125 e.insert(vec![entry.id]).as_slice()
1126 } else {
1127 &[]
1128 }
1129 }
1130 };
1131
1132 let mut new_entry_parent_id = None;
1133 let mut new_entry_kind = EntryKind::Dir;
1134 if let Some(edit_state) = &self.edit_state {
1135 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1136 new_entry_parent_id = Some(edit_state.entry_id);
1137 new_entry_kind = if edit_state.is_dir {
1138 EntryKind::Dir
1139 } else {
1140 EntryKind::File(Default::default())
1141 };
1142 }
1143 }
1144
1145 let mut visible_worktree_entries = Vec::new();
1146 let mut entry_iter = snapshot.entries(true);
1147
1148 while let Some(entry) = entry_iter.entry() {
1149 visible_worktree_entries.push(entry.clone());
1150 if Some(entry.id) == new_entry_parent_id {
1151 visible_worktree_entries.push(Entry {
1152 id: NEW_ENTRY_ID,
1153 kind: new_entry_kind,
1154 path: entry.path.join("\0").into(),
1155 inode: 0,
1156 mtime: entry.mtime,
1157 is_symlink: false,
1158 is_ignored: false,
1159 is_external: false,
1160 git_status: entry.git_status,
1161 });
1162 }
1163 if expanded_dir_ids.binary_search(&entry.id).is_err()
1164 && entry_iter.advance_to_sibling()
1165 {
1166 continue;
1167 }
1168 entry_iter.advance();
1169 }
1170
1171 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1172
1173 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1174 let mut components_a = entry_a.path.components().peekable();
1175 let mut components_b = entry_b.path.components().peekable();
1176 loop {
1177 match (components_a.next(), components_b.next()) {
1178 (Some(component_a), Some(component_b)) => {
1179 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1180 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1181 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1182 let name_a =
1183 UniCase::new(component_a.as_os_str().to_string_lossy());
1184 let name_b =
1185 UniCase::new(component_b.as_os_str().to_string_lossy());
1186 name_a.cmp(&name_b)
1187 });
1188 if !ordering.is_eq() {
1189 return ordering;
1190 }
1191 }
1192 (Some(_), None) => break Ordering::Greater,
1193 (None, Some(_)) => break Ordering::Less,
1194 (None, None) => break Ordering::Equal,
1195 }
1196 }
1197 });
1198 self.visible_entries
1199 .push((worktree_id, visible_worktree_entries));
1200 }
1201
1202 if let Some((worktree_id, entry_id)) = new_selected_entry {
1203 self.selection = Some(Selection {
1204 worktree_id,
1205 entry_id,
1206 });
1207 }
1208 }
1209
1210 fn expand_entry(
1211 &mut self,
1212 worktree_id: WorktreeId,
1213 entry_id: ProjectEntryId,
1214 cx: &mut ViewContext<Self>,
1215 ) {
1216 self.project.update(cx, |project, cx| {
1217 if let Some((worktree, expanded_dir_ids)) = project
1218 .worktree_for_id(worktree_id, cx)
1219 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1220 {
1221 project.expand_entry(worktree_id, entry_id, cx);
1222 let worktree = worktree.read(cx);
1223
1224 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1225 loop {
1226 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1227 expanded_dir_ids.insert(ix, entry.id);
1228 }
1229
1230 if let Some(parent_entry) =
1231 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1232 {
1233 entry = parent_entry;
1234 } else {
1235 break;
1236 }
1237 }
1238 }
1239 }
1240 });
1241 }
1242
1243 fn for_each_visible_entry(
1244 &self,
1245 range: Range<usize>,
1246 cx: &mut ViewContext<ProjectPanel>,
1247 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1248 ) {
1249 let mut ix = 0;
1250 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1251 if ix >= range.end {
1252 return;
1253 }
1254
1255 if ix + visible_worktree_entries.len() <= range.start {
1256 ix += visible_worktree_entries.len();
1257 continue;
1258 }
1259
1260 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1261 let (git_status_setting, show_file_icons, show_folder_icons) = {
1262 let settings = settings::get::<ProjectPanelSettings>(cx);
1263 (
1264 settings.git_status,
1265 settings.file_icons,
1266 settings.folder_icons,
1267 )
1268 };
1269 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1270 let snapshot = worktree.read(cx).snapshot();
1271 let root_name = OsStr::new(snapshot.root_name());
1272 let expanded_entry_ids = self
1273 .expanded_dir_ids
1274 .get(&snapshot.id())
1275 .map(Vec::as_slice)
1276 .unwrap_or(&[]);
1277
1278 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1279 for entry in visible_worktree_entries[entry_range].iter() {
1280 let status = git_status_setting.then(|| entry.git_status).flatten();
1281 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1282 let icon = match entry.kind {
1283 EntryKind::File(_) => {
1284 if show_file_icons {
1285 Some(FileAssociations::get_icon(&entry.path, cx))
1286 } else {
1287 None
1288 }
1289 }
1290 _ => {
1291 if show_folder_icons {
1292 Some(FileAssociations::get_folder_icon(is_expanded, cx))
1293 } else {
1294 Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1295 }
1296 }
1297 };
1298
1299 let mut details = EntryDetails {
1300 filename: entry
1301 .path
1302 .file_name()
1303 .unwrap_or(root_name)
1304 .to_string_lossy()
1305 .to_string(),
1306 icon,
1307 path: entry.path.clone(),
1308 depth: entry.path.components().count(),
1309 kind: entry.kind,
1310 is_ignored: entry.is_ignored,
1311 is_expanded,
1312 is_selected: self.selection.map_or(false, |e| {
1313 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1314 }),
1315 is_editing: false,
1316 is_processing: false,
1317 is_cut: self
1318 .clipboard_entry
1319 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1320 git_status: status,
1321 };
1322
1323 if let Some(edit_state) = &self.edit_state {
1324 let is_edited_entry = if edit_state.is_new_entry {
1325 entry.id == NEW_ENTRY_ID
1326 } else {
1327 entry.id == edit_state.entry_id
1328 };
1329
1330 if is_edited_entry {
1331 if let Some(processing_filename) = &edit_state.processing_filename {
1332 details.is_processing = true;
1333 details.filename.clear();
1334 details.filename.push_str(processing_filename);
1335 } else {
1336 if edit_state.is_new_entry {
1337 details.filename.clear();
1338 }
1339 details.is_editing = true;
1340 }
1341 }
1342 }
1343
1344 callback(entry.id, details, cx);
1345 }
1346 }
1347 ix = end_ix;
1348 }
1349 }
1350
1351 fn render_entry_visual_element<V: 'static>(
1352 details: &EntryDetails,
1353 editor: Option<&ViewHandle<Editor>>,
1354 padding: f32,
1355 row_container_style: ContainerStyle,
1356 style: &ProjectPanelEntry,
1357 cx: &mut ViewContext<V>,
1358 ) -> AnyElement<V> {
1359 let show_editor = details.is_editing && !details.is_processing;
1360
1361 let mut filename_text_style = style.text.clone();
1362 filename_text_style.color = details
1363 .git_status
1364 .as_ref()
1365 .map(|status| match status {
1366 GitFileStatus::Added => style.status.git.inserted,
1367 GitFileStatus::Modified => style.status.git.modified,
1368 GitFileStatus::Conflict => style.status.git.conflict,
1369 })
1370 .unwrap_or(style.text.color);
1371
1372 Flex::row()
1373 .with_child(if let Some(icon) = &details.icon {
1374 Svg::new(icon.to_string())
1375 .with_color(style.icon_color)
1376 .constrained()
1377 .with_max_width(style.icon_size)
1378 .with_max_height(style.icon_size)
1379 .aligned()
1380 .constrained()
1381 .with_width(style.icon_size)
1382 } else {
1383 Empty::new()
1384 .constrained()
1385 .with_max_width(style.icon_size)
1386 .with_max_height(style.icon_size)
1387 .aligned()
1388 .constrained()
1389 .with_width(style.icon_size)
1390 })
1391 .with_child(if show_editor && editor.is_some() {
1392 ChildView::new(editor.as_ref().unwrap(), cx)
1393 .contained()
1394 .with_margin_left(style.icon_spacing)
1395 .aligned()
1396 .left()
1397 .flex(1.0, true)
1398 .into_any()
1399 } else {
1400 Label::new(details.filename.clone(), filename_text_style)
1401 .contained()
1402 .with_margin_left(style.icon_spacing)
1403 .aligned()
1404 .left()
1405 .into_any()
1406 })
1407 .constrained()
1408 .with_height(style.height)
1409 .contained()
1410 .with_style(row_container_style)
1411 .with_padding_left(padding)
1412 .into_any_named("project panel entry visual element")
1413 }
1414
1415 fn render_entry(
1416 entry_id: ProjectEntryId,
1417 details: EntryDetails,
1418 editor: &ViewHandle<Editor>,
1419 dragged_entry_destination: &mut Option<Arc<Path>>,
1420 theme: &theme::ProjectPanel,
1421 cx: &mut ViewContext<Self>,
1422 ) -> AnyElement<Self> {
1423 let kind = details.kind;
1424 let path = details.path.clone();
1425 let settings = settings::get::<ProjectPanelSettings>(cx);
1426 let padding = theme.container.padding.left + details.depth as f32 * settings.indent_size;
1427
1428 let entry_style = if details.is_cut {
1429 &theme.cut_entry
1430 } else if details.is_ignored {
1431 &theme.ignored_entry
1432 } else {
1433 &theme.entry
1434 };
1435
1436 let show_editor = details.is_editing && !details.is_processing;
1437
1438 MouseEventHandler::new::<Self, _>(entry_id.to_usize(), cx, |state, cx| {
1439 let mut style = entry_style
1440 .in_state(details.is_selected)
1441 .style_for(state)
1442 .clone();
1443
1444 if cx
1445 .global::<DragAndDrop<Workspace>>()
1446 .currently_dragged::<ProjectEntryId>(cx.window())
1447 .is_some()
1448 && dragged_entry_destination
1449 .as_ref()
1450 .filter(|destination| details.path.starts_with(destination))
1451 .is_some()
1452 {
1453 style = entry_style.active_state().default.clone();
1454 }
1455
1456 let row_container_style = if show_editor {
1457 theme.filename_editor.container
1458 } else {
1459 style.container
1460 };
1461
1462 Self::render_entry_visual_element(
1463 &details,
1464 Some(editor),
1465 padding,
1466 row_container_style,
1467 &style,
1468 cx,
1469 )
1470 })
1471 .on_click(MouseButton::Left, move |event, this, cx| {
1472 if !show_editor {
1473 if kind.is_dir() {
1474 this.toggle_expanded(entry_id, cx);
1475 } else {
1476 if event.cmd {
1477 this.split_entry(entry_id, cx);
1478 } else if !event.cmd {
1479 this.open_entry(entry_id, event.click_count > 1, cx);
1480 }
1481 }
1482 }
1483 })
1484 .on_down(MouseButton::Right, move |event, this, cx| {
1485 this.deploy_context_menu(event.position, entry_id, cx);
1486 })
1487 .on_up(MouseButton::Left, move |_, this, cx| {
1488 if let Some((_, dragged_entry)) = cx
1489 .global::<DragAndDrop<Workspace>>()
1490 .currently_dragged::<ProjectEntryId>(cx.window())
1491 {
1492 this.move_entry(
1493 *dragged_entry,
1494 entry_id,
1495 matches!(details.kind, EntryKind::File(_)),
1496 cx,
1497 );
1498 }
1499 })
1500 .on_move(move |_, this, cx| {
1501 if cx
1502 .global::<DragAndDrop<Workspace>>()
1503 .currently_dragged::<ProjectEntryId>(cx.window())
1504 .is_some()
1505 {
1506 this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1507 path.parent().map(|parent| Arc::from(parent))
1508 } else {
1509 Some(path.clone())
1510 };
1511 }
1512 })
1513 .as_draggable(entry_id, {
1514 let row_container_style = theme.dragged_entry.container;
1515
1516 move |_, _, cx: &mut ViewContext<Workspace>| {
1517 let theme = theme::current(cx).clone();
1518 Self::render_entry_visual_element(
1519 &details,
1520 None,
1521 padding,
1522 row_container_style,
1523 &theme.project_panel.dragged_entry,
1524 cx,
1525 )
1526 }
1527 })
1528 .with_cursor_style(CursorStyle::PointingHand)
1529 .into_any_named("project panel entry")
1530 }
1531}
1532
1533impl View for ProjectPanel {
1534 fn ui_name() -> &'static str {
1535 "ProjectPanel"
1536 }
1537
1538 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1539 enum ProjectPanel {}
1540 let theme = &theme::current(cx).project_panel;
1541 let mut container_style = theme.container;
1542 let padding = std::mem::take(&mut container_style.padding);
1543 let last_worktree_root_id = self.last_worktree_root_id;
1544
1545 let has_worktree = self.visible_entries.len() != 0;
1546
1547 if has_worktree {
1548 Stack::new()
1549 .with_child(
1550 MouseEventHandler::new::<ProjectPanel, _>(0, cx, |_, cx| {
1551 UniformList::new(
1552 self.list.clone(),
1553 self.visible_entries
1554 .iter()
1555 .map(|(_, worktree_entries)| worktree_entries.len())
1556 .sum(),
1557 cx,
1558 move |this, range, items, cx| {
1559 let theme = theme::current(cx).clone();
1560 let mut dragged_entry_destination =
1561 this.dragged_entry_destination.clone();
1562 this.for_each_visible_entry(range, cx, |id, details, cx| {
1563 items.push(Self::render_entry(
1564 id,
1565 details,
1566 &this.filename_editor,
1567 &mut dragged_entry_destination,
1568 &theme.project_panel,
1569 cx,
1570 ));
1571 });
1572 this.dragged_entry_destination = dragged_entry_destination;
1573 },
1574 )
1575 .with_padding_top(padding.top)
1576 .with_padding_bottom(padding.bottom)
1577 .contained()
1578 .with_style(container_style)
1579 .expanded()
1580 })
1581 .on_down(MouseButton::Right, move |event, this, cx| {
1582 // When deploying the context menu anywhere below the last project entry,
1583 // act as if the user clicked the root of the last worktree.
1584 if let Some(entry_id) = last_worktree_root_id {
1585 this.deploy_context_menu(event.position, entry_id, cx);
1586 }
1587 }),
1588 )
1589 .with_child(ChildView::new(&self.context_menu, cx))
1590 .into_any_named("project panel")
1591 } else {
1592 Flex::column()
1593 .with_child(
1594 MouseEventHandler::new::<Self, _>(2, cx, {
1595 let button_style = theme.open_project_button.clone();
1596 let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1597 move |state, cx| {
1598 let button_style = button_style.style_for(state).clone();
1599 let context_menu_item = context_menu_item_style
1600 .active_state()
1601 .style_for(state)
1602 .clone();
1603
1604 theme::ui::keystroke_label(
1605 "Open a project",
1606 &button_style,
1607 &context_menu_item.keystroke,
1608 Box::new(workspace::Open),
1609 cx,
1610 )
1611 }
1612 })
1613 .on_click(MouseButton::Left, move |_, this, cx| {
1614 if let Some(workspace) = this.workspace.upgrade(cx) {
1615 workspace.update(cx, |workspace, cx| {
1616 if let Some(task) = workspace.open(&Default::default(), cx) {
1617 task.detach_and_log_err(cx);
1618 }
1619 })
1620 }
1621 })
1622 .with_cursor_style(CursorStyle::PointingHand),
1623 )
1624 .contained()
1625 .with_style(container_style)
1626 .into_any_named("empty project panel")
1627 }
1628 }
1629
1630 fn update_keymap_context(&self, keymap: &mut KeymapContext, cx: &AppContext) {
1631 Self::reset_to_default_keymap_context(keymap);
1632 keymap.add_identifier("menu");
1633
1634 if let Some(window) = cx.active_window() {
1635 window.read_with(cx, |cx| {
1636 let identifier = if self.filename_editor.is_focused(cx) {
1637 "editing"
1638 } else {
1639 "not_editing"
1640 };
1641
1642 keymap.add_identifier(identifier);
1643 });
1644 }
1645 }
1646
1647 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1648 if !self.has_focus {
1649 self.has_focus = true;
1650 cx.emit(Event::Focus);
1651 }
1652 }
1653
1654 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1655 self.has_focus = false;
1656 }
1657}
1658
1659impl Entity for ProjectPanel {
1660 type Event = Event;
1661}
1662
1663impl workspace::dock::Panel for ProjectPanel {
1664 fn position(&self, cx: &WindowContext) -> DockPosition {
1665 match settings::get::<ProjectPanelSettings>(cx).dock {
1666 ProjectPanelDockPosition::Left => DockPosition::Left,
1667 ProjectPanelDockPosition::Right => DockPosition::Right,
1668 }
1669 }
1670
1671 fn position_is_valid(&self, position: DockPosition) -> bool {
1672 matches!(position, DockPosition::Left | DockPosition::Right)
1673 }
1674
1675 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1676 settings::update_settings_file::<ProjectPanelSettings>(
1677 self.fs.clone(),
1678 cx,
1679 move |settings| {
1680 let dock = match position {
1681 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1682 DockPosition::Right => ProjectPanelDockPosition::Right,
1683 };
1684 settings.dock = Some(dock);
1685 },
1686 );
1687 }
1688
1689 fn size(&self, cx: &WindowContext) -> f32 {
1690 self.width
1691 .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1692 }
1693
1694 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1695 self.width = size;
1696 self.serialize(cx);
1697 cx.notify();
1698 }
1699
1700 fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
1701 Some("icons/project.svg")
1702 }
1703
1704 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1705 ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1706 }
1707
1708 fn should_change_position_on_event(event: &Self::Event) -> bool {
1709 matches!(event, Event::DockPositionChanged)
1710 }
1711
1712 fn has_focus(&self, _: &WindowContext) -> bool {
1713 self.has_focus
1714 }
1715
1716 fn is_focus_event(event: &Self::Event) -> bool {
1717 matches!(event, Event::Focus)
1718 }
1719}
1720
1721impl ClipboardEntry {
1722 fn is_cut(&self) -> bool {
1723 matches!(self, Self::Cut { .. })
1724 }
1725
1726 fn entry_id(&self) -> ProjectEntryId {
1727 match self {
1728 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1729 *entry_id
1730 }
1731 }
1732 }
1733
1734 fn worktree_id(&self) -> WorktreeId {
1735 match self {
1736 ClipboardEntry::Copied { worktree_id, .. }
1737 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1738 }
1739 }
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744 use super::*;
1745 use gpui::{AnyWindowHandle, TestAppContext, ViewHandle, WindowHandle};
1746 use pretty_assertions::assert_eq;
1747 use project::{project_settings::ProjectSettings, FakeFs};
1748 use serde_json::json;
1749 use settings::SettingsStore;
1750 use std::{
1751 collections::HashSet,
1752 path::{Path, PathBuf},
1753 sync::atomic::{self, AtomicUsize},
1754 };
1755 use workspace::{pane, AppState};
1756
1757 #[gpui::test]
1758 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1759 init_test(cx);
1760
1761 let fs = FakeFs::new(cx.background());
1762 fs.insert_tree(
1763 "/root1",
1764 json!({
1765 ".dockerignore": "",
1766 ".git": {
1767 "HEAD": "",
1768 },
1769 "a": {
1770 "0": { "q": "", "r": "", "s": "" },
1771 "1": { "t": "", "u": "" },
1772 "2": { "v": "", "w": "", "x": "", "y": "" },
1773 },
1774 "b": {
1775 "3": { "Q": "" },
1776 "4": { "R": "", "S": "", "T": "", "U": "" },
1777 },
1778 "C": {
1779 "5": {},
1780 "6": { "V": "", "W": "" },
1781 "7": { "X": "" },
1782 "8": { "Y": {}, "Z": "" }
1783 }
1784 }),
1785 )
1786 .await;
1787 fs.insert_tree(
1788 "/root2",
1789 json!({
1790 "d": {
1791 "9": ""
1792 },
1793 "e": {}
1794 }),
1795 )
1796 .await;
1797
1798 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1799 let workspace = cx
1800 .add_window(|cx| Workspace::test_new(project.clone(), cx))
1801 .root(cx);
1802 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1803 assert_eq!(
1804 visible_entries_as_strings(&panel, 0..50, cx),
1805 &[
1806 "v root1",
1807 " > .git",
1808 " > a",
1809 " > b",
1810 " > C",
1811 " .dockerignore",
1812 "v root2",
1813 " > d",
1814 " > e",
1815 ]
1816 );
1817
1818 toggle_expand_dir(&panel, "root1/b", cx);
1819 assert_eq!(
1820 visible_entries_as_strings(&panel, 0..50, cx),
1821 &[
1822 "v root1",
1823 " > .git",
1824 " > a",
1825 " v b <== selected",
1826 " > 3",
1827 " > 4",
1828 " > C",
1829 " .dockerignore",
1830 "v root2",
1831 " > d",
1832 " > e",
1833 ]
1834 );
1835
1836 assert_eq!(
1837 visible_entries_as_strings(&panel, 6..9, cx),
1838 &[
1839 //
1840 " > C",
1841 " .dockerignore",
1842 "v root2",
1843 ]
1844 );
1845 }
1846
1847 #[gpui::test]
1848 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1849 init_test(cx);
1850 cx.update(|cx| {
1851 cx.update_global::<SettingsStore, _, _>(|store, cx| {
1852 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1853 project_settings.file_scan_exclusions =
1854 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1855 });
1856 });
1857 });
1858
1859 let fs = FakeFs::new(cx.background());
1860 fs.insert_tree(
1861 "/root1",
1862 json!({
1863 ".dockerignore": "",
1864 ".git": {
1865 "HEAD": "",
1866 },
1867 "a": {
1868 "0": { "q": "", "r": "", "s": "" },
1869 "1": { "t": "", "u": "" },
1870 "2": { "v": "", "w": "", "x": "", "y": "" },
1871 },
1872 "b": {
1873 "3": { "Q": "" },
1874 "4": { "R": "", "S": "", "T": "", "U": "" },
1875 },
1876 "C": {
1877 "5": {},
1878 "6": { "V": "", "W": "" },
1879 "7": { "X": "" },
1880 "8": { "Y": {}, "Z": "" }
1881 }
1882 }),
1883 )
1884 .await;
1885 fs.insert_tree(
1886 "/root2",
1887 json!({
1888 "d": {
1889 "4": ""
1890 },
1891 "e": {}
1892 }),
1893 )
1894 .await;
1895
1896 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1897 let workspace = cx
1898 .add_window(|cx| Workspace::test_new(project.clone(), cx))
1899 .root(cx);
1900 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1901 assert_eq!(
1902 visible_entries_as_strings(&panel, 0..50, cx),
1903 &[
1904 "v root1",
1905 " > a",
1906 " > b",
1907 " > C",
1908 " .dockerignore",
1909 "v root2",
1910 " > d",
1911 " > e",
1912 ]
1913 );
1914
1915 toggle_expand_dir(&panel, "root1/b", cx);
1916 assert_eq!(
1917 visible_entries_as_strings(&panel, 0..50, cx),
1918 &[
1919 "v root1",
1920 " > a",
1921 " v b <== selected",
1922 " > 3",
1923 " > C",
1924 " .dockerignore",
1925 "v root2",
1926 " > d",
1927 " > e",
1928 ]
1929 );
1930
1931 toggle_expand_dir(&panel, "root2/d", cx);
1932 assert_eq!(
1933 visible_entries_as_strings(&panel, 0..50, cx),
1934 &[
1935 "v root1",
1936 " > a",
1937 " v b",
1938 " > 3",
1939 " > C",
1940 " .dockerignore",
1941 "v root2",
1942 " v d <== selected",
1943 " > e",
1944 ]
1945 );
1946
1947 toggle_expand_dir(&panel, "root2/e", cx);
1948 assert_eq!(
1949 visible_entries_as_strings(&panel, 0..50, cx),
1950 &[
1951 "v root1",
1952 " > a",
1953 " v b",
1954 " > 3",
1955 " > C",
1956 " .dockerignore",
1957 "v root2",
1958 " v d",
1959 " v e <== selected",
1960 ]
1961 );
1962 }
1963
1964 #[gpui::test(iterations = 30)]
1965 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1966 init_test(cx);
1967
1968 let fs = FakeFs::new(cx.background());
1969 fs.insert_tree(
1970 "/root1",
1971 json!({
1972 ".dockerignore": "",
1973 ".git": {
1974 "HEAD": "",
1975 },
1976 "a": {
1977 "0": { "q": "", "r": "", "s": "" },
1978 "1": { "t": "", "u": "" },
1979 "2": { "v": "", "w": "", "x": "", "y": "" },
1980 },
1981 "b": {
1982 "3": { "Q": "" },
1983 "4": { "R": "", "S": "", "T": "", "U": "" },
1984 },
1985 "C": {
1986 "5": {},
1987 "6": { "V": "", "W": "" },
1988 "7": { "X": "" },
1989 "8": { "Y": {}, "Z": "" }
1990 }
1991 }),
1992 )
1993 .await;
1994 fs.insert_tree(
1995 "/root2",
1996 json!({
1997 "d": {
1998 "9": ""
1999 },
2000 "e": {}
2001 }),
2002 )
2003 .await;
2004
2005 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2006 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2007 let workspace = window.root(cx);
2008 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2009
2010 select_path(&panel, "root1", cx);
2011 assert_eq!(
2012 visible_entries_as_strings(&panel, 0..10, cx),
2013 &[
2014 "v root1 <== selected",
2015 " > .git",
2016 " > a",
2017 " > b",
2018 " > C",
2019 " .dockerignore",
2020 "v root2",
2021 " > d",
2022 " > e",
2023 ]
2024 );
2025
2026 // Add a file with the root folder selected. The filename editor is placed
2027 // before the first file in the root folder.
2028 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2029 window.read_with(cx, |cx| {
2030 let panel = panel.read(cx);
2031 assert!(panel.filename_editor.is_focused(cx));
2032 });
2033 assert_eq!(
2034 visible_entries_as_strings(&panel, 0..10, cx),
2035 &[
2036 "v root1",
2037 " > .git",
2038 " > a",
2039 " > b",
2040 " > C",
2041 " [EDITOR: ''] <== selected",
2042 " .dockerignore",
2043 "v root2",
2044 " > d",
2045 " > e",
2046 ]
2047 );
2048
2049 let confirm = panel.update(cx, |panel, cx| {
2050 panel
2051 .filename_editor
2052 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2053 panel.confirm(&Confirm, cx).unwrap()
2054 });
2055 assert_eq!(
2056 visible_entries_as_strings(&panel, 0..10, cx),
2057 &[
2058 "v root1",
2059 " > .git",
2060 " > a",
2061 " > b",
2062 " > C",
2063 " [PROCESSING: 'the-new-filename'] <== selected",
2064 " .dockerignore",
2065 "v root2",
2066 " > d",
2067 " > e",
2068 ]
2069 );
2070
2071 confirm.await.unwrap();
2072 assert_eq!(
2073 visible_entries_as_strings(&panel, 0..10, cx),
2074 &[
2075 "v root1",
2076 " > .git",
2077 " > a",
2078 " > b",
2079 " > C",
2080 " .dockerignore",
2081 " the-new-filename <== selected",
2082 "v root2",
2083 " > d",
2084 " > e",
2085 ]
2086 );
2087
2088 select_path(&panel, "root1/b", cx);
2089 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2090 assert_eq!(
2091 visible_entries_as_strings(&panel, 0..10, cx),
2092 &[
2093 "v root1",
2094 " > .git",
2095 " > a",
2096 " v b",
2097 " > 3",
2098 " > 4",
2099 " [EDITOR: ''] <== selected",
2100 " > C",
2101 " .dockerignore",
2102 " the-new-filename",
2103 ]
2104 );
2105
2106 panel
2107 .update(cx, |panel, cx| {
2108 panel
2109 .filename_editor
2110 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2111 panel.confirm(&Confirm, cx).unwrap()
2112 })
2113 .await
2114 .unwrap();
2115 assert_eq!(
2116 visible_entries_as_strings(&panel, 0..10, cx),
2117 &[
2118 "v root1",
2119 " > .git",
2120 " > a",
2121 " v b",
2122 " > 3",
2123 " > 4",
2124 " another-filename.txt <== selected",
2125 " > C",
2126 " .dockerignore",
2127 " the-new-filename",
2128 ]
2129 );
2130
2131 select_path(&panel, "root1/b/another-filename.txt", cx);
2132 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2133 assert_eq!(
2134 visible_entries_as_strings(&panel, 0..10, cx),
2135 &[
2136 "v root1",
2137 " > .git",
2138 " > a",
2139 " v b",
2140 " > 3",
2141 " > 4",
2142 " [EDITOR: 'another-filename.txt'] <== selected",
2143 " > C",
2144 " .dockerignore",
2145 " the-new-filename",
2146 ]
2147 );
2148
2149 let confirm = panel.update(cx, |panel, cx| {
2150 panel.filename_editor.update(cx, |editor, cx| {
2151 let file_name_selections = editor.selections.all::<usize>(cx);
2152 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2153 let file_name_selection = &file_name_selections[0];
2154 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2155 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2156
2157 editor.set_text("a-different-filename.tar.gz", cx)
2158 });
2159 panel.confirm(&Confirm, cx).unwrap()
2160 });
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 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2171 " > C",
2172 " .dockerignore",
2173 " the-new-filename",
2174 ]
2175 );
2176
2177 confirm.await.unwrap();
2178 assert_eq!(
2179 visible_entries_as_strings(&panel, 0..10, cx),
2180 &[
2181 "v root1",
2182 " > .git",
2183 " > a",
2184 " v b",
2185 " > 3",
2186 " > 4",
2187 " a-different-filename.tar.gz <== selected",
2188 " > C",
2189 " .dockerignore",
2190 " the-new-filename",
2191 ]
2192 );
2193
2194 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2195 assert_eq!(
2196 visible_entries_as_strings(&panel, 0..10, cx),
2197 &[
2198 "v root1",
2199 " > .git",
2200 " > a",
2201 " v b",
2202 " > 3",
2203 " > 4",
2204 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2205 " > C",
2206 " .dockerignore",
2207 " the-new-filename",
2208 ]
2209 );
2210
2211 panel.update(cx, |panel, cx| {
2212 panel.filename_editor.update(cx, |editor, cx| {
2213 let file_name_selections = editor.selections.all::<usize>(cx);
2214 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2215 let file_name_selection = &file_name_selections[0];
2216 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2217 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");
2218
2219 });
2220 panel.cancel(&Cancel, cx)
2221 });
2222
2223 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2224 assert_eq!(
2225 visible_entries_as_strings(&panel, 0..10, cx),
2226 &[
2227 "v root1",
2228 " > .git",
2229 " > a",
2230 " v b",
2231 " > [EDITOR: ''] <== selected",
2232 " > 3",
2233 " > 4",
2234 " a-different-filename.tar.gz",
2235 " > C",
2236 " .dockerignore",
2237 ]
2238 );
2239
2240 let confirm = panel.update(cx, |panel, cx| {
2241 panel
2242 .filename_editor
2243 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2244 panel.confirm(&Confirm, cx).unwrap()
2245 });
2246 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2247 assert_eq!(
2248 visible_entries_as_strings(&panel, 0..10, cx),
2249 &[
2250 "v root1",
2251 " > .git",
2252 " > a",
2253 " v b",
2254 " > [PROCESSING: 'new-dir']",
2255 " > 3 <== selected",
2256 " > 4",
2257 " a-different-filename.tar.gz",
2258 " > C",
2259 " .dockerignore",
2260 ]
2261 );
2262
2263 confirm.await.unwrap();
2264 assert_eq!(
2265 visible_entries_as_strings(&panel, 0..10, cx),
2266 &[
2267 "v root1",
2268 " > .git",
2269 " > a",
2270 " v b",
2271 " > 3 <== selected",
2272 " > 4",
2273 " > new-dir",
2274 " a-different-filename.tar.gz",
2275 " > C",
2276 " .dockerignore",
2277 ]
2278 );
2279
2280 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2281 assert_eq!(
2282 visible_entries_as_strings(&panel, 0..10, cx),
2283 &[
2284 "v root1",
2285 " > .git",
2286 " > a",
2287 " v b",
2288 " > [EDITOR: '3'] <== selected",
2289 " > 4",
2290 " > new-dir",
2291 " a-different-filename.tar.gz",
2292 " > C",
2293 " .dockerignore",
2294 ]
2295 );
2296
2297 // Dismiss the rename editor when it loses focus.
2298 workspace.update(cx, |_, cx| cx.focus_self());
2299 assert_eq!(
2300 visible_entries_as_strings(&panel, 0..10, cx),
2301 &[
2302 "v root1",
2303 " > .git",
2304 " > a",
2305 " v b",
2306 " > 3 <== selected",
2307 " > 4",
2308 " > new-dir",
2309 " a-different-filename.tar.gz",
2310 " > C",
2311 " .dockerignore",
2312 ]
2313 );
2314 }
2315
2316 #[gpui::test(iterations = 30)]
2317 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2318 init_test(cx);
2319
2320 let fs = FakeFs::new(cx.background());
2321 fs.insert_tree(
2322 "/root1",
2323 json!({
2324 ".dockerignore": "",
2325 ".git": {
2326 "HEAD": "",
2327 },
2328 "a": {
2329 "0": { "q": "", "r": "", "s": "" },
2330 "1": { "t": "", "u": "" },
2331 "2": { "v": "", "w": "", "x": "", "y": "" },
2332 },
2333 "b": {
2334 "3": { "Q": "" },
2335 "4": { "R": "", "S": "", "T": "", "U": "" },
2336 },
2337 "C": {
2338 "5": {},
2339 "6": { "V": "", "W": "" },
2340 "7": { "X": "" },
2341 "8": { "Y": {}, "Z": "" }
2342 }
2343 }),
2344 )
2345 .await;
2346 fs.insert_tree(
2347 "/root2",
2348 json!({
2349 "d": {
2350 "9": ""
2351 },
2352 "e": {}
2353 }),
2354 )
2355 .await;
2356
2357 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2358 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2359 let workspace = window.root(cx);
2360 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2361
2362 select_path(&panel, "root1", cx);
2363 assert_eq!(
2364 visible_entries_as_strings(&panel, 0..10, cx),
2365 &[
2366 "v root1 <== selected",
2367 " > .git",
2368 " > a",
2369 " > b",
2370 " > C",
2371 " .dockerignore",
2372 "v root2",
2373 " > d",
2374 " > e",
2375 ]
2376 );
2377
2378 // Add a file with the root folder selected. The filename editor is placed
2379 // before the first file in the root folder.
2380 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2381 window.read_with(cx, |cx| {
2382 let panel = panel.read(cx);
2383 assert!(panel.filename_editor.is_focused(cx));
2384 });
2385 assert_eq!(
2386 visible_entries_as_strings(&panel, 0..10, cx),
2387 &[
2388 "v root1",
2389 " > .git",
2390 " > a",
2391 " > b",
2392 " > C",
2393 " [EDITOR: ''] <== selected",
2394 " .dockerignore",
2395 "v root2",
2396 " > d",
2397 " > e",
2398 ]
2399 );
2400
2401 let confirm = panel.update(cx, |panel, cx| {
2402 panel.filename_editor.update(cx, |editor, cx| {
2403 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2404 });
2405 panel.confirm(&Confirm, cx).unwrap()
2406 });
2407
2408 assert_eq!(
2409 visible_entries_as_strings(&panel, 0..10, cx),
2410 &[
2411 "v root1",
2412 " > .git",
2413 " > a",
2414 " > b",
2415 " > C",
2416 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2417 " .dockerignore",
2418 "v root2",
2419 " > d",
2420 " > e",
2421 ]
2422 );
2423
2424 confirm.await.unwrap();
2425 assert_eq!(
2426 visible_entries_as_strings(&panel, 0..13, cx),
2427 &[
2428 "v root1",
2429 " > .git",
2430 " > a",
2431 " > b",
2432 " v bdir1",
2433 " v dir2",
2434 " the-new-filename <== selected",
2435 " > C",
2436 " .dockerignore",
2437 "v root2",
2438 " > d",
2439 " > e",
2440 ]
2441 );
2442 }
2443
2444 #[gpui::test]
2445 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2446 init_test(cx);
2447
2448 let fs = FakeFs::new(cx.background());
2449 fs.insert_tree(
2450 "/root1",
2451 json!({
2452 "one.two.txt": "",
2453 "one.txt": ""
2454 }),
2455 )
2456 .await;
2457
2458 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2459 let workspace = cx
2460 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2461 .root(cx);
2462 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2463
2464 panel.update(cx, |panel, cx| {
2465 panel.select_next(&Default::default(), cx);
2466 panel.select_next(&Default::default(), cx);
2467 });
2468
2469 assert_eq!(
2470 visible_entries_as_strings(&panel, 0..50, cx),
2471 &[
2472 //
2473 "v root1",
2474 " one.two.txt <== selected",
2475 " one.txt",
2476 ]
2477 );
2478
2479 // Regression test - file name is created correctly when
2480 // the copied file's name contains multiple dots.
2481 panel.update(cx, |panel, cx| {
2482 panel.copy(&Default::default(), cx);
2483 panel.paste(&Default::default(), cx);
2484 });
2485 cx.foreground().run_until_parked();
2486
2487 assert_eq!(
2488 visible_entries_as_strings(&panel, 0..50, cx),
2489 &[
2490 //
2491 "v root1",
2492 " one.two copy.txt",
2493 " one.two.txt <== selected",
2494 " one.txt",
2495 ]
2496 );
2497
2498 panel.update(cx, |panel, cx| {
2499 panel.paste(&Default::default(), cx);
2500 });
2501 cx.foreground().run_until_parked();
2502
2503 assert_eq!(
2504 visible_entries_as_strings(&panel, 0..50, cx),
2505 &[
2506 //
2507 "v root1",
2508 " one.two copy 1.txt",
2509 " one.two copy.txt",
2510 " one.two.txt <== selected",
2511 " one.txt",
2512 ]
2513 );
2514 }
2515
2516 #[gpui::test]
2517 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2518 init_test_with_editor(cx);
2519
2520 let fs = FakeFs::new(cx.background());
2521 fs.insert_tree(
2522 "/src",
2523 json!({
2524 "test": {
2525 "first.rs": "// First Rust file",
2526 "second.rs": "// Second Rust file",
2527 "third.rs": "// Third Rust file",
2528 }
2529 }),
2530 )
2531 .await;
2532
2533 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2534 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2535 let workspace = window.root(cx);
2536 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2537
2538 toggle_expand_dir(&panel, "src/test", cx);
2539 select_path(&panel, "src/test/first.rs", cx);
2540 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2541 cx.foreground().run_until_parked();
2542 assert_eq!(
2543 visible_entries_as_strings(&panel, 0..10, cx),
2544 &[
2545 "v src",
2546 " v test",
2547 " first.rs <== selected",
2548 " second.rs",
2549 " third.rs"
2550 ]
2551 );
2552 ensure_single_file_is_opened(window, "test/first.rs", cx);
2553
2554 submit_deletion(window.into(), &panel, cx);
2555 assert_eq!(
2556 visible_entries_as_strings(&panel, 0..10, cx),
2557 &[
2558 "v src",
2559 " v test",
2560 " second.rs",
2561 " third.rs"
2562 ],
2563 "Project panel should have no deleted file, no other file is selected in it"
2564 );
2565 ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2566
2567 select_path(&panel, "src/test/second.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 " second.rs <== selected",
2576 " third.rs"
2577 ]
2578 );
2579 ensure_single_file_is_opened(window, "test/second.rs", cx);
2580
2581 window.update(cx, |cx| {
2582 let active_items = workspace
2583 .read(cx)
2584 .panes()
2585 .iter()
2586 .filter_map(|pane| pane.read(cx).active_item())
2587 .collect::<Vec<_>>();
2588 assert_eq!(active_items.len(), 1);
2589 let open_editor = active_items
2590 .into_iter()
2591 .next()
2592 .unwrap()
2593 .downcast::<Editor>()
2594 .expect("Open item should be an editor");
2595 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2596 });
2597 submit_deletion(window.into(), &panel, cx);
2598 assert_eq!(
2599 visible_entries_as_strings(&panel, 0..10, cx),
2600 &["v src", " v test", " third.rs"],
2601 "Project panel should have no deleted file, with one last file remaining"
2602 );
2603 ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2604 }
2605
2606 #[gpui::test]
2607 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2608 init_test_with_editor(cx);
2609
2610 let fs = FakeFs::new(cx.background());
2611 fs.insert_tree(
2612 "/src",
2613 json!({
2614 "test": {
2615 "first.rs": "// First Rust file",
2616 "second.rs": "// Second Rust file",
2617 "third.rs": "// Third Rust file",
2618 }
2619 }),
2620 )
2621 .await;
2622
2623 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2624 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2625 let workspace = window.root(cx);
2626 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2627
2628 select_path(&panel, "src/", cx);
2629 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2630 cx.foreground().run_until_parked();
2631 assert_eq!(
2632 visible_entries_as_strings(&panel, 0..10, cx),
2633 &["v src <== selected", " > test"]
2634 );
2635 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2636 window.read_with(cx, |cx| {
2637 let panel = panel.read(cx);
2638 assert!(panel.filename_editor.is_focused(cx));
2639 });
2640 assert_eq!(
2641 visible_entries_as_strings(&panel, 0..10, cx),
2642 &["v src", " > [EDITOR: ''] <== selected", " > test"]
2643 );
2644 panel.update(cx, |panel, cx| {
2645 panel
2646 .filename_editor
2647 .update(cx, |editor, cx| editor.set_text("test", cx));
2648 assert!(
2649 panel.confirm(&Confirm, cx).is_none(),
2650 "Should not allow to confirm on conflicting new directory name"
2651 )
2652 });
2653 assert_eq!(
2654 visible_entries_as_strings(&panel, 0..10, cx),
2655 &["v src", " > test"],
2656 "File list should be unchanged after failed folder create confirmation"
2657 );
2658
2659 select_path(&panel, "src/test/", cx);
2660 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2661 cx.foreground().run_until_parked();
2662 assert_eq!(
2663 visible_entries_as_strings(&panel, 0..10, cx),
2664 &["v src", " > test <== selected"]
2665 );
2666 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2667 window.read_with(cx, |cx| {
2668 let panel = panel.read(cx);
2669 assert!(panel.filename_editor.is_focused(cx));
2670 });
2671 assert_eq!(
2672 visible_entries_as_strings(&panel, 0..10, cx),
2673 &[
2674 "v src",
2675 " v test",
2676 " [EDITOR: ''] <== selected",
2677 " first.rs",
2678 " second.rs",
2679 " third.rs"
2680 ]
2681 );
2682 panel.update(cx, |panel, cx| {
2683 panel
2684 .filename_editor
2685 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2686 assert!(
2687 panel.confirm(&Confirm, cx).is_none(),
2688 "Should not allow to confirm on conflicting new file name"
2689 )
2690 });
2691 assert_eq!(
2692 visible_entries_as_strings(&panel, 0..10, cx),
2693 &[
2694 "v src",
2695 " v test",
2696 " first.rs",
2697 " second.rs",
2698 " third.rs"
2699 ],
2700 "File list should be unchanged after failed file create confirmation"
2701 );
2702
2703 select_path(&panel, "src/test/first.rs", cx);
2704 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2705 cx.foreground().run_until_parked();
2706 assert_eq!(
2707 visible_entries_as_strings(&panel, 0..10, cx),
2708 &[
2709 "v src",
2710 " v test",
2711 " first.rs <== selected",
2712 " second.rs",
2713 " third.rs"
2714 ],
2715 );
2716 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2717 window.read_with(cx, |cx| {
2718 let panel = panel.read(cx);
2719 assert!(panel.filename_editor.is_focused(cx));
2720 });
2721 assert_eq!(
2722 visible_entries_as_strings(&panel, 0..10, cx),
2723 &[
2724 "v src",
2725 " v test",
2726 " [EDITOR: 'first.rs'] <== selected",
2727 " second.rs",
2728 " third.rs"
2729 ]
2730 );
2731 panel.update(cx, |panel, cx| {
2732 panel
2733 .filename_editor
2734 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2735 assert!(
2736 panel.confirm(&Confirm, cx).is_none(),
2737 "Should not allow to confirm on conflicting file rename"
2738 )
2739 });
2740 assert_eq!(
2741 visible_entries_as_strings(&panel, 0..10, cx),
2742 &[
2743 "v src",
2744 " v test",
2745 " first.rs <== selected",
2746 " second.rs",
2747 " third.rs"
2748 ],
2749 "File list should be unchanged after failed rename confirmation"
2750 );
2751 }
2752
2753 #[gpui::test]
2754 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2755 init_test_with_editor(cx);
2756
2757 let fs = FakeFs::new(cx.background());
2758 fs.insert_tree(
2759 "/src",
2760 json!({
2761 "test": {
2762 "first.rs": "// First Rust file",
2763 "second.rs": "// Second Rust file",
2764 "third.rs": "// Third Rust file",
2765 }
2766 }),
2767 )
2768 .await;
2769
2770 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2771 let workspace = cx
2772 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2773 .root(cx);
2774 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2775
2776 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2777 let _subscription = panel.update(cx, |_, cx| {
2778 let subcription_count = Arc::clone(&new_search_events_count);
2779 cx.subscribe(&cx.handle(), move |_, _, event, _| {
2780 if matches!(event, Event::NewSearchInDirectory { .. }) {
2781 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2782 }
2783 })
2784 });
2785
2786 toggle_expand_dir(&panel, "src/test", cx);
2787 select_path(&panel, "src/test/first.rs", cx);
2788 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2789 cx.foreground().run_until_parked();
2790 assert_eq!(
2791 visible_entries_as_strings(&panel, 0..10, cx),
2792 &[
2793 "v src",
2794 " v test",
2795 " first.rs <== selected",
2796 " second.rs",
2797 " third.rs"
2798 ]
2799 );
2800 panel.update(cx, |panel, cx| {
2801 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2802 });
2803 assert_eq!(
2804 new_search_events_count.load(atomic::Ordering::SeqCst),
2805 0,
2806 "Should not trigger new search in directory when called on a file"
2807 );
2808
2809 select_path(&panel, "src/test", cx);
2810 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2811 cx.foreground().run_until_parked();
2812 assert_eq!(
2813 visible_entries_as_strings(&panel, 0..10, cx),
2814 &[
2815 "v src",
2816 " v test <== selected",
2817 " first.rs",
2818 " second.rs",
2819 " third.rs"
2820 ]
2821 );
2822 panel.update(cx, |panel, cx| {
2823 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2824 });
2825 assert_eq!(
2826 new_search_events_count.load(atomic::Ordering::SeqCst),
2827 1,
2828 "Should trigger new search in directory when called on a directory"
2829 );
2830 }
2831
2832 #[gpui::test]
2833 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2834 init_test_with_editor(cx);
2835
2836 let fs = FakeFs::new(cx.background());
2837 fs.insert_tree(
2838 "/project_root",
2839 json!({
2840 "dir_1": {
2841 "nested_dir": {
2842 "file_a.py": "# File contents",
2843 "file_b.py": "# File contents",
2844 "file_c.py": "# File contents",
2845 },
2846 "file_1.py": "# File contents",
2847 "file_2.py": "# File contents",
2848 "file_3.py": "# File contents",
2849 },
2850 "dir_2": {
2851 "file_1.py": "# File contents",
2852 "file_2.py": "# File contents",
2853 "file_3.py": "# File contents",
2854 }
2855 }),
2856 )
2857 .await;
2858
2859 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2860 let workspace = cx
2861 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2862 .root(cx);
2863 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2864
2865 panel.update(cx, |panel, cx| {
2866 panel.collapse_all_entries(&CollapseAllEntries, cx)
2867 });
2868 cx.foreground().run_until_parked();
2869 assert_eq!(
2870 visible_entries_as_strings(&panel, 0..10, cx),
2871 &["v project_root", " > dir_1", " > dir_2",]
2872 );
2873
2874 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2875 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2876 cx.foreground().run_until_parked();
2877 assert_eq!(
2878 visible_entries_as_strings(&panel, 0..10, cx),
2879 &[
2880 "v project_root",
2881 " v dir_1 <== selected",
2882 " > nested_dir",
2883 " file_1.py",
2884 " file_2.py",
2885 " file_3.py",
2886 " > dir_2",
2887 ]
2888 );
2889 }
2890
2891 #[gpui::test]
2892 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2893 init_test(cx);
2894
2895 let fs = FakeFs::new(cx.background());
2896 fs.as_fake().insert_tree("/root", json!({})).await;
2897 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2898 let workspace = cx
2899 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2900 .root(cx);
2901 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2902
2903 // Make a new buffer with no backing file
2904 workspace.update(cx, |workspace, cx| {
2905 Editor::new_file(workspace, &Default::default(), cx)
2906 });
2907
2908 // "Save as"" the buffer, creating a new backing file for it
2909 let task = workspace.update(cx, |workspace, cx| {
2910 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2911 });
2912
2913 cx.foreground().run_until_parked();
2914 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2915 task.await.unwrap();
2916
2917 // Rename the file
2918 select_path(&panel, "root/new", cx);
2919 assert_eq!(
2920 visible_entries_as_strings(&panel, 0..10, cx),
2921 &["v root", " new <== selected"]
2922 );
2923 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2924 panel.update(cx, |panel, cx| {
2925 panel
2926 .filename_editor
2927 .update(cx, |editor, cx| editor.set_text("newer", cx));
2928 });
2929 panel
2930 .update(cx, |panel, cx| panel.confirm(&Confirm, cx))
2931 .unwrap()
2932 .await
2933 .unwrap();
2934
2935 cx.foreground().run_until_parked();
2936 assert_eq!(
2937 visible_entries_as_strings(&panel, 0..10, cx),
2938 &["v root", " newer <== selected"]
2939 );
2940
2941 workspace
2942 .update(cx, |workspace, cx| {
2943 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2944 })
2945 .await
2946 .unwrap();
2947
2948 cx.foreground().run_until_parked();
2949 // assert that saving the file doesn't restore "new"
2950 assert_eq!(
2951 visible_entries_as_strings(&panel, 0..10, cx),
2952 &["v root", " newer <== selected"]
2953 );
2954 }
2955
2956 fn toggle_expand_dir(
2957 panel: &ViewHandle<ProjectPanel>,
2958 path: impl AsRef<Path>,
2959 cx: &mut TestAppContext,
2960 ) {
2961 let path = path.as_ref();
2962 panel.update(cx, |panel, cx| {
2963 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2964 let worktree = worktree.read(cx);
2965 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2966 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2967 panel.toggle_expanded(entry_id, cx);
2968 return;
2969 }
2970 }
2971 panic!("no worktree for path {:?}", path);
2972 });
2973 }
2974
2975 fn select_path(
2976 panel: &ViewHandle<ProjectPanel>,
2977 path: impl AsRef<Path>,
2978 cx: &mut TestAppContext,
2979 ) {
2980 let path = path.as_ref();
2981 panel.update(cx, |panel, cx| {
2982 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2983 let worktree = worktree.read(cx);
2984 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2985 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2986 panel.selection = Some(Selection {
2987 worktree_id: worktree.id(),
2988 entry_id,
2989 });
2990 return;
2991 }
2992 }
2993 panic!("no worktree for path {:?}", path);
2994 });
2995 }
2996
2997 fn visible_entries_as_strings(
2998 panel: &ViewHandle<ProjectPanel>,
2999 range: Range<usize>,
3000 cx: &mut TestAppContext,
3001 ) -> Vec<String> {
3002 let mut result = Vec::new();
3003 let mut project_entries = HashSet::new();
3004 let mut has_editor = false;
3005
3006 panel.update(cx, |panel, cx| {
3007 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3008 if details.is_editing {
3009 assert!(!has_editor, "duplicate editor entry");
3010 has_editor = true;
3011 } else {
3012 assert!(
3013 project_entries.insert(project_entry),
3014 "duplicate project entry {:?} {:?}",
3015 project_entry,
3016 details
3017 );
3018 }
3019
3020 let indent = " ".repeat(details.depth);
3021 let icon = if details.kind.is_dir() {
3022 if details.is_expanded {
3023 "v "
3024 } else {
3025 "> "
3026 }
3027 } else {
3028 " "
3029 };
3030 let name = if details.is_editing {
3031 format!("[EDITOR: '{}']", details.filename)
3032 } else if details.is_processing {
3033 format!("[PROCESSING: '{}']", details.filename)
3034 } else {
3035 details.filename.clone()
3036 };
3037 let selected = if details.is_selected {
3038 " <== selected"
3039 } else {
3040 ""
3041 };
3042 result.push(format!("{indent}{icon}{name}{selected}"));
3043 });
3044 });
3045
3046 result
3047 }
3048
3049 fn init_test(cx: &mut TestAppContext) {
3050 cx.foreground().forbid_parking();
3051 cx.update(|cx| {
3052 cx.set_global(SettingsStore::test(cx));
3053 init_settings(cx);
3054 theme::init((), cx);
3055 language::init(cx);
3056 editor::init_settings(cx);
3057 crate::init((), cx);
3058 workspace::init_settings(cx);
3059 client::init_settings(cx);
3060 Project::init_settings(cx);
3061
3062 cx.update_global::<SettingsStore, _, _>(|store, cx| {
3063 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3064 project_settings.file_scan_exclusions = Some(Vec::new());
3065 });
3066 });
3067 });
3068 }
3069
3070 fn init_test_with_editor(cx: &mut TestAppContext) {
3071 cx.foreground().forbid_parking();
3072 cx.update(|cx| {
3073 let app_state = AppState::test(cx);
3074 theme::init((), cx);
3075 init_settings(cx);
3076 language::init(cx);
3077 editor::init(cx);
3078 pane::init(cx);
3079 crate::init((), cx);
3080 workspace::init(app_state.clone(), cx);
3081 Project::init_settings(cx);
3082 });
3083 }
3084
3085 fn ensure_single_file_is_opened(
3086 window: WindowHandle<Workspace>,
3087 expected_path: &str,
3088 cx: &mut TestAppContext,
3089 ) {
3090 window.update_root(cx, |workspace, cx| {
3091 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3092 assert_eq!(worktrees.len(), 1);
3093 let worktree_id = WorktreeId::from_usize(worktrees[0].id());
3094
3095 let open_project_paths = workspace
3096 .panes()
3097 .iter()
3098 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3099 .collect::<Vec<_>>();
3100 assert_eq!(
3101 open_project_paths,
3102 vec![ProjectPath {
3103 worktree_id,
3104 path: Arc::from(Path::new(expected_path))
3105 }],
3106 "Should have opened file, selected in project panel"
3107 );
3108 });
3109 }
3110
3111 fn submit_deletion(
3112 window: AnyWindowHandle,
3113 panel: &ViewHandle<ProjectPanel>,
3114 cx: &mut TestAppContext,
3115 ) {
3116 assert!(
3117 !window.has_pending_prompt(cx),
3118 "Should have no prompts before the deletion"
3119 );
3120 panel.update(cx, |panel, cx| {
3121 panel
3122 .delete(&Delete, cx)
3123 .expect("Deletion start")
3124 .detach_and_log_err(cx);
3125 });
3126 assert!(
3127 window.has_pending_prompt(cx),
3128 "Should have a prompt after the deletion"
3129 );
3130 window.simulate_prompt_answer(0, cx);
3131 assert!(
3132 !window.has_pending_prompt(cx),
3133 "Should have no prompts after prompt was replied to"
3134 );
3135 cx.foreground().run_until_parked();
3136 }
3137
3138 fn ensure_no_open_items_and_panes(
3139 window: AnyWindowHandle,
3140 workspace: &ViewHandle<Workspace>,
3141 cx: &mut TestAppContext,
3142 ) {
3143 assert!(
3144 !window.has_pending_prompt(cx),
3145 "Should have no prompts after deletion operation closes the file"
3146 );
3147 window.read_with(cx, |cx| {
3148 let open_project_paths = workspace
3149 .read(cx)
3150 .panes()
3151 .iter()
3152 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3153 .collect::<Vec<_>>();
3154 assert!(
3155 open_project_paths.is_empty(),
3156 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3157 );
3158 });
3159 }
3160}
3161// TODO - a workspace command?