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