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, _: &AppContext) {
1631 Self::reset_to_default_keymap_context(keymap);
1632 keymap.add_identifier("menu");
1633 }
1634
1635 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1636 if !self.has_focus {
1637 self.has_focus = true;
1638 cx.emit(Event::Focus);
1639 }
1640 }
1641
1642 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1643 self.has_focus = false;
1644 }
1645}
1646
1647impl Entity for ProjectPanel {
1648 type Event = Event;
1649}
1650
1651impl workspace::dock::Panel for ProjectPanel {
1652 fn position(&self, cx: &WindowContext) -> DockPosition {
1653 match settings::get::<ProjectPanelSettings>(cx).dock {
1654 ProjectPanelDockPosition::Left => DockPosition::Left,
1655 ProjectPanelDockPosition::Right => DockPosition::Right,
1656 }
1657 }
1658
1659 fn position_is_valid(&self, position: DockPosition) -> bool {
1660 matches!(position, DockPosition::Left | DockPosition::Right)
1661 }
1662
1663 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1664 settings::update_settings_file::<ProjectPanelSettings>(
1665 self.fs.clone(),
1666 cx,
1667 move |settings| {
1668 let dock = match position {
1669 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1670 DockPosition::Right => ProjectPanelDockPosition::Right,
1671 };
1672 settings.dock = Some(dock);
1673 },
1674 );
1675 }
1676
1677 fn size(&self, cx: &WindowContext) -> f32 {
1678 self.width
1679 .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1680 }
1681
1682 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1683 self.width = size;
1684 self.serialize(cx);
1685 cx.notify();
1686 }
1687
1688 fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
1689 Some("icons/project.svg")
1690 }
1691
1692 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1693 ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1694 }
1695
1696 fn should_change_position_on_event(event: &Self::Event) -> bool {
1697 matches!(event, Event::DockPositionChanged)
1698 }
1699
1700 fn has_focus(&self, _: &WindowContext) -> bool {
1701 self.has_focus
1702 }
1703
1704 fn is_focus_event(event: &Self::Event) -> bool {
1705 matches!(event, Event::Focus)
1706 }
1707}
1708
1709impl ClipboardEntry {
1710 fn is_cut(&self) -> bool {
1711 matches!(self, Self::Cut { .. })
1712 }
1713
1714 fn entry_id(&self) -> ProjectEntryId {
1715 match self {
1716 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1717 *entry_id
1718 }
1719 }
1720 }
1721
1722 fn worktree_id(&self) -> WorktreeId {
1723 match self {
1724 ClipboardEntry::Copied { worktree_id, .. }
1725 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1726 }
1727 }
1728}
1729
1730#[cfg(test)]
1731mod tests {
1732 use super::*;
1733 use gpui::{AnyWindowHandle, TestAppContext, ViewHandle, WindowHandle};
1734 use pretty_assertions::assert_eq;
1735 use project::{project_settings::ProjectSettings, FakeFs};
1736 use serde_json::json;
1737 use settings::SettingsStore;
1738 use std::{
1739 collections::HashSet,
1740 path::{Path, PathBuf},
1741 sync::atomic::{self, AtomicUsize},
1742 };
1743 use workspace::{pane, AppState};
1744
1745 #[gpui::test]
1746 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1747 init_test(cx);
1748
1749 let fs = FakeFs::new(cx.background());
1750 fs.insert_tree(
1751 "/root1",
1752 json!({
1753 ".dockerignore": "",
1754 ".git": {
1755 "HEAD": "",
1756 },
1757 "a": {
1758 "0": { "q": "", "r": "", "s": "" },
1759 "1": { "t": "", "u": "" },
1760 "2": { "v": "", "w": "", "x": "", "y": "" },
1761 },
1762 "b": {
1763 "3": { "Q": "" },
1764 "4": { "R": "", "S": "", "T": "", "U": "" },
1765 },
1766 "C": {
1767 "5": {},
1768 "6": { "V": "", "W": "" },
1769 "7": { "X": "" },
1770 "8": { "Y": {}, "Z": "" }
1771 }
1772 }),
1773 )
1774 .await;
1775 fs.insert_tree(
1776 "/root2",
1777 json!({
1778 "d": {
1779 "9": ""
1780 },
1781 "e": {}
1782 }),
1783 )
1784 .await;
1785
1786 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1787 let workspace = cx
1788 .add_window(|cx| Workspace::test_new(project.clone(), cx))
1789 .root(cx);
1790 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1791 assert_eq!(
1792 visible_entries_as_strings(&panel, 0..50, cx),
1793 &[
1794 "v root1",
1795 " > .git",
1796 " > a",
1797 " > b",
1798 " > C",
1799 " .dockerignore",
1800 "v root2",
1801 " > d",
1802 " > e",
1803 ]
1804 );
1805
1806 toggle_expand_dir(&panel, "root1/b", cx);
1807 assert_eq!(
1808 visible_entries_as_strings(&panel, 0..50, cx),
1809 &[
1810 "v root1",
1811 " > .git",
1812 " > a",
1813 " v b <== selected",
1814 " > 3",
1815 " > 4",
1816 " > C",
1817 " .dockerignore",
1818 "v root2",
1819 " > d",
1820 " > e",
1821 ]
1822 );
1823
1824 assert_eq!(
1825 visible_entries_as_strings(&panel, 6..9, cx),
1826 &[
1827 //
1828 " > C",
1829 " .dockerignore",
1830 "v root2",
1831 ]
1832 );
1833 }
1834
1835 #[gpui::test]
1836 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1837 init_test(cx);
1838 cx.update(|cx| {
1839 cx.update_global::<SettingsStore, _, _>(|store, cx| {
1840 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1841 project_settings.file_scan_exclusions =
1842 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1843 });
1844 });
1845 });
1846
1847 let fs = FakeFs::new(cx.background());
1848 fs.insert_tree(
1849 "/root1",
1850 json!({
1851 ".dockerignore": "",
1852 ".git": {
1853 "HEAD": "",
1854 },
1855 "a": {
1856 "0": { "q": "", "r": "", "s": "" },
1857 "1": { "t": "", "u": "" },
1858 "2": { "v": "", "w": "", "x": "", "y": "" },
1859 },
1860 "b": {
1861 "3": { "Q": "" },
1862 "4": { "R": "", "S": "", "T": "", "U": "" },
1863 },
1864 "C": {
1865 "5": {},
1866 "6": { "V": "", "W": "" },
1867 "7": { "X": "" },
1868 "8": { "Y": {}, "Z": "" }
1869 }
1870 }),
1871 )
1872 .await;
1873 fs.insert_tree(
1874 "/root2",
1875 json!({
1876 "d": {
1877 "4": ""
1878 },
1879 "e": {}
1880 }),
1881 )
1882 .await;
1883
1884 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1885 let workspace = cx
1886 .add_window(|cx| Workspace::test_new(project.clone(), cx))
1887 .root(cx);
1888 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1889 assert_eq!(
1890 visible_entries_as_strings(&panel, 0..50, cx),
1891 &[
1892 "v root1",
1893 " > a",
1894 " > b",
1895 " > C",
1896 " .dockerignore",
1897 "v root2",
1898 " > d",
1899 " > e",
1900 ]
1901 );
1902
1903 toggle_expand_dir(&panel, "root1/b", cx);
1904 assert_eq!(
1905 visible_entries_as_strings(&panel, 0..50, cx),
1906 &[
1907 "v root1",
1908 " > a",
1909 " v b <== selected",
1910 " > 3",
1911 " > C",
1912 " .dockerignore",
1913 "v root2",
1914 " > d",
1915 " > e",
1916 ]
1917 );
1918
1919 toggle_expand_dir(&panel, "root2/d", cx);
1920 assert_eq!(
1921 visible_entries_as_strings(&panel, 0..50, cx),
1922 &[
1923 "v root1",
1924 " > a",
1925 " v b",
1926 " > 3",
1927 " > C",
1928 " .dockerignore",
1929 "v root2",
1930 " v d <== selected",
1931 " > e",
1932 ]
1933 );
1934
1935 toggle_expand_dir(&panel, "root2/e", cx);
1936 assert_eq!(
1937 visible_entries_as_strings(&panel, 0..50, cx),
1938 &[
1939 "v root1",
1940 " > a",
1941 " v b",
1942 " > 3",
1943 " > C",
1944 " .dockerignore",
1945 "v root2",
1946 " v d",
1947 " v e <== selected",
1948 ]
1949 );
1950 }
1951
1952 #[gpui::test(iterations = 30)]
1953 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1954 init_test(cx);
1955
1956 let fs = FakeFs::new(cx.background());
1957 fs.insert_tree(
1958 "/root1",
1959 json!({
1960 ".dockerignore": "",
1961 ".git": {
1962 "HEAD": "",
1963 },
1964 "a": {
1965 "0": { "q": "", "r": "", "s": "" },
1966 "1": { "t": "", "u": "" },
1967 "2": { "v": "", "w": "", "x": "", "y": "" },
1968 },
1969 "b": {
1970 "3": { "Q": "" },
1971 "4": { "R": "", "S": "", "T": "", "U": "" },
1972 },
1973 "C": {
1974 "5": {},
1975 "6": { "V": "", "W": "" },
1976 "7": { "X": "" },
1977 "8": { "Y": {}, "Z": "" }
1978 }
1979 }),
1980 )
1981 .await;
1982 fs.insert_tree(
1983 "/root2",
1984 json!({
1985 "d": {
1986 "9": ""
1987 },
1988 "e": {}
1989 }),
1990 )
1991 .await;
1992
1993 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1994 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1995 let workspace = window.root(cx);
1996 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1997
1998 select_path(&panel, "root1", cx);
1999 assert_eq!(
2000 visible_entries_as_strings(&panel, 0..10, cx),
2001 &[
2002 "v root1 <== selected",
2003 " > .git",
2004 " > a",
2005 " > b",
2006 " > C",
2007 " .dockerignore",
2008 "v root2",
2009 " > d",
2010 " > e",
2011 ]
2012 );
2013
2014 // Add a file with the root folder selected. The filename editor is placed
2015 // before the first file in the root folder.
2016 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2017 window.read_with(cx, |cx| {
2018 let panel = panel.read(cx);
2019 assert!(panel.filename_editor.is_focused(cx));
2020 });
2021 assert_eq!(
2022 visible_entries_as_strings(&panel, 0..10, cx),
2023 &[
2024 "v root1",
2025 " > .git",
2026 " > a",
2027 " > b",
2028 " > C",
2029 " [EDITOR: ''] <== selected",
2030 " .dockerignore",
2031 "v root2",
2032 " > d",
2033 " > e",
2034 ]
2035 );
2036
2037 let confirm = panel.update(cx, |panel, cx| {
2038 panel
2039 .filename_editor
2040 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2041 panel.confirm(&Confirm, cx).unwrap()
2042 });
2043 assert_eq!(
2044 visible_entries_as_strings(&panel, 0..10, cx),
2045 &[
2046 "v root1",
2047 " > .git",
2048 " > a",
2049 " > b",
2050 " > C",
2051 " [PROCESSING: 'the-new-filename'] <== selected",
2052 " .dockerignore",
2053 "v root2",
2054 " > d",
2055 " > e",
2056 ]
2057 );
2058
2059 confirm.await.unwrap();
2060 assert_eq!(
2061 visible_entries_as_strings(&panel, 0..10, cx),
2062 &[
2063 "v root1",
2064 " > .git",
2065 " > a",
2066 " > b",
2067 " > C",
2068 " .dockerignore",
2069 " the-new-filename <== selected",
2070 "v root2",
2071 " > d",
2072 " > e",
2073 ]
2074 );
2075
2076 select_path(&panel, "root1/b", cx);
2077 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2078 assert_eq!(
2079 visible_entries_as_strings(&panel, 0..10, cx),
2080 &[
2081 "v root1",
2082 " > .git",
2083 " > a",
2084 " v b",
2085 " > 3",
2086 " > 4",
2087 " [EDITOR: ''] <== selected",
2088 " > C",
2089 " .dockerignore",
2090 " the-new-filename",
2091 ]
2092 );
2093
2094 panel
2095 .update(cx, |panel, cx| {
2096 panel
2097 .filename_editor
2098 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2099 panel.confirm(&Confirm, cx).unwrap()
2100 })
2101 .await
2102 .unwrap();
2103 assert_eq!(
2104 visible_entries_as_strings(&panel, 0..10, cx),
2105 &[
2106 "v root1",
2107 " > .git",
2108 " > a",
2109 " v b",
2110 " > 3",
2111 " > 4",
2112 " another-filename.txt <== selected",
2113 " > C",
2114 " .dockerignore",
2115 " the-new-filename",
2116 ]
2117 );
2118
2119 select_path(&panel, "root1/b/another-filename.txt", cx);
2120 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2121 assert_eq!(
2122 visible_entries_as_strings(&panel, 0..10, cx),
2123 &[
2124 "v root1",
2125 " > .git",
2126 " > a",
2127 " v b",
2128 " > 3",
2129 " > 4",
2130 " [EDITOR: 'another-filename.txt'] <== selected",
2131 " > C",
2132 " .dockerignore",
2133 " the-new-filename",
2134 ]
2135 );
2136
2137 let confirm = panel.update(cx, |panel, cx| {
2138 panel.filename_editor.update(cx, |editor, cx| {
2139 let file_name_selections = editor.selections.all::<usize>(cx);
2140 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2141 let file_name_selection = &file_name_selections[0];
2142 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2143 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2144
2145 editor.set_text("a-different-filename.tar.gz", cx)
2146 });
2147 panel.confirm(&Confirm, cx).unwrap()
2148 });
2149 assert_eq!(
2150 visible_entries_as_strings(&panel, 0..10, cx),
2151 &[
2152 "v root1",
2153 " > .git",
2154 " > a",
2155 " v b",
2156 " > 3",
2157 " > 4",
2158 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2159 " > C",
2160 " .dockerignore",
2161 " the-new-filename",
2162 ]
2163 );
2164
2165 confirm.await.unwrap();
2166 assert_eq!(
2167 visible_entries_as_strings(&panel, 0..10, cx),
2168 &[
2169 "v root1",
2170 " > .git",
2171 " > a",
2172 " v b",
2173 " > 3",
2174 " > 4",
2175 " a-different-filename.tar.gz <== selected",
2176 " > C",
2177 " .dockerignore",
2178 " the-new-filename",
2179 ]
2180 );
2181
2182 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2183 assert_eq!(
2184 visible_entries_as_strings(&panel, 0..10, cx),
2185 &[
2186 "v root1",
2187 " > .git",
2188 " > a",
2189 " v b",
2190 " > 3",
2191 " > 4",
2192 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2193 " > C",
2194 " .dockerignore",
2195 " the-new-filename",
2196 ]
2197 );
2198
2199 panel.update(cx, |panel, cx| {
2200 panel.filename_editor.update(cx, |editor, cx| {
2201 let file_name_selections = editor.selections.all::<usize>(cx);
2202 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2203 let file_name_selection = &file_name_selections[0];
2204 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2205 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");
2206
2207 });
2208 panel.cancel(&Cancel, cx)
2209 });
2210
2211 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2212 assert_eq!(
2213 visible_entries_as_strings(&panel, 0..10, cx),
2214 &[
2215 "v root1",
2216 " > .git",
2217 " > a",
2218 " v b",
2219 " > [EDITOR: ''] <== selected",
2220 " > 3",
2221 " > 4",
2222 " a-different-filename.tar.gz",
2223 " > C",
2224 " .dockerignore",
2225 ]
2226 );
2227
2228 let confirm = panel.update(cx, |panel, cx| {
2229 panel
2230 .filename_editor
2231 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2232 panel.confirm(&Confirm, cx).unwrap()
2233 });
2234 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2235 assert_eq!(
2236 visible_entries_as_strings(&panel, 0..10, cx),
2237 &[
2238 "v root1",
2239 " > .git",
2240 " > a",
2241 " v b",
2242 " > [PROCESSING: 'new-dir']",
2243 " > 3 <== selected",
2244 " > 4",
2245 " a-different-filename.tar.gz",
2246 " > C",
2247 " .dockerignore",
2248 ]
2249 );
2250
2251 confirm.await.unwrap();
2252 assert_eq!(
2253 visible_entries_as_strings(&panel, 0..10, cx),
2254 &[
2255 "v root1",
2256 " > .git",
2257 " > a",
2258 " v b",
2259 " > 3 <== selected",
2260 " > 4",
2261 " > new-dir",
2262 " a-different-filename.tar.gz",
2263 " > C",
2264 " .dockerignore",
2265 ]
2266 );
2267
2268 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2269 assert_eq!(
2270 visible_entries_as_strings(&panel, 0..10, cx),
2271 &[
2272 "v root1",
2273 " > .git",
2274 " > a",
2275 " v b",
2276 " > [EDITOR: '3'] <== selected",
2277 " > 4",
2278 " > new-dir",
2279 " a-different-filename.tar.gz",
2280 " > C",
2281 " .dockerignore",
2282 ]
2283 );
2284
2285 // Dismiss the rename editor when it loses focus.
2286 workspace.update(cx, |_, cx| cx.focus_self());
2287 assert_eq!(
2288 visible_entries_as_strings(&panel, 0..10, cx),
2289 &[
2290 "v root1",
2291 " > .git",
2292 " > a",
2293 " v b",
2294 " > 3 <== selected",
2295 " > 4",
2296 " > new-dir",
2297 " a-different-filename.tar.gz",
2298 " > C",
2299 " .dockerignore",
2300 ]
2301 );
2302 }
2303
2304 #[gpui::test(iterations = 30)]
2305 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2306 init_test(cx);
2307
2308 let fs = FakeFs::new(cx.background());
2309 fs.insert_tree(
2310 "/root1",
2311 json!({
2312 ".dockerignore": "",
2313 ".git": {
2314 "HEAD": "",
2315 },
2316 "a": {
2317 "0": { "q": "", "r": "", "s": "" },
2318 "1": { "t": "", "u": "" },
2319 "2": { "v": "", "w": "", "x": "", "y": "" },
2320 },
2321 "b": {
2322 "3": { "Q": "" },
2323 "4": { "R": "", "S": "", "T": "", "U": "" },
2324 },
2325 "C": {
2326 "5": {},
2327 "6": { "V": "", "W": "" },
2328 "7": { "X": "" },
2329 "8": { "Y": {}, "Z": "" }
2330 }
2331 }),
2332 )
2333 .await;
2334 fs.insert_tree(
2335 "/root2",
2336 json!({
2337 "d": {
2338 "9": ""
2339 },
2340 "e": {}
2341 }),
2342 )
2343 .await;
2344
2345 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2346 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2347 let workspace = window.root(cx);
2348 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2349
2350 select_path(&panel, "root1", cx);
2351 assert_eq!(
2352 visible_entries_as_strings(&panel, 0..10, cx),
2353 &[
2354 "v root1 <== selected",
2355 " > .git",
2356 " > a",
2357 " > b",
2358 " > C",
2359 " .dockerignore",
2360 "v root2",
2361 " > d",
2362 " > e",
2363 ]
2364 );
2365
2366 // Add a file with the root folder selected. The filename editor is placed
2367 // before the first file in the root folder.
2368 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2369 window.read_with(cx, |cx| {
2370 let panel = panel.read(cx);
2371 assert!(panel.filename_editor.is_focused(cx));
2372 });
2373 assert_eq!(
2374 visible_entries_as_strings(&panel, 0..10, cx),
2375 &[
2376 "v root1",
2377 " > .git",
2378 " > a",
2379 " > b",
2380 " > C",
2381 " [EDITOR: ''] <== selected",
2382 " .dockerignore",
2383 "v root2",
2384 " > d",
2385 " > e",
2386 ]
2387 );
2388
2389 let confirm = panel.update(cx, |panel, cx| {
2390 panel.filename_editor.update(cx, |editor, cx| {
2391 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2392 });
2393 panel.confirm(&Confirm, cx).unwrap()
2394 });
2395
2396 assert_eq!(
2397 visible_entries_as_strings(&panel, 0..10, cx),
2398 &[
2399 "v root1",
2400 " > .git",
2401 " > a",
2402 " > b",
2403 " > C",
2404 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2405 " .dockerignore",
2406 "v root2",
2407 " > d",
2408 " > e",
2409 ]
2410 );
2411
2412 confirm.await.unwrap();
2413 assert_eq!(
2414 visible_entries_as_strings(&panel, 0..13, cx),
2415 &[
2416 "v root1",
2417 " > .git",
2418 " > a",
2419 " > b",
2420 " v bdir1",
2421 " v dir2",
2422 " the-new-filename <== selected",
2423 " > C",
2424 " .dockerignore",
2425 "v root2",
2426 " > d",
2427 " > e",
2428 ]
2429 );
2430 }
2431
2432 #[gpui::test]
2433 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2434 init_test(cx);
2435
2436 let fs = FakeFs::new(cx.background());
2437 fs.insert_tree(
2438 "/root1",
2439 json!({
2440 "one.two.txt": "",
2441 "one.txt": ""
2442 }),
2443 )
2444 .await;
2445
2446 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2447 let workspace = cx
2448 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2449 .root(cx);
2450 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2451
2452 panel.update(cx, |panel, cx| {
2453 panel.select_next(&Default::default(), cx);
2454 panel.select_next(&Default::default(), cx);
2455 });
2456
2457 assert_eq!(
2458 visible_entries_as_strings(&panel, 0..50, cx),
2459 &[
2460 //
2461 "v root1",
2462 " one.two.txt <== selected",
2463 " one.txt",
2464 ]
2465 );
2466
2467 // Regression test - file name is created correctly when
2468 // the copied file's name contains multiple dots.
2469 panel.update(cx, |panel, cx| {
2470 panel.copy(&Default::default(), cx);
2471 panel.paste(&Default::default(), cx);
2472 });
2473 cx.foreground().run_until_parked();
2474
2475 assert_eq!(
2476 visible_entries_as_strings(&panel, 0..50, cx),
2477 &[
2478 //
2479 "v root1",
2480 " one.two copy.txt",
2481 " one.two.txt <== selected",
2482 " one.txt",
2483 ]
2484 );
2485
2486 panel.update(cx, |panel, cx| {
2487 panel.paste(&Default::default(), cx);
2488 });
2489 cx.foreground().run_until_parked();
2490
2491 assert_eq!(
2492 visible_entries_as_strings(&panel, 0..50, cx),
2493 &[
2494 //
2495 "v root1",
2496 " one.two copy 1.txt",
2497 " one.two copy.txt",
2498 " one.two.txt <== selected",
2499 " one.txt",
2500 ]
2501 );
2502 }
2503
2504 #[gpui::test]
2505 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2506 init_test_with_editor(cx);
2507
2508 let fs = FakeFs::new(cx.background());
2509 fs.insert_tree(
2510 "/src",
2511 json!({
2512 "test": {
2513 "first.rs": "// First Rust file",
2514 "second.rs": "// Second Rust file",
2515 "third.rs": "// Third Rust file",
2516 }
2517 }),
2518 )
2519 .await;
2520
2521 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2522 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2523 let workspace = window.root(cx);
2524 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2525
2526 toggle_expand_dir(&panel, "src/test", cx);
2527 select_path(&panel, "src/test/first.rs", cx);
2528 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2529 cx.foreground().run_until_parked();
2530 assert_eq!(
2531 visible_entries_as_strings(&panel, 0..10, cx),
2532 &[
2533 "v src",
2534 " v test",
2535 " first.rs <== selected",
2536 " second.rs",
2537 " third.rs"
2538 ]
2539 );
2540 ensure_single_file_is_opened(window, "test/first.rs", cx);
2541
2542 submit_deletion(window.into(), &panel, cx);
2543 assert_eq!(
2544 visible_entries_as_strings(&panel, 0..10, cx),
2545 &[
2546 "v src",
2547 " v test",
2548 " second.rs",
2549 " third.rs"
2550 ],
2551 "Project panel should have no deleted file, no other file is selected in it"
2552 );
2553 ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2554
2555 select_path(&panel, "src/test/second.rs", cx);
2556 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2557 cx.foreground().run_until_parked();
2558 assert_eq!(
2559 visible_entries_as_strings(&panel, 0..10, cx),
2560 &[
2561 "v src",
2562 " v test",
2563 " second.rs <== selected",
2564 " third.rs"
2565 ]
2566 );
2567 ensure_single_file_is_opened(window, "test/second.rs", cx);
2568
2569 window.update(cx, |cx| {
2570 let active_items = workspace
2571 .read(cx)
2572 .panes()
2573 .iter()
2574 .filter_map(|pane| pane.read(cx).active_item())
2575 .collect::<Vec<_>>();
2576 assert_eq!(active_items.len(), 1);
2577 let open_editor = active_items
2578 .into_iter()
2579 .next()
2580 .unwrap()
2581 .downcast::<Editor>()
2582 .expect("Open item should be an editor");
2583 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2584 });
2585 submit_deletion(window.into(), &panel, cx);
2586 assert_eq!(
2587 visible_entries_as_strings(&panel, 0..10, cx),
2588 &["v src", " v test", " third.rs"],
2589 "Project panel should have no deleted file, with one last file remaining"
2590 );
2591 ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2592 }
2593
2594 #[gpui::test]
2595 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2596 init_test_with_editor(cx);
2597
2598 let fs = FakeFs::new(cx.background());
2599 fs.insert_tree(
2600 "/src",
2601 json!({
2602 "test": {
2603 "first.rs": "// First Rust file",
2604 "second.rs": "// Second Rust file",
2605 "third.rs": "// Third Rust file",
2606 }
2607 }),
2608 )
2609 .await;
2610
2611 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2612 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2613 let workspace = window.root(cx);
2614 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2615
2616 select_path(&panel, "src/", cx);
2617 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2618 cx.foreground().run_until_parked();
2619 assert_eq!(
2620 visible_entries_as_strings(&panel, 0..10, cx),
2621 &["v src <== selected", " > test"]
2622 );
2623 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2624 window.read_with(cx, |cx| {
2625 let panel = panel.read(cx);
2626 assert!(panel.filename_editor.is_focused(cx));
2627 });
2628 assert_eq!(
2629 visible_entries_as_strings(&panel, 0..10, cx),
2630 &["v src", " > [EDITOR: ''] <== selected", " > test"]
2631 );
2632 panel.update(cx, |panel, cx| {
2633 panel
2634 .filename_editor
2635 .update(cx, |editor, cx| editor.set_text("test", cx));
2636 assert!(
2637 panel.confirm(&Confirm, cx).is_none(),
2638 "Should not allow to confirm on conflicting new directory name"
2639 )
2640 });
2641 assert_eq!(
2642 visible_entries_as_strings(&panel, 0..10, cx),
2643 &["v src", " > test"],
2644 "File list should be unchanged after failed folder create confirmation"
2645 );
2646
2647 select_path(&panel, "src/test/", cx);
2648 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2649 cx.foreground().run_until_parked();
2650 assert_eq!(
2651 visible_entries_as_strings(&panel, 0..10, cx),
2652 &["v src", " > test <== selected"]
2653 );
2654 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2655 window.read_with(cx, |cx| {
2656 let panel = panel.read(cx);
2657 assert!(panel.filename_editor.is_focused(cx));
2658 });
2659 assert_eq!(
2660 visible_entries_as_strings(&panel, 0..10, cx),
2661 &[
2662 "v src",
2663 " v test",
2664 " [EDITOR: ''] <== selected",
2665 " first.rs",
2666 " second.rs",
2667 " third.rs"
2668 ]
2669 );
2670 panel.update(cx, |panel, cx| {
2671 panel
2672 .filename_editor
2673 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2674 assert!(
2675 panel.confirm(&Confirm, cx).is_none(),
2676 "Should not allow to confirm on conflicting new file name"
2677 )
2678 });
2679 assert_eq!(
2680 visible_entries_as_strings(&panel, 0..10, cx),
2681 &[
2682 "v src",
2683 " v test",
2684 " first.rs",
2685 " second.rs",
2686 " third.rs"
2687 ],
2688 "File list should be unchanged after failed file create confirmation"
2689 );
2690
2691 select_path(&panel, "src/test/first.rs", cx);
2692 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2693 cx.foreground().run_until_parked();
2694 assert_eq!(
2695 visible_entries_as_strings(&panel, 0..10, cx),
2696 &[
2697 "v src",
2698 " v test",
2699 " first.rs <== selected",
2700 " second.rs",
2701 " third.rs"
2702 ],
2703 );
2704 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2705 window.read_with(cx, |cx| {
2706 let panel = panel.read(cx);
2707 assert!(panel.filename_editor.is_focused(cx));
2708 });
2709 assert_eq!(
2710 visible_entries_as_strings(&panel, 0..10, cx),
2711 &[
2712 "v src",
2713 " v test",
2714 " [EDITOR: 'first.rs'] <== selected",
2715 " second.rs",
2716 " third.rs"
2717 ]
2718 );
2719 panel.update(cx, |panel, cx| {
2720 panel
2721 .filename_editor
2722 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2723 assert!(
2724 panel.confirm(&Confirm, cx).is_none(),
2725 "Should not allow to confirm on conflicting file rename"
2726 )
2727 });
2728 assert_eq!(
2729 visible_entries_as_strings(&panel, 0..10, cx),
2730 &[
2731 "v src",
2732 " v test",
2733 " first.rs <== selected",
2734 " second.rs",
2735 " third.rs"
2736 ],
2737 "File list should be unchanged after failed rename confirmation"
2738 );
2739 }
2740
2741 #[gpui::test]
2742 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2743 init_test_with_editor(cx);
2744
2745 let fs = FakeFs::new(cx.background());
2746 fs.insert_tree(
2747 "/src",
2748 json!({
2749 "test": {
2750 "first.rs": "// First Rust file",
2751 "second.rs": "// Second Rust file",
2752 "third.rs": "// Third Rust file",
2753 }
2754 }),
2755 )
2756 .await;
2757
2758 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2759 let workspace = cx
2760 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2761 .root(cx);
2762 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2763
2764 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2765 let _subscription = panel.update(cx, |_, cx| {
2766 let subcription_count = Arc::clone(&new_search_events_count);
2767 cx.subscribe(&cx.handle(), move |_, _, event, _| {
2768 if matches!(event, Event::NewSearchInDirectory { .. }) {
2769 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2770 }
2771 })
2772 });
2773
2774 toggle_expand_dir(&panel, "src/test", cx);
2775 select_path(&panel, "src/test/first.rs", cx);
2776 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2777 cx.foreground().run_until_parked();
2778 assert_eq!(
2779 visible_entries_as_strings(&panel, 0..10, cx),
2780 &[
2781 "v src",
2782 " v test",
2783 " first.rs <== selected",
2784 " second.rs",
2785 " third.rs"
2786 ]
2787 );
2788 panel.update(cx, |panel, cx| {
2789 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2790 });
2791 assert_eq!(
2792 new_search_events_count.load(atomic::Ordering::SeqCst),
2793 0,
2794 "Should not trigger new search in directory when called on a file"
2795 );
2796
2797 select_path(&panel, "src/test", cx);
2798 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2799 cx.foreground().run_until_parked();
2800 assert_eq!(
2801 visible_entries_as_strings(&panel, 0..10, cx),
2802 &[
2803 "v src",
2804 " v test <== selected",
2805 " first.rs",
2806 " second.rs",
2807 " third.rs"
2808 ]
2809 );
2810 panel.update(cx, |panel, cx| {
2811 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2812 });
2813 assert_eq!(
2814 new_search_events_count.load(atomic::Ordering::SeqCst),
2815 1,
2816 "Should trigger new search in directory when called on a directory"
2817 );
2818 }
2819
2820 #[gpui::test]
2821 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2822 init_test_with_editor(cx);
2823
2824 let fs = FakeFs::new(cx.background());
2825 fs.insert_tree(
2826 "/project_root",
2827 json!({
2828 "dir_1": {
2829 "nested_dir": {
2830 "file_a.py": "# File contents",
2831 "file_b.py": "# File contents",
2832 "file_c.py": "# File contents",
2833 },
2834 "file_1.py": "# File contents",
2835 "file_2.py": "# File contents",
2836 "file_3.py": "# File contents",
2837 },
2838 "dir_2": {
2839 "file_1.py": "# File contents",
2840 "file_2.py": "# File contents",
2841 "file_3.py": "# File contents",
2842 }
2843 }),
2844 )
2845 .await;
2846
2847 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2848 let workspace = cx
2849 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2850 .root(cx);
2851 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2852
2853 panel.update(cx, |panel, cx| {
2854 panel.collapse_all_entries(&CollapseAllEntries, cx)
2855 });
2856 cx.foreground().run_until_parked();
2857 assert_eq!(
2858 visible_entries_as_strings(&panel, 0..10, cx),
2859 &["v project_root", " > dir_1", " > dir_2",]
2860 );
2861
2862 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2863 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2864 cx.foreground().run_until_parked();
2865 assert_eq!(
2866 visible_entries_as_strings(&panel, 0..10, cx),
2867 &[
2868 "v project_root",
2869 " v dir_1 <== selected",
2870 " > nested_dir",
2871 " file_1.py",
2872 " file_2.py",
2873 " file_3.py",
2874 " > dir_2",
2875 ]
2876 );
2877 }
2878
2879 #[gpui::test]
2880 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2881 init_test(cx);
2882
2883 let fs = FakeFs::new(cx.background());
2884 fs.as_fake().insert_tree("/root", json!({})).await;
2885 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2886 let workspace = cx
2887 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2888 .root(cx);
2889 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2890
2891 // Make a new buffer with no backing file
2892 workspace.update(cx, |workspace, cx| {
2893 Editor::new_file(workspace, &Default::default(), cx)
2894 });
2895
2896 // "Save as"" the buffer, creating a new backing file for it
2897 let task = workspace.update(cx, |workspace, cx| {
2898 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2899 });
2900
2901 cx.foreground().run_until_parked();
2902 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2903 task.await.unwrap();
2904
2905 // Rename the file
2906 select_path(&panel, "root/new", cx);
2907 assert_eq!(
2908 visible_entries_as_strings(&panel, 0..10, cx),
2909 &["v root", " new <== selected"]
2910 );
2911 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2912 panel.update(cx, |panel, cx| {
2913 panel
2914 .filename_editor
2915 .update(cx, |editor, cx| editor.set_text("newer", cx));
2916 });
2917 panel
2918 .update(cx, |panel, cx| panel.confirm(&Confirm, cx))
2919 .unwrap()
2920 .await
2921 .unwrap();
2922
2923 cx.foreground().run_until_parked();
2924 assert_eq!(
2925 visible_entries_as_strings(&panel, 0..10, cx),
2926 &["v root", " newer <== selected"]
2927 );
2928
2929 workspace
2930 .update(cx, |workspace, cx| {
2931 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2932 })
2933 .await
2934 .unwrap();
2935
2936 cx.foreground().run_until_parked();
2937 // assert that saving the file doesn't restore "new"
2938 assert_eq!(
2939 visible_entries_as_strings(&panel, 0..10, cx),
2940 &["v root", " newer <== selected"]
2941 );
2942 }
2943
2944 fn toggle_expand_dir(
2945 panel: &ViewHandle<ProjectPanel>,
2946 path: impl AsRef<Path>,
2947 cx: &mut TestAppContext,
2948 ) {
2949 let path = path.as_ref();
2950 panel.update(cx, |panel, cx| {
2951 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2952 let worktree = worktree.read(cx);
2953 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2954 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2955 panel.toggle_expanded(entry_id, cx);
2956 return;
2957 }
2958 }
2959 panic!("no worktree for path {:?}", path);
2960 });
2961 }
2962
2963 fn select_path(
2964 panel: &ViewHandle<ProjectPanel>,
2965 path: impl AsRef<Path>,
2966 cx: &mut TestAppContext,
2967 ) {
2968 let path = path.as_ref();
2969 panel.update(cx, |panel, cx| {
2970 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2971 let worktree = worktree.read(cx);
2972 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2973 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2974 panel.selection = Some(Selection {
2975 worktree_id: worktree.id(),
2976 entry_id,
2977 });
2978 return;
2979 }
2980 }
2981 panic!("no worktree for path {:?}", path);
2982 });
2983 }
2984
2985 fn visible_entries_as_strings(
2986 panel: &ViewHandle<ProjectPanel>,
2987 range: Range<usize>,
2988 cx: &mut TestAppContext,
2989 ) -> Vec<String> {
2990 let mut result = Vec::new();
2991 let mut project_entries = HashSet::new();
2992 let mut has_editor = false;
2993
2994 panel.update(cx, |panel, cx| {
2995 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2996 if details.is_editing {
2997 assert!(!has_editor, "duplicate editor entry");
2998 has_editor = true;
2999 } else {
3000 assert!(
3001 project_entries.insert(project_entry),
3002 "duplicate project entry {:?} {:?}",
3003 project_entry,
3004 details
3005 );
3006 }
3007
3008 let indent = " ".repeat(details.depth);
3009 let icon = if details.kind.is_dir() {
3010 if details.is_expanded {
3011 "v "
3012 } else {
3013 "> "
3014 }
3015 } else {
3016 " "
3017 };
3018 let name = if details.is_editing {
3019 format!("[EDITOR: '{}']", details.filename)
3020 } else if details.is_processing {
3021 format!("[PROCESSING: '{}']", details.filename)
3022 } else {
3023 details.filename.clone()
3024 };
3025 let selected = if details.is_selected {
3026 " <== selected"
3027 } else {
3028 ""
3029 };
3030 result.push(format!("{indent}{icon}{name}{selected}"));
3031 });
3032 });
3033
3034 result
3035 }
3036
3037 fn init_test(cx: &mut TestAppContext) {
3038 cx.foreground().forbid_parking();
3039 cx.update(|cx| {
3040 cx.set_global(SettingsStore::test(cx));
3041 init_settings(cx);
3042 theme::init((), cx);
3043 language::init(cx);
3044 editor::init_settings(cx);
3045 crate::init((), cx);
3046 workspace::init_settings(cx);
3047 client::init_settings(cx);
3048 Project::init_settings(cx);
3049
3050 cx.update_global::<SettingsStore, _, _>(|store, cx| {
3051 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3052 project_settings.file_scan_exclusions = Some(Vec::new());
3053 });
3054 });
3055 });
3056 }
3057
3058 fn init_test_with_editor(cx: &mut TestAppContext) {
3059 cx.foreground().forbid_parking();
3060 cx.update(|cx| {
3061 let app_state = AppState::test(cx);
3062 theme::init((), cx);
3063 init_settings(cx);
3064 language::init(cx);
3065 editor::init(cx);
3066 pane::init(cx);
3067 crate::init((), cx);
3068 workspace::init(app_state.clone(), cx);
3069 Project::init_settings(cx);
3070 });
3071 }
3072
3073 fn ensure_single_file_is_opened(
3074 window: WindowHandle<Workspace>,
3075 expected_path: &str,
3076 cx: &mut TestAppContext,
3077 ) {
3078 window.update_root(cx, |workspace, cx| {
3079 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3080 assert_eq!(worktrees.len(), 1);
3081 let worktree_id = WorktreeId::from_usize(worktrees[0].id());
3082
3083 let open_project_paths = workspace
3084 .panes()
3085 .iter()
3086 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3087 .collect::<Vec<_>>();
3088 assert_eq!(
3089 open_project_paths,
3090 vec![ProjectPath {
3091 worktree_id,
3092 path: Arc::from(Path::new(expected_path))
3093 }],
3094 "Should have opened file, selected in project panel"
3095 );
3096 });
3097 }
3098
3099 fn submit_deletion(
3100 window: AnyWindowHandle,
3101 panel: &ViewHandle<ProjectPanel>,
3102 cx: &mut TestAppContext,
3103 ) {
3104 assert!(
3105 !window.has_pending_prompt(cx),
3106 "Should have no prompts before the deletion"
3107 );
3108 panel.update(cx, |panel, cx| {
3109 panel
3110 .delete(&Delete, cx)
3111 .expect("Deletion start")
3112 .detach_and_log_err(cx);
3113 });
3114 assert!(
3115 window.has_pending_prompt(cx),
3116 "Should have a prompt after the deletion"
3117 );
3118 window.simulate_prompt_answer(0, cx);
3119 assert!(
3120 !window.has_pending_prompt(cx),
3121 "Should have no prompts after prompt was replied to"
3122 );
3123 cx.foreground().run_until_parked();
3124 }
3125
3126 fn ensure_no_open_items_and_panes(
3127 window: AnyWindowHandle,
3128 workspace: &ViewHandle<Workspace>,
3129 cx: &mut TestAppContext,
3130 ) {
3131 assert!(
3132 !window.has_pending_prompt(cx),
3133 "Should have no prompts after deletion operation closes the file"
3134 );
3135 window.read_with(cx, |cx| {
3136 let open_project_paths = workspace
3137 .read(cx)
3138 .panes()
3139 .iter()
3140 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3141 .collect::<Vec<_>>();
3142 assert!(
3143 open_project_paths.is_empty(),
3144 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3145 );
3146 });
3147 }
3148}
3149// TODO - a workspace command?