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 IntoElement, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel,
14 Render, 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 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 Some(FileAssociations::get_icon(&entry.path, cx))
1273 } else {
1274 None
1275 }
1276 }
1277 _ => {
1278 if show_folder_icons {
1279 Some(FileAssociations::get_folder_icon(is_expanded, cx))
1280 } else {
1281 Some(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_visual_element(
1339 details: &EntryDetails,
1340 editor: Option<&View<Editor>>,
1341 padding: Pixels,
1342 cx: &mut ViewContext<Self>,
1343 ) -> Div {
1344 let show_editor = details.is_editing && !details.is_processing;
1345
1346 let theme = cx.theme();
1347 let filename_text_color = details
1348 .git_status
1349 .as_ref()
1350 .map(|status| match status {
1351 GitFileStatus::Added => theme.status().created,
1352 GitFileStatus::Modified => theme.status().modified,
1353 GitFileStatus::Conflict => theme.status().conflict,
1354 })
1355 .unwrap_or(theme.status().info);
1356
1357 h_stack()
1358 .child(if let Some(icon) = &details.icon {
1359 div().child(IconElement::from_path(icon.to_string()))
1360 } else {
1361 div()
1362 })
1363 .child(
1364 if let (Some(editor), true) = (editor, show_editor) {
1365 div().w_full().child(editor.clone())
1366 } else {
1367 div()
1368 .text_color(filename_text_color)
1369 .child(Label::new(details.filename.clone()))
1370 }
1371 .ml_1(),
1372 )
1373 .pl(padding)
1374 }
1375
1376 fn render_entry(
1377 &self,
1378 entry_id: ProjectEntryId,
1379 details: EntryDetails,
1380 // dragged_entry_destination: &mut Option<Arc<Path>>,
1381 cx: &mut ViewContext<Self>,
1382 ) -> Stateful<Div> {
1383 let kind = details.kind;
1384 let settings = ProjectPanelSettings::get_global(cx);
1385 const INDENT_SIZE: Pixels = px(16.0);
1386 let padding = INDENT_SIZE + details.depth as f32 * px(settings.indent_size);
1387 let show_editor = details.is_editing && !details.is_processing;
1388 let is_selected = self
1389 .selection
1390 .map_or(false, |selection| selection.entry_id == entry_id);
1391
1392 Self::render_entry_visual_element(&details, Some(&self.filename_editor), padding, cx)
1393 .id(entry_id.to_proto() as usize)
1394 .w_full()
1395 .cursor_pointer()
1396 .when(is_selected, |this| {
1397 this.bg(cx.theme().colors().element_selected)
1398 })
1399 .hover(|style| style.bg(cx.theme().colors().element_hover))
1400 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1401 if !show_editor {
1402 if kind.is_dir() {
1403 this.toggle_expanded(entry_id, cx);
1404 } else {
1405 if event.down.modifiers.command {
1406 this.split_entry(entry_id, cx);
1407 } else {
1408 this.open_entry(entry_id, event.up.click_count > 1, cx);
1409 }
1410 }
1411 }
1412 }))
1413 .on_mouse_down(
1414 MouseButton::Right,
1415 cx.listener(move |this, event: &MouseDownEvent, cx| {
1416 this.deploy_context_menu(event.position, entry_id, cx);
1417 }),
1418 )
1419 // .on_drop::<ProjectEntryId>(|this, event, cx| {
1420 // this.move_entry(
1421 // *dragged_entry,
1422 // entry_id,
1423 // matches!(details.kind, EntryKind::File(_)),
1424 // cx,
1425 // );
1426 // })
1427 }
1428}
1429
1430impl Render for ProjectPanel {
1431 type Element = Focusable<Stateful<Div>>;
1432
1433 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1434 let has_worktree = self.visible_entries.len() != 0;
1435
1436 if has_worktree {
1437 div()
1438 .id("project-panel")
1439 .size_full()
1440 .key_context("ProjectPanel")
1441 .on_action(cx.listener(Self::select_next))
1442 .on_action(cx.listener(Self::select_prev))
1443 .on_action(cx.listener(Self::expand_selected_entry))
1444 .on_action(cx.listener(Self::collapse_selected_entry))
1445 .on_action(cx.listener(Self::collapse_all_entries))
1446 .on_action(cx.listener(Self::new_file))
1447 .on_action(cx.listener(Self::new_directory))
1448 .on_action(cx.listener(Self::rename))
1449 .on_action(cx.listener(Self::delete))
1450 .on_action(cx.listener(Self::confirm))
1451 .on_action(cx.listener(Self::open_file))
1452 .on_action(cx.listener(Self::cancel))
1453 .on_action(cx.listener(Self::cut))
1454 .on_action(cx.listener(Self::copy))
1455 .on_action(cx.listener(Self::copy_path))
1456 .on_action(cx.listener(Self::copy_relative_path))
1457 .on_action(cx.listener(Self::paste))
1458 .on_action(cx.listener(Self::reveal_in_finder))
1459 .on_action(cx.listener(Self::open_in_terminal))
1460 .on_action(cx.listener(Self::new_search_in_directory))
1461 .track_focus(&self.focus_handle)
1462 .child(
1463 uniform_list(
1464 cx.view().clone(),
1465 "entries",
1466 self.visible_entries
1467 .iter()
1468 .map(|(_, worktree_entries)| worktree_entries.len())
1469 .sum(),
1470 {
1471 |this, range, cx| {
1472 let mut items = Vec::new();
1473 this.for_each_visible_entry(range, cx, |id, details, cx| {
1474 items.push(this.render_entry(id, details, cx));
1475 });
1476 items
1477 }
1478 },
1479 )
1480 .size_full()
1481 .track_scroll(self.list.clone()),
1482 )
1483 } else {
1484 v_stack()
1485 .id("empty-project_panel")
1486 .track_focus(&self.focus_handle)
1487 }
1488 }
1489}
1490
1491impl EventEmitter<Event> for ProjectPanel {}
1492
1493impl EventEmitter<PanelEvent> for ProjectPanel {}
1494
1495impl Panel for ProjectPanel {
1496 fn position(&self, cx: &WindowContext) -> DockPosition {
1497 match ProjectPanelSettings::get_global(cx).dock {
1498 ProjectPanelDockPosition::Left => DockPosition::Left,
1499 ProjectPanelDockPosition::Right => DockPosition::Right,
1500 }
1501 }
1502
1503 fn position_is_valid(&self, position: DockPosition) -> bool {
1504 matches!(position, DockPosition::Left | DockPosition::Right)
1505 }
1506
1507 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1508 settings::update_settings_file::<ProjectPanelSettings>(
1509 self.fs.clone(),
1510 cx,
1511 move |settings| {
1512 let dock = match position {
1513 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1514 DockPosition::Right => ProjectPanelDockPosition::Right,
1515 };
1516 settings.dock = Some(dock);
1517 },
1518 );
1519 }
1520
1521 fn size(&self, cx: &WindowContext) -> f32 {
1522 self.width
1523 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1524 }
1525
1526 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1527 self.width = size;
1528 self.serialize(cx);
1529 cx.notify();
1530 }
1531
1532 fn icon(&self, _: &WindowContext) -> Option<ui::Icon> {
1533 Some(ui::Icon::FileTree)
1534 }
1535
1536 fn toggle_action(&self) -> Box<dyn Action> {
1537 Box::new(ToggleFocus)
1538 }
1539
1540 fn has_focus(&self, _: &WindowContext) -> bool {
1541 self.has_focus
1542 }
1543
1544 fn persistent_name() -> &'static str {
1545 "Project Panel"
1546 }
1547}
1548
1549impl FocusableView for ProjectPanel {
1550 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1551 self.focus_handle.clone()
1552 }
1553}
1554
1555impl ClipboardEntry {
1556 fn is_cut(&self) -> bool {
1557 matches!(self, Self::Cut { .. })
1558 }
1559
1560 fn entry_id(&self) -> ProjectEntryId {
1561 match self {
1562 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1563 *entry_id
1564 }
1565 }
1566 }
1567
1568 fn worktree_id(&self) -> WorktreeId {
1569 match self {
1570 ClipboardEntry::Copied { worktree_id, .. }
1571 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1572 }
1573 }
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578 use super::*;
1579 use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1580 use pretty_assertions::assert_eq;
1581 use project::{project_settings::ProjectSettings, FakeFs};
1582 use serde_json::json;
1583 use settings::SettingsStore;
1584 use std::{
1585 collections::HashSet,
1586 path::{Path, PathBuf},
1587 sync::atomic::{self, AtomicUsize},
1588 };
1589 use workspace::AppState;
1590
1591 #[gpui::test]
1592 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1593 init_test(cx);
1594
1595 let fs = FakeFs::new(cx.executor().clone());
1596 fs.insert_tree(
1597 "/root1",
1598 json!({
1599 ".dockerignore": "",
1600 ".git": {
1601 "HEAD": "",
1602 },
1603 "a": {
1604 "0": { "q": "", "r": "", "s": "" },
1605 "1": { "t": "", "u": "" },
1606 "2": { "v": "", "w": "", "x": "", "y": "" },
1607 },
1608 "b": {
1609 "3": { "Q": "" },
1610 "4": { "R": "", "S": "", "T": "", "U": "" },
1611 },
1612 "C": {
1613 "5": {},
1614 "6": { "V": "", "W": "" },
1615 "7": { "X": "" },
1616 "8": { "Y": {}, "Z": "" }
1617 }
1618 }),
1619 )
1620 .await;
1621 fs.insert_tree(
1622 "/root2",
1623 json!({
1624 "d": {
1625 "9": ""
1626 },
1627 "e": {}
1628 }),
1629 )
1630 .await;
1631
1632 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1633 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1634 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1635 let panel = workspace
1636 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1637 .unwrap();
1638 assert_eq!(
1639 visible_entries_as_strings(&panel, 0..50, cx),
1640 &[
1641 "v root1",
1642 " > .git",
1643 " > a",
1644 " > b",
1645 " > C",
1646 " .dockerignore",
1647 "v root2",
1648 " > d",
1649 " > e",
1650 ]
1651 );
1652
1653 toggle_expand_dir(&panel, "root1/b", cx);
1654 assert_eq!(
1655 visible_entries_as_strings(&panel, 0..50, cx),
1656 &[
1657 "v root1",
1658 " > .git",
1659 " > a",
1660 " v b <== selected",
1661 " > 3",
1662 " > 4",
1663 " > C",
1664 " .dockerignore",
1665 "v root2",
1666 " > d",
1667 " > e",
1668 ]
1669 );
1670
1671 assert_eq!(
1672 visible_entries_as_strings(&panel, 6..9, cx),
1673 &[
1674 //
1675 " > C",
1676 " .dockerignore",
1677 "v root2",
1678 ]
1679 );
1680 }
1681
1682 #[gpui::test]
1683 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1684 init_test(cx);
1685 cx.update(|cx| {
1686 cx.update_global::<SettingsStore, _>(|store, cx| {
1687 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1688 project_settings.file_scan_exclusions =
1689 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1690 });
1691 });
1692 });
1693
1694 let fs = FakeFs::new(cx.background_executor.clone());
1695 fs.insert_tree(
1696 "/root1",
1697 json!({
1698 ".dockerignore": "",
1699 ".git": {
1700 "HEAD": "",
1701 },
1702 "a": {
1703 "0": { "q": "", "r": "", "s": "" },
1704 "1": { "t": "", "u": "" },
1705 "2": { "v": "", "w": "", "x": "", "y": "" },
1706 },
1707 "b": {
1708 "3": { "Q": "" },
1709 "4": { "R": "", "S": "", "T": "", "U": "" },
1710 },
1711 "C": {
1712 "5": {},
1713 "6": { "V": "", "W": "" },
1714 "7": { "X": "" },
1715 "8": { "Y": {}, "Z": "" }
1716 }
1717 }),
1718 )
1719 .await;
1720 fs.insert_tree(
1721 "/root2",
1722 json!({
1723 "d": {
1724 "4": ""
1725 },
1726 "e": {}
1727 }),
1728 )
1729 .await;
1730
1731 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1732 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1733 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1734 let panel = workspace
1735 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1736 .unwrap();
1737 assert_eq!(
1738 visible_entries_as_strings(&panel, 0..50, cx),
1739 &[
1740 "v root1",
1741 " > a",
1742 " > b",
1743 " > C",
1744 " .dockerignore",
1745 "v root2",
1746 " > d",
1747 " > e",
1748 ]
1749 );
1750
1751 toggle_expand_dir(&panel, "root1/b", cx);
1752 assert_eq!(
1753 visible_entries_as_strings(&panel, 0..50, cx),
1754 &[
1755 "v root1",
1756 " > a",
1757 " v b <== selected",
1758 " > 3",
1759 " > C",
1760 " .dockerignore",
1761 "v root2",
1762 " > d",
1763 " > e",
1764 ]
1765 );
1766
1767 toggle_expand_dir(&panel, "root2/d", cx);
1768 assert_eq!(
1769 visible_entries_as_strings(&panel, 0..50, cx),
1770 &[
1771 "v root1",
1772 " > a",
1773 " v b",
1774 " > 3",
1775 " > C",
1776 " .dockerignore",
1777 "v root2",
1778 " v d <== selected",
1779 " > e",
1780 ]
1781 );
1782
1783 toggle_expand_dir(&panel, "root2/e", cx);
1784 assert_eq!(
1785 visible_entries_as_strings(&panel, 0..50, cx),
1786 &[
1787 "v root1",
1788 " > a",
1789 " v b",
1790 " > 3",
1791 " > C",
1792 " .dockerignore",
1793 "v root2",
1794 " v d",
1795 " v e <== selected",
1796 ]
1797 );
1798 }
1799
1800 #[gpui::test(iterations = 30)]
1801 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1802 init_test(cx);
1803
1804 let fs = FakeFs::new(cx.executor().clone());
1805 fs.insert_tree(
1806 "/root1",
1807 json!({
1808 ".dockerignore": "",
1809 ".git": {
1810 "HEAD": "",
1811 },
1812 "a": {
1813 "0": { "q": "", "r": "", "s": "" },
1814 "1": { "t": "", "u": "" },
1815 "2": { "v": "", "w": "", "x": "", "y": "" },
1816 },
1817 "b": {
1818 "3": { "Q": "" },
1819 "4": { "R": "", "S": "", "T": "", "U": "" },
1820 },
1821 "C": {
1822 "5": {},
1823 "6": { "V": "", "W": "" },
1824 "7": { "X": "" },
1825 "8": { "Y": {}, "Z": "" }
1826 }
1827 }),
1828 )
1829 .await;
1830 fs.insert_tree(
1831 "/root2",
1832 json!({
1833 "d": {
1834 "9": ""
1835 },
1836 "e": {}
1837 }),
1838 )
1839 .await;
1840
1841 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1842 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1843 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1844 let panel = workspace
1845 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1846 .unwrap();
1847
1848 select_path(&panel, "root1", cx);
1849 assert_eq!(
1850 visible_entries_as_strings(&panel, 0..10, cx),
1851 &[
1852 "v root1 <== selected",
1853 " > .git",
1854 " > a",
1855 " > b",
1856 " > C",
1857 " .dockerignore",
1858 "v root2",
1859 " > d",
1860 " > e",
1861 ]
1862 );
1863
1864 // Add a file with the root folder selected. The filename editor is placed
1865 // before the first file in the root folder.
1866 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1867 panel.update(cx, |panel, cx| {
1868 assert!(panel.filename_editor.read(cx).is_focused(cx));
1869 });
1870 assert_eq!(
1871 visible_entries_as_strings(&panel, 0..10, cx),
1872 &[
1873 "v root1",
1874 " > .git",
1875 " > a",
1876 " > b",
1877 " > C",
1878 " [EDITOR: ''] <== selected",
1879 " .dockerignore",
1880 "v root2",
1881 " > d",
1882 " > e",
1883 ]
1884 );
1885
1886 let confirm = panel.update(cx, |panel, cx| {
1887 panel
1888 .filename_editor
1889 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1890 panel.confirm_edit(cx).unwrap()
1891 });
1892 assert_eq!(
1893 visible_entries_as_strings(&panel, 0..10, cx),
1894 &[
1895 "v root1",
1896 " > .git",
1897 " > a",
1898 " > b",
1899 " > C",
1900 " [PROCESSING: 'the-new-filename'] <== selected",
1901 " .dockerignore",
1902 "v root2",
1903 " > d",
1904 " > e",
1905 ]
1906 );
1907
1908 confirm.await.unwrap();
1909 assert_eq!(
1910 visible_entries_as_strings(&panel, 0..10, cx),
1911 &[
1912 "v root1",
1913 " > .git",
1914 " > a",
1915 " > b",
1916 " > C",
1917 " .dockerignore",
1918 " the-new-filename <== selected",
1919 "v root2",
1920 " > d",
1921 " > e",
1922 ]
1923 );
1924
1925 select_path(&panel, "root1/b", cx);
1926 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1927 assert_eq!(
1928 visible_entries_as_strings(&panel, 0..10, cx),
1929 &[
1930 "v root1",
1931 " > .git",
1932 " > a",
1933 " v b",
1934 " > 3",
1935 " > 4",
1936 " [EDITOR: ''] <== selected",
1937 " > C",
1938 " .dockerignore",
1939 " the-new-filename",
1940 ]
1941 );
1942
1943 panel
1944 .update(cx, |panel, cx| {
1945 panel
1946 .filename_editor
1947 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1948 panel.confirm_edit(cx).unwrap()
1949 })
1950 .await
1951 .unwrap();
1952 assert_eq!(
1953 visible_entries_as_strings(&panel, 0..10, cx),
1954 &[
1955 "v root1",
1956 " > .git",
1957 " > a",
1958 " v b",
1959 " > 3",
1960 " > 4",
1961 " another-filename.txt <== selected",
1962 " > C",
1963 " .dockerignore",
1964 " the-new-filename",
1965 ]
1966 );
1967
1968 select_path(&panel, "root1/b/another-filename.txt", cx);
1969 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1970 assert_eq!(
1971 visible_entries_as_strings(&panel, 0..10, cx),
1972 &[
1973 "v root1",
1974 " > .git",
1975 " > a",
1976 " v b",
1977 " > 3",
1978 " > 4",
1979 " [EDITOR: 'another-filename.txt'] <== selected",
1980 " > C",
1981 " .dockerignore",
1982 " the-new-filename",
1983 ]
1984 );
1985
1986 let confirm = panel.update(cx, |panel, cx| {
1987 panel.filename_editor.update(cx, |editor, cx| {
1988 let file_name_selections = editor.selections.all::<usize>(cx);
1989 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1990 let file_name_selection = &file_name_selections[0];
1991 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1992 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
1993
1994 editor.set_text("a-different-filename.tar.gz", cx)
1995 });
1996 panel.confirm_edit(cx).unwrap()
1997 });
1998 assert_eq!(
1999 visible_entries_as_strings(&panel, 0..10, cx),
2000 &[
2001 "v root1",
2002 " > .git",
2003 " > a",
2004 " v b",
2005 " > 3",
2006 " > 4",
2007 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2008 " > C",
2009 " .dockerignore",
2010 " the-new-filename",
2011 ]
2012 );
2013
2014 confirm.await.unwrap();
2015 assert_eq!(
2016 visible_entries_as_strings(&panel, 0..10, cx),
2017 &[
2018 "v root1",
2019 " > .git",
2020 " > a",
2021 " v b",
2022 " > 3",
2023 " > 4",
2024 " a-different-filename.tar.gz <== selected",
2025 " > C",
2026 " .dockerignore",
2027 " the-new-filename",
2028 ]
2029 );
2030
2031 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2032 assert_eq!(
2033 visible_entries_as_strings(&panel, 0..10, cx),
2034 &[
2035 "v root1",
2036 " > .git",
2037 " > a",
2038 " v b",
2039 " > 3",
2040 " > 4",
2041 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2042 " > C",
2043 " .dockerignore",
2044 " the-new-filename",
2045 ]
2046 );
2047
2048 panel.update(cx, |panel, cx| {
2049 panel.filename_editor.update(cx, |editor, cx| {
2050 let file_name_selections = editor.selections.all::<usize>(cx);
2051 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2052 let file_name_selection = &file_name_selections[0];
2053 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2054 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..");
2055
2056 });
2057 panel.cancel(&Cancel, cx)
2058 });
2059
2060 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2061 assert_eq!(
2062 visible_entries_as_strings(&panel, 0..10, cx),
2063 &[
2064 "v root1",
2065 " > .git",
2066 " > a",
2067 " v b",
2068 " > [EDITOR: ''] <== selected",
2069 " > 3",
2070 " > 4",
2071 " a-different-filename.tar.gz",
2072 " > C",
2073 " .dockerignore",
2074 ]
2075 );
2076
2077 let confirm = panel.update(cx, |panel, cx| {
2078 panel
2079 .filename_editor
2080 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2081 panel.confirm_edit(cx).unwrap()
2082 });
2083 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2084 assert_eq!(
2085 visible_entries_as_strings(&panel, 0..10, cx),
2086 &[
2087 "v root1",
2088 " > .git",
2089 " > a",
2090 " v b",
2091 " > [PROCESSING: 'new-dir']",
2092 " > 3 <== selected",
2093 " > 4",
2094 " a-different-filename.tar.gz",
2095 " > C",
2096 " .dockerignore",
2097 ]
2098 );
2099
2100 confirm.await.unwrap();
2101 assert_eq!(
2102 visible_entries_as_strings(&panel, 0..10, cx),
2103 &[
2104 "v root1",
2105 " > .git",
2106 " > a",
2107 " v b",
2108 " > 3 <== selected",
2109 " > 4",
2110 " > new-dir",
2111 " a-different-filename.tar.gz",
2112 " > C",
2113 " .dockerignore",
2114 ]
2115 );
2116
2117 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2118 assert_eq!(
2119 visible_entries_as_strings(&panel, 0..10, cx),
2120 &[
2121 "v root1",
2122 " > .git",
2123 " > a",
2124 " v b",
2125 " > [EDITOR: '3'] <== selected",
2126 " > 4",
2127 " > new-dir",
2128 " a-different-filename.tar.gz",
2129 " > C",
2130 " .dockerignore",
2131 ]
2132 );
2133
2134 // Dismiss the rename editor when it loses focus.
2135 workspace.update(cx, |_, cx| cx.blur()).unwrap();
2136 assert_eq!(
2137 visible_entries_as_strings(&panel, 0..10, cx),
2138 &[
2139 "v root1",
2140 " > .git",
2141 " > a",
2142 " v b",
2143 " > 3 <== selected",
2144 " > 4",
2145 " > new-dir",
2146 " a-different-filename.tar.gz",
2147 " > C",
2148 " .dockerignore",
2149 ]
2150 );
2151 }
2152
2153 #[gpui::test(iterations = 10)]
2154 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2155 init_test(cx);
2156
2157 let fs = FakeFs::new(cx.executor().clone());
2158 fs.insert_tree(
2159 "/root1",
2160 json!({
2161 ".dockerignore": "",
2162 ".git": {
2163 "HEAD": "",
2164 },
2165 "a": {
2166 "0": { "q": "", "r": "", "s": "" },
2167 "1": { "t": "", "u": "" },
2168 "2": { "v": "", "w": "", "x": "", "y": "" },
2169 },
2170 "b": {
2171 "3": { "Q": "" },
2172 "4": { "R": "", "S": "", "T": "", "U": "" },
2173 },
2174 "C": {
2175 "5": {},
2176 "6": { "V": "", "W": "" },
2177 "7": { "X": "" },
2178 "8": { "Y": {}, "Z": "" }
2179 }
2180 }),
2181 )
2182 .await;
2183 fs.insert_tree(
2184 "/root2",
2185 json!({
2186 "d": {
2187 "9": ""
2188 },
2189 "e": {}
2190 }),
2191 )
2192 .await;
2193
2194 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2195 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2196 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2197 let panel = workspace
2198 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2199 .unwrap();
2200
2201 select_path(&panel, "root1", cx);
2202 assert_eq!(
2203 visible_entries_as_strings(&panel, 0..10, cx),
2204 &[
2205 "v root1 <== selected",
2206 " > .git",
2207 " > a",
2208 " > b",
2209 " > C",
2210 " .dockerignore",
2211 "v root2",
2212 " > d",
2213 " > e",
2214 ]
2215 );
2216
2217 // Add a file with the root folder selected. The filename editor is placed
2218 // before the first file in the root folder.
2219 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2220 panel.update(cx, |panel, cx| {
2221 assert!(panel.filename_editor.read(cx).is_focused(cx));
2222 });
2223 assert_eq!(
2224 visible_entries_as_strings(&panel, 0..10, cx),
2225 &[
2226 "v root1",
2227 " > .git",
2228 " > a",
2229 " > b",
2230 " > C",
2231 " [EDITOR: ''] <== selected",
2232 " .dockerignore",
2233 "v root2",
2234 " > d",
2235 " > e",
2236 ]
2237 );
2238
2239 let confirm = panel.update(cx, |panel, cx| {
2240 panel.filename_editor.update(cx, |editor, cx| {
2241 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2242 });
2243 panel.confirm_edit(cx).unwrap()
2244 });
2245
2246 assert_eq!(
2247 visible_entries_as_strings(&panel, 0..10, cx),
2248 &[
2249 "v root1",
2250 " > .git",
2251 " > a",
2252 " > b",
2253 " > C",
2254 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2255 " .dockerignore",
2256 "v root2",
2257 " > d",
2258 " > e",
2259 ]
2260 );
2261
2262 confirm.await.unwrap();
2263 assert_eq!(
2264 visible_entries_as_strings(&panel, 0..13, cx),
2265 &[
2266 "v root1",
2267 " > .git",
2268 " > a",
2269 " > b",
2270 " v bdir1",
2271 " v dir2",
2272 " the-new-filename <== selected",
2273 " > C",
2274 " .dockerignore",
2275 "v root2",
2276 " > d",
2277 " > e",
2278 ]
2279 );
2280 }
2281
2282 #[gpui::test]
2283 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2284 init_test(cx);
2285
2286 let fs = FakeFs::new(cx.executor().clone());
2287 fs.insert_tree(
2288 "/root1",
2289 json!({
2290 "one.two.txt": "",
2291 "one.txt": ""
2292 }),
2293 )
2294 .await;
2295
2296 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2297 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2298 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2299 let panel = workspace
2300 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2301 .unwrap();
2302
2303 panel.update(cx, |panel, cx| {
2304 panel.select_next(&Default::default(), cx);
2305 panel.select_next(&Default::default(), cx);
2306 });
2307
2308 assert_eq!(
2309 visible_entries_as_strings(&panel, 0..50, cx),
2310 &[
2311 //
2312 "v root1",
2313 " one.two.txt <== selected",
2314 " one.txt",
2315 ]
2316 );
2317
2318 // Regression test - file name is created correctly when
2319 // the copied file's name contains multiple dots.
2320 panel.update(cx, |panel, cx| {
2321 panel.copy(&Default::default(), cx);
2322 panel.paste(&Default::default(), cx);
2323 });
2324 cx.executor().run_until_parked();
2325
2326 assert_eq!(
2327 visible_entries_as_strings(&panel, 0..50, cx),
2328 &[
2329 //
2330 "v root1",
2331 " one.two copy.txt",
2332 " one.two.txt <== selected",
2333 " one.txt",
2334 ]
2335 );
2336
2337 panel.update(cx, |panel, cx| {
2338 panel.paste(&Default::default(), cx);
2339 });
2340 cx.executor().run_until_parked();
2341
2342 assert_eq!(
2343 visible_entries_as_strings(&panel, 0..50, cx),
2344 &[
2345 //
2346 "v root1",
2347 " one.two copy 1.txt",
2348 " one.two copy.txt",
2349 " one.two.txt <== selected",
2350 " one.txt",
2351 ]
2352 );
2353 }
2354
2355 #[gpui::test]
2356 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2357 init_test_with_editor(cx);
2358
2359 let fs = FakeFs::new(cx.executor().clone());
2360 fs.insert_tree(
2361 "/src",
2362 json!({
2363 "test": {
2364 "first.rs": "// First Rust file",
2365 "second.rs": "// Second Rust file",
2366 "third.rs": "// Third Rust file",
2367 }
2368 }),
2369 )
2370 .await;
2371
2372 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2373 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2374 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2375 let panel = workspace
2376 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2377 .unwrap();
2378
2379 toggle_expand_dir(&panel, "src/test", cx);
2380 select_path(&panel, "src/test/first.rs", cx);
2381 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2382 cx.executor().run_until_parked();
2383 assert_eq!(
2384 visible_entries_as_strings(&panel, 0..10, cx),
2385 &[
2386 "v src",
2387 " v test",
2388 " first.rs <== selected",
2389 " second.rs",
2390 " third.rs"
2391 ]
2392 );
2393 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2394
2395 submit_deletion(&panel, cx);
2396 assert_eq!(
2397 visible_entries_as_strings(&panel, 0..10, cx),
2398 &[
2399 "v src",
2400 " v test",
2401 " second.rs",
2402 " third.rs"
2403 ],
2404 "Project panel should have no deleted file, no other file is selected in it"
2405 );
2406 ensure_no_open_items_and_panes(&workspace, cx);
2407
2408 select_path(&panel, "src/test/second.rs", cx);
2409 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2410 cx.executor().run_until_parked();
2411 assert_eq!(
2412 visible_entries_as_strings(&panel, 0..10, cx),
2413 &[
2414 "v src",
2415 " v test",
2416 " second.rs <== selected",
2417 " third.rs"
2418 ]
2419 );
2420 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2421
2422 workspace
2423 .update(cx, |workspace, cx| {
2424 let active_items = workspace
2425 .panes()
2426 .iter()
2427 .filter_map(|pane| pane.read(cx).active_item())
2428 .collect::<Vec<_>>();
2429 assert_eq!(active_items.len(), 1);
2430 let open_editor = active_items
2431 .into_iter()
2432 .next()
2433 .unwrap()
2434 .downcast::<Editor>()
2435 .expect("Open item should be an editor");
2436 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2437 })
2438 .unwrap();
2439 submit_deletion(&panel, cx);
2440 assert_eq!(
2441 visible_entries_as_strings(&panel, 0..10, cx),
2442 &["v src", " v test", " third.rs"],
2443 "Project panel should have no deleted file, with one last file remaining"
2444 );
2445 ensure_no_open_items_and_panes(&workspace, cx);
2446 }
2447
2448 #[gpui::test]
2449 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2450 init_test_with_editor(cx);
2451
2452 let fs = FakeFs::new(cx.executor().clone());
2453 fs.insert_tree(
2454 "/src",
2455 json!({
2456 "test": {
2457 "first.rs": "// First Rust file",
2458 "second.rs": "// Second Rust file",
2459 "third.rs": "// Third Rust file",
2460 }
2461 }),
2462 )
2463 .await;
2464
2465 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2466 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2467 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2468 let panel = workspace
2469 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2470 .unwrap();
2471
2472 select_path(&panel, "src/", cx);
2473 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2474 cx.executor().run_until_parked();
2475 assert_eq!(
2476 visible_entries_as_strings(&panel, 0..10, cx),
2477 &[
2478 //
2479 "v src <== selected",
2480 " > test"
2481 ]
2482 );
2483 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2484 panel.update(cx, |panel, cx| {
2485 assert!(panel.filename_editor.read(cx).is_focused(cx));
2486 });
2487 assert_eq!(
2488 visible_entries_as_strings(&panel, 0..10, cx),
2489 &[
2490 //
2491 "v src",
2492 " > [EDITOR: ''] <== selected",
2493 " > test"
2494 ]
2495 );
2496 panel.update(cx, |panel, cx| {
2497 panel
2498 .filename_editor
2499 .update(cx, |editor, cx| editor.set_text("test", cx));
2500 assert!(
2501 panel.confirm_edit(cx).is_none(),
2502 "Should not allow to confirm on conflicting new directory name"
2503 )
2504 });
2505 assert_eq!(
2506 visible_entries_as_strings(&panel, 0..10, cx),
2507 &[
2508 //
2509 "v src",
2510 " > test"
2511 ],
2512 "File list should be unchanged after failed folder create confirmation"
2513 );
2514
2515 select_path(&panel, "src/test/", cx);
2516 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2517 cx.executor().run_until_parked();
2518 assert_eq!(
2519 visible_entries_as_strings(&panel, 0..10, cx),
2520 &[
2521 //
2522 "v src",
2523 " > test <== selected"
2524 ]
2525 );
2526 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2527 panel.update(cx, |panel, cx| {
2528 assert!(panel.filename_editor.read(cx).is_focused(cx));
2529 });
2530 assert_eq!(
2531 visible_entries_as_strings(&panel, 0..10, cx),
2532 &[
2533 "v src",
2534 " v test",
2535 " [EDITOR: ''] <== selected",
2536 " first.rs",
2537 " second.rs",
2538 " third.rs"
2539 ]
2540 );
2541 panel.update(cx, |panel, cx| {
2542 panel
2543 .filename_editor
2544 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2545 assert!(
2546 panel.confirm_edit(cx).is_none(),
2547 "Should not allow to confirm on conflicting new file name"
2548 )
2549 });
2550 assert_eq!(
2551 visible_entries_as_strings(&panel, 0..10, cx),
2552 &[
2553 "v src",
2554 " v test",
2555 " first.rs",
2556 " second.rs",
2557 " third.rs"
2558 ],
2559 "File list should be unchanged after failed file create confirmation"
2560 );
2561
2562 select_path(&panel, "src/test/first.rs", cx);
2563 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2564 cx.executor().run_until_parked();
2565 assert_eq!(
2566 visible_entries_as_strings(&panel, 0..10, cx),
2567 &[
2568 "v src",
2569 " v test",
2570 " first.rs <== selected",
2571 " second.rs",
2572 " third.rs"
2573 ],
2574 );
2575 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2576 panel.update(cx, |panel, cx| {
2577 assert!(panel.filename_editor.read(cx).is_focused(cx));
2578 });
2579 assert_eq!(
2580 visible_entries_as_strings(&panel, 0..10, cx),
2581 &[
2582 "v src",
2583 " v test",
2584 " [EDITOR: 'first.rs'] <== selected",
2585 " second.rs",
2586 " third.rs"
2587 ]
2588 );
2589 panel.update(cx, |panel, cx| {
2590 panel
2591 .filename_editor
2592 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2593 assert!(
2594 panel.confirm_edit(cx).is_none(),
2595 "Should not allow to confirm on conflicting file rename"
2596 )
2597 });
2598 assert_eq!(
2599 visible_entries_as_strings(&panel, 0..10, cx),
2600 &[
2601 "v src",
2602 " v test",
2603 " first.rs <== selected",
2604 " second.rs",
2605 " third.rs"
2606 ],
2607 "File list should be unchanged after failed rename confirmation"
2608 );
2609 }
2610
2611 #[gpui::test]
2612 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2613 init_test_with_editor(cx);
2614
2615 let fs = FakeFs::new(cx.executor().clone());
2616 fs.insert_tree(
2617 "/src",
2618 json!({
2619 "test": {
2620 "first.rs": "// First Rust file",
2621 "second.rs": "// Second Rust file",
2622 "third.rs": "// Third Rust file",
2623 }
2624 }),
2625 )
2626 .await;
2627
2628 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2629 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2630 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2631 let panel = workspace
2632 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2633 .unwrap();
2634
2635 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2636 let _subscription = panel.update(cx, |_, cx| {
2637 let subcription_count = Arc::clone(&new_search_events_count);
2638 let view = cx.view().clone();
2639 cx.subscribe(&view, move |_, _, event, _| {
2640 if matches!(event, Event::NewSearchInDirectory { .. }) {
2641 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2642 }
2643 })
2644 });
2645
2646 toggle_expand_dir(&panel, "src/test", cx);
2647 select_path(&panel, "src/test/first.rs", cx);
2648 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2649 cx.executor().run_until_parked();
2650 assert_eq!(
2651 visible_entries_as_strings(&panel, 0..10, cx),
2652 &[
2653 "v src",
2654 " v test",
2655 " first.rs <== selected",
2656 " second.rs",
2657 " third.rs"
2658 ]
2659 );
2660 panel.update(cx, |panel, cx| {
2661 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2662 });
2663 assert_eq!(
2664 new_search_events_count.load(atomic::Ordering::SeqCst),
2665 0,
2666 "Should not trigger new search in directory when called on a file"
2667 );
2668
2669 select_path(&panel, "src/test", cx);
2670 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2671 cx.executor().run_until_parked();
2672 assert_eq!(
2673 visible_entries_as_strings(&panel, 0..10, cx),
2674 &[
2675 "v src",
2676 " v test <== selected",
2677 " first.rs",
2678 " second.rs",
2679 " third.rs"
2680 ]
2681 );
2682 panel.update(cx, |panel, cx| {
2683 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2684 });
2685 assert_eq!(
2686 new_search_events_count.load(atomic::Ordering::SeqCst),
2687 1,
2688 "Should trigger new search in directory when called on a directory"
2689 );
2690 }
2691
2692 #[gpui::test]
2693 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2694 init_test_with_editor(cx);
2695
2696 let fs = FakeFs::new(cx.executor().clone());
2697 fs.insert_tree(
2698 "/project_root",
2699 json!({
2700 "dir_1": {
2701 "nested_dir": {
2702 "file_a.py": "# File contents",
2703 "file_b.py": "# File contents",
2704 "file_c.py": "# File contents",
2705 },
2706 "file_1.py": "# File contents",
2707 "file_2.py": "# File contents",
2708 "file_3.py": "# File contents",
2709 },
2710 "dir_2": {
2711 "file_1.py": "# File contents",
2712 "file_2.py": "# File contents",
2713 "file_3.py": "# File contents",
2714 }
2715 }),
2716 )
2717 .await;
2718
2719 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2720 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2721 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2722 let panel = workspace
2723 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2724 .unwrap();
2725
2726 panel.update(cx, |panel, cx| {
2727 panel.collapse_all_entries(&CollapseAllEntries, cx)
2728 });
2729 cx.executor().run_until_parked();
2730 assert_eq!(
2731 visible_entries_as_strings(&panel, 0..10, cx),
2732 &["v project_root", " > dir_1", " > dir_2",]
2733 );
2734
2735 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2736 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2737 cx.executor().run_until_parked();
2738 assert_eq!(
2739 visible_entries_as_strings(&panel, 0..10, cx),
2740 &[
2741 "v project_root",
2742 " v dir_1 <== selected",
2743 " > nested_dir",
2744 " file_1.py",
2745 " file_2.py",
2746 " file_3.py",
2747 " > dir_2",
2748 ]
2749 );
2750 }
2751
2752 #[gpui::test]
2753 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2754 init_test(cx);
2755
2756 let fs = FakeFs::new(cx.executor().clone());
2757 fs.as_fake().insert_tree("/root", json!({})).await;
2758 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2759 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2760 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2761 let panel = workspace
2762 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2763 .unwrap();
2764
2765 // Make a new buffer with no backing file
2766 workspace
2767 .update(cx, |workspace, cx| {
2768 Editor::new_file(workspace, &Default::default(), cx)
2769 })
2770 .unwrap();
2771
2772 // "Save as"" the buffer, creating a new backing file for it
2773 let save_task = workspace
2774 .update(cx, |workspace, cx| {
2775 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2776 })
2777 .unwrap();
2778
2779 cx.executor().run_until_parked();
2780 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2781 save_task.await.unwrap();
2782
2783 // Rename the file
2784 select_path(&panel, "root/new", cx);
2785 assert_eq!(
2786 visible_entries_as_strings(&panel, 0..10, cx),
2787 &["v root", " new <== selected"]
2788 );
2789 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2790 panel.update(cx, |panel, cx| {
2791 panel
2792 .filename_editor
2793 .update(cx, |editor, cx| editor.set_text("newer", cx));
2794 });
2795 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2796
2797 cx.executor().run_until_parked();
2798 assert_eq!(
2799 visible_entries_as_strings(&panel, 0..10, cx),
2800 &["v root", " newer <== selected"]
2801 );
2802
2803 workspace
2804 .update(cx, |workspace, cx| {
2805 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2806 })
2807 .unwrap()
2808 .await
2809 .unwrap();
2810
2811 cx.executor().run_until_parked();
2812 // assert that saving the file doesn't restore "new"
2813 assert_eq!(
2814 visible_entries_as_strings(&panel, 0..10, cx),
2815 &["v root", " newer <== selected"]
2816 );
2817 }
2818
2819 fn toggle_expand_dir(
2820 panel: &View<ProjectPanel>,
2821 path: impl AsRef<Path>,
2822 cx: &mut VisualTestContext,
2823 ) {
2824 let path = path.as_ref();
2825 panel.update(cx, |panel, cx| {
2826 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2827 let worktree = worktree.read(cx);
2828 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2829 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2830 panel.toggle_expanded(entry_id, cx);
2831 return;
2832 }
2833 }
2834 panic!("no worktree for path {:?}", path);
2835 });
2836 }
2837
2838 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
2839 let path = path.as_ref();
2840 panel.update(cx, |panel, cx| {
2841 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2842 let worktree = worktree.read(cx);
2843 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2844 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2845 panel.selection = Some(Selection {
2846 worktree_id: worktree.id(),
2847 entry_id,
2848 });
2849 return;
2850 }
2851 }
2852 panic!("no worktree for path {:?}", path);
2853 });
2854 }
2855
2856 fn visible_entries_as_strings(
2857 panel: &View<ProjectPanel>,
2858 range: Range<usize>,
2859 cx: &mut VisualTestContext,
2860 ) -> Vec<String> {
2861 let mut result = Vec::new();
2862 let mut project_entries = HashSet::new();
2863 let mut has_editor = false;
2864
2865 panel.update(cx, |panel, cx| {
2866 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2867 if details.is_editing {
2868 assert!(!has_editor, "duplicate editor entry");
2869 has_editor = true;
2870 } else {
2871 assert!(
2872 project_entries.insert(project_entry),
2873 "duplicate project entry {:?} {:?}",
2874 project_entry,
2875 details
2876 );
2877 }
2878
2879 let indent = " ".repeat(details.depth);
2880 let icon = if details.kind.is_dir() {
2881 if details.is_expanded {
2882 "v "
2883 } else {
2884 "> "
2885 }
2886 } else {
2887 " "
2888 };
2889 let name = if details.is_editing {
2890 format!("[EDITOR: '{}']", details.filename)
2891 } else if details.is_processing {
2892 format!("[PROCESSING: '{}']", details.filename)
2893 } else {
2894 details.filename.clone()
2895 };
2896 let selected = if details.is_selected {
2897 " <== selected"
2898 } else {
2899 ""
2900 };
2901 result.push(format!("{indent}{icon}{name}{selected}"));
2902 });
2903 });
2904
2905 result
2906 }
2907
2908 fn init_test(cx: &mut TestAppContext) {
2909 cx.update(|cx| {
2910 let settings_store = SettingsStore::test(cx);
2911 cx.set_global(settings_store);
2912 init_settings(cx);
2913 theme::init(theme::LoadThemes::JustBase, cx);
2914 language::init(cx);
2915 editor::init_settings(cx);
2916 crate::init((), cx);
2917 workspace::init_settings(cx);
2918 client::init_settings(cx);
2919 Project::init_settings(cx);
2920
2921 cx.update_global::<SettingsStore, _>(|store, cx| {
2922 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2923 project_settings.file_scan_exclusions = Some(Vec::new());
2924 });
2925 });
2926 });
2927 }
2928
2929 fn init_test_with_editor(cx: &mut TestAppContext) {
2930 cx.update(|cx| {
2931 let app_state = AppState::test(cx);
2932 theme::init(theme::LoadThemes::JustBase, cx);
2933 init_settings(cx);
2934 language::init(cx);
2935 editor::init(cx);
2936 crate::init((), cx);
2937 workspace::init(app_state.clone(), cx);
2938 Project::init_settings(cx);
2939 });
2940 }
2941
2942 fn ensure_single_file_is_opened(
2943 window: &WindowHandle<Workspace>,
2944 expected_path: &str,
2945 cx: &mut TestAppContext,
2946 ) {
2947 window
2948 .update(cx, |workspace, cx| {
2949 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2950 assert_eq!(worktrees.len(), 1);
2951 let worktree_id = worktrees[0].read(cx).id();
2952
2953 let open_project_paths = workspace
2954 .panes()
2955 .iter()
2956 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2957 .collect::<Vec<_>>();
2958 assert_eq!(
2959 open_project_paths,
2960 vec![ProjectPath {
2961 worktree_id,
2962 path: Arc::from(Path::new(expected_path))
2963 }],
2964 "Should have opened file, selected in project panel"
2965 );
2966 })
2967 .unwrap();
2968 }
2969
2970 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
2971 assert!(
2972 !cx.has_pending_prompt(),
2973 "Should have no prompts before the deletion"
2974 );
2975 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
2976 assert!(
2977 cx.has_pending_prompt(),
2978 "Should have a prompt after the deletion"
2979 );
2980 cx.simulate_prompt_answer(0);
2981 assert!(
2982 !cx.has_pending_prompt(),
2983 "Should have no prompts after prompt was replied to"
2984 );
2985 cx.executor().run_until_parked();
2986 }
2987
2988 fn ensure_no_open_items_and_panes(
2989 workspace: &WindowHandle<Workspace>,
2990 cx: &mut VisualTestContext,
2991 ) {
2992 assert!(
2993 !cx.has_pending_prompt(),
2994 "Should have no prompts after deletion operation closes the file"
2995 );
2996 workspace
2997 .read_with(cx, |workspace, cx| {
2998 let open_project_paths = workspace
2999 .panes()
3000 .iter()
3001 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3002 .collect::<Vec<_>>();
3003 assert!(
3004 open_project_paths.is_empty(),
3005 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3006 );
3007 })
3008 .unwrap();
3009 }
3010}