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