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