1mod project_panel_settings;
2use client::{ErrorCode, ErrorExt};
3use settings::Settings;
4
5use db::kvp::KEY_VALUE_STORE;
6use editor::{actions::Cancel, items::entry_git_aware_label_color, scroll::Autoscroll, Editor};
7use file_icons::FileIcons;
8
9use anyhow::{anyhow, Result};
10use collections::{hash_map, HashMap};
11use gpui::{
12 actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AppContext,
13 AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle,
14 FocusableView, InteractiveElement, KeyContext, Model, MouseButton, MouseDownEvent,
15 ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task,
16 UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext,
17};
18use menu::{Confirm, SelectNext, SelectPrev};
19use project::{
20 repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
21 Worktree, WorktreeId,
22};
23use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
24use serde::{Deserialize, Serialize};
25use std::{
26 cmp::Ordering,
27 ffi::OsStr,
28 ops::Range,
29 path::{Path, PathBuf},
30 sync::Arc,
31};
32use theme::ThemeSettings;
33use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem};
34use unicase::UniCase;
35use util::{maybe, NumericPrefixWithSuffix, ResultExt, TryFutureExt};
36use workspace::{
37 dock::{DockPosition, Panel, PanelEvent},
38 notifications::DetachAndPromptErr,
39 Workspace,
40};
41
42const PROJECT_PANEL_KEY: &str = "ProjectPanel";
43const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
44
45pub struct ProjectPanel {
46 project: Model<Project>,
47 fs: Arc<dyn Fs>,
48 scroll_handle: UniformListScrollHandle,
49 focus_handle: FocusHandle,
50 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
51 last_worktree_root_id: Option<ProjectEntryId>,
52 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
53 selection: Option<Selection>,
54 context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
55 edit_state: Option<EditState>,
56 filename_editor: View<Editor>,
57 clipboard_entry: Option<ClipboardEntry>,
58 _dragged_entry_destination: Option<Arc<Path>>,
59 workspace: WeakView<Workspace>,
60 width: Option<Pixels>,
61 pending_serialization: Task<Option<()>>,
62}
63
64#[derive(Copy, Clone, Debug)]
65struct Selection {
66 worktree_id: WorktreeId,
67 entry_id: ProjectEntryId,
68}
69
70#[derive(Clone, Debug)]
71struct EditState {
72 worktree_id: WorktreeId,
73 entry_id: ProjectEntryId,
74 is_new_entry: bool,
75 is_dir: bool,
76 processing_filename: Option<String>,
77}
78
79#[derive(Copy, Clone)]
80pub enum ClipboardEntry {
81 Copied {
82 worktree_id: WorktreeId,
83 entry_id: ProjectEntryId,
84 },
85 Cut {
86 worktree_id: WorktreeId,
87 entry_id: ProjectEntryId,
88 },
89}
90
91#[derive(Debug, PartialEq, Eq, Clone)]
92pub struct EntryDetails {
93 filename: String,
94 icon: Option<Arc<str>>,
95 path: Arc<Path>,
96 depth: usize,
97 kind: EntryKind,
98 is_ignored: bool,
99 is_expanded: bool,
100 is_selected: bool,
101 is_editing: bool,
102 is_processing: bool,
103 is_cut: bool,
104 git_status: Option<GitFileStatus>,
105 is_dotenv: bool,
106}
107
108#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
109pub struct Delete {
110 #[serde(default)]
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_icons::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::<FileIcons>(|_, 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 FileIcons::get_icon(&entry.path, cx)
1332 } else {
1333 None
1334 }
1335 }
1336 _ => {
1337 if show_folder_icons {
1338 FileIcons::get_folder_icon(is_expanded, cx)
1339 } else {
1340 FileIcons::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 h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
1438 } else {
1439 h_flex().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 h_flex().h_6().child(
1446 Label::new(file_name)
1447 .single_line()
1448 .color(filename_text_color),
1449 )
1450 }
1451 .ml_1(),
1452 )
1453 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1454 if event.down.button == MouseButton::Right || event.down.first_mouse {
1455 return;
1456 }
1457 if !show_editor {
1458 if kind.is_dir() {
1459 this.toggle_expanded(entry_id, cx);
1460 } else {
1461 if event.down.modifiers.secondary() {
1462 this.split_entry(entry_id, cx);
1463 } else {
1464 this.open_entry(entry_id, event.up.click_count > 1, cx);
1465 }
1466 }
1467 }
1468 }))
1469 .on_secondary_mouse_down(cx.listener(
1470 move |this, event: &MouseDownEvent, cx| {
1471 // Stop propagation to prevent the catch-all context menu for the project
1472 // panel from being deployed.
1473 cx.stop_propagation();
1474 this.deploy_context_menu(event.position, entry_id, cx);
1475 },
1476 )),
1477 )
1478 }
1479
1480 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1481 let mut dispatch_context = KeyContext::default();
1482 dispatch_context.add("ProjectPanel");
1483 dispatch_context.add("menu");
1484
1485 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1486 "editing"
1487 } else {
1488 "not_editing"
1489 };
1490
1491 dispatch_context.add(identifier);
1492 dispatch_context
1493 }
1494
1495 fn reveal_entry(
1496 &mut self,
1497 project: Model<Project>,
1498 entry_id: ProjectEntryId,
1499 skip_ignored: bool,
1500 cx: &mut ViewContext<'_, ProjectPanel>,
1501 ) {
1502 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1503 let worktree = worktree.read(cx);
1504 if skip_ignored
1505 && worktree
1506 .entry_for_id(entry_id)
1507 .map_or(true, |entry| entry.is_ignored)
1508 {
1509 return;
1510 }
1511
1512 let worktree_id = worktree.id();
1513 self.expand_entry(worktree_id, entry_id, cx);
1514 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1515 self.autoscroll(cx);
1516 cx.notify();
1517 }
1518 }
1519}
1520
1521impl Render for ProjectPanel {
1522 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1523 let has_worktree = self.visible_entries.len() != 0;
1524 let project = self.project.read(cx);
1525
1526 if has_worktree {
1527 div()
1528 .id("project-panel")
1529 .size_full()
1530 .relative()
1531 .key_context(self.dispatch_context(cx))
1532 .on_action(cx.listener(Self::select_next))
1533 .on_action(cx.listener(Self::select_prev))
1534 .on_action(cx.listener(Self::expand_selected_entry))
1535 .on_action(cx.listener(Self::collapse_selected_entry))
1536 .on_action(cx.listener(Self::collapse_all_entries))
1537 .on_action(cx.listener(Self::open))
1538 .on_action(cx.listener(Self::confirm))
1539 .on_action(cx.listener(Self::cancel))
1540 .on_action(cx.listener(Self::copy_path))
1541 .on_action(cx.listener(Self::copy_relative_path))
1542 .on_action(cx.listener(Self::new_search_in_directory))
1543 .when(!project.is_read_only(), |el| {
1544 el.on_action(cx.listener(Self::new_file))
1545 .on_action(cx.listener(Self::new_directory))
1546 .on_action(cx.listener(Self::rename))
1547 .on_action(cx.listener(Self::delete))
1548 .on_action(cx.listener(Self::cut))
1549 .on_action(cx.listener(Self::copy))
1550 .on_action(cx.listener(Self::paste))
1551 })
1552 .when(project.is_local(), |el| {
1553 el.on_action(cx.listener(Self::reveal_in_finder))
1554 .on_action(cx.listener(Self::open_in_terminal))
1555 })
1556 .on_mouse_down(
1557 MouseButton::Right,
1558 cx.listener(move |this, event: &MouseDownEvent, cx| {
1559 // When deploying the context menu anywhere below the last project entry,
1560 // act as if the user clicked the root of the last worktree.
1561 if let Some(entry_id) = this.last_worktree_root_id {
1562 this.deploy_context_menu(event.position, entry_id, cx);
1563 }
1564 }),
1565 )
1566 .track_focus(&self.focus_handle)
1567 .child(
1568 uniform_list(
1569 cx.view().clone(),
1570 "entries",
1571 self.visible_entries
1572 .iter()
1573 .map(|(_, worktree_entries)| worktree_entries.len())
1574 .sum(),
1575 {
1576 |this, range, cx| {
1577 let mut items = Vec::new();
1578 this.for_each_visible_entry(range, cx, |id, details, cx| {
1579 items.push(this.render_entry(id, details, cx));
1580 });
1581 items
1582 }
1583 },
1584 )
1585 .size_full()
1586 .track_scroll(self.scroll_handle.clone()),
1587 )
1588 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1589 deferred(
1590 anchored()
1591 .position(*position)
1592 .anchor(gpui::AnchorCorner::TopLeft)
1593 .child(menu.clone()),
1594 )
1595 .with_priority(1)
1596 }))
1597 } else {
1598 v_flex()
1599 .id("empty-project_panel")
1600 .size_full()
1601 .p_4()
1602 .track_focus(&self.focus_handle)
1603 .child(
1604 Button::new("open_project", "Open a project")
1605 .style(ButtonStyle::Filled)
1606 .full_width()
1607 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1608 .on_click(cx.listener(|this, _, cx| {
1609 this.workspace
1610 .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1611 .log_err();
1612 })),
1613 )
1614 }
1615 }
1616}
1617
1618impl Render for DraggedProjectEntryView {
1619 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1620 let settings = ProjectPanelSettings::get_global(cx);
1621 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1622 h_flex()
1623 .font(ui_font)
1624 .bg(cx.theme().colors().background)
1625 .w(self.width)
1626 .child(
1627 ListItem::new(self.entry_id.to_proto() as usize)
1628 .indent_level(self.details.depth)
1629 .indent_step_size(px(settings.indent_size))
1630 .child(if let Some(icon) = &self.details.icon {
1631 div().child(Icon::from_path(icon.to_string()))
1632 } else {
1633 div()
1634 })
1635 .child(Label::new(self.details.filename.clone())),
1636 )
1637 }
1638}
1639
1640impl EventEmitter<Event> for ProjectPanel {}
1641
1642impl EventEmitter<PanelEvent> for ProjectPanel {}
1643
1644impl Panel for ProjectPanel {
1645 fn position(&self, cx: &WindowContext) -> DockPosition {
1646 match ProjectPanelSettings::get_global(cx).dock {
1647 ProjectPanelDockPosition::Left => DockPosition::Left,
1648 ProjectPanelDockPosition::Right => DockPosition::Right,
1649 }
1650 }
1651
1652 fn position_is_valid(&self, position: DockPosition) -> bool {
1653 matches!(position, DockPosition::Left | DockPosition::Right)
1654 }
1655
1656 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1657 settings::update_settings_file::<ProjectPanelSettings>(
1658 self.fs.clone(),
1659 cx,
1660 move |settings| {
1661 let dock = match position {
1662 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1663 DockPosition::Right => ProjectPanelDockPosition::Right,
1664 };
1665 settings.dock = Some(dock);
1666 },
1667 );
1668 }
1669
1670 fn size(&self, cx: &WindowContext) -> Pixels {
1671 self.width
1672 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1673 }
1674
1675 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1676 self.width = size;
1677 self.serialize(cx);
1678 cx.notify();
1679 }
1680
1681 fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
1682 Some(ui::IconName::FileTree)
1683 }
1684
1685 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1686 Some("Project Panel")
1687 }
1688
1689 fn toggle_action(&self) -> Box<dyn Action> {
1690 Box::new(ToggleFocus)
1691 }
1692
1693 fn persistent_name() -> &'static str {
1694 "Project Panel"
1695 }
1696
1697 fn starts_open(&self, cx: &WindowContext) -> bool {
1698 self.project.read(cx).visible_worktrees(cx).any(|tree| {
1699 tree.read(cx)
1700 .root_entry()
1701 .map_or(false, |entry| entry.is_dir())
1702 })
1703 }
1704}
1705
1706impl FocusableView for ProjectPanel {
1707 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1708 self.focus_handle.clone()
1709 }
1710}
1711
1712impl ClipboardEntry {
1713 fn is_cut(&self) -> bool {
1714 matches!(self, Self::Cut { .. })
1715 }
1716
1717 fn entry_id(&self) -> ProjectEntryId {
1718 match self {
1719 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1720 *entry_id
1721 }
1722 }
1723 }
1724
1725 fn worktree_id(&self) -> WorktreeId {
1726 match self {
1727 ClipboardEntry::Copied { worktree_id, .. }
1728 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1729 }
1730 }
1731}
1732
1733#[cfg(test)]
1734mod tests {
1735 use super::*;
1736 use collections::HashSet;
1737 use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1738 use pretty_assertions::assert_eq;
1739 use project::{FakeFs, WorktreeSettings};
1740 use serde_json::json;
1741 use settings::SettingsStore;
1742 use std::path::{Path, PathBuf};
1743 use workspace::AppState;
1744
1745 #[gpui::test]
1746 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1747 init_test(cx);
1748
1749 let fs = FakeFs::new(cx.executor().clone());
1750 fs.insert_tree(
1751 "/root1",
1752 json!({
1753 ".dockerignore": "",
1754 ".git": {
1755 "HEAD": "",
1756 },
1757 "a": {
1758 "0": { "q": "", "r": "", "s": "" },
1759 "1": { "t": "", "u": "" },
1760 "2": { "v": "", "w": "", "x": "", "y": "" },
1761 },
1762 "b": {
1763 "3": { "Q": "" },
1764 "4": { "R": "", "S": "", "T": "", "U": "" },
1765 },
1766 "C": {
1767 "5": {},
1768 "6": { "V": "", "W": "" },
1769 "7": { "X": "" },
1770 "8": { "Y": {}, "Z": "" }
1771 }
1772 }),
1773 )
1774 .await;
1775 fs.insert_tree(
1776 "/root2",
1777 json!({
1778 "d": {
1779 "9": ""
1780 },
1781 "e": {}
1782 }),
1783 )
1784 .await;
1785
1786 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1787 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1788 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1789 let panel = workspace
1790 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1791 .unwrap();
1792 assert_eq!(
1793 visible_entries_as_strings(&panel, 0..50, cx),
1794 &[
1795 "v root1",
1796 " > .git",
1797 " > a",
1798 " > b",
1799 " > C",
1800 " .dockerignore",
1801 "v root2",
1802 " > d",
1803 " > e",
1804 ]
1805 );
1806
1807 toggle_expand_dir(&panel, "root1/b", cx);
1808 assert_eq!(
1809 visible_entries_as_strings(&panel, 0..50, cx),
1810 &[
1811 "v root1",
1812 " > .git",
1813 " > a",
1814 " v b <== selected",
1815 " > 3",
1816 " > 4",
1817 " > C",
1818 " .dockerignore",
1819 "v root2",
1820 " > d",
1821 " > e",
1822 ]
1823 );
1824
1825 assert_eq!(
1826 visible_entries_as_strings(&panel, 6..9, cx),
1827 &[
1828 //
1829 " > C",
1830 " .dockerignore",
1831 "v root2",
1832 ]
1833 );
1834 }
1835
1836 #[gpui::test]
1837 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1838 init_test(cx);
1839 cx.update(|cx| {
1840 cx.update_global::<SettingsStore, _>(|store, cx| {
1841 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
1842 worktree_settings.file_scan_exclusions =
1843 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1844 });
1845 });
1846 });
1847
1848 let fs = FakeFs::new(cx.background_executor.clone());
1849 fs.insert_tree(
1850 "/root1",
1851 json!({
1852 ".dockerignore": "",
1853 ".git": {
1854 "HEAD": "",
1855 },
1856 "a": {
1857 "0": { "q": "", "r": "", "s": "" },
1858 "1": { "t": "", "u": "" },
1859 "2": { "v": "", "w": "", "x": "", "y": "" },
1860 },
1861 "b": {
1862 "3": { "Q": "" },
1863 "4": { "R": "", "S": "", "T": "", "U": "" },
1864 },
1865 "C": {
1866 "5": {},
1867 "6": { "V": "", "W": "" },
1868 "7": { "X": "" },
1869 "8": { "Y": {}, "Z": "" }
1870 }
1871 }),
1872 )
1873 .await;
1874 fs.insert_tree(
1875 "/root2",
1876 json!({
1877 "d": {
1878 "4": ""
1879 },
1880 "e": {}
1881 }),
1882 )
1883 .await;
1884
1885 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1886 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1887 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1888 let panel = workspace
1889 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1890 .unwrap();
1891 assert_eq!(
1892 visible_entries_as_strings(&panel, 0..50, cx),
1893 &[
1894 "v root1",
1895 " > a",
1896 " > b",
1897 " > C",
1898 " .dockerignore",
1899 "v root2",
1900 " > d",
1901 " > e",
1902 ]
1903 );
1904
1905 toggle_expand_dir(&panel, "root1/b", cx);
1906 assert_eq!(
1907 visible_entries_as_strings(&panel, 0..50, cx),
1908 &[
1909 "v root1",
1910 " > a",
1911 " v b <== selected",
1912 " > 3",
1913 " > C",
1914 " .dockerignore",
1915 "v root2",
1916 " > d",
1917 " > e",
1918 ]
1919 );
1920
1921 toggle_expand_dir(&panel, "root2/d", cx);
1922 assert_eq!(
1923 visible_entries_as_strings(&panel, 0..50, cx),
1924 &[
1925 "v root1",
1926 " > a",
1927 " v b",
1928 " > 3",
1929 " > C",
1930 " .dockerignore",
1931 "v root2",
1932 " v d <== selected",
1933 " > e",
1934 ]
1935 );
1936
1937 toggle_expand_dir(&panel, "root2/e", cx);
1938 assert_eq!(
1939 visible_entries_as_strings(&panel, 0..50, cx),
1940 &[
1941 "v root1",
1942 " > a",
1943 " v b",
1944 " > 3",
1945 " > C",
1946 " .dockerignore",
1947 "v root2",
1948 " v d",
1949 " v e <== selected",
1950 ]
1951 );
1952 }
1953
1954 #[gpui::test(iterations = 30)]
1955 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1956 init_test(cx);
1957
1958 let fs = FakeFs::new(cx.executor().clone());
1959 fs.insert_tree(
1960 "/root1",
1961 json!({
1962 ".dockerignore": "",
1963 ".git": {
1964 "HEAD": "",
1965 },
1966 "a": {
1967 "0": { "q": "", "r": "", "s": "" },
1968 "1": { "t": "", "u": "" },
1969 "2": { "v": "", "w": "", "x": "", "y": "" },
1970 },
1971 "b": {
1972 "3": { "Q": "" },
1973 "4": { "R": "", "S": "", "T": "", "U": "" },
1974 },
1975 "C": {
1976 "5": {},
1977 "6": { "V": "", "W": "" },
1978 "7": { "X": "" },
1979 "8": { "Y": {}, "Z": "" }
1980 }
1981 }),
1982 )
1983 .await;
1984 fs.insert_tree(
1985 "/root2",
1986 json!({
1987 "d": {
1988 "9": ""
1989 },
1990 "e": {}
1991 }),
1992 )
1993 .await;
1994
1995 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1996 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1997 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1998 let panel = workspace
1999 .update(cx, |workspace, cx| {
2000 let panel = ProjectPanel::new(workspace, cx);
2001 workspace.add_panel(panel.clone(), cx);
2002 panel
2003 })
2004 .unwrap();
2005
2006 select_path(&panel, "root1", cx);
2007 assert_eq!(
2008 visible_entries_as_strings(&panel, 0..10, cx),
2009 &[
2010 "v root1 <== selected",
2011 " > .git",
2012 " > a",
2013 " > b",
2014 " > C",
2015 " .dockerignore",
2016 "v root2",
2017 " > d",
2018 " > e",
2019 ]
2020 );
2021
2022 // Add a file with the root folder selected. The filename editor is placed
2023 // before the first file in the root folder.
2024 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2025 panel.update(cx, |panel, cx| {
2026 assert!(panel.filename_editor.read(cx).is_focused(cx));
2027 });
2028 assert_eq!(
2029 visible_entries_as_strings(&panel, 0..10, cx),
2030 &[
2031 "v root1",
2032 " > .git",
2033 " > a",
2034 " > b",
2035 " > C",
2036 " [EDITOR: ''] <== selected",
2037 " .dockerignore",
2038 "v root2",
2039 " > d",
2040 " > e",
2041 ]
2042 );
2043
2044 let confirm = panel.update(cx, |panel, cx| {
2045 panel
2046 .filename_editor
2047 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2048 panel.confirm_edit(cx).unwrap()
2049 });
2050 assert_eq!(
2051 visible_entries_as_strings(&panel, 0..10, cx),
2052 &[
2053 "v root1",
2054 " > .git",
2055 " > a",
2056 " > b",
2057 " > C",
2058 " [PROCESSING: 'the-new-filename'] <== selected",
2059 " .dockerignore",
2060 "v root2",
2061 " > d",
2062 " > e",
2063 ]
2064 );
2065
2066 confirm.await.unwrap();
2067 assert_eq!(
2068 visible_entries_as_strings(&panel, 0..10, cx),
2069 &[
2070 "v root1",
2071 " > .git",
2072 " > a",
2073 " > b",
2074 " > C",
2075 " .dockerignore",
2076 " the-new-filename <== selected",
2077 "v root2",
2078 " > d",
2079 " > e",
2080 ]
2081 );
2082
2083 select_path(&panel, "root1/b", cx);
2084 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2085 assert_eq!(
2086 visible_entries_as_strings(&panel, 0..10, cx),
2087 &[
2088 "v root1",
2089 " > .git",
2090 " > a",
2091 " v b",
2092 " > 3",
2093 " > 4",
2094 " [EDITOR: ''] <== selected",
2095 " > C",
2096 " .dockerignore",
2097 " the-new-filename",
2098 ]
2099 );
2100
2101 panel
2102 .update(cx, |panel, cx| {
2103 panel
2104 .filename_editor
2105 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2106 panel.confirm_edit(cx).unwrap()
2107 })
2108 .await
2109 .unwrap();
2110 assert_eq!(
2111 visible_entries_as_strings(&panel, 0..10, cx),
2112 &[
2113 "v root1",
2114 " > .git",
2115 " > a",
2116 " v b",
2117 " > 3",
2118 " > 4",
2119 " another-filename.txt <== selected",
2120 " > C",
2121 " .dockerignore",
2122 " the-new-filename",
2123 ]
2124 );
2125
2126 select_path(&panel, "root1/b/another-filename.txt", cx);
2127 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2128 assert_eq!(
2129 visible_entries_as_strings(&panel, 0..10, cx),
2130 &[
2131 "v root1",
2132 " > .git",
2133 " > a",
2134 " v b",
2135 " > 3",
2136 " > 4",
2137 " [EDITOR: 'another-filename.txt'] <== selected",
2138 " > C",
2139 " .dockerignore",
2140 " the-new-filename",
2141 ]
2142 );
2143
2144 let confirm = panel.update(cx, |panel, cx| {
2145 panel.filename_editor.update(cx, |editor, cx| {
2146 let file_name_selections = editor.selections.all::<usize>(cx);
2147 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2148 let file_name_selection = &file_name_selections[0];
2149 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2150 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2151
2152 editor.set_text("a-different-filename.tar.gz", cx)
2153 });
2154 panel.confirm_edit(cx).unwrap()
2155 });
2156 assert_eq!(
2157 visible_entries_as_strings(&panel, 0..10, cx),
2158 &[
2159 "v root1",
2160 " > .git",
2161 " > a",
2162 " v b",
2163 " > 3",
2164 " > 4",
2165 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2166 " > C",
2167 " .dockerignore",
2168 " the-new-filename",
2169 ]
2170 );
2171
2172 confirm.await.unwrap();
2173 assert_eq!(
2174 visible_entries_as_strings(&panel, 0..10, cx),
2175 &[
2176 "v root1",
2177 " > .git",
2178 " > a",
2179 " v b",
2180 " > 3",
2181 " > 4",
2182 " a-different-filename.tar.gz <== selected",
2183 " > C",
2184 " .dockerignore",
2185 " the-new-filename",
2186 ]
2187 );
2188
2189 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2190 assert_eq!(
2191 visible_entries_as_strings(&panel, 0..10, cx),
2192 &[
2193 "v root1",
2194 " > .git",
2195 " > a",
2196 " v b",
2197 " > 3",
2198 " > 4",
2199 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2200 " > C",
2201 " .dockerignore",
2202 " the-new-filename",
2203 ]
2204 );
2205
2206 panel.update(cx, |panel, cx| {
2207 panel.filename_editor.update(cx, |editor, cx| {
2208 let file_name_selections = editor.selections.all::<usize>(cx);
2209 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2210 let file_name_selection = &file_name_selections[0];
2211 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2212 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..");
2213
2214 });
2215 panel.cancel(&Cancel, cx)
2216 });
2217
2218 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2219 assert_eq!(
2220 visible_entries_as_strings(&panel, 0..10, cx),
2221 &[
2222 "v root1",
2223 " > .git",
2224 " > a",
2225 " v b",
2226 " > [EDITOR: ''] <== selected",
2227 " > 3",
2228 " > 4",
2229 " a-different-filename.tar.gz",
2230 " > C",
2231 " .dockerignore",
2232 ]
2233 );
2234
2235 let confirm = panel.update(cx, |panel, cx| {
2236 panel
2237 .filename_editor
2238 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2239 panel.confirm_edit(cx).unwrap()
2240 });
2241 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2242 assert_eq!(
2243 visible_entries_as_strings(&panel, 0..10, cx),
2244 &[
2245 "v root1",
2246 " > .git",
2247 " > a",
2248 " v b",
2249 " > [PROCESSING: 'new-dir']",
2250 " > 3 <== selected",
2251 " > 4",
2252 " a-different-filename.tar.gz",
2253 " > C",
2254 " .dockerignore",
2255 ]
2256 );
2257
2258 confirm.await.unwrap();
2259 assert_eq!(
2260 visible_entries_as_strings(&panel, 0..10, cx),
2261 &[
2262 "v root1",
2263 " > .git",
2264 " > a",
2265 " v b",
2266 " > 3 <== selected",
2267 " > 4",
2268 " > new-dir",
2269 " a-different-filename.tar.gz",
2270 " > C",
2271 " .dockerignore",
2272 ]
2273 );
2274
2275 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2276 assert_eq!(
2277 visible_entries_as_strings(&panel, 0..10, cx),
2278 &[
2279 "v root1",
2280 " > .git",
2281 " > a",
2282 " v b",
2283 " > [EDITOR: '3'] <== selected",
2284 " > 4",
2285 " > new-dir",
2286 " a-different-filename.tar.gz",
2287 " > C",
2288 " .dockerignore",
2289 ]
2290 );
2291
2292 // Dismiss the rename editor when it loses focus.
2293 workspace.update(cx, |_, cx| cx.blur()).unwrap();
2294 assert_eq!(
2295 visible_entries_as_strings(&panel, 0..10, cx),
2296 &[
2297 "v root1",
2298 " > .git",
2299 " > a",
2300 " v b",
2301 " > 3 <== selected",
2302 " > 4",
2303 " > new-dir",
2304 " a-different-filename.tar.gz",
2305 " > C",
2306 " .dockerignore",
2307 ]
2308 );
2309 }
2310
2311 #[gpui::test(iterations = 10)]
2312 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2313 init_test(cx);
2314
2315 let fs = FakeFs::new(cx.executor().clone());
2316 fs.insert_tree(
2317 "/root1",
2318 json!({
2319 ".dockerignore": "",
2320 ".git": {
2321 "HEAD": "",
2322 },
2323 "a": {
2324 "0": { "q": "", "r": "", "s": "" },
2325 "1": { "t": "", "u": "" },
2326 "2": { "v": "", "w": "", "x": "", "y": "" },
2327 },
2328 "b": {
2329 "3": { "Q": "" },
2330 "4": { "R": "", "S": "", "T": "", "U": "" },
2331 },
2332 "C": {
2333 "5": {},
2334 "6": { "V": "", "W": "" },
2335 "7": { "X": "" },
2336 "8": { "Y": {}, "Z": "" }
2337 }
2338 }),
2339 )
2340 .await;
2341 fs.insert_tree(
2342 "/root2",
2343 json!({
2344 "d": {
2345 "9": ""
2346 },
2347 "e": {}
2348 }),
2349 )
2350 .await;
2351
2352 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2353 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2354 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2355 let panel = workspace
2356 .update(cx, |workspace, cx| {
2357 let panel = ProjectPanel::new(workspace, cx);
2358 workspace.add_panel(panel.clone(), cx);
2359 panel
2360 })
2361 .unwrap();
2362
2363 select_path(&panel, "root1", cx);
2364 assert_eq!(
2365 visible_entries_as_strings(&panel, 0..10, cx),
2366 &[
2367 "v root1 <== selected",
2368 " > .git",
2369 " > a",
2370 " > b",
2371 " > C",
2372 " .dockerignore",
2373 "v root2",
2374 " > d",
2375 " > e",
2376 ]
2377 );
2378
2379 // Add a file with the root folder selected. The filename editor is placed
2380 // before the first file in the root folder.
2381 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2382 panel.update(cx, |panel, cx| {
2383 assert!(panel.filename_editor.read(cx).is_focused(cx));
2384 });
2385 assert_eq!(
2386 visible_entries_as_strings(&panel, 0..10, cx),
2387 &[
2388 "v root1",
2389 " > .git",
2390 " > a",
2391 " > b",
2392 " > C",
2393 " [EDITOR: ''] <== selected",
2394 " .dockerignore",
2395 "v root2",
2396 " > d",
2397 " > e",
2398 ]
2399 );
2400
2401 let confirm = panel.update(cx, |panel, cx| {
2402 panel.filename_editor.update(cx, |editor, cx| {
2403 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2404 });
2405 panel.confirm_edit(cx).unwrap()
2406 });
2407
2408 assert_eq!(
2409 visible_entries_as_strings(&panel, 0..10, cx),
2410 &[
2411 "v root1",
2412 " > .git",
2413 " > a",
2414 " > b",
2415 " > C",
2416 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2417 " .dockerignore",
2418 "v root2",
2419 " > d",
2420 " > e",
2421 ]
2422 );
2423
2424 confirm.await.unwrap();
2425 assert_eq!(
2426 visible_entries_as_strings(&panel, 0..13, cx),
2427 &[
2428 "v root1",
2429 " > .git",
2430 " > a",
2431 " > b",
2432 " v bdir1",
2433 " v dir2",
2434 " the-new-filename <== selected",
2435 " > C",
2436 " .dockerignore",
2437 "v root2",
2438 " > d",
2439 " > e",
2440 ]
2441 );
2442 }
2443
2444 #[gpui::test]
2445 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2446 init_test(cx);
2447
2448 let fs = FakeFs::new(cx.executor().clone());
2449 fs.insert_tree(
2450 "/root1",
2451 json!({
2452 "one.two.txt": "",
2453 "one.txt": ""
2454 }),
2455 )
2456 .await;
2457
2458 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2459 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2460 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2461 let panel = workspace
2462 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2463 .unwrap();
2464
2465 panel.update(cx, |panel, cx| {
2466 panel.select_next(&Default::default(), cx);
2467 panel.select_next(&Default::default(), cx);
2468 });
2469
2470 assert_eq!(
2471 visible_entries_as_strings(&panel, 0..50, cx),
2472 &[
2473 //
2474 "v root1",
2475 " one.two.txt <== selected",
2476 " one.txt",
2477 ]
2478 );
2479
2480 // Regression test - file name is created correctly when
2481 // the copied file's name contains multiple dots.
2482 panel.update(cx, |panel, cx| {
2483 panel.copy(&Default::default(), cx);
2484 panel.paste(&Default::default(), cx);
2485 });
2486 cx.executor().run_until_parked();
2487
2488 assert_eq!(
2489 visible_entries_as_strings(&panel, 0..50, cx),
2490 &[
2491 //
2492 "v root1",
2493 " one.two copy.txt",
2494 " one.two.txt <== selected",
2495 " one.txt",
2496 ]
2497 );
2498
2499 panel.update(cx, |panel, cx| {
2500 panel.paste(&Default::default(), cx);
2501 });
2502 cx.executor().run_until_parked();
2503
2504 assert_eq!(
2505 visible_entries_as_strings(&panel, 0..50, cx),
2506 &[
2507 //
2508 "v root1",
2509 " one.two copy 1.txt",
2510 " one.two copy.txt",
2511 " one.two.txt <== selected",
2512 " one.txt",
2513 ]
2514 );
2515 }
2516
2517 #[gpui::test]
2518 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
2519 init_test(cx);
2520
2521 let fs = FakeFs::new(cx.executor().clone());
2522 fs.insert_tree(
2523 "/root",
2524 json!({
2525 "a": {
2526 "one.txt": "",
2527 "two.txt": "",
2528 "inner_dir": {
2529 "three.txt": "",
2530 "four.txt": "",
2531 }
2532 },
2533 "b": {}
2534 }),
2535 )
2536 .await;
2537
2538 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
2539 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2540 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2541 let panel = workspace
2542 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2543 .unwrap();
2544
2545 select_path(&panel, "root/a", cx);
2546 panel.update(cx, |panel, cx| {
2547 panel.copy(&Default::default(), cx);
2548 panel.select_next(&Default::default(), cx);
2549 panel.paste(&Default::default(), cx);
2550 });
2551 cx.executor().run_until_parked();
2552
2553 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
2554 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
2555
2556 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
2557 assert_ne!(
2558 pasted_dir_file, None,
2559 "Pasted directory file should have an entry"
2560 );
2561
2562 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
2563 assert_ne!(
2564 pasted_dir_inner_dir, None,
2565 "Directories inside pasted directory should have an entry"
2566 );
2567
2568 toggle_expand_dir(&panel, "root/b", cx);
2569 toggle_expand_dir(&panel, "root/b/a", cx);
2570 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
2571
2572 assert_eq!(
2573 visible_entries_as_strings(&panel, 0..50, cx),
2574 &[
2575 //
2576 "v root",
2577 " > a",
2578 " v b",
2579 " v a",
2580 " v inner_dir <== selected",
2581 " four.txt",
2582 " three.txt",
2583 " one.txt",
2584 " two.txt",
2585 ]
2586 );
2587
2588 select_path(&panel, "root", cx);
2589 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2590 cx.executor().run_until_parked();
2591 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2592 cx.executor().run_until_parked();
2593 assert_eq!(
2594 visible_entries_as_strings(&panel, 0..50, cx),
2595 &[
2596 //
2597 "v root <== selected",
2598 " > a",
2599 " > a copy",
2600 " > a copy 1",
2601 " v b",
2602 " v a",
2603 " v inner_dir",
2604 " four.txt",
2605 " three.txt",
2606 " one.txt",
2607 " two.txt"
2608 ]
2609 );
2610 }
2611
2612 #[gpui::test]
2613 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2614 init_test_with_editor(cx);
2615
2616 let fs = FakeFs::new(cx.executor().clone());
2617 fs.insert_tree(
2618 "/src",
2619 json!({
2620 "test": {
2621 "first.rs": "// First Rust file",
2622 "second.rs": "// Second Rust file",
2623 "third.rs": "// Third Rust file",
2624 }
2625 }),
2626 )
2627 .await;
2628
2629 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2630 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2631 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2632 let panel = workspace
2633 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2634 .unwrap();
2635
2636 toggle_expand_dir(&panel, "src/test", cx);
2637 select_path(&panel, "src/test/first.rs", cx);
2638 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2639 cx.executor().run_until_parked();
2640 assert_eq!(
2641 visible_entries_as_strings(&panel, 0..10, cx),
2642 &[
2643 "v src",
2644 " v test",
2645 " first.rs <== selected",
2646 " second.rs",
2647 " third.rs"
2648 ]
2649 );
2650 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2651
2652 submit_deletion(&panel, cx);
2653 assert_eq!(
2654 visible_entries_as_strings(&panel, 0..10, cx),
2655 &[
2656 "v src",
2657 " v test",
2658 " second.rs",
2659 " third.rs"
2660 ],
2661 "Project panel should have no deleted file, no other file is selected in it"
2662 );
2663 ensure_no_open_items_and_panes(&workspace, cx);
2664
2665 select_path(&panel, "src/test/second.rs", cx);
2666 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2667 cx.executor().run_until_parked();
2668 assert_eq!(
2669 visible_entries_as_strings(&panel, 0..10, cx),
2670 &[
2671 "v src",
2672 " v test",
2673 " second.rs <== selected",
2674 " third.rs"
2675 ]
2676 );
2677 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2678
2679 workspace
2680 .update(cx, |workspace, cx| {
2681 let active_items = workspace
2682 .panes()
2683 .iter()
2684 .filter_map(|pane| pane.read(cx).active_item())
2685 .collect::<Vec<_>>();
2686 assert_eq!(active_items.len(), 1);
2687 let open_editor = active_items
2688 .into_iter()
2689 .next()
2690 .unwrap()
2691 .downcast::<Editor>()
2692 .expect("Open item should be an editor");
2693 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2694 })
2695 .unwrap();
2696 submit_deletion_skipping_prompt(&panel, cx);
2697 assert_eq!(
2698 visible_entries_as_strings(&panel, 0..10, cx),
2699 &["v src", " v test", " third.rs"],
2700 "Project panel should have no deleted file, with one last file remaining"
2701 );
2702 ensure_no_open_items_and_panes(&workspace, cx);
2703 }
2704
2705 #[gpui::test]
2706 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2707 init_test_with_editor(cx);
2708
2709 let fs = FakeFs::new(cx.executor().clone());
2710 fs.insert_tree(
2711 "/src",
2712 json!({
2713 "test": {
2714 "first.rs": "// First Rust file",
2715 "second.rs": "// Second Rust file",
2716 "third.rs": "// Third Rust file",
2717 }
2718 }),
2719 )
2720 .await;
2721
2722 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2723 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2724 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2725 let panel = workspace
2726 .update(cx, |workspace, cx| {
2727 let panel = ProjectPanel::new(workspace, cx);
2728 workspace.add_panel(panel.clone(), cx);
2729 panel
2730 })
2731 .unwrap();
2732
2733 select_path(&panel, "src/", cx);
2734 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2735 cx.executor().run_until_parked();
2736 assert_eq!(
2737 visible_entries_as_strings(&panel, 0..10, cx),
2738 &[
2739 //
2740 "v src <== selected",
2741 " > test"
2742 ]
2743 );
2744 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2745 panel.update(cx, |panel, cx| {
2746 assert!(panel.filename_editor.read(cx).is_focused(cx));
2747 });
2748 assert_eq!(
2749 visible_entries_as_strings(&panel, 0..10, cx),
2750 &[
2751 //
2752 "v src",
2753 " > [EDITOR: ''] <== selected",
2754 " > test"
2755 ]
2756 );
2757 panel.update(cx, |panel, cx| {
2758 panel
2759 .filename_editor
2760 .update(cx, |editor, cx| editor.set_text("test", cx));
2761 assert!(
2762 panel.confirm_edit(cx).is_none(),
2763 "Should not allow to confirm on conflicting new directory name"
2764 )
2765 });
2766 assert_eq!(
2767 visible_entries_as_strings(&panel, 0..10, cx),
2768 &[
2769 //
2770 "v src",
2771 " > test"
2772 ],
2773 "File list should be unchanged after failed folder create confirmation"
2774 );
2775
2776 select_path(&panel, "src/test/", cx);
2777 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2778 cx.executor().run_until_parked();
2779 assert_eq!(
2780 visible_entries_as_strings(&panel, 0..10, cx),
2781 &[
2782 //
2783 "v src",
2784 " > test <== selected"
2785 ]
2786 );
2787 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2788 panel.update(cx, |panel, cx| {
2789 assert!(panel.filename_editor.read(cx).is_focused(cx));
2790 });
2791 assert_eq!(
2792 visible_entries_as_strings(&panel, 0..10, cx),
2793 &[
2794 "v src",
2795 " v test",
2796 " [EDITOR: ''] <== selected",
2797 " first.rs",
2798 " second.rs",
2799 " third.rs"
2800 ]
2801 );
2802 panel.update(cx, |panel, cx| {
2803 panel
2804 .filename_editor
2805 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2806 assert!(
2807 panel.confirm_edit(cx).is_none(),
2808 "Should not allow to confirm on conflicting new file name"
2809 )
2810 });
2811 assert_eq!(
2812 visible_entries_as_strings(&panel, 0..10, cx),
2813 &[
2814 "v src",
2815 " v test",
2816 " first.rs",
2817 " second.rs",
2818 " third.rs"
2819 ],
2820 "File list should be unchanged after failed file create confirmation"
2821 );
2822
2823 select_path(&panel, "src/test/first.rs", cx);
2824 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2825 cx.executor().run_until_parked();
2826 assert_eq!(
2827 visible_entries_as_strings(&panel, 0..10, cx),
2828 &[
2829 "v src",
2830 " v test",
2831 " first.rs <== selected",
2832 " second.rs",
2833 " third.rs"
2834 ],
2835 );
2836 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2837 panel.update(cx, |panel, cx| {
2838 assert!(panel.filename_editor.read(cx).is_focused(cx));
2839 });
2840 assert_eq!(
2841 visible_entries_as_strings(&panel, 0..10, cx),
2842 &[
2843 "v src",
2844 " v test",
2845 " [EDITOR: 'first.rs'] <== selected",
2846 " second.rs",
2847 " third.rs"
2848 ]
2849 );
2850 panel.update(cx, |panel, cx| {
2851 panel
2852 .filename_editor
2853 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2854 assert!(
2855 panel.confirm_edit(cx).is_none(),
2856 "Should not allow to confirm on conflicting file rename"
2857 )
2858 });
2859 assert_eq!(
2860 visible_entries_as_strings(&panel, 0..10, cx),
2861 &[
2862 "v src",
2863 " v test",
2864 " first.rs <== selected",
2865 " second.rs",
2866 " third.rs"
2867 ],
2868 "File list should be unchanged after failed rename confirmation"
2869 );
2870 }
2871
2872 #[gpui::test]
2873 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
2874 init_test_with_editor(cx);
2875
2876 let fs = FakeFs::new(cx.executor().clone());
2877 fs.insert_tree(
2878 "/project_root",
2879 json!({
2880 "dir_1": {
2881 "nested_dir": {
2882 "file_a.py": "# File contents",
2883 }
2884 },
2885 "file_1.py": "# File contents",
2886 }),
2887 )
2888 .await;
2889
2890 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2891 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2892 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2893 let panel = workspace
2894 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2895 .unwrap();
2896
2897 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2898 cx.executor().run_until_parked();
2899 select_path(&panel, "project_root/dir_1", cx);
2900 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2901 select_path(&panel, "project_root/dir_1/nested_dir", cx);
2902 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2903 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2904 cx.executor().run_until_parked();
2905 assert_eq!(
2906 visible_entries_as_strings(&panel, 0..10, cx),
2907 &[
2908 "v project_root",
2909 " v dir_1",
2910 " > nested_dir <== selected",
2911 " file_1.py",
2912 ]
2913 );
2914 }
2915
2916 #[gpui::test]
2917 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2918 init_test_with_editor(cx);
2919
2920 let fs = FakeFs::new(cx.executor().clone());
2921 fs.insert_tree(
2922 "/project_root",
2923 json!({
2924 "dir_1": {
2925 "nested_dir": {
2926 "file_a.py": "# File contents",
2927 "file_b.py": "# File contents",
2928 "file_c.py": "# File contents",
2929 },
2930 "file_1.py": "# File contents",
2931 "file_2.py": "# File contents",
2932 "file_3.py": "# File contents",
2933 },
2934 "dir_2": {
2935 "file_1.py": "# File contents",
2936 "file_2.py": "# File contents",
2937 "file_3.py": "# File contents",
2938 }
2939 }),
2940 )
2941 .await;
2942
2943 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2944 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2945 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2946 let panel = workspace
2947 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2948 .unwrap();
2949
2950 panel.update(cx, |panel, cx| {
2951 panel.collapse_all_entries(&CollapseAllEntries, cx)
2952 });
2953 cx.executor().run_until_parked();
2954 assert_eq!(
2955 visible_entries_as_strings(&panel, 0..10, cx),
2956 &["v project_root", " > dir_1", " > dir_2",]
2957 );
2958
2959 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2960 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2961 cx.executor().run_until_parked();
2962 assert_eq!(
2963 visible_entries_as_strings(&panel, 0..10, cx),
2964 &[
2965 "v project_root",
2966 " v dir_1 <== selected",
2967 " > nested_dir",
2968 " file_1.py",
2969 " file_2.py",
2970 " file_3.py",
2971 " > dir_2",
2972 ]
2973 );
2974 }
2975
2976 #[gpui::test]
2977 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2978 init_test(cx);
2979
2980 let fs = FakeFs::new(cx.executor().clone());
2981 fs.as_fake().insert_tree("/root", json!({})).await;
2982 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2983 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2984 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2985 let panel = workspace
2986 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2987 .unwrap();
2988
2989 // Make a new buffer with no backing file
2990 workspace
2991 .update(cx, |workspace, cx| {
2992 Editor::new_file(workspace, &Default::default(), cx)
2993 })
2994 .unwrap();
2995
2996 // "Save as"" the buffer, creating a new backing file for it
2997 let save_task = workspace
2998 .update(cx, |workspace, cx| {
2999 workspace.save_active_item(workspace::SaveIntent::Save, cx)
3000 })
3001 .unwrap();
3002
3003 cx.executor().run_until_parked();
3004 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3005 save_task.await.unwrap();
3006
3007 // Rename the file
3008 select_path(&panel, "root/new", cx);
3009 assert_eq!(
3010 visible_entries_as_strings(&panel, 0..10, cx),
3011 &["v root", " new <== selected"]
3012 );
3013 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3014 panel.update(cx, |panel, cx| {
3015 panel
3016 .filename_editor
3017 .update(cx, |editor, cx| editor.set_text("newer", cx));
3018 });
3019 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3020
3021 cx.executor().run_until_parked();
3022 assert_eq!(
3023 visible_entries_as_strings(&panel, 0..10, cx),
3024 &["v root", " newer <== selected"]
3025 );
3026
3027 workspace
3028 .update(cx, |workspace, cx| {
3029 workspace.save_active_item(workspace::SaveIntent::Save, cx)
3030 })
3031 .unwrap()
3032 .await
3033 .unwrap();
3034
3035 cx.executor().run_until_parked();
3036 // assert that saving the file doesn't restore "new"
3037 assert_eq!(
3038 visible_entries_as_strings(&panel, 0..10, cx),
3039 &["v root", " newer <== selected"]
3040 );
3041 }
3042
3043 #[gpui::test]
3044 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3045 init_test_with_editor(cx);
3046 cx.update(|cx| {
3047 cx.update_global::<SettingsStore, _>(|store, cx| {
3048 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3049 worktree_settings.file_scan_exclusions = Some(Vec::new());
3050 });
3051 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3052 project_panel_settings.auto_reveal_entries = Some(false)
3053 });
3054 })
3055 });
3056
3057 let fs = FakeFs::new(cx.background_executor.clone());
3058 fs.insert_tree(
3059 "/project_root",
3060 json!({
3061 ".git": {},
3062 ".gitignore": "**/gitignored_dir",
3063 "dir_1": {
3064 "file_1.py": "# File 1_1 contents",
3065 "file_2.py": "# File 1_2 contents",
3066 "file_3.py": "# File 1_3 contents",
3067 "gitignored_dir": {
3068 "file_a.py": "# File contents",
3069 "file_b.py": "# File contents",
3070 "file_c.py": "# File contents",
3071 },
3072 },
3073 "dir_2": {
3074 "file_1.py": "# File 2_1 contents",
3075 "file_2.py": "# File 2_2 contents",
3076 "file_3.py": "# File 2_3 contents",
3077 }
3078 }),
3079 )
3080 .await;
3081
3082 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3083 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3084 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3085 let panel = workspace
3086 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3087 .unwrap();
3088
3089 assert_eq!(
3090 visible_entries_as_strings(&panel, 0..20, cx),
3091 &[
3092 "v project_root",
3093 " > .git",
3094 " > dir_1",
3095 " > dir_2",
3096 " .gitignore",
3097 ]
3098 );
3099
3100 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3101 .expect("dir 1 file is not ignored and should have an entry");
3102 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3103 .expect("dir 2 file is not ignored and should have an entry");
3104 let gitignored_dir_file =
3105 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3106 assert_eq!(
3107 gitignored_dir_file, None,
3108 "File in the gitignored dir should not have an entry before its dir is toggled"
3109 );
3110
3111 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3112 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3113 cx.executor().run_until_parked();
3114 assert_eq!(
3115 visible_entries_as_strings(&panel, 0..20, cx),
3116 &[
3117 "v project_root",
3118 " > .git",
3119 " v dir_1",
3120 " v gitignored_dir <== selected",
3121 " file_a.py",
3122 " file_b.py",
3123 " file_c.py",
3124 " file_1.py",
3125 " file_2.py",
3126 " file_3.py",
3127 " > dir_2",
3128 " .gitignore",
3129 ],
3130 "Should show gitignored dir file list in the project panel"
3131 );
3132 let gitignored_dir_file =
3133 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3134 .expect("after gitignored dir got opened, a file entry should be present");
3135
3136 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3137 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3138 assert_eq!(
3139 visible_entries_as_strings(&panel, 0..20, cx),
3140 &[
3141 "v project_root",
3142 " > .git",
3143 " > dir_1 <== selected",
3144 " > dir_2",
3145 " .gitignore",
3146 ],
3147 "Should hide all dir contents again and prepare for the auto reveal test"
3148 );
3149
3150 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3151 panel.update(cx, |panel, cx| {
3152 panel.project.update(cx, |_, cx| {
3153 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3154 })
3155 });
3156 cx.run_until_parked();
3157 assert_eq!(
3158 visible_entries_as_strings(&panel, 0..20, cx),
3159 &[
3160 "v project_root",
3161 " > .git",
3162 " > dir_1 <== selected",
3163 " > dir_2",
3164 " .gitignore",
3165 ],
3166 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3167 );
3168 }
3169
3170 cx.update(|cx| {
3171 cx.update_global::<SettingsStore, _>(|store, cx| {
3172 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3173 project_panel_settings.auto_reveal_entries = Some(true)
3174 });
3175 })
3176 });
3177
3178 panel.update(cx, |panel, cx| {
3179 panel.project.update(cx, |_, cx| {
3180 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3181 })
3182 });
3183 cx.run_until_parked();
3184 assert_eq!(
3185 visible_entries_as_strings(&panel, 0..20, cx),
3186 &[
3187 "v project_root",
3188 " > .git",
3189 " v dir_1",
3190 " > gitignored_dir",
3191 " file_1.py <== selected",
3192 " file_2.py",
3193 " file_3.py",
3194 " > dir_2",
3195 " .gitignore",
3196 ],
3197 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3198 );
3199
3200 panel.update(cx, |panel, cx| {
3201 panel.project.update(cx, |_, cx| {
3202 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3203 })
3204 });
3205 cx.run_until_parked();
3206 assert_eq!(
3207 visible_entries_as_strings(&panel, 0..20, cx),
3208 &[
3209 "v project_root",
3210 " > .git",
3211 " v dir_1",
3212 " > gitignored_dir",
3213 " file_1.py",
3214 " file_2.py",
3215 " file_3.py",
3216 " v dir_2",
3217 " file_1.py <== selected",
3218 " file_2.py",
3219 " file_3.py",
3220 " .gitignore",
3221 ],
3222 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3223 );
3224
3225 panel.update(cx, |panel, cx| {
3226 panel.project.update(cx, |_, cx| {
3227 cx.emit(project::Event::ActiveEntryChanged(Some(
3228 gitignored_dir_file,
3229 )))
3230 })
3231 });
3232 cx.run_until_parked();
3233 assert_eq!(
3234 visible_entries_as_strings(&panel, 0..20, cx),
3235 &[
3236 "v project_root",
3237 " > .git",
3238 " v dir_1",
3239 " > gitignored_dir",
3240 " file_1.py",
3241 " file_2.py",
3242 " file_3.py",
3243 " v dir_2",
3244 " file_1.py <== selected",
3245 " file_2.py",
3246 " file_3.py",
3247 " .gitignore",
3248 ],
3249 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3250 );
3251
3252 panel.update(cx, |panel, cx| {
3253 panel.project.update(cx, |_, cx| {
3254 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3255 })
3256 });
3257 cx.run_until_parked();
3258 assert_eq!(
3259 visible_entries_as_strings(&panel, 0..20, cx),
3260 &[
3261 "v project_root",
3262 " > .git",
3263 " v dir_1",
3264 " v gitignored_dir",
3265 " file_a.py <== selected",
3266 " file_b.py",
3267 " file_c.py",
3268 " file_1.py",
3269 " file_2.py",
3270 " file_3.py",
3271 " v dir_2",
3272 " file_1.py",
3273 " file_2.py",
3274 " file_3.py",
3275 " .gitignore",
3276 ],
3277 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3278 );
3279 }
3280
3281 #[gpui::test]
3282 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3283 init_test_with_editor(cx);
3284 cx.update(|cx| {
3285 cx.update_global::<SettingsStore, _>(|store, cx| {
3286 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3287 worktree_settings.file_scan_exclusions = Some(Vec::new());
3288 });
3289 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3290 project_panel_settings.auto_reveal_entries = Some(false)
3291 });
3292 })
3293 });
3294
3295 let fs = FakeFs::new(cx.background_executor.clone());
3296 fs.insert_tree(
3297 "/project_root",
3298 json!({
3299 ".git": {},
3300 ".gitignore": "**/gitignored_dir",
3301 "dir_1": {
3302 "file_1.py": "# File 1_1 contents",
3303 "file_2.py": "# File 1_2 contents",
3304 "file_3.py": "# File 1_3 contents",
3305 "gitignored_dir": {
3306 "file_a.py": "# File contents",
3307 "file_b.py": "# File contents",
3308 "file_c.py": "# File contents",
3309 },
3310 },
3311 "dir_2": {
3312 "file_1.py": "# File 2_1 contents",
3313 "file_2.py": "# File 2_2 contents",
3314 "file_3.py": "# File 2_3 contents",
3315 }
3316 }),
3317 )
3318 .await;
3319
3320 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3321 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3322 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3323 let panel = workspace
3324 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3325 .unwrap();
3326
3327 assert_eq!(
3328 visible_entries_as_strings(&panel, 0..20, cx),
3329 &[
3330 "v project_root",
3331 " > .git",
3332 " > dir_1",
3333 " > dir_2",
3334 " .gitignore",
3335 ]
3336 );
3337
3338 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3339 .expect("dir 1 file is not ignored and should have an entry");
3340 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3341 .expect("dir 2 file is not ignored and should have an entry");
3342 let gitignored_dir_file =
3343 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3344 assert_eq!(
3345 gitignored_dir_file, None,
3346 "File in the gitignored dir should not have an entry before its dir is toggled"
3347 );
3348
3349 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3350 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3351 cx.run_until_parked();
3352 assert_eq!(
3353 visible_entries_as_strings(&panel, 0..20, cx),
3354 &[
3355 "v project_root",
3356 " > .git",
3357 " v dir_1",
3358 " v gitignored_dir <== selected",
3359 " file_a.py",
3360 " file_b.py",
3361 " file_c.py",
3362 " file_1.py",
3363 " file_2.py",
3364 " file_3.py",
3365 " > dir_2",
3366 " .gitignore",
3367 ],
3368 "Should show gitignored dir file list in the project panel"
3369 );
3370 let gitignored_dir_file =
3371 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3372 .expect("after gitignored dir got opened, a file entry should be present");
3373
3374 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3375 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3376 assert_eq!(
3377 visible_entries_as_strings(&panel, 0..20, cx),
3378 &[
3379 "v project_root",
3380 " > .git",
3381 " > dir_1 <== selected",
3382 " > dir_2",
3383 " .gitignore",
3384 ],
3385 "Should hide all dir contents again and prepare for the explicit reveal test"
3386 );
3387
3388 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3389 panel.update(cx, |panel, cx| {
3390 panel.project.update(cx, |_, cx| {
3391 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3392 })
3393 });
3394 cx.run_until_parked();
3395 assert_eq!(
3396 visible_entries_as_strings(&panel, 0..20, cx),
3397 &[
3398 "v project_root",
3399 " > .git",
3400 " > dir_1 <== selected",
3401 " > dir_2",
3402 " .gitignore",
3403 ],
3404 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3405 );
3406 }
3407
3408 panel.update(cx, |panel, cx| {
3409 panel.project.update(cx, |_, cx| {
3410 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3411 })
3412 });
3413 cx.run_until_parked();
3414 assert_eq!(
3415 visible_entries_as_strings(&panel, 0..20, cx),
3416 &[
3417 "v project_root",
3418 " > .git",
3419 " v dir_1",
3420 " > gitignored_dir",
3421 " file_1.py <== selected",
3422 " file_2.py",
3423 " file_3.py",
3424 " > dir_2",
3425 " .gitignore",
3426 ],
3427 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3428 );
3429
3430 panel.update(cx, |panel, cx| {
3431 panel.project.update(cx, |_, cx| {
3432 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3433 })
3434 });
3435 cx.run_until_parked();
3436 assert_eq!(
3437 visible_entries_as_strings(&panel, 0..20, cx),
3438 &[
3439 "v project_root",
3440 " > .git",
3441 " v dir_1",
3442 " > gitignored_dir",
3443 " file_1.py",
3444 " file_2.py",
3445 " file_3.py",
3446 " v dir_2",
3447 " file_1.py <== selected",
3448 " file_2.py",
3449 " file_3.py",
3450 " .gitignore",
3451 ],
3452 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3453 );
3454
3455 panel.update(cx, |panel, cx| {
3456 panel.project.update(cx, |_, cx| {
3457 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3458 })
3459 });
3460 cx.run_until_parked();
3461 assert_eq!(
3462 visible_entries_as_strings(&panel, 0..20, cx),
3463 &[
3464 "v project_root",
3465 " > .git",
3466 " v dir_1",
3467 " v gitignored_dir",
3468 " file_a.py <== selected",
3469 " file_b.py",
3470 " file_c.py",
3471 " file_1.py",
3472 " file_2.py",
3473 " file_3.py",
3474 " v dir_2",
3475 " file_1.py",
3476 " file_2.py",
3477 " file_3.py",
3478 " .gitignore",
3479 ],
3480 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3481 );
3482 }
3483
3484 fn toggle_expand_dir(
3485 panel: &View<ProjectPanel>,
3486 path: impl AsRef<Path>,
3487 cx: &mut VisualTestContext,
3488 ) {
3489 let path = path.as_ref();
3490 panel.update(cx, |panel, cx| {
3491 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3492 let worktree = worktree.read(cx);
3493 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3494 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3495 panel.toggle_expanded(entry_id, cx);
3496 return;
3497 }
3498 }
3499 panic!("no worktree for path {:?}", path);
3500 });
3501 }
3502
3503 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3504 let path = path.as_ref();
3505 panel.update(cx, |panel, cx| {
3506 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3507 let worktree = worktree.read(cx);
3508 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3509 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3510 panel.selection = Some(crate::Selection {
3511 worktree_id: worktree.id(),
3512 entry_id,
3513 });
3514 return;
3515 }
3516 }
3517 panic!("no worktree for path {:?}", path);
3518 });
3519 }
3520
3521 fn find_project_entry(
3522 panel: &View<ProjectPanel>,
3523 path: impl AsRef<Path>,
3524 cx: &mut VisualTestContext,
3525 ) -> Option<ProjectEntryId> {
3526 let path = path.as_ref();
3527 panel.update(cx, |panel, cx| {
3528 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3529 let worktree = worktree.read(cx);
3530 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3531 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3532 }
3533 }
3534 panic!("no worktree for path {path:?}");
3535 })
3536 }
3537
3538 fn visible_entries_as_strings(
3539 panel: &View<ProjectPanel>,
3540 range: Range<usize>,
3541 cx: &mut VisualTestContext,
3542 ) -> Vec<String> {
3543 let mut result = Vec::new();
3544 let mut project_entries = HashSet::default();
3545 let mut has_editor = false;
3546
3547 panel.update(cx, |panel, cx| {
3548 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3549 if details.is_editing {
3550 assert!(!has_editor, "duplicate editor entry");
3551 has_editor = true;
3552 } else {
3553 assert!(
3554 project_entries.insert(project_entry),
3555 "duplicate project entry {:?} {:?}",
3556 project_entry,
3557 details
3558 );
3559 }
3560
3561 let indent = " ".repeat(details.depth);
3562 let icon = if details.kind.is_dir() {
3563 if details.is_expanded {
3564 "v "
3565 } else {
3566 "> "
3567 }
3568 } else {
3569 " "
3570 };
3571 let name = if details.is_editing {
3572 format!("[EDITOR: '{}']", details.filename)
3573 } else if details.is_processing {
3574 format!("[PROCESSING: '{}']", details.filename)
3575 } else {
3576 details.filename.clone()
3577 };
3578 let selected = if details.is_selected {
3579 " <== selected"
3580 } else {
3581 ""
3582 };
3583 result.push(format!("{indent}{icon}{name}{selected}"));
3584 });
3585 });
3586
3587 result
3588 }
3589
3590 fn init_test(cx: &mut TestAppContext) {
3591 cx.update(|cx| {
3592 let settings_store = SettingsStore::test(cx);
3593 cx.set_global(settings_store);
3594 init_settings(cx);
3595 theme::init(theme::LoadThemes::JustBase, cx);
3596 language::init(cx);
3597 editor::init_settings(cx);
3598 crate::init((), cx);
3599 workspace::init_settings(cx);
3600 client::init_settings(cx);
3601 Project::init_settings(cx);
3602
3603 cx.update_global::<SettingsStore, _>(|store, cx| {
3604 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3605 worktree_settings.file_scan_exclusions = Some(Vec::new());
3606 });
3607 });
3608 });
3609 }
3610
3611 fn init_test_with_editor(cx: &mut TestAppContext) {
3612 cx.update(|cx| {
3613 let app_state = AppState::test(cx);
3614 theme::init(theme::LoadThemes::JustBase, cx);
3615 init_settings(cx);
3616 language::init(cx);
3617 editor::init(cx);
3618 crate::init((), cx);
3619 workspace::init(app_state.clone(), cx);
3620 Project::init_settings(cx);
3621 });
3622 }
3623
3624 fn ensure_single_file_is_opened(
3625 window: &WindowHandle<Workspace>,
3626 expected_path: &str,
3627 cx: &mut TestAppContext,
3628 ) {
3629 window
3630 .update(cx, |workspace, cx| {
3631 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3632 assert_eq!(worktrees.len(), 1);
3633 let worktree_id = worktrees[0].read(cx).id();
3634
3635 let open_project_paths = workspace
3636 .panes()
3637 .iter()
3638 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3639 .collect::<Vec<_>>();
3640 assert_eq!(
3641 open_project_paths,
3642 vec![ProjectPath {
3643 worktree_id,
3644 path: Arc::from(Path::new(expected_path))
3645 }],
3646 "Should have opened file, selected in project panel"
3647 );
3648 })
3649 .unwrap();
3650 }
3651
3652 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3653 assert!(
3654 !cx.has_pending_prompt(),
3655 "Should have no prompts before the deletion"
3656 );
3657 panel.update(cx, |panel, cx| {
3658 panel.delete(&Delete { skip_prompt: false }, cx)
3659 });
3660 assert!(
3661 cx.has_pending_prompt(),
3662 "Should have a prompt after the deletion"
3663 );
3664 cx.simulate_prompt_answer(0);
3665 assert!(
3666 !cx.has_pending_prompt(),
3667 "Should have no prompts after prompt was replied to"
3668 );
3669 cx.executor().run_until_parked();
3670 }
3671
3672 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3673 assert!(
3674 !cx.has_pending_prompt(),
3675 "Should have no prompts before the deletion"
3676 );
3677 panel.update(cx, |panel, cx| {
3678 panel.delete(&Delete { skip_prompt: true }, cx)
3679 });
3680 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
3681 cx.executor().run_until_parked();
3682 }
3683
3684 fn ensure_no_open_items_and_panes(
3685 workspace: &WindowHandle<Workspace>,
3686 cx: &mut VisualTestContext,
3687 ) {
3688 assert!(
3689 !cx.has_pending_prompt(),
3690 "Should have no prompts after deletion operation closes the file"
3691 );
3692 workspace
3693 .read_with(cx, |workspace, cx| {
3694 let open_project_paths = workspace
3695 .panes()
3696 .iter()
3697 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3698 .collect::<Vec<_>>();
3699 assert!(
3700 open_project_paths.is_empty(),
3701 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3702 );
3703 })
3704 .unwrap();
3705 }
3706}