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