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