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, Component, Div, EventEmitter, FocusHandle, Focusable, FocusableView,
13 InteractiveComponent, Model, MouseButton, ParentComponent, Pixels, Point, PromptLevel, Render,
14 Stateful, StatefulInteractiveComponent, 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 dbg!("OLA!");
251 let new_dock_position = this.position(cx);
252 if new_dock_position != old_dock_position {
253 old_dock_position = new_dock_position;
254 cx.emit(PanelEvent::ChangePosition);
255 }
256 })
257 .detach();
258
259 this
260 });
261
262 cx.subscribe(&project_panel, {
263 let project_panel = project_panel.downgrade();
264 move |workspace, _, event, cx| match event {
265 &Event::OpenedEntry {
266 entry_id,
267 focus_opened_item,
268 } => {
269 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
270 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
271 workspace
272 .open_path(
273 ProjectPath {
274 worktree_id: worktree.read(cx).id(),
275 path: entry.path.clone(),
276 },
277 None,
278 focus_opened_item,
279 cx,
280 )
281 .detach_and_log_err(cx);
282 if !focus_opened_item {
283 if let Some(project_panel) = project_panel.upgrade() {
284 let focus_handle = project_panel.read(cx).focus_handle.clone();
285 cx.focus(&focus_handle);
286 }
287 }
288 }
289 }
290 }
291 &Event::SplitEntry { entry_id } => {
292 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
293 if let Some(_entry) = worktree.read(cx).entry_for_id(entry_id) {
294 // workspace
295 // .split_path(
296 // ProjectPath {
297 // worktree_id: worktree.read(cx).id(),
298 // path: entry.path.clone(),
299 // },
300 // cx,
301 // )
302 // .detach_and_log_err(cx);
303 }
304 }
305 }
306 _ => {}
307 }
308 })
309 .detach();
310
311 project_panel
312 }
313
314 pub async fn load(
315 workspace: WeakView<Workspace>,
316 mut cx: AsyncWindowContext,
317 ) -> Result<View<Self>> {
318 let serialized_panel = cx
319 .background_executor()
320 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
321 .await
322 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
323 .log_err()
324 .flatten()
325 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
326 .transpose()
327 .log_err()
328 .flatten();
329
330 workspace.update(&mut cx, |workspace, cx| {
331 let panel = ProjectPanel::new(workspace, cx);
332 if let Some(serialized_panel) = serialized_panel {
333 panel.update(cx, |panel, cx| {
334 panel.width = serialized_panel.width;
335 cx.notify();
336 });
337 }
338 panel
339 })
340 }
341
342 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
343 let width = self.width;
344 self.pending_serialization = cx.background_executor().spawn(
345 async move {
346 KEY_VALUE_STORE
347 .write_kvp(
348 PROJECT_PANEL_KEY.into(),
349 serde_json::to_string(&SerializedProjectPanel { width })?,
350 )
351 .await?;
352 anyhow::Ok(())
353 }
354 .log_err(),
355 );
356 }
357
358 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
359 if !self.has_focus {
360 self.has_focus = true;
361 cx.emit(Event::Focus);
362 }
363 }
364
365 fn focus_out(&mut self, _: &mut ViewContext<Self>) {
366 self.has_focus = false;
367 }
368
369 fn deploy_context_menu(
370 &mut self,
371 _position: Point<Pixels>,
372 _entry_id: ProjectEntryId,
373 _cx: &mut ViewContext<Self>,
374 ) {
375 todo!()
376 // let project = self.project.read(cx);
377
378 // let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
379 // id
380 // } else {
381 // return;
382 // };
383
384 // self.selection = Some(Selection {
385 // worktree_id,
386 // entry_id,
387 // });
388
389 // let mut menu_entries = Vec::new();
390 // if let Some((worktree, entry)) = self.selected_entry(cx) {
391 // let is_root = Some(entry) == worktree.root_entry();
392 // if !project.is_remote() {
393 // menu_entries.push(ContextMenuItem::action(
394 // "Add Folder to Project",
395 // workspace::AddFolderToProject,
396 // ));
397 // if is_root {
398 // let project = self.project.clone();
399 // menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
400 // project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
401 // }));
402 // }
403 // }
404 // menu_entries.push(ContextMenuItem::action("New File", NewFile));
405 // menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
406 // menu_entries.push(ContextMenuItem::Separator);
407 // menu_entries.push(ContextMenuItem::action("Cut", Cut));
408 // menu_entries.push(ContextMenuItem::action("Copy", Copy));
409 // if let Some(clipboard_entry) = self.clipboard_entry {
410 // if clipboard_entry.worktree_id() == worktree.id() {
411 // menu_entries.push(ContextMenuItem::action("Paste", Paste));
412 // }
413 // }
414 // menu_entries.push(ContextMenuItem::Separator);
415 // menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
416 // menu_entries.push(ContextMenuItem::action(
417 // "Copy Relative Path",
418 // CopyRelativePath,
419 // ));
420
421 // if entry.is_dir() {
422 // menu_entries.push(ContextMenuItem::Separator);
423 // }
424 // menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
425 // if entry.is_dir() {
426 // menu_entries.push(ContextMenuItem::action("Open in Terminal", OpenInTerminal));
427 // menu_entries.push(ContextMenuItem::action(
428 // "Search Inside",
429 // NewSearchInDirectory,
430 // ));
431 // }
432
433 // menu_entries.push(ContextMenuItem::Separator);
434 // menu_entries.push(ContextMenuItem::action("Rename", Rename));
435 // if !is_root {
436 // menu_entries.push(ContextMenuItem::action("Delete", Delete));
437 // }
438 // }
439
440 // // self.context_menu.update(cx, |menu, cx| {
441 // // menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
442 // // });
443
444 // cx.notify();
445 }
446
447 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
448 if let Some((worktree, entry)) = self.selected_entry(cx) {
449 if entry.is_dir() {
450 let worktree_id = worktree.id();
451 let entry_id = entry.id;
452 let expanded_dir_ids =
453 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
454 expanded_dir_ids
455 } else {
456 return;
457 };
458
459 match expanded_dir_ids.binary_search(&entry_id) {
460 Ok(_) => self.select_next(&SelectNext, cx),
461 Err(ix) => {
462 self.project.update(cx, |project, cx| {
463 project.expand_entry(worktree_id, entry_id, cx);
464 });
465
466 expanded_dir_ids.insert(ix, entry_id);
467 self.update_visible_entries(None, cx);
468 cx.notify();
469 }
470 }
471 }
472 }
473 }
474
475 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
476 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
477 let worktree_id = worktree.id();
478 let expanded_dir_ids =
479 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
480 expanded_dir_ids
481 } else {
482 return;
483 };
484
485 loop {
486 let entry_id = entry.id;
487 match expanded_dir_ids.binary_search(&entry_id) {
488 Ok(ix) => {
489 expanded_dir_ids.remove(ix);
490 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
491 cx.notify();
492 break;
493 }
494 Err(_) => {
495 if let Some(parent_entry) =
496 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
497 {
498 entry = parent_entry;
499 } else {
500 break;
501 }
502 }
503 }
504 }
505 }
506 }
507
508 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
509 self.expanded_dir_ids.clear();
510 self.update_visible_entries(None, cx);
511 cx.notify();
512 }
513
514 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
515 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
516 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
517 self.project.update(cx, |project, cx| {
518 match expanded_dir_ids.binary_search(&entry_id) {
519 Ok(ix) => {
520 expanded_dir_ids.remove(ix);
521 }
522 Err(ix) => {
523 project.expand_entry(worktree_id, entry_id, cx);
524 expanded_dir_ids.insert(ix, entry_id);
525 }
526 }
527 });
528 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
529 cx.focus(&self.focus_handle);
530 cx.notify();
531 }
532 }
533 }
534
535 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
536 if let Some(selection) = self.selection {
537 let (mut worktree_ix, mut entry_ix, _) =
538 self.index_for_selection(selection).unwrap_or_default();
539 if entry_ix > 0 {
540 entry_ix -= 1;
541 } else if worktree_ix > 0 {
542 worktree_ix -= 1;
543 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
544 } else {
545 return;
546 }
547
548 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
549 self.selection = Some(Selection {
550 worktree_id: *worktree_id,
551 entry_id: worktree_entries[entry_ix].id,
552 });
553 self.autoscroll(cx);
554 cx.notify();
555 } else {
556 self.select_first(cx);
557 }
558 }
559
560 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
561 if let Some(task) = self.confirm_edit(cx) {
562 task.detach_and_log_err(cx);
563 }
564 }
565
566 fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
567 if let Some((_, entry)) = self.selected_entry(cx) {
568 if entry.is_file() {
569 self.open_entry(entry.id, true, cx);
570 }
571 }
572 }
573
574 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
575 let edit_state = self.edit_state.as_mut()?;
576 cx.focus(&self.focus_handle);
577
578 let worktree_id = edit_state.worktree_id;
579 let is_new_entry = edit_state.is_new_entry;
580 let is_dir = edit_state.is_dir;
581 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
582 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
583 let filename = self.filename_editor.read(cx).text(cx);
584
585 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
586 let edit_task;
587 let edited_entry_id;
588 if is_new_entry {
589 self.selection = Some(Selection {
590 worktree_id,
591 entry_id: NEW_ENTRY_ID,
592 });
593 let new_path = entry.path.join(&filename.trim_start_matches("/"));
594 if path_already_exists(new_path.as_path()) {
595 return None;
596 }
597
598 edited_entry_id = NEW_ENTRY_ID;
599 edit_task = self.project.update(cx, |project, cx| {
600 project.create_entry((worktree_id, &new_path), is_dir, cx)
601 })?;
602 } else {
603 let new_path = if let Some(parent) = entry.path.clone().parent() {
604 parent.join(&filename)
605 } else {
606 filename.clone().into()
607 };
608 if path_already_exists(new_path.as_path()) {
609 return None;
610 }
611
612 edited_entry_id = entry.id;
613 edit_task = self.project.update(cx, |project, cx| {
614 project.rename_entry(entry.id, new_path.as_path(), cx)
615 })?;
616 };
617
618 edit_state.processing_filename = Some(filename);
619 cx.notify();
620
621 Some(cx.spawn(|this, mut cx| async move {
622 let new_entry = edit_task.await;
623 this.update(&mut cx, |this, cx| {
624 this.edit_state.take();
625 cx.notify();
626 })?;
627
628 let new_entry = new_entry?;
629 this.update(&mut cx, |this, cx| {
630 if let Some(selection) = &mut this.selection {
631 if selection.entry_id == edited_entry_id {
632 selection.worktree_id = worktree_id;
633 selection.entry_id = new_entry.id;
634 this.expand_to_selection(cx);
635 }
636 }
637 this.update_visible_entries(None, cx);
638 if is_new_entry && !is_dir {
639 this.open_entry(new_entry.id, true, cx);
640 }
641 cx.notify();
642 })?;
643 Ok(())
644 }))
645 }
646
647 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
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<Self> {
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<Self, Div<Self>> {
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(move |this, event, 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(MouseButton::Right, move |this, event, cx| {
1414 this.deploy_context_menu(event.position, entry_id, cx);
1415 })
1416 // .on_drop::<ProjectEntryId>(|this, event, cx| {
1417 // this.move_entry(
1418 // *dragged_entry,
1419 // entry_id,
1420 // matches!(details.kind, EntryKind::File(_)),
1421 // cx,
1422 // );
1423 // })
1424 }
1425}
1426
1427impl Render for ProjectPanel {
1428 type Element = Focusable<Self, Stateful<Self, Div<Self>>>;
1429
1430 fn render(&mut self, _cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1431 let has_worktree = self.visible_entries.len() != 0;
1432
1433 if has_worktree {
1434 div()
1435 .id("project-panel")
1436 .size_full()
1437 .key_context("ProjectPanel")
1438 .on_action(Self::select_next)
1439 .on_action(Self::select_prev)
1440 .on_action(Self::expand_selected_entry)
1441 .on_action(Self::collapse_selected_entry)
1442 .on_action(Self::collapse_all_entries)
1443 .on_action(Self::new_file)
1444 .on_action(Self::new_directory)
1445 .on_action(Self::rename)
1446 .on_action(Self::delete)
1447 .on_action(Self::confirm)
1448 .on_action(Self::open_file)
1449 .on_action(Self::cancel)
1450 .on_action(Self::cut)
1451 .on_action(Self::copy)
1452 .on_action(Self::copy_path)
1453 .on_action(Self::copy_relative_path)
1454 .on_action(Self::paste)
1455 .on_action(Self::reveal_in_finder)
1456 .on_action(Self::open_in_terminal)
1457 .on_action(Self::new_search_in_directory)
1458 .track_focus(&self.focus_handle)
1459 .child(
1460 uniform_list(
1461 "entries",
1462 self.visible_entries
1463 .iter()
1464 .map(|(_, worktree_entries)| worktree_entries.len())
1465 .sum(),
1466 |this: &mut Self, range, cx| {
1467 let mut items = Vec::new();
1468 this.for_each_visible_entry(range, cx, |id, details, cx| {
1469 items.push(this.render_entry(id, details, cx));
1470 });
1471 items
1472 },
1473 )
1474 .size_full()
1475 .track_scroll(self.list.clone()),
1476 )
1477 } else {
1478 v_stack()
1479 .id("empty-project_panel")
1480 .track_focus(&self.focus_handle)
1481 }
1482 }
1483}
1484
1485impl EventEmitter<Event> for ProjectPanel {}
1486
1487impl EventEmitter<PanelEvent> for ProjectPanel {}
1488
1489impl Panel for ProjectPanel {
1490 fn position(&self, cx: &WindowContext) -> DockPosition {
1491 match ProjectPanelSettings::get_global(cx).dock {
1492 ProjectPanelDockPosition::Left => DockPosition::Left,
1493 ProjectPanelDockPosition::Right => DockPosition::Right,
1494 }
1495 }
1496
1497 fn position_is_valid(&self, position: DockPosition) -> bool {
1498 matches!(position, DockPosition::Left | DockPosition::Right)
1499 }
1500
1501 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1502 settings::update_settings_file::<ProjectPanelSettings>(
1503 self.fs.clone(),
1504 cx,
1505 move |settings| {
1506 let dock = match position {
1507 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1508 DockPosition::Right => ProjectPanelDockPosition::Right,
1509 };
1510 settings.dock = Some(dock);
1511 },
1512 );
1513 }
1514
1515 fn size(&self, cx: &WindowContext) -> f32 {
1516 self.width
1517 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1518 }
1519
1520 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1521 self.width = size;
1522 self.serialize(cx);
1523 cx.notify();
1524 }
1525
1526 fn icon(&self, _: &WindowContext) -> Option<ui::Icon> {
1527 Some(ui::Icon::FileTree)
1528 }
1529
1530 fn toggle_action(&self) -> Box<dyn Action> {
1531 Box::new(ToggleFocus)
1532 }
1533
1534 fn has_focus(&self, _: &WindowContext) -> bool {
1535 self.has_focus
1536 }
1537
1538 fn persistent_name() -> &'static str {
1539 "Project Panel"
1540 }
1541}
1542
1543impl FocusableView for ProjectPanel {
1544 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1545 self.focus_handle.clone()
1546 }
1547}
1548
1549impl ClipboardEntry {
1550 fn is_cut(&self) -> bool {
1551 matches!(self, Self::Cut { .. })
1552 }
1553
1554 fn entry_id(&self) -> ProjectEntryId {
1555 match self {
1556 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1557 *entry_id
1558 }
1559 }
1560 }
1561
1562 fn worktree_id(&self) -> WorktreeId {
1563 match self {
1564 ClipboardEntry::Copied { worktree_id, .. }
1565 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1566 }
1567 }
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572 use super::*;
1573 use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1574 use pretty_assertions::assert_eq;
1575 use project::{project_settings::ProjectSettings, FakeFs};
1576 use serde_json::json;
1577 use settings::SettingsStore;
1578 use std::{
1579 collections::HashSet,
1580 path::{Path, PathBuf},
1581 sync::atomic::{self, AtomicUsize},
1582 };
1583 use workspace::AppState;
1584
1585 #[gpui::test]
1586 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1587 init_test(cx);
1588
1589 let fs = FakeFs::new(cx.executor().clone());
1590 fs.insert_tree(
1591 "/root1",
1592 json!({
1593 ".dockerignore": "",
1594 ".git": {
1595 "HEAD": "",
1596 },
1597 "a": {
1598 "0": { "q": "", "r": "", "s": "" },
1599 "1": { "t": "", "u": "" },
1600 "2": { "v": "", "w": "", "x": "", "y": "" },
1601 },
1602 "b": {
1603 "3": { "Q": "" },
1604 "4": { "R": "", "S": "", "T": "", "U": "" },
1605 },
1606 "C": {
1607 "5": {},
1608 "6": { "V": "", "W": "" },
1609 "7": { "X": "" },
1610 "8": { "Y": {}, "Z": "" }
1611 }
1612 }),
1613 )
1614 .await;
1615 fs.insert_tree(
1616 "/root2",
1617 json!({
1618 "d": {
1619 "9": ""
1620 },
1621 "e": {}
1622 }),
1623 )
1624 .await;
1625
1626 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1627 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1628 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1629 let panel = workspace
1630 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1631 .unwrap();
1632 assert_eq!(
1633 visible_entries_as_strings(&panel, 0..50, cx),
1634 &[
1635 "v root1",
1636 " > .git",
1637 " > a",
1638 " > b",
1639 " > C",
1640 " .dockerignore",
1641 "v root2",
1642 " > d",
1643 " > e",
1644 ]
1645 );
1646
1647 toggle_expand_dir(&panel, "root1/b", cx);
1648 assert_eq!(
1649 visible_entries_as_strings(&panel, 0..50, cx),
1650 &[
1651 "v root1",
1652 " > .git",
1653 " > a",
1654 " v b <== selected",
1655 " > 3",
1656 " > 4",
1657 " > C",
1658 " .dockerignore",
1659 "v root2",
1660 " > d",
1661 " > e",
1662 ]
1663 );
1664
1665 assert_eq!(
1666 visible_entries_as_strings(&panel, 6..9, cx),
1667 &[
1668 //
1669 " > C",
1670 " .dockerignore",
1671 "v root2",
1672 ]
1673 );
1674 }
1675
1676 #[gpui::test]
1677 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1678 init_test(cx);
1679 cx.update(|cx| {
1680 cx.update_global::<SettingsStore, _>(|store, cx| {
1681 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1682 project_settings.file_scan_exclusions =
1683 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1684 });
1685 });
1686 });
1687
1688 let fs = FakeFs::new(cx.background_executor.clone());
1689 fs.insert_tree(
1690 "/root1",
1691 json!({
1692 ".dockerignore": "",
1693 ".git": {
1694 "HEAD": "",
1695 },
1696 "a": {
1697 "0": { "q": "", "r": "", "s": "" },
1698 "1": { "t": "", "u": "" },
1699 "2": { "v": "", "w": "", "x": "", "y": "" },
1700 },
1701 "b": {
1702 "3": { "Q": "" },
1703 "4": { "R": "", "S": "", "T": "", "U": "" },
1704 },
1705 "C": {
1706 "5": {},
1707 "6": { "V": "", "W": "" },
1708 "7": { "X": "" },
1709 "8": { "Y": {}, "Z": "" }
1710 }
1711 }),
1712 )
1713 .await;
1714 fs.insert_tree(
1715 "/root2",
1716 json!({
1717 "d": {
1718 "4": ""
1719 },
1720 "e": {}
1721 }),
1722 )
1723 .await;
1724
1725 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1726 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1727 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1728 let panel = workspace
1729 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1730 .unwrap();
1731 assert_eq!(
1732 visible_entries_as_strings(&panel, 0..50, cx),
1733 &[
1734 "v root1",
1735 " > a",
1736 " > b",
1737 " > C",
1738 " .dockerignore",
1739 "v root2",
1740 " > d",
1741 " > e",
1742 ]
1743 );
1744
1745 toggle_expand_dir(&panel, "root1/b", cx);
1746 assert_eq!(
1747 visible_entries_as_strings(&panel, 0..50, cx),
1748 &[
1749 "v root1",
1750 " > a",
1751 " v b <== selected",
1752 " > 3",
1753 " > C",
1754 " .dockerignore",
1755 "v root2",
1756 " > d",
1757 " > e",
1758 ]
1759 );
1760
1761 toggle_expand_dir(&panel, "root2/d", cx);
1762 assert_eq!(
1763 visible_entries_as_strings(&panel, 0..50, cx),
1764 &[
1765 "v root1",
1766 " > a",
1767 " v b",
1768 " > 3",
1769 " > C",
1770 " .dockerignore",
1771 "v root2",
1772 " v d <== selected",
1773 " > e",
1774 ]
1775 );
1776
1777 toggle_expand_dir(&panel, "root2/e", cx);
1778 assert_eq!(
1779 visible_entries_as_strings(&panel, 0..50, cx),
1780 &[
1781 "v root1",
1782 " > a",
1783 " v b",
1784 " > 3",
1785 " > C",
1786 " .dockerignore",
1787 "v root2",
1788 " v d",
1789 " v e <== selected",
1790 ]
1791 );
1792 }
1793
1794 #[gpui::test(iterations = 30)]
1795 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1796 init_test(cx);
1797
1798 let fs = FakeFs::new(cx.executor().clone());
1799 fs.insert_tree(
1800 "/root1",
1801 json!({
1802 ".dockerignore": "",
1803 ".git": {
1804 "HEAD": "",
1805 },
1806 "a": {
1807 "0": { "q": "", "r": "", "s": "" },
1808 "1": { "t": "", "u": "" },
1809 "2": { "v": "", "w": "", "x": "", "y": "" },
1810 },
1811 "b": {
1812 "3": { "Q": "" },
1813 "4": { "R": "", "S": "", "T": "", "U": "" },
1814 },
1815 "C": {
1816 "5": {},
1817 "6": { "V": "", "W": "" },
1818 "7": { "X": "" },
1819 "8": { "Y": {}, "Z": "" }
1820 }
1821 }),
1822 )
1823 .await;
1824 fs.insert_tree(
1825 "/root2",
1826 json!({
1827 "d": {
1828 "9": ""
1829 },
1830 "e": {}
1831 }),
1832 )
1833 .await;
1834
1835 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1836 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1837 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1838 let panel = workspace
1839 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1840 .unwrap();
1841
1842 select_path(&panel, "root1", cx);
1843 assert_eq!(
1844 visible_entries_as_strings(&panel, 0..10, cx),
1845 &[
1846 "v root1 <== selected",
1847 " > .git",
1848 " > a",
1849 " > b",
1850 " > C",
1851 " .dockerignore",
1852 "v root2",
1853 " > d",
1854 " > e",
1855 ]
1856 );
1857
1858 // Add a file with the root folder selected. The filename editor is placed
1859 // before the first file in the root folder.
1860 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1861 panel.update(cx, |panel, cx| {
1862 assert!(panel.filename_editor.read(cx).is_focused(cx));
1863 });
1864 assert_eq!(
1865 visible_entries_as_strings(&panel, 0..10, cx),
1866 &[
1867 "v root1",
1868 " > .git",
1869 " > a",
1870 " > b",
1871 " > C",
1872 " [EDITOR: ''] <== selected",
1873 " .dockerignore",
1874 "v root2",
1875 " > d",
1876 " > e",
1877 ]
1878 );
1879
1880 let confirm = panel.update(cx, |panel, cx| {
1881 panel
1882 .filename_editor
1883 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1884 panel.confirm_edit(cx).unwrap()
1885 });
1886 assert_eq!(
1887 visible_entries_as_strings(&panel, 0..10, cx),
1888 &[
1889 "v root1",
1890 " > .git",
1891 " > a",
1892 " > b",
1893 " > C",
1894 " [PROCESSING: 'the-new-filename'] <== selected",
1895 " .dockerignore",
1896 "v root2",
1897 " > d",
1898 " > e",
1899 ]
1900 );
1901
1902 confirm.await.unwrap();
1903 assert_eq!(
1904 visible_entries_as_strings(&panel, 0..10, cx),
1905 &[
1906 "v root1",
1907 " > .git",
1908 " > a",
1909 " > b",
1910 " > C",
1911 " .dockerignore",
1912 " the-new-filename <== selected",
1913 "v root2",
1914 " > d",
1915 " > e",
1916 ]
1917 );
1918
1919 select_path(&panel, "root1/b", cx);
1920 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1921 assert_eq!(
1922 visible_entries_as_strings(&panel, 0..10, cx),
1923 &[
1924 "v root1",
1925 " > .git",
1926 " > a",
1927 " v b",
1928 " > 3",
1929 " > 4",
1930 " [EDITOR: ''] <== selected",
1931 " > C",
1932 " .dockerignore",
1933 " the-new-filename",
1934 ]
1935 );
1936
1937 panel
1938 .update(cx, |panel, cx| {
1939 panel
1940 .filename_editor
1941 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1942 panel.confirm_edit(cx).unwrap()
1943 })
1944 .await
1945 .unwrap();
1946 assert_eq!(
1947 visible_entries_as_strings(&panel, 0..10, cx),
1948 &[
1949 "v root1",
1950 " > .git",
1951 " > a",
1952 " v b",
1953 " > 3",
1954 " > 4",
1955 " another-filename.txt <== selected",
1956 " > C",
1957 " .dockerignore",
1958 " the-new-filename",
1959 ]
1960 );
1961
1962 select_path(&panel, "root1/b/another-filename.txt", cx);
1963 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1964 assert_eq!(
1965 visible_entries_as_strings(&panel, 0..10, cx),
1966 &[
1967 "v root1",
1968 " > .git",
1969 " > a",
1970 " v b",
1971 " > 3",
1972 " > 4",
1973 " [EDITOR: 'another-filename.txt'] <== selected",
1974 " > C",
1975 " .dockerignore",
1976 " the-new-filename",
1977 ]
1978 );
1979
1980 let confirm = panel.update(cx, |panel, cx| {
1981 panel.filename_editor.update(cx, |editor, cx| {
1982 let file_name_selections = editor.selections.all::<usize>(cx);
1983 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1984 let file_name_selection = &file_name_selections[0];
1985 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1986 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
1987
1988 editor.set_text("a-different-filename.tar.gz", cx)
1989 });
1990 panel.confirm_edit(cx).unwrap()
1991 });
1992 assert_eq!(
1993 visible_entries_as_strings(&panel, 0..10, cx),
1994 &[
1995 "v root1",
1996 " > .git",
1997 " > a",
1998 " v b",
1999 " > 3",
2000 " > 4",
2001 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2002 " > C",
2003 " .dockerignore",
2004 " the-new-filename",
2005 ]
2006 );
2007
2008 confirm.await.unwrap();
2009 assert_eq!(
2010 visible_entries_as_strings(&panel, 0..10, cx),
2011 &[
2012 "v root1",
2013 " > .git",
2014 " > a",
2015 " v b",
2016 " > 3",
2017 " > 4",
2018 " a-different-filename.tar.gz <== selected",
2019 " > C",
2020 " .dockerignore",
2021 " the-new-filename",
2022 ]
2023 );
2024
2025 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2026 assert_eq!(
2027 visible_entries_as_strings(&panel, 0..10, cx),
2028 &[
2029 "v root1",
2030 " > .git",
2031 " > a",
2032 " v b",
2033 " > 3",
2034 " > 4",
2035 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2036 " > C",
2037 " .dockerignore",
2038 " the-new-filename",
2039 ]
2040 );
2041
2042 panel.update(cx, |panel, cx| {
2043 panel.filename_editor.update(cx, |editor, cx| {
2044 let file_name_selections = editor.selections.all::<usize>(cx);
2045 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2046 let file_name_selection = &file_name_selections[0];
2047 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2048 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..");
2049
2050 });
2051 panel.cancel(&Cancel, cx)
2052 });
2053
2054 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2055 assert_eq!(
2056 visible_entries_as_strings(&panel, 0..10, cx),
2057 &[
2058 "v root1",
2059 " > .git",
2060 " > a",
2061 " v b",
2062 " > [EDITOR: ''] <== selected",
2063 " > 3",
2064 " > 4",
2065 " a-different-filename.tar.gz",
2066 " > C",
2067 " .dockerignore",
2068 ]
2069 );
2070
2071 let confirm = panel.update(cx, |panel, cx| {
2072 panel
2073 .filename_editor
2074 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2075 panel.confirm_edit(cx).unwrap()
2076 });
2077 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2078 assert_eq!(
2079 visible_entries_as_strings(&panel, 0..10, cx),
2080 &[
2081 "v root1",
2082 " > .git",
2083 " > a",
2084 " v b",
2085 " > [PROCESSING: 'new-dir']",
2086 " > 3 <== selected",
2087 " > 4",
2088 " a-different-filename.tar.gz",
2089 " > C",
2090 " .dockerignore",
2091 ]
2092 );
2093
2094 confirm.await.unwrap();
2095 assert_eq!(
2096 visible_entries_as_strings(&panel, 0..10, cx),
2097 &[
2098 "v root1",
2099 " > .git",
2100 " > a",
2101 " v b",
2102 " > 3 <== selected",
2103 " > 4",
2104 " > new-dir",
2105 " a-different-filename.tar.gz",
2106 " > C",
2107 " .dockerignore",
2108 ]
2109 );
2110
2111 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2112 assert_eq!(
2113 visible_entries_as_strings(&panel, 0..10, cx),
2114 &[
2115 "v root1",
2116 " > .git",
2117 " > a",
2118 " v b",
2119 " > [EDITOR: '3'] <== selected",
2120 " > 4",
2121 " > new-dir",
2122 " a-different-filename.tar.gz",
2123 " > C",
2124 " .dockerignore",
2125 ]
2126 );
2127
2128 // Dismiss the rename editor when it loses focus.
2129 workspace.update(cx, |_, cx| cx.blur()).unwrap();
2130 assert_eq!(
2131 visible_entries_as_strings(&panel, 0..10, cx),
2132 &[
2133 "v root1",
2134 " > .git",
2135 " > a",
2136 " v b",
2137 " > 3 <== selected",
2138 " > 4",
2139 " > new-dir",
2140 " a-different-filename.tar.gz",
2141 " > C",
2142 " .dockerignore",
2143 ]
2144 );
2145 }
2146
2147 #[gpui::test(iterations = 10)]
2148 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2149 init_test(cx);
2150
2151 let fs = FakeFs::new(cx.executor().clone());
2152 fs.insert_tree(
2153 "/root1",
2154 json!({
2155 ".dockerignore": "",
2156 ".git": {
2157 "HEAD": "",
2158 },
2159 "a": {
2160 "0": { "q": "", "r": "", "s": "" },
2161 "1": { "t": "", "u": "" },
2162 "2": { "v": "", "w": "", "x": "", "y": "" },
2163 },
2164 "b": {
2165 "3": { "Q": "" },
2166 "4": { "R": "", "S": "", "T": "", "U": "" },
2167 },
2168 "C": {
2169 "5": {},
2170 "6": { "V": "", "W": "" },
2171 "7": { "X": "" },
2172 "8": { "Y": {}, "Z": "" }
2173 }
2174 }),
2175 )
2176 .await;
2177 fs.insert_tree(
2178 "/root2",
2179 json!({
2180 "d": {
2181 "9": ""
2182 },
2183 "e": {}
2184 }),
2185 )
2186 .await;
2187
2188 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2189 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2190 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2191 let panel = workspace
2192 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2193 .unwrap();
2194
2195 select_path(&panel, "root1", cx);
2196 assert_eq!(
2197 visible_entries_as_strings(&panel, 0..10, cx),
2198 &[
2199 "v root1 <== selected",
2200 " > .git",
2201 " > a",
2202 " > b",
2203 " > C",
2204 " .dockerignore",
2205 "v root2",
2206 " > d",
2207 " > e",
2208 ]
2209 );
2210
2211 // Add a file with the root folder selected. The filename editor is placed
2212 // before the first file in the root folder.
2213 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2214 panel.update(cx, |panel, cx| {
2215 assert!(panel.filename_editor.read(cx).is_focused(cx));
2216 });
2217 assert_eq!(
2218 visible_entries_as_strings(&panel, 0..10, cx),
2219 &[
2220 "v root1",
2221 " > .git",
2222 " > a",
2223 " > b",
2224 " > C",
2225 " [EDITOR: ''] <== selected",
2226 " .dockerignore",
2227 "v root2",
2228 " > d",
2229 " > e",
2230 ]
2231 );
2232
2233 let confirm = panel.update(cx, |panel, cx| {
2234 panel.filename_editor.update(cx, |editor, cx| {
2235 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2236 });
2237 panel.confirm_edit(cx).unwrap()
2238 });
2239
2240 assert_eq!(
2241 visible_entries_as_strings(&panel, 0..10, cx),
2242 &[
2243 "v root1",
2244 " > .git",
2245 " > a",
2246 " > b",
2247 " > C",
2248 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2249 " .dockerignore",
2250 "v root2",
2251 " > d",
2252 " > e",
2253 ]
2254 );
2255
2256 confirm.await.unwrap();
2257 assert_eq!(
2258 visible_entries_as_strings(&panel, 0..13, cx),
2259 &[
2260 "v root1",
2261 " > .git",
2262 " > a",
2263 " > b",
2264 " v bdir1",
2265 " v dir2",
2266 " the-new-filename <== selected",
2267 " > C",
2268 " .dockerignore",
2269 "v root2",
2270 " > d",
2271 " > e",
2272 ]
2273 );
2274 }
2275
2276 #[gpui::test]
2277 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2278 init_test(cx);
2279
2280 let fs = FakeFs::new(cx.executor().clone());
2281 fs.insert_tree(
2282 "/root1",
2283 json!({
2284 "one.two.txt": "",
2285 "one.txt": ""
2286 }),
2287 )
2288 .await;
2289
2290 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2291 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2292 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2293 let panel = workspace
2294 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2295 .unwrap();
2296
2297 panel.update(cx, |panel, cx| {
2298 panel.select_next(&Default::default(), cx);
2299 panel.select_next(&Default::default(), cx);
2300 });
2301
2302 assert_eq!(
2303 visible_entries_as_strings(&panel, 0..50, cx),
2304 &[
2305 //
2306 "v root1",
2307 " one.two.txt <== selected",
2308 " one.txt",
2309 ]
2310 );
2311
2312 // Regression test - file name is created correctly when
2313 // the copied file's name contains multiple dots.
2314 panel.update(cx, |panel, cx| {
2315 panel.copy(&Default::default(), cx);
2316 panel.paste(&Default::default(), cx);
2317 });
2318 cx.executor().run_until_parked();
2319
2320 assert_eq!(
2321 visible_entries_as_strings(&panel, 0..50, cx),
2322 &[
2323 //
2324 "v root1",
2325 " one.two copy.txt",
2326 " one.two.txt <== selected",
2327 " one.txt",
2328 ]
2329 );
2330
2331 panel.update(cx, |panel, cx| {
2332 panel.paste(&Default::default(), cx);
2333 });
2334 cx.executor().run_until_parked();
2335
2336 assert_eq!(
2337 visible_entries_as_strings(&panel, 0..50, cx),
2338 &[
2339 //
2340 "v root1",
2341 " one.two copy 1.txt",
2342 " one.two copy.txt",
2343 " one.two.txt <== selected",
2344 " one.txt",
2345 ]
2346 );
2347 }
2348
2349 #[gpui::test]
2350 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2351 init_test_with_editor(cx);
2352
2353 let fs = FakeFs::new(cx.executor().clone());
2354 fs.insert_tree(
2355 "/src",
2356 json!({
2357 "test": {
2358 "first.rs": "// First Rust file",
2359 "second.rs": "// Second Rust file",
2360 "third.rs": "// Third Rust file",
2361 }
2362 }),
2363 )
2364 .await;
2365
2366 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2367 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2368 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2369 let panel = workspace
2370 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2371 .unwrap();
2372
2373 toggle_expand_dir(&panel, "src/test", cx);
2374 select_path(&panel, "src/test/first.rs", cx);
2375 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2376 cx.executor().run_until_parked();
2377 assert_eq!(
2378 visible_entries_as_strings(&panel, 0..10, cx),
2379 &[
2380 "v src",
2381 " v test",
2382 " first.rs <== selected",
2383 " second.rs",
2384 " third.rs"
2385 ]
2386 );
2387 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2388
2389 submit_deletion(&panel, cx);
2390 assert_eq!(
2391 visible_entries_as_strings(&panel, 0..10, cx),
2392 &[
2393 "v src",
2394 " v test",
2395 " second.rs",
2396 " third.rs"
2397 ],
2398 "Project panel should have no deleted file, no other file is selected in it"
2399 );
2400 ensure_no_open_items_and_panes(&workspace, cx);
2401
2402 select_path(&panel, "src/test/second.rs", cx);
2403 panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2404 cx.executor().run_until_parked();
2405 assert_eq!(
2406 visible_entries_as_strings(&panel, 0..10, cx),
2407 &[
2408 "v src",
2409 " v test",
2410 " second.rs <== selected",
2411 " third.rs"
2412 ]
2413 );
2414 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2415
2416 workspace
2417 .update(cx, |workspace, cx| {
2418 let active_items = workspace
2419 .panes()
2420 .iter()
2421 .filter_map(|pane| pane.read(cx).active_item())
2422 .collect::<Vec<_>>();
2423 assert_eq!(active_items.len(), 1);
2424 let open_editor = active_items
2425 .into_iter()
2426 .next()
2427 .unwrap()
2428 .downcast::<Editor>()
2429 .expect("Open item should be an editor");
2430 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2431 })
2432 .unwrap();
2433 submit_deletion(&panel, cx);
2434 assert_eq!(
2435 visible_entries_as_strings(&panel, 0..10, cx),
2436 &["v src", " v test", " third.rs"],
2437 "Project panel should have no deleted file, with one last file remaining"
2438 );
2439 ensure_no_open_items_and_panes(&workspace, cx);
2440 }
2441
2442 #[gpui::test]
2443 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2444 init_test_with_editor(cx);
2445
2446 let fs = FakeFs::new(cx.executor().clone());
2447 fs.insert_tree(
2448 "/src",
2449 json!({
2450 "test": {
2451 "first.rs": "// First Rust file",
2452 "second.rs": "// Second Rust file",
2453 "third.rs": "// Third Rust file",
2454 }
2455 }),
2456 )
2457 .await;
2458
2459 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2460 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2461 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2462 let panel = workspace
2463 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2464 .unwrap();
2465
2466 select_path(&panel, "src/", cx);
2467 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2468 cx.executor().run_until_parked();
2469 assert_eq!(
2470 visible_entries_as_strings(&panel, 0..10, cx),
2471 &[
2472 //
2473 "v src <== selected",
2474 " > test"
2475 ]
2476 );
2477 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2478 panel.update(cx, |panel, cx| {
2479 assert!(panel.filename_editor.read(cx).is_focused(cx));
2480 });
2481 assert_eq!(
2482 visible_entries_as_strings(&panel, 0..10, cx),
2483 &[
2484 //
2485 "v src",
2486 " > [EDITOR: ''] <== selected",
2487 " > test"
2488 ]
2489 );
2490 panel.update(cx, |panel, cx| {
2491 panel
2492 .filename_editor
2493 .update(cx, |editor, cx| editor.set_text("test", cx));
2494 assert!(
2495 panel.confirm_edit(cx).is_none(),
2496 "Should not allow to confirm on conflicting new directory name"
2497 )
2498 });
2499 assert_eq!(
2500 visible_entries_as_strings(&panel, 0..10, cx),
2501 &[
2502 //
2503 "v src",
2504 " > test"
2505 ],
2506 "File list should be unchanged after failed folder create confirmation"
2507 );
2508
2509 select_path(&panel, "src/test/", cx);
2510 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2511 cx.executor().run_until_parked();
2512 assert_eq!(
2513 visible_entries_as_strings(&panel, 0..10, cx),
2514 &[
2515 //
2516 "v src",
2517 " > test <== selected"
2518 ]
2519 );
2520 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2521 panel.update(cx, |panel, cx| {
2522 assert!(panel.filename_editor.read(cx).is_focused(cx));
2523 });
2524 assert_eq!(
2525 visible_entries_as_strings(&panel, 0..10, cx),
2526 &[
2527 "v src",
2528 " v test",
2529 " [EDITOR: ''] <== selected",
2530 " first.rs",
2531 " second.rs",
2532 " third.rs"
2533 ]
2534 );
2535 panel.update(cx, |panel, cx| {
2536 panel
2537 .filename_editor
2538 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2539 assert!(
2540 panel.confirm_edit(cx).is_none(),
2541 "Should not allow to confirm on conflicting new file name"
2542 )
2543 });
2544 assert_eq!(
2545 visible_entries_as_strings(&panel, 0..10, cx),
2546 &[
2547 "v src",
2548 " v test",
2549 " first.rs",
2550 " second.rs",
2551 " third.rs"
2552 ],
2553 "File list should be unchanged after failed file create confirmation"
2554 );
2555
2556 select_path(&panel, "src/test/first.rs", cx);
2557 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2558 cx.executor().run_until_parked();
2559 assert_eq!(
2560 visible_entries_as_strings(&panel, 0..10, cx),
2561 &[
2562 "v src",
2563 " v test",
2564 " first.rs <== selected",
2565 " second.rs",
2566 " third.rs"
2567 ],
2568 );
2569 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2570 panel.update(cx, |panel, cx| {
2571 assert!(panel.filename_editor.read(cx).is_focused(cx));
2572 });
2573 assert_eq!(
2574 visible_entries_as_strings(&panel, 0..10, cx),
2575 &[
2576 "v src",
2577 " v test",
2578 " [EDITOR: 'first.rs'] <== selected",
2579 " second.rs",
2580 " third.rs"
2581 ]
2582 );
2583 panel.update(cx, |panel, cx| {
2584 panel
2585 .filename_editor
2586 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2587 assert!(
2588 panel.confirm_edit(cx).is_none(),
2589 "Should not allow to confirm on conflicting file rename"
2590 )
2591 });
2592 assert_eq!(
2593 visible_entries_as_strings(&panel, 0..10, cx),
2594 &[
2595 "v src",
2596 " v test",
2597 " first.rs <== selected",
2598 " second.rs",
2599 " third.rs"
2600 ],
2601 "File list should be unchanged after failed rename confirmation"
2602 );
2603 }
2604
2605 #[gpui::test]
2606 async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2607 init_test_with_editor(cx);
2608
2609 let fs = FakeFs::new(cx.executor().clone());
2610 fs.insert_tree(
2611 "/src",
2612 json!({
2613 "test": {
2614 "first.rs": "// First Rust file",
2615 "second.rs": "// Second Rust file",
2616 "third.rs": "// Third Rust file",
2617 }
2618 }),
2619 )
2620 .await;
2621
2622 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2623 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2624 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2625 let panel = workspace
2626 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2627 .unwrap();
2628
2629 let new_search_events_count = Arc::new(AtomicUsize::new(0));
2630 let _subscription = panel.update(cx, |_, cx| {
2631 let subcription_count = Arc::clone(&new_search_events_count);
2632 let view = cx.view().clone();
2633 cx.subscribe(&view, move |_, _, event, _| {
2634 if matches!(event, Event::NewSearchInDirectory { .. }) {
2635 subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2636 }
2637 })
2638 });
2639
2640 toggle_expand_dir(&panel, "src/test", cx);
2641 select_path(&panel, "src/test/first.rs", cx);
2642 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2643 cx.executor().run_until_parked();
2644 assert_eq!(
2645 visible_entries_as_strings(&panel, 0..10, cx),
2646 &[
2647 "v src",
2648 " v test",
2649 " first.rs <== selected",
2650 " second.rs",
2651 " third.rs"
2652 ]
2653 );
2654 panel.update(cx, |panel, cx| {
2655 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2656 });
2657 assert_eq!(
2658 new_search_events_count.load(atomic::Ordering::SeqCst),
2659 0,
2660 "Should not trigger new search in directory when called on a file"
2661 );
2662
2663 select_path(&panel, "src/test", cx);
2664 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2665 cx.executor().run_until_parked();
2666 assert_eq!(
2667 visible_entries_as_strings(&panel, 0..10, cx),
2668 &[
2669 "v src",
2670 " v test <== selected",
2671 " first.rs",
2672 " second.rs",
2673 " third.rs"
2674 ]
2675 );
2676 panel.update(cx, |panel, cx| {
2677 panel.new_search_in_directory(&NewSearchInDirectory, cx)
2678 });
2679 assert_eq!(
2680 new_search_events_count.load(atomic::Ordering::SeqCst),
2681 1,
2682 "Should trigger new search in directory when called on a directory"
2683 );
2684 }
2685
2686 #[gpui::test]
2687 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2688 init_test_with_editor(cx);
2689
2690 let fs = FakeFs::new(cx.executor().clone());
2691 fs.insert_tree(
2692 "/project_root",
2693 json!({
2694 "dir_1": {
2695 "nested_dir": {
2696 "file_a.py": "# File contents",
2697 "file_b.py": "# File contents",
2698 "file_c.py": "# File contents",
2699 },
2700 "file_1.py": "# File contents",
2701 "file_2.py": "# File contents",
2702 "file_3.py": "# File contents",
2703 },
2704 "dir_2": {
2705 "file_1.py": "# File contents",
2706 "file_2.py": "# File contents",
2707 "file_3.py": "# File contents",
2708 }
2709 }),
2710 )
2711 .await;
2712
2713 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2714 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2715 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2716 let panel = workspace
2717 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2718 .unwrap();
2719
2720 panel.update(cx, |panel, cx| {
2721 panel.collapse_all_entries(&CollapseAllEntries, cx)
2722 });
2723 cx.executor().run_until_parked();
2724 assert_eq!(
2725 visible_entries_as_strings(&panel, 0..10, cx),
2726 &["v project_root", " > dir_1", " > dir_2",]
2727 );
2728
2729 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2730 toggle_expand_dir(&panel, "project_root/dir_1", cx);
2731 cx.executor().run_until_parked();
2732 assert_eq!(
2733 visible_entries_as_strings(&panel, 0..10, cx),
2734 &[
2735 "v project_root",
2736 " v dir_1 <== selected",
2737 " > nested_dir",
2738 " file_1.py",
2739 " file_2.py",
2740 " file_3.py",
2741 " > dir_2",
2742 ]
2743 );
2744 }
2745
2746 #[gpui::test]
2747 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2748 init_test(cx);
2749
2750 let fs = FakeFs::new(cx.executor().clone());
2751 fs.as_fake().insert_tree("/root", json!({})).await;
2752 let project = Project::test(fs, ["/root".as_ref()], cx).await;
2753 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2754 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2755 let panel = workspace
2756 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2757 .unwrap();
2758
2759 // Make a new buffer with no backing file
2760 workspace
2761 .update(cx, |workspace, cx| {
2762 Editor::new_file(workspace, &Default::default(), cx)
2763 })
2764 .unwrap();
2765
2766 // "Save as"" the buffer, creating a new backing file for it
2767 let save_task = workspace
2768 .update(cx, |workspace, cx| {
2769 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2770 })
2771 .unwrap();
2772
2773 cx.executor().run_until_parked();
2774 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2775 save_task.await.unwrap();
2776
2777 // Rename the file
2778 select_path(&panel, "root/new", cx);
2779 assert_eq!(
2780 visible_entries_as_strings(&panel, 0..10, cx),
2781 &["v root", " new <== selected"]
2782 );
2783 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2784 panel.update(cx, |panel, cx| {
2785 panel
2786 .filename_editor
2787 .update(cx, |editor, cx| editor.set_text("newer", cx));
2788 });
2789 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2790
2791 cx.executor().run_until_parked();
2792 assert_eq!(
2793 visible_entries_as_strings(&panel, 0..10, cx),
2794 &["v root", " newer <== selected"]
2795 );
2796
2797 workspace
2798 .update(cx, |workspace, cx| {
2799 workspace.save_active_item(workspace::SaveIntent::Save, cx)
2800 })
2801 .unwrap()
2802 .await
2803 .unwrap();
2804
2805 cx.executor().run_until_parked();
2806 // assert that saving the file doesn't restore "new"
2807 assert_eq!(
2808 visible_entries_as_strings(&panel, 0..10, cx),
2809 &["v root", " newer <== selected"]
2810 );
2811 }
2812
2813 fn toggle_expand_dir(
2814 panel: &View<ProjectPanel>,
2815 path: impl AsRef<Path>,
2816 cx: &mut VisualTestContext,
2817 ) {
2818 let path = path.as_ref();
2819 panel.update(cx, |panel, cx| {
2820 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2821 let worktree = worktree.read(cx);
2822 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2823 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2824 panel.toggle_expanded(entry_id, cx);
2825 return;
2826 }
2827 }
2828 panic!("no worktree for path {:?}", path);
2829 });
2830 }
2831
2832 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
2833 let path = path.as_ref();
2834 panel.update(cx, |panel, cx| {
2835 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
2836 let worktree = worktree.read(cx);
2837 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2838 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2839 panel.selection = Some(Selection {
2840 worktree_id: worktree.id(),
2841 entry_id,
2842 });
2843 return;
2844 }
2845 }
2846 panic!("no worktree for path {:?}", path);
2847 });
2848 }
2849
2850 fn visible_entries_as_strings(
2851 panel: &View<ProjectPanel>,
2852 range: Range<usize>,
2853 cx: &mut VisualTestContext,
2854 ) -> Vec<String> {
2855 let mut result = Vec::new();
2856 let mut project_entries = HashSet::new();
2857 let mut has_editor = false;
2858
2859 panel.update(cx, |panel, cx| {
2860 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2861 if details.is_editing {
2862 assert!(!has_editor, "duplicate editor entry");
2863 has_editor = true;
2864 } else {
2865 assert!(
2866 project_entries.insert(project_entry),
2867 "duplicate project entry {:?} {:?}",
2868 project_entry,
2869 details
2870 );
2871 }
2872
2873 let indent = " ".repeat(details.depth);
2874 let icon = if details.kind.is_dir() {
2875 if details.is_expanded {
2876 "v "
2877 } else {
2878 "> "
2879 }
2880 } else {
2881 " "
2882 };
2883 let name = if details.is_editing {
2884 format!("[EDITOR: '{}']", details.filename)
2885 } else if details.is_processing {
2886 format!("[PROCESSING: '{}']", details.filename)
2887 } else {
2888 details.filename.clone()
2889 };
2890 let selected = if details.is_selected {
2891 " <== selected"
2892 } else {
2893 ""
2894 };
2895 result.push(format!("{indent}{icon}{name}{selected}"));
2896 });
2897 });
2898
2899 result
2900 }
2901
2902 fn init_test(cx: &mut TestAppContext) {
2903 cx.update(|cx| {
2904 let settings_store = SettingsStore::test(cx);
2905 cx.set_global(settings_store);
2906 init_settings(cx);
2907 theme::init(theme::LoadThemes::JustBase, cx);
2908 language::init(cx);
2909 editor::init_settings(cx);
2910 crate::init((), cx);
2911 workspace::init_settings(cx);
2912 client::init_settings(cx);
2913 Project::init_settings(cx);
2914
2915 cx.update_global::<SettingsStore, _>(|store, cx| {
2916 store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2917 project_settings.file_scan_exclusions = Some(Vec::new());
2918 });
2919 });
2920 });
2921 }
2922
2923 fn init_test_with_editor(cx: &mut TestAppContext) {
2924 cx.update(|cx| {
2925 let app_state = AppState::test(cx);
2926 theme::init(theme::LoadThemes::JustBase, cx);
2927 init_settings(cx);
2928 language::init(cx);
2929 editor::init(cx);
2930 crate::init((), cx);
2931 workspace::init(app_state.clone(), cx);
2932 Project::init_settings(cx);
2933 });
2934 }
2935
2936 fn ensure_single_file_is_opened(
2937 window: &WindowHandle<Workspace>,
2938 expected_path: &str,
2939 cx: &mut TestAppContext,
2940 ) {
2941 window
2942 .update(cx, |workspace, cx| {
2943 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2944 assert_eq!(worktrees.len(), 1);
2945 let worktree_id = worktrees[0].read(cx).id();
2946
2947 let open_project_paths = workspace
2948 .panes()
2949 .iter()
2950 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2951 .collect::<Vec<_>>();
2952 assert_eq!(
2953 open_project_paths,
2954 vec![ProjectPath {
2955 worktree_id,
2956 path: Arc::from(Path::new(expected_path))
2957 }],
2958 "Should have opened file, selected in project panel"
2959 );
2960 })
2961 .unwrap();
2962 }
2963
2964 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
2965 assert!(
2966 !cx.has_pending_prompt(),
2967 "Should have no prompts before the deletion"
2968 );
2969 panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
2970 assert!(
2971 cx.has_pending_prompt(),
2972 "Should have a prompt after the deletion"
2973 );
2974 cx.simulate_prompt_answer(0);
2975 assert!(
2976 !cx.has_pending_prompt(),
2977 "Should have no prompts after prompt was replied to"
2978 );
2979 cx.executor().run_until_parked();
2980 }
2981
2982 fn ensure_no_open_items_and_panes(
2983 workspace: &WindowHandle<Workspace>,
2984 cx: &mut VisualTestContext,
2985 ) {
2986 assert!(
2987 !cx.has_pending_prompt(),
2988 "Should have no prompts after deletion operation closes the file"
2989 );
2990 workspace
2991 .read_with(cx, |workspace, cx| {
2992 let open_project_paths = workspace
2993 .panes()
2994 .iter()
2995 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2996 .collect::<Vec<_>>();
2997 assert!(
2998 open_project_paths.is_empty(),
2999 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3000 );
3001 })
3002 .unwrap();
3003 }
3004}