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