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