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