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