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