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