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 );
401 if is_root {
402 menu = menu.entry(
403 "Remove from Project",
404 cx.handler_for(&this, move |this, cx| {
405 this.project.update(cx, |project, cx| {
406 project.remove_worktree(worktree_id, cx)
407 });
408 }),
409 );
410 }
411 }
412
413 menu = menu
414 .action("New File", Box::new(NewFile))
415 .action("New Folder", Box::new(NewDirectory))
416 .separator()
417 .action("Cut", Box::new(Cut))
418 .action("Copy", Box::new(Copy));
419
420 if let Some(clipboard_entry) = self.clipboard_entry {
421 if clipboard_entry.worktree_id() == worktree_id {
422 menu = menu.action("Paste", Box::new(Paste));
423 }
424 }
425
426 menu = menu
427 .separator()
428 .action("Copy Path", Box::new(CopyPath))
429 .action("Copy Relative Path", Box::new(CopyRelativePath))
430 .separator()
431 .action("Reveal in Finder", Box::new(RevealInFinder));
432
433 if is_dir {
434 menu = menu
435 .action("Open in Terminal", Box::new(OpenInTerminal))
436 .action("Search Inside", Box::new(NewSearchInDirectory))
437 }
438
439 menu = menu.separator().action("Rename", Box::new(Rename));
440
441 if !is_root {
442 menu = menu.action("Delete", Box::new(Delete));
443 }
444
445 menu
446 });
447
448 cx.focus_view(&context_menu);
449 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
450 this.context_menu.take();
451 cx.notify();
452 });
453 self.context_menu = Some((context_menu, position, subscription));
454 }
455
456 cx.notify();
457 }
458
459 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
460 if let Some((worktree, entry)) = self.selected_entry(cx) {
461 if entry.is_dir() {
462 let worktree_id = worktree.id();
463 let entry_id = entry.id;
464 let expanded_dir_ids =
465 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
466 expanded_dir_ids
467 } else {
468 return;
469 };
470
471 match expanded_dir_ids.binary_search(&entry_id) {
472 Ok(_) => self.select_next(&SelectNext, cx),
473 Err(ix) => {
474 self.project.update(cx, |project, cx| {
475 project.expand_entry(worktree_id, entry_id, cx);
476 });
477
478 expanded_dir_ids.insert(ix, entry_id);
479 self.update_visible_entries(None, cx);
480 cx.notify();
481 }
482 }
483 }
484 }
485 }
486
487 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
488 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
489 let worktree_id = worktree.id();
490 let expanded_dir_ids =
491 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
492 expanded_dir_ids
493 } else {
494 return;
495 };
496
497 loop {
498 let entry_id = entry.id;
499 match expanded_dir_ids.binary_search(&entry_id) {
500 Ok(ix) => {
501 expanded_dir_ids.remove(ix);
502 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
503 cx.notify();
504 break;
505 }
506 Err(_) => {
507 if let Some(parent_entry) =
508 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
509 {
510 entry = parent_entry;
511 } else {
512 break;
513 }
514 }
515 }
516 }
517 }
518 }
519
520 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
521 self.expanded_dir_ids.clear();
522 self.update_visible_entries(None, cx);
523 cx.notify();
524 }
525
526 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
527 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
528 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
529 self.project.update(cx, |project, cx| {
530 match expanded_dir_ids.binary_search(&entry_id) {
531 Ok(ix) => {
532 expanded_dir_ids.remove(ix);
533 }
534 Err(ix) => {
535 project.expand_entry(worktree_id, entry_id, cx);
536 expanded_dir_ids.insert(ix, entry_id);
537 }
538 }
539 });
540 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
541 cx.focus(&self.focus_handle);
542 cx.notify();
543 }
544 }
545 }
546
547 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
548 if let Some(selection) = self.selection {
549 let (mut worktree_ix, mut entry_ix, _) =
550 self.index_for_selection(selection).unwrap_or_default();
551 if entry_ix > 0 {
552 entry_ix -= 1;
553 } else if worktree_ix > 0 {
554 worktree_ix -= 1;
555 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
556 } else {
557 return;
558 }
559
560 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
561 self.selection = Some(Selection {
562 worktree_id: *worktree_id,
563 entry_id: worktree_entries[entry_ix].id,
564 });
565 self.autoscroll(cx);
566 cx.notify();
567 } else {
568 self.select_first(cx);
569 }
570 }
571
572 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
573 if let Some(task) = self.confirm_edit(cx) {
574 task.detach_and_log_err(cx);
575 }
576 }
577
578 fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
579 if let Some((_, entry)) = self.selected_entry(cx) {
580 if entry.is_file() {
581 self.open_entry(entry.id, true, cx);
582 }
583 }
584 }
585
586 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
587 let edit_state = self.edit_state.as_mut()?;
588 cx.focus(&self.focus_handle);
589
590 let worktree_id = edit_state.worktree_id;
591 let is_new_entry = edit_state.is_new_entry;
592 let is_dir = edit_state.is_dir;
593 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
594 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
595 let filename = self.filename_editor.read(cx).text(cx);
596
597 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
598 let edit_task;
599 let edited_entry_id;
600 if is_new_entry {
601 self.selection = Some(Selection {
602 worktree_id,
603 entry_id: NEW_ENTRY_ID,
604 });
605 let new_path = entry.path.join(&filename.trim_start_matches("/"));
606 if path_already_exists(new_path.as_path()) {
607 return None;
608 }
609
610 edited_entry_id = NEW_ENTRY_ID;
611 edit_task = self.project.update(cx, |project, cx| {
612 project.create_entry((worktree_id, &new_path), is_dir, cx)
613 });
614 } else {
615 let new_path = if let Some(parent) = entry.path.clone().parent() {
616 parent.join(&filename)
617 } else {
618 filename.clone().into()
619 };
620 if path_already_exists(new_path.as_path()) {
621 return None;
622 }
623
624 edited_entry_id = entry.id;
625 edit_task = self.project.update(cx, |project, cx| {
626 project.rename_entry(entry.id, new_path.as_path(), cx)
627 });
628 };
629
630 edit_state.processing_filename = Some(filename);
631 cx.notify();
632
633 Some(cx.spawn(|this, mut cx| async move {
634 let new_entry = edit_task.await;
635 this.update(&mut cx, |this, cx| {
636 this.edit_state.take();
637 cx.notify();
638 })?;
639
640 if let Some(new_entry) = new_entry? {
641 this.update(&mut cx, |this, cx| {
642 if let Some(selection) = &mut this.selection {
643 if selection.entry_id == edited_entry_id {
644 selection.worktree_id = worktree_id;
645 selection.entry_id = new_entry.id;
646 this.expand_to_selection(cx);
647 }
648 }
649 this.update_visible_entries(None, cx);
650 if is_new_entry && !is_dir {
651 this.open_entry(new_entry.id, true, cx);
652 }
653 cx.notify();
654 })?;
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 self.project
936 .update(cx, |project, cx| {
937 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
938 })
939 .detach_and_log_err(cx)
940 } else {
941 self.project
942 .update(cx, |project, cx| {
943 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
944 })
945 .detach_and_log_err(cx)
946 }
947
948 Some(())
949 });
950 }
951
952 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
953 if let Some((worktree, entry)) = self.selected_entry(cx) {
954 cx.write_to_clipboard(ClipboardItem::new(
955 worktree
956 .abs_path()
957 .join(&entry.path)
958 .to_string_lossy()
959 .to_string(),
960 ));
961 }
962 }
963
964 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
965 if let Some((_, entry)) = self.selected_entry(cx) {
966 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
967 }
968 }
969
970 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
971 if let Some((worktree, entry)) = self.selected_entry(cx) {
972 cx.reveal_path(&worktree.abs_path().join(&entry.path));
973 }
974 }
975
976 fn open_in_terminal(&mut self, _: &OpenInTerminal, _cx: &mut ViewContext<Self>) {
977 todo!()
978 // if let Some((worktree, entry)) = self.selected_entry(cx) {
979 // let window = cx.window();
980 // let view_id = cx.view_id();
981 // let path = worktree.abs_path().join(&entry.path);
982
983 // cx.app_context()
984 // .spawn(|mut cx| async move {
985 // window.dispatch_action(
986 // view_id,
987 // &workspace::OpenTerminal {
988 // working_directory: path,
989 // },
990 // &mut cx,
991 // );
992 // })
993 // .detach();
994 // }
995 }
996
997 pub fn new_search_in_directory(
998 &mut self,
999 _: &NewSearchInDirectory,
1000 cx: &mut ViewContext<Self>,
1001 ) {
1002 if let Some((_, entry)) = self.selected_entry(cx) {
1003 if entry.is_dir() {
1004 cx.emit(Event::NewSearchInDirectory {
1005 dir_entry: entry.clone(),
1006 });
1007 }
1008 }
1009 }
1010
1011 // todo!()
1012 // fn move_entry(
1013 // &mut self,
1014 // entry_to_move: ProjectEntryId,
1015 // destination: ProjectEntryId,
1016 // destination_is_file: bool,
1017 // cx: &mut ViewContext<Self>,
1018 // ) {
1019 // let destination_worktree = self.project.update(cx, |project, cx| {
1020 // let entry_path = project.path_for_entry(entry_to_move, cx)?;
1021 // let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1022
1023 // let mut destination_path = destination_entry_path.as_ref();
1024 // if destination_is_file {
1025 // destination_path = destination_path.parent()?;
1026 // }
1027
1028 // let mut new_path = destination_path.to_path_buf();
1029 // new_path.push(entry_path.path.file_name()?);
1030 // if new_path != entry_path.path.as_ref() {
1031 // let task = project.rename_entry(entry_to_move, new_path, cx);
1032 // cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1033 // }
1034
1035 // Some(project.worktree_id_for_entry(destination, cx)?)
1036 // });
1037
1038 // if let Some(destination_worktree) = destination_worktree {
1039 // self.expand_entry(destination_worktree, destination, cx);
1040 // }
1041 // }
1042
1043 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1044 let mut entry_index = 0;
1045 let mut visible_entries_index = 0;
1046 for (worktree_index, (worktree_id, worktree_entries)) in
1047 self.visible_entries.iter().enumerate()
1048 {
1049 if *worktree_id == selection.worktree_id {
1050 for entry in worktree_entries {
1051 if entry.id == selection.entry_id {
1052 return Some((worktree_index, entry_index, visible_entries_index));
1053 } else {
1054 visible_entries_index += 1;
1055 entry_index += 1;
1056 }
1057 }
1058 break;
1059 } else {
1060 visible_entries_index += worktree_entries.len();
1061 }
1062 }
1063 None
1064 }
1065
1066 pub fn selected_entry<'a>(
1067 &self,
1068 cx: &'a AppContext,
1069 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1070 let (worktree, entry) = self.selected_entry_handle(cx)?;
1071 Some((worktree.read(cx), entry))
1072 }
1073
1074 fn selected_entry_handle<'a>(
1075 &self,
1076 cx: &'a AppContext,
1077 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1078 let selection = self.selection?;
1079 let project = self.project.read(cx);
1080 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1081 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1082 Some((worktree, entry))
1083 }
1084
1085 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1086 let (worktree, entry) = self.selected_entry(cx)?;
1087 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1088
1089 for path in entry.path.ancestors() {
1090 let Some(entry) = worktree.entry_for_path(path) else {
1091 continue;
1092 };
1093 if entry.is_dir() {
1094 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1095 expanded_dir_ids.insert(idx, entry.id);
1096 }
1097 }
1098 }
1099
1100 Some(())
1101 }
1102
1103 fn update_visible_entries(
1104 &mut self,
1105 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1106 cx: &mut ViewContext<Self>,
1107 ) {
1108 let project = self.project.read(cx);
1109 self.last_worktree_root_id = project
1110 .visible_worktrees(cx)
1111 .rev()
1112 .next()
1113 .and_then(|worktree| worktree.read(cx).root_entry())
1114 .map(|entry| entry.id);
1115
1116 self.visible_entries.clear();
1117 for worktree in project.visible_worktrees(cx) {
1118 let snapshot = worktree.read(cx).snapshot();
1119 let worktree_id = snapshot.id();
1120
1121 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1122 hash_map::Entry::Occupied(e) => e.into_mut(),
1123 hash_map::Entry::Vacant(e) => {
1124 // The first time a worktree's root entry becomes available,
1125 // mark that root entry as expanded.
1126 if let Some(entry) = snapshot.root_entry() {
1127 e.insert(vec![entry.id]).as_slice()
1128 } else {
1129 &[]
1130 }
1131 }
1132 };
1133
1134 let mut new_entry_parent_id = None;
1135 let mut new_entry_kind = EntryKind::Dir;
1136 if let Some(edit_state) = &self.edit_state {
1137 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1138 new_entry_parent_id = Some(edit_state.entry_id);
1139 new_entry_kind = if edit_state.is_dir {
1140 EntryKind::Dir
1141 } else {
1142 EntryKind::File(Default::default())
1143 };
1144 }
1145 }
1146
1147 let mut visible_worktree_entries = Vec::new();
1148 let mut entry_iter = snapshot.entries(true);
1149
1150 while let Some(entry) = entry_iter.entry() {
1151 visible_worktree_entries.push(entry.clone());
1152 if Some(entry.id) == new_entry_parent_id {
1153 visible_worktree_entries.push(Entry {
1154 id: NEW_ENTRY_ID,
1155 kind: new_entry_kind,
1156 path: entry.path.join("\0").into(),
1157 inode: 0,
1158 mtime: entry.mtime,
1159 is_symlink: false,
1160 is_ignored: false,
1161 is_external: false,
1162 git_status: entry.git_status,
1163 });
1164 }
1165 if expanded_dir_ids.binary_search(&entry.id).is_err()
1166 && entry_iter.advance_to_sibling()
1167 {
1168 continue;
1169 }
1170 entry_iter.advance();
1171 }
1172
1173 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1174
1175 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1176 let mut components_a = entry_a.path.components().peekable();
1177 let mut components_b = entry_b.path.components().peekable();
1178 loop {
1179 match (components_a.next(), components_b.next()) {
1180 (Some(component_a), Some(component_b)) => {
1181 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1182 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1183 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1184 let name_a =
1185 UniCase::new(component_a.as_os_str().to_string_lossy());
1186 let name_b =
1187 UniCase::new(component_b.as_os_str().to_string_lossy());
1188 name_a.cmp(&name_b)
1189 });
1190 if !ordering.is_eq() {
1191 return ordering;
1192 }
1193 }
1194 (Some(_), None) => break Ordering::Greater,
1195 (None, Some(_)) => break Ordering::Less,
1196 (None, None) => break Ordering::Equal,
1197 }
1198 }
1199 });
1200 self.visible_entries
1201 .push((worktree_id, visible_worktree_entries));
1202 }
1203
1204 if let Some((worktree_id, entry_id)) = new_selected_entry {
1205 self.selection = Some(Selection {
1206 worktree_id,
1207 entry_id,
1208 });
1209 }
1210 }
1211
1212 fn expand_entry(
1213 &mut self,
1214 worktree_id: WorktreeId,
1215 entry_id: ProjectEntryId,
1216 cx: &mut ViewContext<Self>,
1217 ) {
1218 self.project.update(cx, |project, cx| {
1219 if let Some((worktree, expanded_dir_ids)) = project
1220 .worktree_for_id(worktree_id, cx)
1221 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1222 {
1223 project.expand_entry(worktree_id, entry_id, cx);
1224 let worktree = worktree.read(cx);
1225
1226 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1227 loop {
1228 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1229 expanded_dir_ids.insert(ix, entry.id);
1230 }
1231
1232 if let Some(parent_entry) =
1233 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1234 {
1235 entry = parent_entry;
1236 } else {
1237 break;
1238 }
1239 }
1240 }
1241 }
1242 });
1243 }
1244
1245 fn for_each_visible_entry(
1246 &self,
1247 range: Range<usize>,
1248 cx: &mut ViewContext<ProjectPanel>,
1249 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1250 ) {
1251 let mut ix = 0;
1252 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1253 if ix >= range.end {
1254 return;
1255 }
1256
1257 if ix + visible_worktree_entries.len() <= range.start {
1258 ix += visible_worktree_entries.len();
1259 continue;
1260 }
1261
1262 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1263 let (git_status_setting, show_file_icons, show_folder_icons) = {
1264 let settings = ProjectPanelSettings::get_global(cx);
1265 (
1266 settings.git_status,
1267 settings.file_icons,
1268 settings.folder_icons,
1269 )
1270 };
1271 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1272 let snapshot = worktree.read(cx).snapshot();
1273 let root_name = OsStr::new(snapshot.root_name());
1274 let expanded_entry_ids = self
1275 .expanded_dir_ids
1276 .get(&snapshot.id())
1277 .map(Vec::as_slice)
1278 .unwrap_or(&[]);
1279
1280 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1281 for entry in visible_worktree_entries[entry_range].iter() {
1282 let status = git_status_setting.then(|| entry.git_status).flatten();
1283 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1284 let icon = match entry.kind {
1285 EntryKind::File(_) => {
1286 if show_file_icons {
1287 FileAssociations::get_icon(&entry.path, cx)
1288 } else {
1289 None
1290 }
1291 }
1292 _ => {
1293 if show_folder_icons {
1294 FileAssociations::get_folder_icon(is_expanded, cx)
1295 } else {
1296 FileAssociations::get_chevron_icon(is_expanded, cx)
1297 }
1298 }
1299 };
1300
1301 let mut details = EntryDetails {
1302 filename: entry
1303 .path
1304 .file_name()
1305 .unwrap_or(root_name)
1306 .to_string_lossy()
1307 .to_string(),
1308 icon,
1309 path: entry.path.clone(),
1310 depth: entry.path.components().count(),
1311 kind: entry.kind,
1312 is_ignored: entry.is_ignored,
1313 is_expanded,
1314 is_selected: self.selection.map_or(false, |e| {
1315 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1316 }),
1317 is_editing: false,
1318 is_processing: false,
1319 is_cut: self
1320 .clipboard_entry
1321 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1322 git_status: status,
1323 };
1324
1325 if let Some(edit_state) = &self.edit_state {
1326 let is_edited_entry = if edit_state.is_new_entry {
1327 entry.id == NEW_ENTRY_ID
1328 } else {
1329 entry.id == edit_state.entry_id
1330 };
1331
1332 if is_edited_entry {
1333 if let Some(processing_filename) = &edit_state.processing_filename {
1334 details.is_processing = true;
1335 details.filename.clear();
1336 details.filename.push_str(processing_filename);
1337 } else {
1338 if edit_state.is_new_entry {
1339 details.filename.clear();
1340 }
1341 details.is_editing = true;
1342 }
1343 }
1344 }
1345
1346 callback(entry.id, details, cx);
1347 }
1348 }
1349 ix = end_ix;
1350 }
1351 }
1352
1353 fn render_entry(
1354 &self,
1355 entry_id: ProjectEntryId,
1356 details: EntryDetails,
1357 // dragged_entry_destination: &mut Option<Arc<Path>>,
1358 cx: &mut ViewContext<Self>,
1359 ) -> ListItem {
1360 let kind = details.kind;
1361 let settings = ProjectPanelSettings::get_global(cx);
1362 let show_editor = details.is_editing && !details.is_processing;
1363 let is_selected = self
1364 .selection
1365 .map_or(false, |selection| selection.entry_id == entry_id);
1366
1367 let theme = cx.theme();
1368 let filename_text_color = details
1369 .git_status
1370 .as_ref()
1371 .map(|status| match status {
1372 GitFileStatus::Added => theme.status().created,
1373 GitFileStatus::Modified => theme.status().modified,
1374 GitFileStatus::Conflict => theme.status().conflict,
1375 })
1376 .unwrap_or(theme.status().info);
1377
1378 ListItem::new(entry_id.to_proto() as usize)
1379 .indent_level(details.depth)
1380 .indent_step_size(px(settings.indent_size))
1381 .selected(is_selected)
1382 .child(if let Some(icon) = &details.icon {
1383 div().child(IconElement::from_path(icon.to_string()))
1384 } else {
1385 div()
1386 })
1387 .child(
1388 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1389 div().h_full().w_full().child(editor.clone())
1390 } else {
1391 div()
1392 .text_color(filename_text_color)
1393 .child(Label::new(details.filename.clone()))
1394 }
1395 .ml_1(),
1396 )
1397 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1398 if event.down.button == MouseButton::Right {
1399 return;
1400 }
1401 if !show_editor {
1402 if kind.is_dir() {
1403 this.toggle_expanded(entry_id, cx);
1404 } else {
1405 if event.down.modifiers.command {
1406 this.split_entry(entry_id, cx);
1407 } else {
1408 this.open_entry(entry_id, event.up.click_count > 1, cx);
1409 }
1410 }
1411 }
1412 }))
1413 .on_secondary_mouse_down(cx.listener(move |this, event: &MouseDownEvent, cx| {
1414 this.deploy_context_menu(event.position, entry_id, cx);
1415 }))
1416 // .on_drop::<ProjectEntryId>(|this, event, cx| {
1417 // this.move_entry(
1418 // *dragged_entry,
1419 // entry_id,
1420 // matches!(details.kind, EntryKind::File(_)),
1421 // cx,
1422 // );
1423 // })
1424 }
1425
1426 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1427 let mut dispatch_context = KeyContext::default();
1428 dispatch_context.add("ProjectPanel");
1429 dispatch_context.add("menu");
1430
1431 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1432 "editing"
1433 } else {
1434 "not_editing"
1435 };
1436
1437 dispatch_context.add(identifier);
1438
1439 dispatch_context
1440 }
1441}
1442
1443impl Render for ProjectPanel {
1444 type Element = Focusable<Stateful<Div>>;
1445
1446 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1447 let has_worktree = self.visible_entries.len() != 0;
1448
1449 if has_worktree {
1450 div()
1451 .id("project-panel")
1452 .size_full()
1453 .relative()
1454 .key_context(self.dispatch_context(cx))
1455 .on_action(cx.listener(Self::select_next))
1456 .on_action(cx.listener(Self::select_prev))
1457 .on_action(cx.listener(Self::expand_selected_entry))
1458 .on_action(cx.listener(Self::collapse_selected_entry))
1459 .on_action(cx.listener(Self::collapse_all_entries))
1460 .on_action(cx.listener(Self::new_file))
1461 .on_action(cx.listener(Self::new_directory))
1462 .on_action(cx.listener(Self::rename))
1463 .on_action(cx.listener(Self::delete))
1464 .on_action(cx.listener(Self::confirm))
1465 .on_action(cx.listener(Self::open_file))
1466 .on_action(cx.listener(Self::cancel))
1467 .on_action(cx.listener(Self::cut))
1468 .on_action(cx.listener(Self::copy))
1469 .on_action(cx.listener(Self::copy_path))
1470 .on_action(cx.listener(Self::copy_relative_path))
1471 .on_action(cx.listener(Self::paste))
1472 .on_action(cx.listener(Self::reveal_in_finder))
1473 .on_action(cx.listener(Self::open_in_terminal))
1474 .on_action(cx.listener(Self::new_search_in_directory))
1475 .track_focus(&self.focus_handle)
1476 .child(
1477 uniform_list(
1478 cx.view().clone(),
1479 "entries",
1480 self.visible_entries
1481 .iter()
1482 .map(|(_, worktree_entries)| worktree_entries.len())
1483 .sum(),
1484 {
1485 |this, range, cx| {
1486 let mut items = Vec::new();
1487 this.for_each_visible_entry(range, cx, |id, details, cx| {
1488 items.push(this.render_entry(id, details, cx));
1489 });
1490 items
1491 }
1492 },
1493 )
1494 .size_full()
1495 .track_scroll(self.list.clone()),
1496 )
1497 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1498 overlay()
1499 .position(*position)
1500 .anchor(gpui::AnchorCorner::TopLeft)
1501 .child(menu.clone())
1502 }))
1503 } else {
1504 v_stack()
1505 .id("empty-project_panel")
1506 .track_focus(&self.focus_handle)
1507 }
1508 }
1509}
1510
1511impl EventEmitter<Event> for ProjectPanel {}
1512
1513impl EventEmitter<PanelEvent> for ProjectPanel {}
1514
1515impl Panel for ProjectPanel {
1516 fn position(&self, cx: &WindowContext) -> DockPosition {
1517 match ProjectPanelSettings::get_global(cx).dock {
1518 ProjectPanelDockPosition::Left => DockPosition::Left,
1519 ProjectPanelDockPosition::Right => DockPosition::Right,
1520 }
1521 }
1522
1523 fn position_is_valid(&self, position: DockPosition) -> bool {
1524 matches!(position, DockPosition::Left | DockPosition::Right)
1525 }
1526
1527 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1528 settings::update_settings_file::<ProjectPanelSettings>(
1529 self.fs.clone(),
1530 cx,
1531 move |settings| {
1532 let dock = match position {
1533 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1534 DockPosition::Right => ProjectPanelDockPosition::Right,
1535 };
1536 settings.dock = Some(dock);
1537 },
1538 );
1539 }
1540
1541 fn size(&self, cx: &WindowContext) -> f32 {
1542 self.width
1543 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1544 }
1545
1546 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1547 self.width = size;
1548 self.serialize(cx);
1549 cx.notify();
1550 }
1551
1552 fn icon(&self, _: &WindowContext) -> Option<ui::Icon> {
1553 Some(ui::Icon::FileTree)
1554 }
1555
1556 fn toggle_action(&self) -> Box<dyn Action> {
1557 Box::new(ToggleFocus)
1558 }
1559
1560 fn has_focus(&self, _: &WindowContext) -> bool {
1561 self.has_focus
1562 }
1563
1564 fn persistent_name() -> &'static str {
1565 "Project Panel"
1566 }
1567}
1568
1569impl FocusableView for ProjectPanel {
1570 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1571 self.focus_handle.clone()
1572 }
1573}
1574
1575impl ClipboardEntry {
1576 fn is_cut(&self) -> bool {
1577 matches!(self, Self::Cut { .. })
1578 }
1579
1580 fn entry_id(&self) -> ProjectEntryId {
1581 match self {
1582 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1583 *entry_id
1584 }
1585 }
1586 }
1587
1588 fn worktree_id(&self) -> WorktreeId {
1589 match self {
1590 ClipboardEntry::Copied { worktree_id, .. }
1591 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1592 }
1593 }
1594}
1595
1596#[cfg(test)]
1597mod tests {
1598 use super::*;
1599 use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1600 use pretty_assertions::assert_eq;
1601 use project::{project_settings::ProjectSettings, FakeFs};
1602 use serde_json::json;
1603 use settings::SettingsStore;
1604 use std::{
1605 collections::HashSet,
1606 path::{Path, PathBuf},
1607 sync::atomic::{self, AtomicUsize},
1608 };
1609 use workspace::AppState;
1610
1611 #[gpui::test]
1612 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1613 init_test(cx);
1614
1615 let fs = FakeFs::new(cx.executor().clone());
1616 fs.insert_tree(
1617 "/root1",
1618 json!({
1619 ".dockerignore": "",
1620 ".git": {
1621 "HEAD": "",
1622 },
1623 "a": {
1624 "0": { "q": "", "r": "", "s": "" },
1625 "1": { "t": "", "u": "" },
1626 "2": { "v": "", "w": "", "x": "", "y": "" },
1627 },
1628 "b": {
1629 "3": { "Q": "" },
1630 "4": { "R": "", "S": "", "T": "", "U": "" },
1631 },
1632 "C": {
1633 "5": {},
1634 "6": { "V": "", "W": "" },
1635 "7": { "X": "" },
1636 "8": { "Y": {}, "Z": "" }
1637 }
1638 }),
1639 )
1640 .await;
1641 fs.insert_tree(
1642 "/root2",
1643 json!({
1644 "d": {
1645 "9": ""
1646 },
1647 "e": {}
1648 }),
1649 )
1650 .await;
1651
1652 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1653 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1654 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1655 let panel = workspace
1656 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1657 .unwrap();
1658 assert_eq!(
1659 visible_entries_as_strings(&panel, 0..50, cx),
1660 &[
1661 "v root1",
1662 " > .git",
1663 " > a",
1664 " > b",
1665 " > C",
1666 " .dockerignore",
1667 "v root2",
1668 " > d",
1669 " > e",
1670 ]
1671 );
1672
1673 toggle_expand_dir(&panel, "root1/b", cx);
1674 assert_eq!(
1675 visible_entries_as_strings(&panel, 0..50, cx),
1676 &[
1677 "v root1",
1678 " > .git",
1679 " > a",
1680 " v b <== selected",
1681 " > 3",
1682 " > 4",
1683 " > C",
1684 " .dockerignore",
1685 "v root2",
1686 " > d",
1687 " > e",
1688 ]
1689 );
1690
1691 assert_eq!(
1692 visible_entries_as_strings(&panel, 6..9, cx),
1693 &[
1694 //
1695 " > C",
1696 " .dockerignore",
1697 "v root2",
1698 ]
1699 );
1700 }
1701
1702 #[gpui::test]
1703 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1704 init_test(cx);
1705 cx.update(|cx| {
1706 cx.update_global::<SettingsStore, _>(|store, cx| {
1707 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1708 project_settings.file_scan_exclusions =
1709 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1710 });
1711 });
1712 });
1713
1714 let fs = FakeFs::new(cx.background_executor.clone());
1715 fs.insert_tree(
1716 "/root1",
1717 json!({
1718 ".dockerignore": "",
1719 ".git": {
1720 "HEAD": "",
1721 },
1722 "a": {
1723 "0": { "q": "", "r": "", "s": "" },
1724 "1": { "t": "", "u": "" },
1725 "2": { "v": "", "w": "", "x": "", "y": "" },
1726 },
1727 "b": {
1728 "3": { "Q": "" },
1729 "4": { "R": "", "S": "", "T": "", "U": "" },
1730 },
1731 "C": {
1732 "5": {},
1733 "6": { "V": "", "W": "" },
1734 "7": { "X": "" },
1735 "8": { "Y": {}, "Z": "" }
1736 }
1737 }),
1738 )
1739 .await;
1740 fs.insert_tree(
1741 "/root2",
1742 json!({
1743 "d": {
1744 "4": ""
1745 },
1746 "e": {}
1747 }),
1748 )
1749 .await;
1750
1751 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1752 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1753 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1754 let panel = workspace
1755 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1756 .unwrap();
1757 assert_eq!(
1758 visible_entries_as_strings(&panel, 0..50, cx),
1759 &[
1760 "v root1",
1761 " > a",
1762 " > b",
1763 " > C",
1764 " .dockerignore",
1765 "v root2",
1766 " > d",
1767 " > e",
1768 ]
1769 );
1770
1771 toggle_expand_dir(&panel, "root1/b", cx);
1772 assert_eq!(
1773 visible_entries_as_strings(&panel, 0..50, cx),
1774 &[
1775 "v root1",
1776 " > a",
1777 " v b <== selected",
1778 " > 3",
1779 " > C",
1780 " .dockerignore",
1781 "v root2",
1782 " > d",
1783 " > e",
1784 ]
1785 );
1786
1787 toggle_expand_dir(&panel, "root2/d", cx);
1788 assert_eq!(
1789 visible_entries_as_strings(&panel, 0..50, cx),
1790 &[
1791 "v root1",
1792 " > a",
1793 " v b",
1794 " > 3",
1795 " > C",
1796 " .dockerignore",
1797 "v root2",
1798 " v d <== selected",
1799 " > e",
1800 ]
1801 );
1802
1803 toggle_expand_dir(&panel, "root2/e", cx);
1804 assert_eq!(
1805 visible_entries_as_strings(&panel, 0..50, cx),
1806 &[
1807 "v root1",
1808 " > a",
1809 " v b",
1810 " > 3",
1811 " > C",
1812 " .dockerignore",
1813 "v root2",
1814 " v d",
1815 " v e <== selected",
1816 ]
1817 );
1818 }
1819
1820 #[gpui::test(iterations = 30)]
1821 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1822 init_test(cx);
1823
1824 let fs = FakeFs::new(cx.executor().clone());
1825 fs.insert_tree(
1826 "/root1",
1827 json!({
1828 ".dockerignore": "",
1829 ".git": {
1830 "HEAD": "",
1831 },
1832 "a": {
1833 "0": { "q": "", "r": "", "s": "" },
1834 "1": { "t": "", "u": "" },
1835 "2": { "v": "", "w": "", "x": "", "y": "" },
1836 },
1837 "b": {
1838 "3": { "Q": "" },
1839 "4": { "R": "", "S": "", "T": "", "U": "" },
1840 },
1841 "C": {
1842 "5": {},
1843 "6": { "V": "", "W": "" },
1844 "7": { "X": "" },
1845 "8": { "Y": {}, "Z": "" }
1846 }
1847 }),
1848 )
1849 .await;
1850 fs.insert_tree(
1851 "/root2",
1852 json!({
1853 "d": {
1854 "9": ""
1855 },
1856 "e": {}
1857 }),
1858 )
1859 .await;
1860
1861 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1862 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1863 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1864 let panel = workspace
1865 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1866 .unwrap();
1867
1868 select_path(&panel, "root1", cx);
1869 assert_eq!(
1870 visible_entries_as_strings(&panel, 0..10, cx),
1871 &[
1872 "v root1 <== selected",
1873 " > .git",
1874 " > a",
1875 " > b",
1876 " > C",
1877 " .dockerignore",
1878 "v root2",
1879 " > d",
1880 " > e",
1881 ]
1882 );
1883
1884 // Add a file with the root folder selected. The filename editor is placed
1885 // before the first file in the root folder.
1886 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1887 panel.update(cx, |panel, cx| {
1888 assert!(panel.filename_editor.read(cx).is_focused(cx));
1889 });
1890 assert_eq!(
1891 visible_entries_as_strings(&panel, 0..10, cx),
1892 &[
1893 "v root1",
1894 " > .git",
1895 " > a",
1896 " > b",
1897 " > C",
1898 " [EDITOR: ''] <== selected",
1899 " .dockerignore",
1900 "v root2",
1901 " > d",
1902 " > e",
1903 ]
1904 );
1905
1906 let confirm = panel.update(cx, |panel, cx| {
1907 panel
1908 .filename_editor
1909 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1910 panel.confirm_edit(cx).unwrap()
1911 });
1912 assert_eq!(
1913 visible_entries_as_strings(&panel, 0..10, cx),
1914 &[
1915 "v root1",
1916 " > .git",
1917 " > a",
1918 " > b",
1919 " > C",
1920 " [PROCESSING: 'the-new-filename'] <== selected",
1921 " .dockerignore",
1922 "v root2",
1923 " > d",
1924 " > e",
1925 ]
1926 );
1927
1928 confirm.await.unwrap();
1929 assert_eq!(
1930 visible_entries_as_strings(&panel, 0..10, cx),
1931 &[
1932 "v root1",
1933 " > .git",
1934 " > a",
1935 " > b",
1936 " > C",
1937 " .dockerignore",
1938 " the-new-filename <== selected",
1939 "v root2",
1940 " > d",
1941 " > e",
1942 ]
1943 );
1944
1945 select_path(&panel, "root1/b", cx);
1946 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1947 assert_eq!(
1948 visible_entries_as_strings(&panel, 0..10, cx),
1949 &[
1950 "v root1",
1951 " > .git",
1952 " > a",
1953 " v b",
1954 " > 3",
1955 " > 4",
1956 " [EDITOR: ''] <== selected",
1957 " > C",
1958 " .dockerignore",
1959 " the-new-filename",
1960 ]
1961 );
1962
1963 panel
1964 .update(cx, |panel, cx| {
1965 panel
1966 .filename_editor
1967 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1968 panel.confirm_edit(cx).unwrap()
1969 })
1970 .await
1971 .unwrap();
1972 assert_eq!(
1973 visible_entries_as_strings(&panel, 0..10, cx),
1974 &[
1975 "v root1",
1976 " > .git",
1977 " > a",
1978 " v b",
1979 " > 3",
1980 " > 4",
1981 " another-filename.txt <== selected",
1982 " > C",
1983 " .dockerignore",
1984 " the-new-filename",
1985 ]
1986 );
1987
1988 select_path(&panel, "root1/b/another-filename.txt", cx);
1989 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1990 assert_eq!(
1991 visible_entries_as_strings(&panel, 0..10, cx),
1992 &[
1993 "v root1",
1994 " > .git",
1995 " > a",
1996 " v b",
1997 " > 3",
1998 " > 4",
1999 " [EDITOR: 'another-filename.txt'] <== selected",
2000 " > C",
2001 " .dockerignore",
2002 " the-new-filename",
2003 ]
2004 );
2005
2006 let confirm = panel.update(cx, |panel, cx| {
2007 panel.filename_editor.update(cx, |editor, cx| {
2008 let file_name_selections = editor.selections.all::<usize>(cx);
2009 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2010 let file_name_selection = &file_name_selections[0];
2011 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2012 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2013
2014 editor.set_text("a-different-filename.tar.gz", cx)
2015 });
2016 panel.confirm_edit(cx).unwrap()
2017 });
2018 assert_eq!(
2019 visible_entries_as_strings(&panel, 0..10, cx),
2020 &[
2021 "v root1",
2022 " > .git",
2023 " > a",
2024 " v b",
2025 " > 3",
2026 " > 4",
2027 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2028 " > C",
2029 " .dockerignore",
2030 " the-new-filename",
2031 ]
2032 );
2033
2034 confirm.await.unwrap();
2035 assert_eq!(
2036 visible_entries_as_strings(&panel, 0..10, cx),
2037 &[
2038 "v root1",
2039 " > .git",
2040 " > a",
2041 " v b",
2042 " > 3",
2043 " > 4",
2044 " a-different-filename.tar.gz <== selected",
2045 " > C",
2046 " .dockerignore",
2047 " the-new-filename",
2048 ]
2049 );
2050
2051 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2052 assert_eq!(
2053 visible_entries_as_strings(&panel, 0..10, cx),
2054 &[
2055 "v root1",
2056 " > .git",
2057 " > a",
2058 " v b",
2059 " > 3",
2060 " > 4",
2061 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2062 " > C",
2063 " .dockerignore",
2064 " the-new-filename",
2065 ]
2066 );
2067
2068 panel.update(cx, |panel, cx| {
2069 panel.filename_editor.update(cx, |editor, cx| {
2070 let file_name_selections = editor.selections.all::<usize>(cx);
2071 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2072 let file_name_selection = &file_name_selections[0];
2073 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2074 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..");
2075
2076 });
2077 panel.cancel(&Cancel, cx)
2078 });
2079
2080 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2081 assert_eq!(
2082 visible_entries_as_strings(&panel, 0..10, cx),
2083 &[
2084 "v root1",
2085 " > .git",
2086 " > a",
2087 " v b",
2088 " > [EDITOR: ''] <== selected",
2089 " > 3",
2090 " > 4",
2091 " a-different-filename.tar.gz",
2092 " > C",
2093 " .dockerignore",
2094 ]
2095 );
2096
2097 let confirm = panel.update(cx, |panel, cx| {
2098 panel
2099 .filename_editor
2100 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2101 panel.confirm_edit(cx).unwrap()
2102 });
2103 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2104 assert_eq!(
2105 visible_entries_as_strings(&panel, 0..10, cx),
2106 &[
2107 "v root1",
2108 " > .git",
2109 " > a",
2110 " v b",
2111 " > [PROCESSING: 'new-dir']",
2112 " > 3 <== selected",
2113 " > 4",
2114 " a-different-filename.tar.gz",
2115 " > C",
2116 " .dockerignore",
2117 ]
2118 );
2119
2120 confirm.await.unwrap();
2121 assert_eq!(
2122 visible_entries_as_strings(&panel, 0..10, cx),
2123 &[
2124 "v root1",
2125 " > .git",
2126 " > a",
2127 " v b",
2128 " > 3 <== selected",
2129 " > 4",
2130 " > new-dir",
2131 " a-different-filename.tar.gz",
2132 " > C",
2133 " .dockerignore",
2134 ]
2135 );
2136
2137 panel.update(cx, |panel, cx| panel.rename(&Default::default(), 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 " > [EDITOR: '3'] <== selected",
2146 " > 4",
2147 " > new-dir",
2148 " a-different-filename.tar.gz",
2149 " > C",
2150 " .dockerignore",
2151 ]
2152 );
2153
2154 // Dismiss the rename editor when it loses focus.
2155 workspace.update(cx, |_, cx| cx.blur()).unwrap();
2156 assert_eq!(
2157 visible_entries_as_strings(&panel, 0..10, cx),
2158 &[
2159 "v root1",
2160 " > .git",
2161 " > a",
2162 " v b",
2163 " > 3 <== selected",
2164 " > 4",
2165 " > new-dir",
2166 " a-different-filename.tar.gz",
2167 " > C",
2168 " .dockerignore",
2169 ]
2170 );
2171 }
2172
2173 #[gpui::test(iterations = 10)]
2174 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2175 init_test(cx);
2176
2177 let fs = FakeFs::new(cx.executor().clone());
2178 fs.insert_tree(
2179 "/root1",
2180 json!({
2181 ".dockerignore": "",
2182 ".git": {
2183 "HEAD": "",
2184 },
2185 "a": {
2186 "0": { "q": "", "r": "", "s": "" },
2187 "1": { "t": "", "u": "" },
2188 "2": { "v": "", "w": "", "x": "", "y": "" },
2189 },
2190 "b": {
2191 "3": { "Q": "" },
2192 "4": { "R": "", "S": "", "T": "", "U": "" },
2193 },
2194 "C": {
2195 "5": {},
2196 "6": { "V": "", "W": "" },
2197 "7": { "X": "" },
2198 "8": { "Y": {}, "Z": "" }
2199 }
2200 }),
2201 )
2202 .await;
2203 fs.insert_tree(
2204 "/root2",
2205 json!({
2206 "d": {
2207 "9": ""
2208 },
2209 "e": {}
2210 }),
2211 )
2212 .await;
2213
2214 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2215 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2216 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2217 let panel = workspace
2218 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2219 .unwrap();
2220
2221 select_path(&panel, "root1", cx);
2222 assert_eq!(
2223 visible_entries_as_strings(&panel, 0..10, cx),
2224 &[
2225 "v root1 <== selected",
2226 " > .git",
2227 " > a",
2228 " > b",
2229 " > C",
2230 " .dockerignore",
2231 "v root2",
2232 " > d",
2233 " > e",
2234 ]
2235 );
2236
2237 // Add a file with the root folder selected. The filename editor is placed
2238 // before the first file in the root folder.
2239 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2240 panel.update(cx, |panel, cx| {
2241 assert!(panel.filename_editor.read(cx).is_focused(cx));
2242 });
2243 assert_eq!(
2244 visible_entries_as_strings(&panel, 0..10, cx),
2245 &[
2246 "v root1",
2247 " > .git",
2248 " > a",
2249 " > b",
2250 " > C",
2251 " [EDITOR: ''] <== selected",
2252 " .dockerignore",
2253 "v root2",
2254 " > d",
2255 " > e",
2256 ]
2257 );
2258
2259 let confirm = panel.update(cx, |panel, cx| {
2260 panel.filename_editor.update(cx, |editor, cx| {
2261 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2262 });
2263 panel.confirm_edit(cx).unwrap()
2264 });
2265
2266 assert_eq!(
2267 visible_entries_as_strings(&panel, 0..10, cx),
2268 &[
2269 "v root1",
2270 " > .git",
2271 " > a",
2272 " > b",
2273 " > C",
2274 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2275 " .dockerignore",
2276 "v root2",
2277 " > d",
2278 " > e",
2279 ]
2280 );
2281
2282 confirm.await.unwrap();
2283 assert_eq!(
2284 visible_entries_as_strings(&panel, 0..13, cx),
2285 &[
2286 "v root1",
2287 " > .git",
2288 " > a",
2289 " > b",
2290 " v bdir1",
2291 " v dir2",
2292 " the-new-filename <== selected",
2293 " > C",
2294 " .dockerignore",
2295 "v root2",
2296 " > d",
2297 " > e",
2298 ]
2299 );
2300 }
2301
2302 #[gpui::test]
2303 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2304 init_test(cx);
2305
2306 let fs = FakeFs::new(cx.executor().clone());
2307 fs.insert_tree(
2308 "/root1",
2309 json!({
2310 "one.two.txt": "",
2311 "one.txt": ""
2312 }),
2313 )
2314 .await;
2315
2316 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2317 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2318 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2319 let panel = workspace
2320 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2321 .unwrap();
2322
2323 panel.update(cx, |panel, cx| {
2324 panel.select_next(&Default::default(), cx);
2325 panel.select_next(&Default::default(), cx);
2326 });
2327
2328 assert_eq!(
2329 visible_entries_as_strings(&panel, 0..50, cx),
2330 &[
2331 //
2332 "v root1",
2333 " one.two.txt <== selected",
2334 " one.txt",
2335 ]
2336 );
2337
2338 // Regression test - file name is created correctly when
2339 // the copied file's name contains multiple dots.
2340 panel.update(cx, |panel, cx| {
2341 panel.copy(&Default::default(), cx);
2342 panel.paste(&Default::default(), cx);
2343 });
2344 cx.executor().run_until_parked();
2345
2346 assert_eq!(
2347 visible_entries_as_strings(&panel, 0..50, cx),
2348 &[
2349 //
2350 "v root1",
2351 " one.two copy.txt",
2352 " one.two.txt <== selected",
2353 " one.txt",
2354 ]
2355 );
2356
2357 panel.update(cx, |panel, cx| {
2358 panel.paste(&Default::default(), cx);
2359 });
2360 cx.executor().run_until_parked();
2361
2362 assert_eq!(
2363 visible_entries_as_strings(&panel, 0..50, cx),
2364 &[
2365 //
2366 "v root1",
2367 " one.two copy 1.txt",
2368 " one.two copy.txt",
2369 " one.two.txt <== selected",
2370 " one.txt",
2371 ]
2372 );
2373 }
2374
2375 #[gpui::test]
2376 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2377 init_test_with_editor(cx);
2378
2379 let fs = FakeFs::new(cx.executor().clone());
2380 fs.insert_tree(
2381 "/src",
2382 json!({
2383 "test": {
2384 "first.rs": "// First Rust file",
2385 "second.rs": "// Second Rust file",
2386 "third.rs": "// Third Rust file",
2387 }
2388 }),
2389 )
2390 .await;
2391
2392 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2393 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2394 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2395 let panel = workspace
2396 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2397 .unwrap();
2398
2399 toggle_expand_dir(&panel, "src/test", cx);
2400 select_path(&panel, "src/test/first.rs", cx);
2401 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2402 cx.executor().run_until_parked();
2403 assert_eq!(
2404 visible_entries_as_strings(&panel, 0..10, cx),
2405 &[
2406 "v src",
2407 " v test",
2408 " first.rs <== selected",
2409 " second.rs",
2410 " third.rs"
2411 ]
2412 );
2413 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2414
2415 submit_deletion(&panel, cx);
2416 assert_eq!(
2417 visible_entries_as_strings(&panel, 0..10, cx),
2418 &[
2419 "v src",
2420 " v test",
2421 " second.rs",
2422 " third.rs"
2423 ],
2424 "Project panel should have no deleted file, no other file is selected in it"
2425 );
2426 ensure_no_open_items_and_panes(&workspace, cx);
2427
2428 select_path(&panel, "src/test/second.rs", cx);
2429 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2430 cx.executor().run_until_parked();
2431 assert_eq!(
2432 visible_entries_as_strings(&panel, 0..10, cx),
2433 &[
2434 "v src",
2435 " v test",
2436 " second.rs <== selected",
2437 " third.rs"
2438 ]
2439 );
2440 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2441
2442 workspace
2443 .update(cx, |workspace, cx| {
2444 let active_items = workspace
2445 .panes()
2446 .iter()
2447 .filter_map(|pane| pane.read(cx).active_item())
2448 .collect::<Vec<_>>();
2449 assert_eq!(active_items.len(), 1);
2450 let open_editor = active_items
2451 .into_iter()
2452 .next()
2453 .unwrap()
2454 .downcast::<Editor>()
2455 .expect("Open item should be an editor");
2456 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2457 })
2458 .unwrap();
2459 submit_deletion(&panel, cx);
2460 assert_eq!(
2461 visible_entries_as_strings(&panel, 0..10, cx),
2462 &["v src", " v test", " third.rs"],
2463 "Project panel should have no deleted file, with one last file remaining"
2464 );
2465 ensure_no_open_items_and_panes(&workspace, cx);
2466 }
2467
2468 #[gpui::test]
2469 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2470 init_test_with_editor(cx);
2471
2472 let fs = FakeFs::new(cx.executor().clone());
2473 fs.insert_tree(
2474 "/src",
2475 json!({
2476 "test": {
2477 "first.rs": "// First Rust file",
2478 "second.rs": "// Second Rust file",
2479 "third.rs": "// Third Rust file",
2480 }
2481 }),
2482 )
2483 .await;
2484
2485 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2486 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2487 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2488 let panel = workspace
2489 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2490 .unwrap();
2491
2492 select_path(&panel, "src/", cx);
2493 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2494 cx.executor().run_until_parked();
2495 assert_eq!(
2496 visible_entries_as_strings(&panel, 0..10, cx),
2497 &[
2498 //
2499 "v src <== selected",
2500 " > test"
2501 ]
2502 );
2503 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2504 panel.update(cx, |panel, cx| {
2505 assert!(panel.filename_editor.read(cx).is_focused(cx));
2506 });
2507 assert_eq!(
2508 visible_entries_as_strings(&panel, 0..10, cx),
2509 &[
2510 //
2511 "v src",
2512 " > [EDITOR: ''] <== selected",
2513 " > test"
2514 ]
2515 );
2516 panel.update(cx, |panel, cx| {
2517 panel
2518 .filename_editor
2519 .update(cx, |editor, cx| editor.set_text("test", cx));
2520 assert!(
2521 panel.confirm_edit(cx).is_none(),
2522 "Should not allow to confirm on conflicting new directory name"
2523 )
2524 });
2525 assert_eq!(
2526 visible_entries_as_strings(&panel, 0..10, cx),
2527 &[
2528 //
2529 "v src",
2530 " > test"
2531 ],
2532 "File list should be unchanged after failed folder create confirmation"
2533 );
2534
2535 select_path(&panel, "src/test/", cx);
2536 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2537 cx.executor().run_until_parked();
2538 assert_eq!(
2539 visible_entries_as_strings(&panel, 0..10, cx),
2540 &[
2541 //
2542 "v src",
2543 " > test <== selected"
2544 ]
2545 );
2546 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2547 panel.update(cx, |panel, cx| {
2548 assert!(panel.filename_editor.read(cx).is_focused(cx));
2549 });
2550 assert_eq!(
2551 visible_entries_as_strings(&panel, 0..10, cx),
2552 &[
2553 "v src",
2554 " v test",
2555 " [EDITOR: ''] <== selected",
2556 " first.rs",
2557 " second.rs",
2558 " third.rs"
2559 ]
2560 );
2561 panel.update(cx, |panel, cx| {
2562 panel
2563 .filename_editor
2564 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2565 assert!(
2566 panel.confirm_edit(cx).is_none(),
2567 "Should not allow to confirm on conflicting new file name"
2568 )
2569 });
2570 assert_eq!(
2571 visible_entries_as_strings(&panel, 0..10, cx),
2572 &[
2573 "v src",
2574 " v test",
2575 " first.rs",
2576 " second.rs",
2577 " third.rs"
2578 ],
2579 "File list should be unchanged after failed file create confirmation"
2580 );
2581
2582 select_path(&panel, "src/test/first.rs", cx);
2583 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2584 cx.executor().run_until_parked();
2585 assert_eq!(
2586 visible_entries_as_strings(&panel, 0..10, cx),
2587 &[
2588 "v src",
2589 " v test",
2590 " first.rs <== selected",
2591 " second.rs",
2592 " third.rs"
2593 ],
2594 );
2595 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2596 panel.update(cx, |panel, cx| {
2597 assert!(panel.filename_editor.read(cx).is_focused(cx));
2598 });
2599 assert_eq!(
2600 visible_entries_as_strings(&panel, 0..10, cx),
2601 &[
2602 "v src",
2603 " v test",
2604 " [EDITOR: 'first.rs'] <== selected",
2605 " second.rs",
2606 " third.rs"
2607 ]
2608 );
2609 panel.update(cx, |panel, cx| {
2610 panel
2611 .filename_editor
2612 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2613 assert!(
2614 panel.confirm_edit(cx).is_none(),
2615 "Should not allow to confirm on conflicting file rename"
2616 )
2617 });
2618 assert_eq!(
2619 visible_entries_as_strings(&panel, 0..10, cx),
2620 &[
2621 "v src",
2622 " v test",
2623 " first.rs <== selected",
2624 " second.rs",
2625 " third.rs"
2626 ],
2627 "File list should be unchanged after failed rename confirmation"
2628 );
2629 }
2630
2631 #[gpui::test]
2632 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2633 init_test_with_editor(cx);
2634
2635 let fs = FakeFs::new(cx.executor().clone());
2636 fs.insert_tree(
2637 "/src",
2638 json!({
2639 "test": {
2640 "first.rs": "// First Rust file",
2641 "second.rs": "// Second Rust file",
2642 "third.rs": "// Third Rust file",
2643 }
2644 }),
2645 )
2646 .await;
2647
2648 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2649 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2650 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2651 let panel = workspace
2652 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2653 .unwrap();
2654
2655 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2656 let _subscription = panel.update(cx, |_, cx| {
2657 let subcription_count = Arc::clone(&new_search_events_count);
2658 let view = cx.view().clone();
2659 cx.subscribe(&view, move |_, _, event, _| {
2660 if matches!(event, Event::NewSearchInDirectory { .. }) {
2661 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2662 }
2663 })
2664 });
2665
2666 toggle_expand_dir(&panel, "src/test", cx);
2667 select_path(&panel, "src/test/first.rs", cx);
2668 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2669 cx.executor().run_until_parked();
2670 assert_eq!(
2671 visible_entries_as_strings(&panel, 0..10, cx),
2672 &[
2673 "v src",
2674 " v test",
2675 " first.rs <== selected",
2676 " second.rs",
2677 " third.rs"
2678 ]
2679 );
2680 panel.update(cx, |panel, cx| {
2681 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2682 });
2683 assert_eq!(
2684 new_search_events_count.load(atomic::Ordering::SeqCst),
2685 0,
2686 "Should not trigger new search in directory when called on a file"
2687 );
2688
2689 select_path(&panel, "src/test", cx);
2690 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2691 cx.executor().run_until_parked();
2692 assert_eq!(
2693 visible_entries_as_strings(&panel, 0..10, cx),
2694 &[
2695 "v src",
2696 " v test <== selected",
2697 " first.rs",
2698 " second.rs",
2699 " third.rs"
2700 ]
2701 );
2702 panel.update(cx, |panel, cx| {
2703 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2704 });
2705 assert_eq!(
2706 new_search_events_count.load(atomic::Ordering::SeqCst),
2707 1,
2708 "Should trigger new search in directory when called on a directory"
2709 );
2710 }
2711
2712 #[gpui::test]
2713 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2714 init_test_with_editor(cx);
2715
2716 let fs = FakeFs::new(cx.executor().clone());
2717 fs.insert_tree(
2718 "/project_root",
2719 json!({
2720 "dir_1": {
2721 "nested_dir": {
2722 "file_a.py": "# File contents",
2723 "file_b.py": "# File contents",
2724 "file_c.py": "# File contents",
2725 },
2726 "file_1.py": "# File contents",
2727 "file_2.py": "# File contents",
2728 "file_3.py": "# File contents",
2729 },
2730 "dir_2": {
2731 "file_1.py": "# File contents",
2732 "file_2.py": "# File contents",
2733 "file_3.py": "# File contents",
2734 }
2735 }),
2736 )
2737 .await;
2738
2739 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2740 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2741 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2742 let panel = workspace
2743 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2744 .unwrap();
2745
2746 panel.update(cx, |panel, cx| {
2747 panel.collapse_all_entries(&CollapseAllEntries, cx)
2748 });
2749 cx.executor().run_until_parked();
2750 assert_eq!(
2751 visible_entries_as_strings(&panel, 0..10, cx),
2752 &["v project_root", " > dir_1", " > dir_2",]
2753 );
2754
2755 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2756 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2757 cx.executor().run_until_parked();
2758 assert_eq!(
2759 visible_entries_as_strings(&panel, 0..10, cx),
2760 &[
2761 "v project_root",
2762 " v dir_1 <== selected",
2763 " > nested_dir",
2764 " file_1.py",
2765 " file_2.py",
2766 " file_3.py",
2767 " > dir_2",
2768 ]
2769 );
2770 }
2771
2772 #[gpui::test]
2773 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2774 init_test(cx);
2775
2776 let fs = FakeFs::new(cx.executor().clone());
2777 fs.as_fake().insert_tree("/root", json!({})).await;
2778 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2779 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2780 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2781 let panel = workspace
2782 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2783 .unwrap();
2784
2785 // Make a new buffer with no backing file
2786 workspace
2787 .update(cx, |workspace, cx| {
2788 Editor::new_file(workspace, &Default::default(), cx)
2789 })
2790 .unwrap();
2791
2792 // "Save as"" the buffer, creating a new backing file for it
2793 let save_task = workspace
2794 .update(cx, |workspace, cx| {
2795 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2796 })
2797 .unwrap();
2798
2799 cx.executor().run_until_parked();
2800 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2801 save_task.await.unwrap();
2802
2803 // Rename the file
2804 select_path(&panel, "root/new", cx);
2805 assert_eq!(
2806 visible_entries_as_strings(&panel, 0..10, cx),
2807 &["v root", " new <== selected"]
2808 );
2809 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2810 panel.update(cx, |panel, cx| {
2811 panel
2812 .filename_editor
2813 .update(cx, |editor, cx| editor.set_text("newer", cx));
2814 });
2815 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2816
2817 cx.executor().run_until_parked();
2818 assert_eq!(
2819 visible_entries_as_strings(&panel, 0..10, cx),
2820 &["v root", " newer <== selected"]
2821 );
2822
2823 workspace
2824 .update(cx, |workspace, cx| {
2825 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2826 })
2827 .unwrap()
2828 .await
2829 .unwrap();
2830
2831 cx.executor().run_until_parked();
2832 // assert that saving the file doesn't restore "new"
2833 assert_eq!(
2834 visible_entries_as_strings(&panel, 0..10, cx),
2835 &["v root", " newer <== selected"]
2836 );
2837 }
2838
2839 fn toggle_expand_dir(
2840 panel: &View<ProjectPanel>,
2841 path: impl AsRef<Path>,
2842 cx: &mut VisualTestContext,
2843 ) {
2844 let path = path.as_ref();
2845 panel.update(cx, |panel, cx| {
2846 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2847 let worktree = worktree.read(cx);
2848 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2849 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2850 panel.toggle_expanded(entry_id, cx);
2851 return;
2852 }
2853 }
2854 panic!("no worktree for path {:?}", path);
2855 });
2856 }
2857
2858 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
2859 let path = path.as_ref();
2860 panel.update(cx, |panel, cx| {
2861 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2862 let worktree = worktree.read(cx);
2863 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2864 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2865 panel.selection = Some(crate::Selection {
2866 worktree_id: worktree.id(),
2867 entry_id,
2868 });
2869 return;
2870 }
2871 }
2872 panic!("no worktree for path {:?}", path);
2873 });
2874 }
2875
2876 fn visible_entries_as_strings(
2877 panel: &View<ProjectPanel>,
2878 range: Range<usize>,
2879 cx: &mut VisualTestContext,
2880 ) -> Vec<String> {
2881 let mut result = Vec::new();
2882 let mut project_entries = HashSet::new();
2883 let mut has_editor = false;
2884
2885 panel.update(cx, |panel, cx| {
2886 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2887 if details.is_editing {
2888 assert!(!has_editor, "duplicate editor entry");
2889 has_editor = true;
2890 } else {
2891 assert!(
2892 project_entries.insert(project_entry),
2893 "duplicate project entry {:?} {:?}",
2894 project_entry,
2895 details
2896 );
2897 }
2898
2899 let indent = " ".repeat(details.depth);
2900 let icon = if details.kind.is_dir() {
2901 if details.is_expanded {
2902 "v "
2903 } else {
2904 "> "
2905 }
2906 } else {
2907 " "
2908 };
2909 let name = if details.is_editing {
2910 format!("[EDITOR: '{}']", details.filename)
2911 } else if details.is_processing {
2912 format!("[PROCESSING: '{}']", details.filename)
2913 } else {
2914 details.filename.clone()
2915 };
2916 let selected = if details.is_selected {
2917 " <== selected"
2918 } else {
2919 ""
2920 };
2921 result.push(format!("{indent}{icon}{name}{selected}"));
2922 });
2923 });
2924
2925 result
2926 }
2927
2928 fn init_test(cx: &mut TestAppContext) {
2929 cx.update(|cx| {
2930 let settings_store = SettingsStore::test(cx);
2931 cx.set_global(settings_store);
2932 init_settings(cx);
2933 theme::init(theme::LoadThemes::JustBase, cx);
2934 language::init(cx);
2935 editor::init_settings(cx);
2936 crate::init((), cx);
2937 workspace::init_settings(cx);
2938 client::init_settings(cx);
2939 Project::init_settings(cx);
2940
2941 cx.update_global::<SettingsStore, _>(|store, cx| {
2942 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2943 project_settings.file_scan_exclusions = Some(Vec::new());
2944 });
2945 });
2946 });
2947 }
2948
2949 fn init_test_with_editor(cx: &mut TestAppContext) {
2950 cx.update(|cx| {
2951 let app_state = AppState::test(cx);
2952 theme::init(theme::LoadThemes::JustBase, cx);
2953 init_settings(cx);
2954 language::init(cx);
2955 editor::init(cx);
2956 crate::init((), cx);
2957 workspace::init(app_state.clone(), cx);
2958 Project::init_settings(cx);
2959 });
2960 }
2961
2962 fn ensure_single_file_is_opened(
2963 window: &WindowHandle<Workspace>,
2964 expected_path: &str,
2965 cx: &mut TestAppContext,
2966 ) {
2967 window
2968 .update(cx, |workspace, cx| {
2969 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2970 assert_eq!(worktrees.len(), 1);
2971 let worktree_id = worktrees[0].read(cx).id();
2972
2973 let open_project_paths = workspace
2974 .panes()
2975 .iter()
2976 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2977 .collect::<Vec<_>>();
2978 assert_eq!(
2979 open_project_paths,
2980 vec![ProjectPath {
2981 worktree_id,
2982 path: Arc::from(Path::new(expected_path))
2983 }],
2984 "Should have opened file, selected in project panel"
2985 );
2986 })
2987 .unwrap();
2988 }
2989
2990 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
2991 assert!(
2992 !cx.has_pending_prompt(),
2993 "Should have no prompts before the deletion"
2994 );
2995 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
2996 assert!(
2997 cx.has_pending_prompt(),
2998 "Should have a prompt after the deletion"
2999 );
3000 cx.simulate_prompt_answer(0);
3001 assert!(
3002 !cx.has_pending_prompt(),
3003 "Should have no prompts after prompt was replied to"
3004 );
3005 cx.executor().run_until_parked();
3006 }
3007
3008 fn ensure_no_open_items_and_panes(
3009 workspace: &WindowHandle<Workspace>,
3010 cx: &mut VisualTestContext,
3011 ) {
3012 assert!(
3013 !cx.has_pending_prompt(),
3014 "Should have no prompts after deletion operation closes the file"
3015 );
3016 workspace
3017 .read_with(cx, |workspace, cx| {
3018 let open_project_paths = workspace
3019 .panes()
3020 .iter()
3021 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3022 .collect::<Vec<_>>();
3023 assert!(
3024 open_project_paths.is_empty(),
3025 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3026 );
3027 })
3028 .unwrap();
3029 }
3030}