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::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(iterations = 30)]
1836 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1837 init_test(cx);
1838
1839 let fs = FakeFs::new(cx.background());
1840 fs.insert_tree(
1841 "/root1",
1842 json!({
1843 ".dockerignore": "",
1844 ".git": {
1845 "HEAD": "",
1846 },
1847 "a": {
1848 "0": { "q": "", "r": "", "s": "" },
1849 "1": { "t": "", "u": "" },
1850 "2": { "v": "", "w": "", "x": "", "y": "" },
1851 },
1852 "b": {
1853 "3": { "Q": "" },
1854 "4": { "R": "", "S": "", "T": "", "U": "" },
1855 },
1856 "C": {
1857 "5": {},
1858 "6": { "V": "", "W": "" },
1859 "7": { "X": "" },
1860 "8": { "Y": {}, "Z": "" }
1861 }
1862 }),
1863 )
1864 .await;
1865 fs.insert_tree(
1866 "/root2",
1867 json!({
1868 "d": {
1869 "9": ""
1870 },
1871 "e": {}
1872 }),
1873 )
1874 .await;
1875
1876 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1877 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1878 let workspace = window.root(cx);
1879 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1880
1881 select_path(&panel, "root1", cx);
1882 assert_eq!(
1883 visible_entries_as_strings(&panel, 0..10, cx),
1884 &[
1885 "v root1 <== selected",
1886 " > .git",
1887 " > a",
1888 " > b",
1889 " > C",
1890 " .dockerignore",
1891 "v root2",
1892 " > d",
1893 " > e",
1894 ]
1895 );
1896
1897 // Add a file with the root folder selected. The filename editor is placed
1898 // before the first file in the root folder.
1899 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1900 window.read_with(cx, |cx| {
1901 let panel = panel.read(cx);
1902 assert!(panel.filename_editor.is_focused(cx));
1903 });
1904 assert_eq!(
1905 visible_entries_as_strings(&panel, 0..10, cx),
1906 &[
1907 "v root1",
1908 " > .git",
1909 " > a",
1910 " > b",
1911 " > C",
1912 " [EDITOR: ''] <== selected",
1913 " .dockerignore",
1914 "v root2",
1915 " > d",
1916 " > e",
1917 ]
1918 );
1919
1920 let confirm = panel.update(cx, |panel, cx| {
1921 panel
1922 .filename_editor
1923 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1924 panel.confirm(&Confirm, cx).unwrap()
1925 });
1926 assert_eq!(
1927 visible_entries_as_strings(&panel, 0..10, cx),
1928 &[
1929 "v root1",
1930 " > .git",
1931 " > a",
1932 " > b",
1933 " > C",
1934 " [PROCESSING: 'the-new-filename'] <== selected",
1935 " .dockerignore",
1936 "v root2",
1937 " > d",
1938 " > e",
1939 ]
1940 );
1941
1942 confirm.await.unwrap();
1943 assert_eq!(
1944 visible_entries_as_strings(&panel, 0..10, cx),
1945 &[
1946 "v root1",
1947 " > .git",
1948 " > a",
1949 " > b",
1950 " > C",
1951 " .dockerignore",
1952 " the-new-filename <== selected",
1953 "v root2",
1954 " > d",
1955 " > e",
1956 ]
1957 );
1958
1959 select_path(&panel, "root1/b", cx);
1960 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1961 assert_eq!(
1962 visible_entries_as_strings(&panel, 0..10, cx),
1963 &[
1964 "v root1",
1965 " > .git",
1966 " > a",
1967 " v b",
1968 " > 3",
1969 " > 4",
1970 " [EDITOR: ''] <== selected",
1971 " > C",
1972 " .dockerignore",
1973 " the-new-filename",
1974 ]
1975 );
1976
1977 panel
1978 .update(cx, |panel, cx| {
1979 panel
1980 .filename_editor
1981 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1982 panel.confirm(&Confirm, cx).unwrap()
1983 })
1984 .await
1985 .unwrap();
1986 assert_eq!(
1987 visible_entries_as_strings(&panel, 0..10, cx),
1988 &[
1989 "v root1",
1990 " > .git",
1991 " > a",
1992 " v b",
1993 " > 3",
1994 " > 4",
1995 " another-filename.txt <== selected",
1996 " > C",
1997 " .dockerignore",
1998 " the-new-filename",
1999 ]
2000 );
2001
2002 select_path(&panel, "root1/b/another-filename.txt", cx);
2003 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2004 assert_eq!(
2005 visible_entries_as_strings(&panel, 0..10, cx),
2006 &[
2007 "v root1",
2008 " > .git",
2009 " > a",
2010 " v b",
2011 " > 3",
2012 " > 4",
2013 " [EDITOR: 'another-filename.txt'] <== selected",
2014 " > C",
2015 " .dockerignore",
2016 " the-new-filename",
2017 ]
2018 );
2019
2020 let confirm = panel.update(cx, |panel, cx| {
2021 panel.filename_editor.update(cx, |editor, cx| {
2022 let file_name_selections = editor.selections.all::<usize>(cx);
2023 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2024 let file_name_selection = &file_name_selections[0];
2025 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2026 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2027
2028 editor.set_text("a-different-filename.tar.gz", cx)
2029 });
2030 panel.confirm(&Confirm, cx).unwrap()
2031 });
2032 assert_eq!(
2033 visible_entries_as_strings(&panel, 0..10, cx),
2034 &[
2035 "v root1",
2036 " > .git",
2037 " > a",
2038 " v b",
2039 " > 3",
2040 " > 4",
2041 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2042 " > C",
2043 " .dockerignore",
2044 " the-new-filename",
2045 ]
2046 );
2047
2048 confirm.await.unwrap();
2049 assert_eq!(
2050 visible_entries_as_strings(&panel, 0..10, cx),
2051 &[
2052 "v root1",
2053 " > .git",
2054 " > a",
2055 " v b",
2056 " > 3",
2057 " > 4",
2058 " a-different-filename.tar.gz <== selected",
2059 " > C",
2060 " .dockerignore",
2061 " the-new-filename",
2062 ]
2063 );
2064
2065 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2066 assert_eq!(
2067 visible_entries_as_strings(&panel, 0..10, cx),
2068 &[
2069 "v root1",
2070 " > .git",
2071 " > a",
2072 " v b",
2073 " > 3",
2074 " > 4",
2075 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2076 " > C",
2077 " .dockerignore",
2078 " the-new-filename",
2079 ]
2080 );
2081
2082 panel.update(cx, |panel, cx| {
2083 panel.filename_editor.update(cx, |editor, cx| {
2084 let file_name_selections = editor.selections.all::<usize>(cx);
2085 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2086 let file_name_selection = &file_name_selections[0];
2087 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2088 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");
2089
2090 });
2091 panel.cancel(&Cancel, cx)
2092 });
2093
2094 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2095 assert_eq!(
2096 visible_entries_as_strings(&panel, 0..10, cx),
2097 &[
2098 "v root1",
2099 " > .git",
2100 " > a",
2101 " v b",
2102 " > [EDITOR: ''] <== selected",
2103 " > 3",
2104 " > 4",
2105 " a-different-filename.tar.gz",
2106 " > C",
2107 " .dockerignore",
2108 ]
2109 );
2110
2111 let confirm = panel.update(cx, |panel, cx| {
2112 panel
2113 .filename_editor
2114 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2115 panel.confirm(&Confirm, cx).unwrap()
2116 });
2117 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2118 assert_eq!(
2119 visible_entries_as_strings(&panel, 0..10, cx),
2120 &[
2121 "v root1",
2122 " > .git",
2123 " > a",
2124 " v b",
2125 " > [PROCESSING: 'new-dir']",
2126 " > 3 <== selected",
2127 " > 4",
2128 " a-different-filename.tar.gz",
2129 " > C",
2130 " .dockerignore",
2131 ]
2132 );
2133
2134 confirm.await.unwrap();
2135 assert_eq!(
2136 visible_entries_as_strings(&panel, 0..10, cx),
2137 &[
2138 "v root1",
2139 " > .git",
2140 " > a",
2141 " v b",
2142 " > 3 <== selected",
2143 " > 4",
2144 " > new-dir",
2145 " a-different-filename.tar.gz",
2146 " > C",
2147 " .dockerignore",
2148 ]
2149 );
2150
2151 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2152 assert_eq!(
2153 visible_entries_as_strings(&panel, 0..10, cx),
2154 &[
2155 "v root1",
2156 " > .git",
2157 " > a",
2158 " v b",
2159 " > [EDITOR: '3'] <== selected",
2160 " > 4",
2161 " > new-dir",
2162 " a-different-filename.tar.gz",
2163 " > C",
2164 " .dockerignore",
2165 ]
2166 );
2167
2168 // Dismiss the rename editor when it loses focus.
2169 workspace.update(cx, |_, cx| cx.focus_self());
2170 assert_eq!(
2171 visible_entries_as_strings(&panel, 0..10, cx),
2172 &[
2173 "v root1",
2174 " > .git",
2175 " > a",
2176 " v b",
2177 " > 3 <== selected",
2178 " > 4",
2179 " > new-dir",
2180 " a-different-filename.tar.gz",
2181 " > C",
2182 " .dockerignore",
2183 ]
2184 );
2185 }
2186
2187 #[gpui::test(iterations = 30)]
2188 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2189 init_test(cx);
2190
2191 let fs = FakeFs::new(cx.background());
2192 fs.insert_tree(
2193 "/root1",
2194 json!({
2195 ".dockerignore": "",
2196 ".git": {
2197 "HEAD": "",
2198 },
2199 "a": {
2200 "0": { "q": "", "r": "", "s": "" },
2201 "1": { "t": "", "u": "" },
2202 "2": { "v": "", "w": "", "x": "", "y": "" },
2203 },
2204 "b": {
2205 "3": { "Q": "" },
2206 "4": { "R": "", "S": "", "T": "", "U": "" },
2207 },
2208 "C": {
2209 "5": {},
2210 "6": { "V": "", "W": "" },
2211 "7": { "X": "" },
2212 "8": { "Y": {}, "Z": "" }
2213 }
2214 }),
2215 )
2216 .await;
2217 fs.insert_tree(
2218 "/root2",
2219 json!({
2220 "d": {
2221 "9": ""
2222 },
2223 "e": {}
2224 }),
2225 )
2226 .await;
2227
2228 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2229 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2230 let workspace = window.root(cx);
2231 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2232
2233 select_path(&panel, "root1", cx);
2234 assert_eq!(
2235 visible_entries_as_strings(&panel, 0..10, cx),
2236 &[
2237 "v root1 <== selected",
2238 " > .git",
2239 " > a",
2240 " > b",
2241 " > C",
2242 " .dockerignore",
2243 "v root2",
2244 " > d",
2245 " > e",
2246 ]
2247 );
2248
2249 // Add a file with the root folder selected. The filename editor is placed
2250 // before the first file in the root folder.
2251 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2252 window.read_with(cx, |cx| {
2253 let panel = panel.read(cx);
2254 assert!(panel.filename_editor.is_focused(cx));
2255 });
2256 assert_eq!(
2257 visible_entries_as_strings(&panel, 0..10, cx),
2258 &[
2259 "v root1",
2260 " > .git",
2261 " > a",
2262 " > b",
2263 " > C",
2264 " [EDITOR: ''] <== selected",
2265 " .dockerignore",
2266 "v root2",
2267 " > d",
2268 " > e",
2269 ]
2270 );
2271
2272 let confirm = panel.update(cx, |panel, cx| {
2273 panel.filename_editor.update(cx, |editor, cx| {
2274 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2275 });
2276 panel.confirm(&Confirm, cx).unwrap()
2277 });
2278
2279 assert_eq!(
2280 visible_entries_as_strings(&panel, 0..10, cx),
2281 &[
2282 "v root1",
2283 " > .git",
2284 " > a",
2285 " > b",
2286 " > C",
2287 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2288 " .dockerignore",
2289 "v root2",
2290 " > d",
2291 " > e",
2292 ]
2293 );
2294
2295 confirm.await.unwrap();
2296 assert_eq!(
2297 visible_entries_as_strings(&panel, 0..13, cx),
2298 &[
2299 "v root1",
2300 " > .git",
2301 " > a",
2302 " > b",
2303 " v bdir1",
2304 " v dir2",
2305 " the-new-filename <== selected",
2306 " > C",
2307 " .dockerignore",
2308 "v root2",
2309 " > d",
2310 " > e",
2311 ]
2312 );
2313 }
2314
2315 #[gpui::test]
2316 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2317 init_test(cx);
2318
2319 let fs = FakeFs::new(cx.background());
2320 fs.insert_tree(
2321 "/root1",
2322 json!({
2323 "one.two.txt": "",
2324 "one.txt": ""
2325 }),
2326 )
2327 .await;
2328
2329 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2330 let workspace = cx
2331 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2332 .root(cx);
2333 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2334
2335 panel.update(cx, |panel, cx| {
2336 panel.select_next(&Default::default(), cx);
2337 panel.select_next(&Default::default(), cx);
2338 });
2339
2340 assert_eq!(
2341 visible_entries_as_strings(&panel, 0..50, cx),
2342 &[
2343 //
2344 "v root1",
2345 " one.two.txt <== selected",
2346 " one.txt",
2347 ]
2348 );
2349
2350 // Regression test - file name is created correctly when
2351 // the copied file's name contains multiple dots.
2352 panel.update(cx, |panel, cx| {
2353 panel.copy(&Default::default(), cx);
2354 panel.paste(&Default::default(), cx);
2355 });
2356 cx.foreground().run_until_parked();
2357
2358 assert_eq!(
2359 visible_entries_as_strings(&panel, 0..50, cx),
2360 &[
2361 //
2362 "v root1",
2363 " one.two copy.txt",
2364 " one.two.txt <== selected",
2365 " one.txt",
2366 ]
2367 );
2368
2369 panel.update(cx, |panel, cx| {
2370 panel.paste(&Default::default(), cx);
2371 });
2372 cx.foreground().run_until_parked();
2373
2374 assert_eq!(
2375 visible_entries_as_strings(&panel, 0..50, cx),
2376 &[
2377 //
2378 "v root1",
2379 " one.two copy 1.txt",
2380 " one.two copy.txt",
2381 " one.two.txt <== selected",
2382 " one.txt",
2383 ]
2384 );
2385 }
2386
2387 #[gpui::test]
2388 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2389 init_test_with_editor(cx);
2390
2391 let fs = FakeFs::new(cx.background());
2392 fs.insert_tree(
2393 "/src",
2394 json!({
2395 "test": {
2396 "first.rs": "// First Rust file",
2397 "second.rs": "// Second Rust file",
2398 "third.rs": "// Third Rust file",
2399 }
2400 }),
2401 )
2402 .await;
2403
2404 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2405 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2406 let workspace = window.root(cx);
2407 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2408
2409 toggle_expand_dir(&panel, "src/test", cx);
2410 select_path(&panel, "src/test/first.rs", cx);
2411 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2412 cx.foreground().run_until_parked();
2413 assert_eq!(
2414 visible_entries_as_strings(&panel, 0..10, cx),
2415 &[
2416 "v src",
2417 " v test",
2418 " first.rs <== selected",
2419 " second.rs",
2420 " third.rs"
2421 ]
2422 );
2423 ensure_single_file_is_opened(window, "test/first.rs", cx);
2424
2425 submit_deletion(window.into(), &panel, cx);
2426 assert_eq!(
2427 visible_entries_as_strings(&panel, 0..10, cx),
2428 &[
2429 "v src",
2430 " v test",
2431 " second.rs",
2432 " third.rs"
2433 ],
2434 "Project panel should have no deleted file, no other file is selected in it"
2435 );
2436 ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2437
2438 select_path(&panel, "src/test/second.rs", cx);
2439 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2440 cx.foreground().run_until_parked();
2441 assert_eq!(
2442 visible_entries_as_strings(&panel, 0..10, cx),
2443 &[
2444 "v src",
2445 " v test",
2446 " second.rs <== selected",
2447 " third.rs"
2448 ]
2449 );
2450 ensure_single_file_is_opened(window, "test/second.rs", cx);
2451
2452 window.update(cx, |cx| {
2453 let active_items = workspace
2454 .read(cx)
2455 .panes()
2456 .iter()
2457 .filter_map(|pane| pane.read(cx).active_item())
2458 .collect::<Vec<_>>();
2459 assert_eq!(active_items.len(), 1);
2460 let open_editor = active_items
2461 .into_iter()
2462 .next()
2463 .unwrap()
2464 .downcast::<Editor>()
2465 .expect("Open item should be an editor");
2466 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2467 });
2468 submit_deletion(window.into(), &panel, cx);
2469 assert_eq!(
2470 visible_entries_as_strings(&panel, 0..10, cx),
2471 &["v src", " v test", " third.rs"],
2472 "Project panel should have no deleted file, with one last file remaining"
2473 );
2474 ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2475 }
2476
2477 #[gpui::test]
2478 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2479 init_test_with_editor(cx);
2480
2481 let fs = FakeFs::new(cx.background());
2482 fs.insert_tree(
2483 "/src",
2484 json!({
2485 "test": {
2486 "first.rs": "// First Rust file",
2487 "second.rs": "// Second Rust file",
2488 "third.rs": "// Third Rust file",
2489 }
2490 }),
2491 )
2492 .await;
2493
2494 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2495 let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2496 let workspace = window.root(cx);
2497 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2498
2499 select_path(&panel, "src/", cx);
2500 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2501 cx.foreground().run_until_parked();
2502 assert_eq!(
2503 visible_entries_as_strings(&panel, 0..10, cx),
2504 &["v src <== selected", " > test"]
2505 );
2506 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2507 window.read_with(cx, |cx| {
2508 let panel = panel.read(cx);
2509 assert!(panel.filename_editor.is_focused(cx));
2510 });
2511 assert_eq!(
2512 visible_entries_as_strings(&panel, 0..10, cx),
2513 &["v src", " > [EDITOR: ''] <== selected", " > test"]
2514 );
2515 panel.update(cx, |panel, cx| {
2516 panel
2517 .filename_editor
2518 .update(cx, |editor, cx| editor.set_text("test", cx));
2519 assert!(
2520 panel.confirm(&Confirm, cx).is_none(),
2521 "Should not allow to confirm on conflicting new directory name"
2522 )
2523 });
2524 assert_eq!(
2525 visible_entries_as_strings(&panel, 0..10, cx),
2526 &["v src", " > test"],
2527 "File list should be unchanged after failed folder create confirmation"
2528 );
2529
2530 select_path(&panel, "src/test/", cx);
2531 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2532 cx.foreground().run_until_parked();
2533 assert_eq!(
2534 visible_entries_as_strings(&panel, 0..10, cx),
2535 &["v src", " > test <== selected"]
2536 );
2537 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2538 window.read_with(cx, |cx| {
2539 let panel = panel.read(cx);
2540 assert!(panel.filename_editor.is_focused(cx));
2541 });
2542 assert_eq!(
2543 visible_entries_as_strings(&panel, 0..10, cx),
2544 &[
2545 "v src",
2546 " v test",
2547 " [EDITOR: ''] <== selected",
2548 " first.rs",
2549 " second.rs",
2550 " third.rs"
2551 ]
2552 );
2553 panel.update(cx, |panel, cx| {
2554 panel
2555 .filename_editor
2556 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2557 assert!(
2558 panel.confirm(&Confirm, cx).is_none(),
2559 "Should not allow to confirm on conflicting new file name"
2560 )
2561 });
2562 assert_eq!(
2563 visible_entries_as_strings(&panel, 0..10, cx),
2564 &[
2565 "v src",
2566 " v test",
2567 " first.rs",
2568 " second.rs",
2569 " third.rs"
2570 ],
2571 "File list should be unchanged after failed file create confirmation"
2572 );
2573
2574 select_path(&panel, "src/test/first.rs", cx);
2575 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2576 cx.foreground().run_until_parked();
2577 assert_eq!(
2578 visible_entries_as_strings(&panel, 0..10, cx),
2579 &[
2580 "v src",
2581 " v test",
2582 " first.rs <== selected",
2583 " second.rs",
2584 " third.rs"
2585 ],
2586 );
2587 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2588 window.read_with(cx, |cx| {
2589 let panel = panel.read(cx);
2590 assert!(panel.filename_editor.is_focused(cx));
2591 });
2592 assert_eq!(
2593 visible_entries_as_strings(&panel, 0..10, cx),
2594 &[
2595 "v src",
2596 " v test",
2597 " [EDITOR: 'first.rs'] <== selected",
2598 " second.rs",
2599 " third.rs"
2600 ]
2601 );
2602 panel.update(cx, |panel, cx| {
2603 panel
2604 .filename_editor
2605 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2606 assert!(
2607 panel.confirm(&Confirm, cx).is_none(),
2608 "Should not allow to confirm on conflicting file rename"
2609 )
2610 });
2611 assert_eq!(
2612 visible_entries_as_strings(&panel, 0..10, cx),
2613 &[
2614 "v src",
2615 " v test",
2616 " first.rs <== selected",
2617 " second.rs",
2618 " third.rs"
2619 ],
2620 "File list should be unchanged after failed rename confirmation"
2621 );
2622 }
2623
2624 #[gpui::test]
2625 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2626 init_test_with_editor(cx);
2627
2628 let fs = FakeFs::new(cx.background());
2629 fs.insert_tree(
2630 "/src",
2631 json!({
2632 "test": {
2633 "first.rs": "// First Rust file",
2634 "second.rs": "// Second Rust file",
2635 "third.rs": "// Third Rust file",
2636 }
2637 }),
2638 )
2639 .await;
2640
2641 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2642 let workspace = cx
2643 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2644 .root(cx);
2645 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2646
2647 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2648 let _subscription = panel.update(cx, |_, cx| {
2649 let subcription_count = Arc::clone(&new_search_events_count);
2650 cx.subscribe(&cx.handle(), move |_, _, event, _| {
2651 if matches!(event, Event::NewSearchInDirectory { .. }) {
2652 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2653 }
2654 })
2655 });
2656
2657 toggle_expand_dir(&panel, "src/test", cx);
2658 select_path(&panel, "src/test/first.rs", cx);
2659 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2660 cx.foreground().run_until_parked();
2661 assert_eq!(
2662 visible_entries_as_strings(&panel, 0..10, cx),
2663 &[
2664 "v src",
2665 " v test",
2666 " first.rs <== selected",
2667 " second.rs",
2668 " third.rs"
2669 ]
2670 );
2671 panel.update(cx, |panel, cx| {
2672 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2673 });
2674 assert_eq!(
2675 new_search_events_count.load(atomic::Ordering::SeqCst),
2676 0,
2677 "Should not trigger new search in directory when called on a file"
2678 );
2679
2680 select_path(&panel, "src/test", cx);
2681 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2682 cx.foreground().run_until_parked();
2683 assert_eq!(
2684 visible_entries_as_strings(&panel, 0..10, cx),
2685 &[
2686 "v src",
2687 " v test <== selected",
2688 " first.rs",
2689 " second.rs",
2690 " third.rs"
2691 ]
2692 );
2693 panel.update(cx, |panel, cx| {
2694 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2695 });
2696 assert_eq!(
2697 new_search_events_count.load(atomic::Ordering::SeqCst),
2698 1,
2699 "Should trigger new search in directory when called on a directory"
2700 );
2701 }
2702
2703 #[gpui::test]
2704 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2705 init_test_with_editor(cx);
2706
2707 let fs = FakeFs::new(cx.background());
2708 fs.insert_tree(
2709 "/project_root",
2710 json!({
2711 "dir_1": {
2712 "nested_dir": {
2713 "file_a.py": "# File contents",
2714 "file_b.py": "# File contents",
2715 "file_c.py": "# File contents",
2716 },
2717 "file_1.py": "# File contents",
2718 "file_2.py": "# File contents",
2719 "file_3.py": "# File contents",
2720 },
2721 "dir_2": {
2722 "file_1.py": "# File contents",
2723 "file_2.py": "# File contents",
2724 "file_3.py": "# File contents",
2725 }
2726 }),
2727 )
2728 .await;
2729
2730 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2731 let workspace = cx
2732 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2733 .root(cx);
2734 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2735
2736 panel.update(cx, |panel, cx| {
2737 panel.collapse_all_entries(&CollapseAllEntries, cx)
2738 });
2739 cx.foreground().run_until_parked();
2740 assert_eq!(
2741 visible_entries_as_strings(&panel, 0..10, cx),
2742 &["v project_root", " > dir_1", " > dir_2",]
2743 );
2744
2745 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2746 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2747 cx.foreground().run_until_parked();
2748 assert_eq!(
2749 visible_entries_as_strings(&panel, 0..10, cx),
2750 &[
2751 "v project_root",
2752 " v dir_1 <== selected",
2753 " > nested_dir",
2754 " file_1.py",
2755 " file_2.py",
2756 " file_3.py",
2757 " > dir_2",
2758 ]
2759 );
2760 }
2761
2762 #[gpui::test]
2763 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2764 init_test(cx);
2765
2766 let fs = FakeFs::new(cx.background());
2767 fs.as_fake().insert_tree("/root", json!({})).await;
2768 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2769 let workspace = cx
2770 .add_window(|cx| Workspace::test_new(project.clone(), cx))
2771 .root(cx);
2772 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2773
2774 // Make a new buffer with no backing file
2775 workspace.update(cx, |workspace, cx| {
2776 Editor::new_file(workspace, &Default::default(), cx)
2777 });
2778
2779 // "Save as"" the buffer, creating a new backing file for it
2780 let task = workspace.update(cx, |workspace, cx| {
2781 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2782 });
2783
2784 cx.foreground().run_until_parked();
2785 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2786 task.await.unwrap();
2787
2788 // Rename the file
2789 select_path(&panel, "root/new", cx);
2790 assert_eq!(
2791 visible_entries_as_strings(&panel, 0..10, cx),
2792 &["v root", " new <== selected"]
2793 );
2794 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2795 panel.update(cx, |panel, cx| {
2796 panel
2797 .filename_editor
2798 .update(cx, |editor, cx| editor.set_text("newer", cx));
2799 });
2800 panel
2801 .update(cx, |panel, cx| panel.confirm(&Confirm, cx))
2802 .unwrap()
2803 .await
2804 .unwrap();
2805
2806 cx.foreground().run_until_parked();
2807 assert_eq!(
2808 visible_entries_as_strings(&panel, 0..10, cx),
2809 &["v root", " newer <== selected"]
2810 );
2811
2812 workspace
2813 .update(cx, |workspace, cx| {
2814 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2815 })
2816 .await
2817 .unwrap();
2818
2819 cx.foreground().run_until_parked();
2820 // assert that saving the file doesn't restore "new"
2821 assert_eq!(
2822 visible_entries_as_strings(&panel, 0..10, cx),
2823 &["v root", " newer <== selected"]
2824 );
2825 }
2826
2827 fn toggle_expand_dir(
2828 panel: &ViewHandle<ProjectPanel>,
2829 path: impl AsRef<Path>,
2830 cx: &mut TestAppContext,
2831 ) {
2832 let path = path.as_ref();
2833 panel.update(cx, |panel, cx| {
2834 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2835 let worktree = worktree.read(cx);
2836 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2837 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2838 panel.toggle_expanded(entry_id, cx);
2839 return;
2840 }
2841 }
2842 panic!("no worktree for path {:?}", path);
2843 });
2844 }
2845
2846 fn select_path(
2847 panel: &ViewHandle<ProjectPanel>,
2848 path: impl AsRef<Path>,
2849 cx: &mut TestAppContext,
2850 ) {
2851 let path = path.as_ref();
2852 panel.update(cx, |panel, cx| {
2853 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2854 let worktree = worktree.read(cx);
2855 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2856 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2857 panel.selection = Some(Selection {
2858 worktree_id: worktree.id(),
2859 entry_id,
2860 });
2861 return;
2862 }
2863 }
2864 panic!("no worktree for path {:?}", path);
2865 });
2866 }
2867
2868 fn visible_entries_as_strings(
2869 panel: &ViewHandle<ProjectPanel>,
2870 range: Range<usize>,
2871 cx: &mut TestAppContext,
2872 ) -> Vec<String> {
2873 let mut result = Vec::new();
2874 let mut project_entries = HashSet::new();
2875 let mut has_editor = false;
2876
2877 panel.update(cx, |panel, cx| {
2878 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2879 if details.is_editing {
2880 assert!(!has_editor, "duplicate editor entry");
2881 has_editor = true;
2882 } else {
2883 assert!(
2884 project_entries.insert(project_entry),
2885 "duplicate project entry {:?} {:?}",
2886 project_entry,
2887 details
2888 );
2889 }
2890
2891 let indent = " ".repeat(details.depth);
2892 let icon = if details.kind.is_dir() {
2893 if details.is_expanded {
2894 "v "
2895 } else {
2896 "> "
2897 }
2898 } else {
2899 " "
2900 };
2901 let name = if details.is_editing {
2902 format!("[EDITOR: '{}']", details.filename)
2903 } else if details.is_processing {
2904 format!("[PROCESSING: '{}']", details.filename)
2905 } else {
2906 details.filename.clone()
2907 };
2908 let selected = if details.is_selected {
2909 " <== selected"
2910 } else {
2911 ""
2912 };
2913 result.push(format!("{indent}{icon}{name}{selected}"));
2914 });
2915 });
2916
2917 result
2918 }
2919
2920 fn init_test(cx: &mut TestAppContext) {
2921 cx.foreground().forbid_parking();
2922 cx.update(|cx| {
2923 cx.set_global(SettingsStore::test(cx));
2924 init_settings(cx);
2925 theme::init((), cx);
2926 language::init(cx);
2927 editor::init_settings(cx);
2928 crate::init((), cx);
2929 workspace::init_settings(cx);
2930 client::init_settings(cx);
2931 Project::init_settings(cx);
2932 });
2933 }
2934
2935 fn init_test_with_editor(cx: &mut TestAppContext) {
2936 cx.foreground().forbid_parking();
2937 cx.update(|cx| {
2938 let app_state = AppState::test(cx);
2939 theme::init((), cx);
2940 init_settings(cx);
2941 language::init(cx);
2942 editor::init(cx);
2943 pane::init(cx);
2944 crate::init((), cx);
2945 workspace::init(app_state.clone(), cx);
2946 Project::init_settings(cx);
2947 });
2948 }
2949
2950 fn ensure_single_file_is_opened(
2951 window: WindowHandle<Workspace>,
2952 expected_path: &str,
2953 cx: &mut TestAppContext,
2954 ) {
2955 window.update_root(cx, |workspace, cx| {
2956 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2957 assert_eq!(worktrees.len(), 1);
2958 let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2959
2960 let open_project_paths = workspace
2961 .panes()
2962 .iter()
2963 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2964 .collect::<Vec<_>>();
2965 assert_eq!(
2966 open_project_paths,
2967 vec![ProjectPath {
2968 worktree_id,
2969 path: Arc::from(Path::new(expected_path))
2970 }],
2971 "Should have opened file, selected in project panel"
2972 );
2973 });
2974 }
2975
2976 fn submit_deletion(
2977 window: AnyWindowHandle,
2978 panel: &ViewHandle<ProjectPanel>,
2979 cx: &mut TestAppContext,
2980 ) {
2981 assert!(
2982 !window.has_pending_prompt(cx),
2983 "Should have no prompts before the deletion"
2984 );
2985 panel.update(cx, |panel, cx| {
2986 panel
2987 .delete(&Delete, cx)
2988 .expect("Deletion start")
2989 .detach_and_log_err(cx);
2990 });
2991 assert!(
2992 window.has_pending_prompt(cx),
2993 "Should have a prompt after the deletion"
2994 );
2995 window.simulate_prompt_answer(0, cx);
2996 assert!(
2997 !window.has_pending_prompt(cx),
2998 "Should have no prompts after prompt was replied to"
2999 );
3000 cx.foreground().run_until_parked();
3001 }
3002
3003 fn ensure_no_open_items_and_panes(
3004 window: AnyWindowHandle,
3005 workspace: &ViewHandle<Workspace>,
3006 cx: &mut TestAppContext,
3007 ) {
3008 assert!(
3009 !window.has_pending_prompt(cx),
3010 "Should have no prompts after deletion operation closes the file"
3011 );
3012 window.read_with(cx, |cx| {
3013 let open_project_paths = workspace
3014 .read(cx)
3015 .panes()
3016 .iter()
3017 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3018 .collect::<Vec<_>>();
3019 assert!(
3020 open_project_paths.is_empty(),
3021 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3022 );
3023 });
3024 }
3025}
3026// TODO - a workspace command?