project_panel.rs

   1mod project_panel_settings;
   2use client::{ErrorCode, ErrorExt};
   3use settings::{Settings, SettingsStore};
   4
   5use db::kvp::KEY_VALUE_STORE;
   6use editor::{items::entry_git_aware_label_color, scroll::Autoscroll, Editor};
   7use file_icons::FileIcons;
   8
   9use anyhow::{anyhow, Result};
  10use collections::{hash_map, BTreeSet, HashMap};
  11use git::repository::GitFileStatus;
  12use gpui::{
  13    actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
  14    AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, EventEmitter,
  15    FocusHandle, FocusableView, InteractiveElement, KeyContext, ListSizingBehavior, Model,
  16    MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful,
  17    Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _,
  18    WeakView, WindowContext,
  19};
  20use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
  21use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
  22use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
  23use serde::{Deserialize, Serialize};
  24use std::{
  25    cmp::Ordering,
  26    collections::HashSet,
  27    ffi::OsStr,
  28    ops::Range,
  29    path::{Path, PathBuf},
  30    sync::Arc,
  31};
  32use theme::ThemeSettings;
  33use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem, Tooltip};
  34use unicase::UniCase;
  35use util::{maybe, NumericPrefixWithSuffix, ResultExt, TryFutureExt};
  36use workspace::{
  37    dock::{DockPosition, Panel, PanelEvent},
  38    notifications::{DetachAndPromptErr, NotifyTaskExt},
  39    DraggedSelection, OpenInTerminal, SelectedEntry, Workspace,
  40};
  41use worktree::CreatedEntry;
  42
  43const PROJECT_PANEL_KEY: &str = "ProjectPanel";
  44const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  45
  46pub struct ProjectPanel {
  47    project: Model<Project>,
  48    fs: Arc<dyn Fs>,
  49    scroll_handle: UniformListScrollHandle,
  50    focus_handle: FocusHandle,
  51    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  52    last_worktree_root_id: Option<ProjectEntryId>,
  53    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  54    unfolded_dir_ids: HashSet<ProjectEntryId>,
  55    // Currently selected entry in a file tree
  56    selection: Option<SelectedEntry>,
  57    marked_entries: BTreeSet<SelectedEntry>,
  58    context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
  59    edit_state: Option<EditState>,
  60    filename_editor: View<Editor>,
  61    clipboard: Option<ClipboardEntry>,
  62    _dragged_entry_destination: Option<Arc<Path>>,
  63    workspace: WeakView<Workspace>,
  64    width: Option<Pixels>,
  65    pending_serialization: Task<Option<()>>,
  66}
  67
  68#[derive(Clone, Debug)]
  69struct EditState {
  70    worktree_id: WorktreeId,
  71    entry_id: ProjectEntryId,
  72    is_new_entry: bool,
  73    is_dir: bool,
  74    processing_filename: Option<String>,
  75}
  76
  77#[derive(Clone, Debug)]
  78enum ClipboardEntry {
  79    Copied(BTreeSet<SelectedEntry>),
  80    Cut(BTreeSet<SelectedEntry>),
  81}
  82
  83#[derive(Debug, PartialEq, Eq, Clone)]
  84pub struct EntryDetails {
  85    filename: String,
  86    icon: Option<Arc<str>>,
  87    path: Arc<Path>,
  88    depth: usize,
  89    kind: EntryKind,
  90    is_ignored: bool,
  91    is_expanded: bool,
  92    is_selected: bool,
  93    is_marked: bool,
  94    is_editing: bool,
  95    is_processing: bool,
  96    is_cut: bool,
  97    git_status: Option<GitFileStatus>,
  98    is_private: bool,
  99    worktree_id: WorktreeId,
 100    canonical_path: Option<PathBuf>,
 101}
 102
 103#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
 104pub struct Delete {
 105    #[serde(default)]
 106    pub skip_prompt: bool,
 107}
 108
 109#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
 110pub struct Trash {
 111    #[serde(default)]
 112    pub skip_prompt: bool,
 113}
 114
 115impl_actions!(project_panel, [Delete, Trash]);
 116
 117actions!(
 118    project_panel,
 119    [
 120        ExpandSelectedEntry,
 121        CollapseSelectedEntry,
 122        CollapseAllEntries,
 123        NewDirectory,
 124        NewFile,
 125        Copy,
 126        CopyPath,
 127        CopyRelativePath,
 128        Duplicate,
 129        RevealInFinder,
 130        Cut,
 131        Paste,
 132        Rename,
 133        Open,
 134        OpenPermanent,
 135        ToggleFocus,
 136        NewSearchInDirectory,
 137        UnfoldDirectory,
 138        FoldDirectory,
 139        SelectParent,
 140    ]
 141);
 142
 143pub fn init_settings(cx: &mut AppContext) {
 144    ProjectPanelSettings::register(cx);
 145}
 146
 147pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
 148    init_settings(cx);
 149    file_icons::init(assets, cx);
 150
 151    cx.observe_new_views(|workspace: &mut Workspace, _| {
 152        workspace.register_action(|workspace, _: &ToggleFocus, cx| {
 153            workspace.toggle_panel_focus::<ProjectPanel>(cx);
 154        });
 155    })
 156    .detach();
 157}
 158
 159#[derive(Debug)]
 160pub enum Event {
 161    OpenedEntry {
 162        entry_id: ProjectEntryId,
 163        focus_opened_item: bool,
 164        allow_preview: bool,
 165        mark_selected: bool,
 166    },
 167    SplitEntry {
 168        entry_id: ProjectEntryId,
 169    },
 170    Focus,
 171}
 172
 173#[derive(Serialize, Deserialize)]
 174struct SerializedProjectPanel {
 175    width: Option<Pixels>,
 176}
 177
 178struct DraggedProjectEntryView {
 179    selection: SelectedEntry,
 180    details: EntryDetails,
 181    width: Pixels,
 182    selections: Arc<BTreeSet<SelectedEntry>>,
 183}
 184
 185impl ProjectPanel {
 186    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 187        let project = workspace.project().clone();
 188        let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
 189            let focus_handle = cx.focus_handle();
 190            cx.on_focus(&focus_handle, Self::focus_in).detach();
 191
 192            cx.subscribe(&project, |this, project, event, cx| match event {
 193                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 194                    if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
 195                        this.reveal_entry(project, *entry_id, true, cx);
 196                    }
 197                }
 198                project::Event::RevealInProjectPanel(entry_id) => {
 199                    this.reveal_entry(project, *entry_id, false, cx);
 200                    cx.emit(PanelEvent::Activate);
 201                }
 202                project::Event::ActivateProjectPanel => {
 203                    cx.emit(PanelEvent::Activate);
 204                }
 205                project::Event::WorktreeRemoved(id) => {
 206                    this.expanded_dir_ids.remove(id);
 207                    this.update_visible_entries(None, cx);
 208                    cx.notify();
 209                }
 210                project::Event::WorktreeUpdatedEntries(_, _)
 211                | project::Event::WorktreeAdded
 212                | project::Event::WorktreeOrderChanged => {
 213                    this.update_visible_entries(None, cx);
 214                    cx.notify();
 215                }
 216                _ => {}
 217            })
 218            .detach();
 219
 220            let filename_editor = cx.new_view(|cx| Editor::single_line(cx));
 221
 222            cx.subscribe(&filename_editor, |this, _, event, cx| match event {
 223                editor::EditorEvent::BufferEdited
 224                | editor::EditorEvent::SelectionsChanged { .. } => {
 225                    this.autoscroll(cx);
 226                }
 227                editor::EditorEvent::Blurred => {
 228                    if this
 229                        .edit_state
 230                        .as_ref()
 231                        .map_or(false, |state| state.processing_filename.is_none())
 232                    {
 233                        this.edit_state = None;
 234                        this.update_visible_entries(None, cx);
 235                    }
 236                }
 237                _ => {}
 238            })
 239            .detach();
 240
 241            cx.observe_global::<FileIcons>(|_, cx| {
 242                cx.notify();
 243            })
 244            .detach();
 245
 246            let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
 247            cx.observe_global::<SettingsStore>(move |_, cx| {
 248                let new_settings = *ProjectPanelSettings::get_global(cx);
 249                if project_panel_settings != new_settings {
 250                    project_panel_settings = new_settings;
 251                    cx.notify();
 252                }
 253            })
 254            .detach();
 255
 256            let mut this = Self {
 257                project: project.clone(),
 258                fs: workspace.app_state().fs.clone(),
 259                scroll_handle: UniformListScrollHandle::new(),
 260                focus_handle,
 261                visible_entries: Default::default(),
 262                last_worktree_root_id: Default::default(),
 263                expanded_dir_ids: Default::default(),
 264                unfolded_dir_ids: Default::default(),
 265                selection: None,
 266                marked_entries: Default::default(),
 267                edit_state: None,
 268                context_menu: None,
 269                filename_editor,
 270                clipboard: None,
 271                _dragged_entry_destination: None,
 272                workspace: workspace.weak_handle(),
 273                width: None,
 274                pending_serialization: Task::ready(None),
 275            };
 276            this.update_visible_entries(None, cx);
 277
 278            this
 279        });
 280
 281        cx.subscribe(&project_panel, {
 282            let project_panel = project_panel.downgrade();
 283            move |workspace, _, event, cx| match event {
 284                &Event::OpenedEntry {
 285                    entry_id,
 286                    focus_opened_item,
 287                    allow_preview,
 288                    mark_selected
 289                } => {
 290                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 291                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 292                            let file_path = entry.path.clone();
 293                            let worktree_id = worktree.read(cx).id();
 294                            let entry_id = entry.id;
 295
 296                                project_panel.update(cx, |this, _| {
 297                                    if !mark_selected {
 298                                        this.marked_entries.clear();
 299                                    }
 300                                    this.marked_entries.insert(SelectedEntry {
 301                                        worktree_id,
 302                                        entry_id
 303                                    });
 304                                }).ok();
 305
 306
 307                            workspace
 308                                .open_path_preview(
 309                                    ProjectPath {
 310                                        worktree_id,
 311                                        path: file_path.clone(),
 312                                    },
 313                                    None,
 314                                    focus_opened_item,
 315                                    allow_preview,
 316                                    cx,
 317                                )
 318                                .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
 319                                    match e.error_code() {
 320                                        ErrorCode::Disconnected => Some("Disconnected from remote project".to_string()),
 321                                        ErrorCode::UnsharedItem => Some(format!(
 322                                            "{} is not shared by the host. This could be because it has been marked as `private`",
 323                                            file_path.display()
 324                                        )),
 325                                        _ => None,
 326                                    }
 327                                });
 328
 329                            if let Some(project_panel) = project_panel.upgrade() {
 330                                // Always select the entry, regardless of whether it is opened or not.
 331                                project_panel.update(cx, |project_panel, _| {
 332                                    project_panel.selection = Some(SelectedEntry {
 333                                        worktree_id,
 334                                        entry_id
 335                                    });
 336                                });
 337                                if !focus_opened_item {
 338                                    let focus_handle = project_panel.read(cx).focus_handle.clone();
 339                                    cx.focus(&focus_handle);
 340                                }
 341                            }
 342                        }
 343                    }
 344                }
 345                &Event::SplitEntry { entry_id } => {
 346                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 347                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 348                            workspace
 349                                .split_path(
 350                                    ProjectPath {
 351                                        worktree_id: worktree.read(cx).id(),
 352                                        path: entry.path.clone(),
 353                                    },
 354                                    cx,
 355                                )
 356                                .detach_and_log_err(cx);
 357                        }
 358                    }
 359                }
 360                _ => {}
 361            }
 362        })
 363        .detach();
 364
 365        project_panel
 366    }
 367
 368    pub async fn load(
 369        workspace: WeakView<Workspace>,
 370        mut cx: AsyncWindowContext,
 371    ) -> Result<View<Self>> {
 372        let serialized_panel = cx
 373            .background_executor()
 374            .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
 375            .await
 376            .map_err(|e| anyhow!("Failed to load project panel: {}", e))
 377            .log_err()
 378            .flatten()
 379            .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
 380            .transpose()
 381            .log_err()
 382            .flatten();
 383
 384        workspace.update(&mut cx, |workspace, cx| {
 385            let panel = ProjectPanel::new(workspace, cx);
 386            if let Some(serialized_panel) = serialized_panel {
 387                panel.update(cx, |panel, cx| {
 388                    panel.width = serialized_panel.width.map(|px| px.round());
 389                    cx.notify();
 390                });
 391            }
 392            panel
 393        })
 394    }
 395
 396    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 397        let width = self.width;
 398        self.pending_serialization = cx.background_executor().spawn(
 399            async move {
 400                KEY_VALUE_STORE
 401                    .write_kvp(
 402                        PROJECT_PANEL_KEY.into(),
 403                        serde_json::to_string(&SerializedProjectPanel { width })?,
 404                    )
 405                    .await?;
 406                anyhow::Ok(())
 407            }
 408            .log_err(),
 409        );
 410    }
 411
 412    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 413        if !self.focus_handle.contains_focused(cx) {
 414            cx.emit(Event::Focus);
 415        }
 416    }
 417
 418    fn deploy_context_menu(
 419        &mut self,
 420        position: Point<Pixels>,
 421        entry_id: ProjectEntryId,
 422        cx: &mut ViewContext<Self>,
 423    ) {
 424        let this = cx.view().clone();
 425        let project = self.project.read(cx);
 426
 427        let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 428            id
 429        } else {
 430            return;
 431        };
 432
 433        self.selection = Some(SelectedEntry {
 434            worktree_id,
 435            entry_id,
 436        });
 437
 438        if let Some((worktree, entry)) = self.selected_entry(cx) {
 439            let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
 440            let is_root = Some(entry) == worktree.root_entry();
 441            let is_dir = entry.is_dir();
 442            let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
 443            let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
 444            let worktree_id = worktree.id();
 445            let is_local = project.is_local();
 446            let is_read_only = project.is_read_only();
 447            let is_remote = project.is_remote();
 448
 449            let context_menu = ContextMenu::build(cx, |menu, cx| {
 450                menu.context(self.focus_handle.clone()).when_else(
 451                    is_read_only,
 452                    |menu| {
 453                        menu.action("Copy Relative Path", Box::new(CopyRelativePath))
 454                            .when(is_dir, |menu| {
 455                                menu.action("Search Inside", Box::new(NewSearchInDirectory))
 456                            })
 457                    },
 458                    |menu| {
 459                        menu.action("New File", Box::new(NewFile))
 460                            .action("New Folder", Box::new(NewDirectory))
 461                            .separator()
 462                            .action("Reveal in Finder", Box::new(RevealInFinder))
 463                            .action("Open in Terminal", Box::new(OpenInTerminal))
 464                            .when(is_dir, |menu| {
 465                                menu.separator()
 466                                    .action("Find in Folder…", Box::new(NewSearchInDirectory))
 467                            })
 468                            .when(is_unfoldable, |menu| {
 469                                menu.action("Unfold Directory", Box::new(UnfoldDirectory))
 470                            })
 471                            .when(is_foldable, |menu| {
 472                                menu.action("Fold Directory", Box::new(FoldDirectory))
 473                            })
 474                            .separator()
 475                            .action("Cut", Box::new(Cut))
 476                            .action("Copy", Box::new(Copy))
 477                            .action("Duplicate", Box::new(Duplicate))
 478                            // TODO: Paste should always be visible, cbut disabled when clipboard is empty
 479                            .when_some(self.clipboard.as_ref(), |menu, entry| {
 480                                let entries_for_worktree_id = (SelectedEntry {
 481                                    worktree_id,
 482                                    entry_id: ProjectEntryId::MIN,
 483                                })
 484                                    ..(SelectedEntry {
 485                                        worktree_id,
 486                                        entry_id: ProjectEntryId::MAX,
 487                                    });
 488                                menu.when(
 489                                    entry
 490                                        .items()
 491                                        .range(entries_for_worktree_id)
 492                                        .next()
 493                                        .is_some(),
 494                                    |menu| menu.action("Paste", Box::new(Paste)),
 495                                )
 496                            })
 497                            .separator()
 498                            .action("Copy Path", Box::new(CopyPath))
 499                            .action("Copy Relative Path", Box::new(CopyRelativePath))
 500                            .separator()
 501                            .action("Rename", Box::new(Rename))
 502                            .when(!is_root, |menu| {
 503                                menu.action("Trash", Box::new(Trash { skip_prompt: false }))
 504                                    .action("Delete", Box::new(Delete { skip_prompt: false }))
 505                            })
 506                            .when(is_local & is_root, |menu| {
 507                                menu.separator()
 508                                    .when(!is_remote, |menu| {
 509                                        menu.action(
 510                                            "Add Folder to Project…",
 511                                            Box::new(workspace::AddFolderToProject),
 512                                        )
 513                                    })
 514                                    .entry(
 515                                        "Remove from Project",
 516                                        None,
 517                                        cx.handler_for(&this, move |this, cx| {
 518                                            this.project.update(cx, |project, cx| {
 519                                                project.remove_worktree(worktree_id, cx)
 520                                            });
 521                                        }),
 522                                    )
 523                            })
 524                            .when(is_local & is_root, |menu| {
 525                                menu.separator()
 526                                    .action("Collapse All", Box::new(CollapseAllEntries))
 527                            })
 528                    },
 529                )
 530            });
 531
 532            cx.focus_view(&context_menu);
 533            let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
 534                this.context_menu.take();
 535                cx.notify();
 536            });
 537            self.context_menu = Some((context_menu, position, subscription));
 538        }
 539
 540        cx.notify();
 541    }
 542
 543    fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
 544        if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
 545            return false;
 546        }
 547
 548        if let Some(parent_path) = entry.path.parent() {
 549            let snapshot = worktree.snapshot();
 550            let mut child_entries = snapshot.child_entries(&parent_path);
 551            if let Some(child) = child_entries.next() {
 552                if child_entries.next().is_none() {
 553                    return child.kind.is_dir();
 554                }
 555            }
 556        };
 557        false
 558    }
 559
 560    fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
 561        if entry.is_dir() {
 562            let snapshot = worktree.snapshot();
 563
 564            let mut child_entries = snapshot.child_entries(&entry.path);
 565            if let Some(child) = child_entries.next() {
 566                if child_entries.next().is_none() {
 567                    return child.kind.is_dir();
 568                }
 569            }
 570        }
 571        false
 572    }
 573
 574    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 575        if let Some((worktree, entry)) = self.selected_entry(cx) {
 576            if entry.is_dir() {
 577                let worktree_id = worktree.id();
 578                let entry_id = entry.id;
 579                let expanded_dir_ids =
 580                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 581                        expanded_dir_ids
 582                    } else {
 583                        return;
 584                    };
 585
 586                match expanded_dir_ids.binary_search(&entry_id) {
 587                    Ok(_) => self.select_next(&SelectNext, cx),
 588                    Err(ix) => {
 589                        self.project.update(cx, |project, cx| {
 590                            project.expand_entry(worktree_id, entry_id, cx);
 591                        });
 592
 593                        expanded_dir_ids.insert(ix, entry_id);
 594                        self.update_visible_entries(None, cx);
 595                        cx.notify();
 596                    }
 597                }
 598            }
 599        }
 600    }
 601
 602    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 603        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 604            let worktree_id = worktree.id();
 605            let expanded_dir_ids =
 606                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 607                    expanded_dir_ids
 608                } else {
 609                    return;
 610                };
 611
 612            loop {
 613                let entry_id = entry.id;
 614                match expanded_dir_ids.binary_search(&entry_id) {
 615                    Ok(ix) => {
 616                        expanded_dir_ids.remove(ix);
 617                        self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 618                        cx.notify();
 619                        break;
 620                    }
 621                    Err(_) => {
 622                        if let Some(parent_entry) =
 623                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 624                        {
 625                            entry = parent_entry;
 626                        } else {
 627                            break;
 628                        }
 629                    }
 630                }
 631            }
 632        }
 633    }
 634
 635    pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
 636        // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
 637        // (which is it's default behaviour when there's no entry for a worktree in expanded_dir_ids).
 638        self.expanded_dir_ids
 639            .retain(|_, expanded_entries| expanded_entries.is_empty());
 640        self.update_visible_entries(None, cx);
 641        cx.notify();
 642    }
 643
 644    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 645        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 646            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 647                self.project.update(cx, |project, cx| {
 648                    match expanded_dir_ids.binary_search(&entry_id) {
 649                        Ok(ix) => {
 650                            expanded_dir_ids.remove(ix);
 651                        }
 652                        Err(ix) => {
 653                            project.expand_entry(worktree_id, entry_id, cx);
 654                            expanded_dir_ids.insert(ix, entry_id);
 655                        }
 656                    }
 657                });
 658                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 659                cx.focus(&self.focus_handle);
 660                cx.notify();
 661            }
 662        }
 663    }
 664
 665    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 666        if let Some(selection) = self.selection {
 667            let (mut worktree_ix, mut entry_ix, _) =
 668                self.index_for_selection(selection).unwrap_or_default();
 669            if entry_ix > 0 {
 670                entry_ix -= 1;
 671            } else if worktree_ix > 0 {
 672                worktree_ix -= 1;
 673                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 674            } else {
 675                return;
 676            }
 677
 678            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 679            let selection = SelectedEntry {
 680                worktree_id: *worktree_id,
 681                entry_id: worktree_entries[entry_ix].id,
 682            };
 683            self.selection = Some(selection);
 684            if cx.modifiers().shift {
 685                self.marked_entries.insert(selection);
 686            }
 687            self.autoscroll(cx);
 688            cx.notify();
 689        } else {
 690            self.select_first(&SelectFirst {}, cx);
 691        }
 692    }
 693
 694    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 695        if let Some(task) = self.confirm_edit(cx) {
 696            task.detach_and_notify_err(cx);
 697        }
 698    }
 699
 700    fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
 701        self.open_internal(false, true, false, cx);
 702    }
 703
 704    fn open_permanent(&mut self, _: &OpenPermanent, cx: &mut ViewContext<Self>) {
 705        self.open_internal(true, false, true, cx);
 706    }
 707
 708    fn open_internal(
 709        &mut self,
 710        mark_selected: bool,
 711        allow_preview: bool,
 712        focus_opened_item: bool,
 713        cx: &mut ViewContext<Self>,
 714    ) {
 715        if let Some((_, entry)) = self.selected_entry(cx) {
 716            if entry.is_file() {
 717                self.open_entry(
 718                    entry.id,
 719                    mark_selected,
 720                    focus_opened_item,
 721                    allow_preview,
 722                    cx,
 723                );
 724            } else {
 725                self.toggle_expanded(entry.id, cx);
 726            }
 727        }
 728    }
 729
 730    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 731        let edit_state = self.edit_state.as_mut()?;
 732        cx.focus(&self.focus_handle);
 733
 734        let worktree_id = edit_state.worktree_id;
 735        let is_new_entry = edit_state.is_new_entry;
 736        let filename = self.filename_editor.read(cx).text(cx);
 737        edit_state.is_dir = edit_state.is_dir
 738            || (edit_state.is_new_entry && filename.ends_with(std::path::MAIN_SEPARATOR));
 739        let is_dir = edit_state.is_dir;
 740        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 741        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 742
 743        let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
 744        let edit_task;
 745        let edited_entry_id;
 746        if is_new_entry {
 747            self.selection = Some(SelectedEntry {
 748                worktree_id,
 749                entry_id: NEW_ENTRY_ID,
 750            });
 751            let new_path = entry.path.join(&filename.trim_start_matches('/'));
 752            if path_already_exists(new_path.as_path()) {
 753                return None;
 754            }
 755
 756            edited_entry_id = NEW_ENTRY_ID;
 757            edit_task = self.project.update(cx, |project, cx| {
 758                project.create_entry((worktree_id, &new_path), is_dir, cx)
 759            });
 760        } else {
 761            let new_path = if let Some(parent) = entry.path.clone().parent() {
 762                parent.join(&filename)
 763            } else {
 764                filename.clone().into()
 765            };
 766            if path_already_exists(new_path.as_path()) {
 767                return None;
 768            }
 769
 770            edited_entry_id = entry.id;
 771            edit_task = self.project.update(cx, |project, cx| {
 772                project.rename_entry(entry.id, new_path.as_path(), cx)
 773            });
 774        };
 775
 776        edit_state.processing_filename = Some(filename);
 777        cx.notify();
 778
 779        Some(cx.spawn(|project_panel, mut cx| async move {
 780            let new_entry = edit_task.await;
 781            project_panel.update(&mut cx, |project_panel, cx| {
 782                project_panel.edit_state.take();
 783                cx.notify();
 784            })?;
 785
 786            match new_entry {
 787                Err(e) => {
 788                    project_panel.update(&mut cx, |project_panel, cx| {
 789                        project_panel.marked_entries.clear();
 790                        project_panel.update_visible_entries(None, cx);
 791                    }).ok();
 792                    Err(e)?;
 793                }
 794                Ok(CreatedEntry::Included(new_entry)) => {
 795                    project_panel.update(&mut cx, |project_panel, cx| {
 796                        if let Some(selection) = &mut project_panel.selection {
 797                            if selection.entry_id == edited_entry_id {
 798                                selection.worktree_id = worktree_id;
 799                                selection.entry_id = new_entry.id;
 800                                project_panel.marked_entries.clear();
 801                                project_panel.expand_to_selection(cx);
 802                            }
 803                        }
 804                        project_panel.update_visible_entries(None, cx);
 805                        if is_new_entry && !is_dir {
 806                            project_panel.open_entry(new_entry.id, false, true, false, cx);
 807                        }
 808                        cx.notify();
 809                    })?;
 810                }
 811                Ok(CreatedEntry::Excluded { abs_path }) => {
 812                    if let Some(open_task) = project_panel
 813                        .update(&mut cx, |project_panel, cx| {
 814                            project_panel.marked_entries.clear();
 815                            project_panel.update_visible_entries(None, cx);
 816
 817                            if is_dir {
 818                                project_panel.project.update(cx, |_, cx| {
 819                                    cx.emit(project::Event::Notification(format!(
 820                                        "Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel"
 821                                    )))
 822                                });
 823                                None
 824                            } else {
 825                                project_panel
 826                                    .workspace
 827                                    .update(cx, |workspace, cx| {
 828                                        workspace.open_abs_path(abs_path, true, cx)
 829                                    })
 830                                    .ok()
 831                            }
 832                        })
 833                        .ok()
 834                        .flatten()
 835                    {
 836                        let _ = open_task.await?;
 837                    }
 838                }
 839            }
 840            Ok(())
 841        }))
 842    }
 843
 844    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 845        self.edit_state = None;
 846        self.update_visible_entries(None, cx);
 847        self.marked_entries.clear();
 848        cx.focus(&self.focus_handle);
 849        cx.notify();
 850    }
 851
 852    fn open_entry(
 853        &mut self,
 854        entry_id: ProjectEntryId,
 855        mark_selected: bool,
 856        focus_opened_item: bool,
 857        allow_preview: bool,
 858        cx: &mut ViewContext<Self>,
 859    ) {
 860        cx.emit(Event::OpenedEntry {
 861            entry_id,
 862            focus_opened_item,
 863            allow_preview,
 864            mark_selected,
 865        });
 866    }
 867
 868    fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 869        cx.emit(Event::SplitEntry { entry_id });
 870    }
 871
 872    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 873        self.add_entry(false, cx)
 874    }
 875
 876    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 877        self.add_entry(true, cx)
 878    }
 879
 880    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 881        if let Some(SelectedEntry {
 882            worktree_id,
 883            entry_id,
 884        }) = self.selection
 885        {
 886            let directory_id;
 887            if let Some((worktree, expanded_dir_ids)) = self
 888                .project
 889                .read(cx)
 890                .worktree_for_id(worktree_id, cx)
 891                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 892            {
 893                let worktree = worktree.read(cx);
 894                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 895                    loop {
 896                        if entry.is_dir() {
 897                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 898                                expanded_dir_ids.insert(ix, entry.id);
 899                            }
 900                            directory_id = entry.id;
 901                            break;
 902                        } else {
 903                            if let Some(parent_path) = entry.path.parent() {
 904                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 905                                    entry = parent_entry;
 906                                    continue;
 907                                }
 908                            }
 909                            return;
 910                        }
 911                    }
 912                } else {
 913                    return;
 914                };
 915            } else {
 916                return;
 917            };
 918            self.marked_entries.clear();
 919            self.edit_state = Some(EditState {
 920                worktree_id,
 921                entry_id: directory_id,
 922                is_new_entry: true,
 923                is_dir,
 924                processing_filename: None,
 925            });
 926            self.filename_editor.update(cx, |editor, cx| {
 927                editor.clear(cx);
 928                editor.focus(cx);
 929            });
 930            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 931            self.autoscroll(cx);
 932            cx.notify();
 933        }
 934    }
 935
 936    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 937        if let Some(SelectedEntry {
 938            worktree_id,
 939            entry_id,
 940        }) = self.selection
 941        {
 942            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 943                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 944                    self.edit_state = Some(EditState {
 945                        worktree_id,
 946                        entry_id,
 947                        is_new_entry: false,
 948                        is_dir: entry.is_dir(),
 949                        processing_filename: None,
 950                    });
 951                    let file_name = entry
 952                        .path
 953                        .file_name()
 954                        .map(|s| s.to_string_lossy())
 955                        .unwrap_or_default()
 956                        .to_string();
 957                    let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
 958                    let selection_end =
 959                        file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
 960                    self.filename_editor.update(cx, |editor, cx| {
 961                        editor.set_text(file_name, cx);
 962                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 963                            s.select_ranges([0..selection_end])
 964                        });
 965                        editor.focus(cx);
 966                    });
 967                    self.update_visible_entries(None, cx);
 968                    self.autoscroll(cx);
 969                    cx.notify();
 970                }
 971            }
 972        }
 973    }
 974
 975    fn trash(&mut self, action: &Trash, cx: &mut ViewContext<Self>) {
 976        self.remove(true, action.skip_prompt, cx);
 977    }
 978
 979    fn delete(&mut self, action: &Delete, cx: &mut ViewContext<Self>) {
 980        self.remove(false, action.skip_prompt, cx);
 981    }
 982
 983    fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) {
 984        maybe!({
 985            if self.marked_entries.is_empty() && self.selection.is_none() {
 986                return None;
 987            }
 988            let project = self.project.read(cx);
 989            let items_to_delete = self.marked_entries();
 990            let file_paths = items_to_delete
 991                .into_iter()
 992                .filter_map(|selection| {
 993                    Some((
 994                        selection.entry_id,
 995                        project
 996                            .path_for_entry(selection.entry_id, cx)?
 997                            .path
 998                            .file_name()?
 999                            .to_string_lossy()
1000                            .into_owned(),
1001                    ))
1002                })
1003                .collect::<Vec<_>>();
1004            if file_paths.is_empty() {
1005                return None;
1006            }
1007            let answer = if !skip_prompt {
1008                let operation = if trash { "Trash" } else { "Delete" };
1009
1010                let prompt =
1011                    if let Some((_, path)) = file_paths.first().filter(|_| file_paths.len() == 1) {
1012                        format!("{operation} {path}?")
1013                    } else {
1014                        const CUTOFF_POINT: usize = 10;
1015                        let names = if file_paths.len() > CUTOFF_POINT {
1016                            let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
1017                            let mut paths = file_paths
1018                                .iter()
1019                                .map(|(_, path)| path.clone())
1020                                .take(CUTOFF_POINT)
1021                                .collect::<Vec<_>>();
1022                            paths.truncate(CUTOFF_POINT);
1023                            if truncated_path_counts == 1 {
1024                                paths.push(".. 1 file not shown".into());
1025                            } else {
1026                                paths.push(format!(".. {} files not shown", truncated_path_counts));
1027                            }
1028                            paths
1029                        } else {
1030                            file_paths.iter().map(|(_, path)| path.clone()).collect()
1031                        };
1032
1033                        format!(
1034                            "Do you want to {} the following {} files?\n{}",
1035                            operation.to_lowercase(),
1036                            file_paths.len(),
1037                            names.join("\n")
1038                        )
1039                    };
1040                Some(cx.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"]))
1041            } else {
1042                None
1043            };
1044
1045            cx.spawn(|this, mut cx| async move {
1046                if let Some(answer) = answer {
1047                    if answer.await != Ok(0) {
1048                        return Result::<(), anyhow::Error>::Ok(());
1049                    }
1050                }
1051                for (entry_id, _) in file_paths {
1052                    this.update(&mut cx, |this, cx| {
1053                        this.project
1054                            .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
1055                            .ok_or_else(|| anyhow!("no such entry"))
1056                    })??
1057                    .await?;
1058                }
1059                Result::<(), anyhow::Error>::Ok(())
1060            })
1061            .detach_and_log_err(cx);
1062            Some(())
1063        });
1064    }
1065
1066    fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
1067        if let Some((worktree, entry)) = self.selected_entry(cx) {
1068            self.unfolded_dir_ids.insert(entry.id);
1069
1070            let snapshot = worktree.snapshot();
1071            let mut parent_path = entry.path.parent();
1072            while let Some(path) = parent_path {
1073                if let Some(parent_entry) = worktree.entry_for_path(path) {
1074                    let mut children_iter = snapshot.child_entries(path);
1075
1076                    if children_iter.by_ref().take(2).count() > 1 {
1077                        break;
1078                    }
1079
1080                    self.unfolded_dir_ids.insert(parent_entry.id);
1081                    parent_path = path.parent();
1082                } else {
1083                    break;
1084                }
1085            }
1086
1087            self.update_visible_entries(None, cx);
1088            self.autoscroll(cx);
1089            cx.notify();
1090        }
1091    }
1092
1093    fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext<Self>) {
1094        if let Some((worktree, entry)) = self.selected_entry(cx) {
1095            self.unfolded_dir_ids.remove(&entry.id);
1096
1097            let snapshot = worktree.snapshot();
1098            let mut path = &*entry.path;
1099            loop {
1100                let mut child_entries_iter = snapshot.child_entries(path);
1101                if let Some(child) = child_entries_iter.next() {
1102                    if child_entries_iter.next().is_none() && child.is_dir() {
1103                        self.unfolded_dir_ids.remove(&child.id);
1104                        path = &*child.path;
1105                    } else {
1106                        break;
1107                    }
1108                } else {
1109                    break;
1110                }
1111            }
1112
1113            self.update_visible_entries(None, cx);
1114            self.autoscroll(cx);
1115            cx.notify();
1116        }
1117    }
1118
1119    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1120        if let Some(selection) = self.selection {
1121            let (mut worktree_ix, mut entry_ix, _) =
1122                self.index_for_selection(selection).unwrap_or_default();
1123            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
1124                if entry_ix + 1 < worktree_entries.len() {
1125                    entry_ix += 1;
1126                } else {
1127                    worktree_ix += 1;
1128                    entry_ix = 0;
1129                }
1130            }
1131
1132            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
1133                if let Some(entry) = worktree_entries.get(entry_ix) {
1134                    let selection = SelectedEntry {
1135                        worktree_id: *worktree_id,
1136                        entry_id: entry.id,
1137                    };
1138                    self.selection = Some(selection);
1139                    if cx.modifiers().shift {
1140                        self.marked_entries.insert(selection);
1141                    }
1142
1143                    self.autoscroll(cx);
1144                    cx.notify();
1145                }
1146            }
1147        } else {
1148            self.select_first(&SelectFirst {}, cx);
1149        }
1150    }
1151
1152    fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
1153        if let Some((worktree, entry)) = self.selected_entry(cx) {
1154            if let Some(parent) = entry.path.parent() {
1155                if let Some(parent_entry) = worktree.entry_for_path(parent) {
1156                    self.selection = Some(SelectedEntry {
1157                        worktree_id: worktree.id(),
1158                        entry_id: parent_entry.id,
1159                    });
1160                    self.autoscroll(cx);
1161                    cx.notify();
1162                }
1163            }
1164        } else {
1165            self.select_first(&SelectFirst {}, cx);
1166        }
1167    }
1168
1169    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
1170        let worktree = self
1171            .visible_entries
1172            .first()
1173            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
1174        if let Some(worktree) = worktree {
1175            let worktree = worktree.read(cx);
1176            let worktree_id = worktree.id();
1177            if let Some(root_entry) = worktree.root_entry() {
1178                let selection = SelectedEntry {
1179                    worktree_id,
1180                    entry_id: root_entry.id,
1181                };
1182                self.selection = Some(selection);
1183                if cx.modifiers().shift {
1184                    self.marked_entries.insert(selection);
1185                }
1186                self.autoscroll(cx);
1187                cx.notify();
1188            }
1189        }
1190    }
1191
1192    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
1193        let worktree = self
1194            .visible_entries
1195            .last()
1196            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
1197        if let Some(worktree) = worktree {
1198            let worktree = worktree.read(cx);
1199            let worktree_id = worktree.id();
1200            if let Some(last_entry) = worktree.entries(true).last() {
1201                self.selection = Some(SelectedEntry {
1202                    worktree_id,
1203                    entry_id: last_entry.id,
1204                });
1205                self.autoscroll(cx);
1206                cx.notify();
1207            }
1208        }
1209    }
1210
1211    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
1212        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
1213            self.scroll_handle.scroll_to_item(index);
1214            cx.notify();
1215        }
1216    }
1217
1218    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
1219        let entries = self.marked_entries();
1220        if !entries.is_empty() {
1221            self.clipboard = Some(ClipboardEntry::Cut(entries));
1222            cx.notify();
1223        }
1224    }
1225
1226    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
1227        let entries = self.marked_entries();
1228        if !entries.is_empty() {
1229            self.clipboard = Some(ClipboardEntry::Copied(entries));
1230            cx.notify();
1231        }
1232    }
1233
1234    fn create_paste_path(
1235        &self,
1236        source: &SelectedEntry,
1237        (worktree, target_entry): (Model<Worktree>, &Entry),
1238        cx: &AppContext,
1239    ) -> Option<PathBuf> {
1240        let mut new_path = target_entry.path.to_path_buf();
1241        // If we're pasting into a file, or a directory into itself, go up one level.
1242        if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
1243            new_path.pop();
1244        }
1245        let clipboard_entry_file_name = self
1246            .project
1247            .read(cx)
1248            .path_for_entry(source.entry_id, cx)?
1249            .path
1250            .file_name()?
1251            .to_os_string();
1252        new_path.push(&clipboard_entry_file_name);
1253        let extension = new_path.extension().map(|e| e.to_os_string());
1254        let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
1255        let mut ix = 0;
1256        {
1257            let worktree = worktree.read(cx);
1258            while worktree.entry_for_path(&new_path).is_some() {
1259                new_path.pop();
1260
1261                let mut new_file_name = file_name_without_extension.to_os_string();
1262                new_file_name.push(" copy");
1263                if ix > 0 {
1264                    new_file_name.push(format!(" {}", ix));
1265                }
1266                if let Some(extension) = extension.as_ref() {
1267                    new_file_name.push(".");
1268                    new_file_name.push(extension);
1269                }
1270
1271                new_path.push(new_file_name);
1272                ix += 1;
1273            }
1274        }
1275        Some(new_path)
1276    }
1277
1278    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
1279        maybe!({
1280            let (worktree, entry) = self.selected_entry_handle(cx)?;
1281            let entry = entry.clone();
1282            let worktree_id = worktree.read(cx).id();
1283            let clipboard_entries = self
1284                .clipboard
1285                .as_ref()
1286                .filter(|clipboard| !clipboard.items().is_empty())?;
1287
1288            for clipboard_entry in clipboard_entries.items() {
1289                if clipboard_entry.worktree_id != worktree_id {
1290                    return None;
1291                }
1292                let new_path =
1293                    self.create_paste_path(clipboard_entry, self.selected_entry_handle(cx)?, cx)?;
1294                if clipboard_entries.is_cut() {
1295                    self.project
1296                        .update(cx, |project, cx| {
1297                            project.rename_entry(clipboard_entry.entry_id, new_path, cx)
1298                        })
1299                        .detach_and_log_err(cx)
1300                } else {
1301                    self.project
1302                        .update(cx, |project, cx| {
1303                            project.copy_entry(clipboard_entry.entry_id, new_path, cx)
1304                        })
1305                        .detach_and_log_err(cx)
1306                }
1307            }
1308            self.expand_entry(worktree_id, entry.id, cx);
1309            Some(())
1310        });
1311    }
1312
1313    fn duplicate(&mut self, _: &Duplicate, cx: &mut ViewContext<Self>) {
1314        self.copy(&Copy {}, cx);
1315        self.paste(&Paste {}, cx);
1316    }
1317
1318    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
1319        if let Some((worktree, entry)) = self.selected_entry(cx) {
1320            cx.write_to_clipboard(ClipboardItem::new(
1321                worktree
1322                    .abs_path()
1323                    .join(&entry.path)
1324                    .to_string_lossy()
1325                    .to_string(),
1326            ));
1327        }
1328    }
1329
1330    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
1331        if let Some((_, entry)) = self.selected_entry(cx) {
1332            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
1333        }
1334    }
1335
1336    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
1337        if let Some((worktree, entry)) = self.selected_entry(cx) {
1338            cx.reveal_path(&worktree.abs_path().join(&entry.path));
1339        }
1340    }
1341
1342    fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
1343        if let Some((worktree, entry)) = self.selected_entry(cx) {
1344            let abs_path = worktree.abs_path().join(&entry.path);
1345            let working_directory = if entry.is_dir() {
1346                Some(abs_path)
1347            } else {
1348                if entry.is_symlink {
1349                    abs_path.canonicalize().ok()
1350                } else {
1351                    Some(abs_path)
1352                }
1353                .and_then(|path| Some(path.parent()?.to_path_buf()))
1354            };
1355            if let Some(working_directory) = working_directory {
1356                cx.dispatch_action(workspace::OpenTerminal { working_directory }.boxed_clone())
1357            }
1358        }
1359    }
1360
1361    pub fn new_search_in_directory(
1362        &mut self,
1363        _: &NewSearchInDirectory,
1364        cx: &mut ViewContext<Self>,
1365    ) {
1366        if let Some((worktree, entry)) = self.selected_entry(cx) {
1367            if entry.is_dir() {
1368                let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
1369                let dir_path = if include_root {
1370                    let mut full_path = PathBuf::from(worktree.root_name());
1371                    full_path.push(&entry.path);
1372                    Arc::from(full_path)
1373                } else {
1374                    entry.path.clone()
1375                };
1376
1377                self.workspace
1378                    .update(cx, |workspace, cx| {
1379                        search::ProjectSearchView::new_search_in_directory(
1380                            workspace, &dir_path, cx,
1381                        );
1382                    })
1383                    .ok();
1384            }
1385        }
1386    }
1387
1388    fn move_entry(
1389        &mut self,
1390        entry_to_move: ProjectEntryId,
1391        destination: ProjectEntryId,
1392        destination_is_file: bool,
1393        cx: &mut ViewContext<Self>,
1394    ) {
1395        if self
1396            .project
1397            .read(cx)
1398            .entry_is_worktree_root(entry_to_move, cx)
1399        {
1400            self.move_worktree_root(entry_to_move, destination, cx)
1401        } else {
1402            self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
1403        }
1404    }
1405
1406    fn move_worktree_root(
1407        &mut self,
1408        entry_to_move: ProjectEntryId,
1409        destination: ProjectEntryId,
1410        cx: &mut ViewContext<Self>,
1411    ) {
1412        self.project.update(cx, |project, cx| {
1413            let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
1414                return;
1415            };
1416            let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
1417                return;
1418            };
1419
1420            let worktree_id = worktree_to_move.read(cx).id();
1421            let destination_id = destination_worktree.read(cx).id();
1422
1423            project
1424                .move_worktree(worktree_id, destination_id, cx)
1425                .log_err();
1426        });
1427        return;
1428    }
1429
1430    fn move_worktree_entry(
1431        &mut self,
1432        entry_to_move: ProjectEntryId,
1433        destination: ProjectEntryId,
1434        destination_is_file: bool,
1435        cx: &mut ViewContext<Self>,
1436    ) {
1437        let destination_worktree = self.project.update(cx, |project, cx| {
1438            let entry_path = project.path_for_entry(entry_to_move, cx)?;
1439            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1440
1441            let mut destination_path = destination_entry_path.as_ref();
1442            if destination_is_file {
1443                destination_path = destination_path.parent()?;
1444            }
1445
1446            let mut new_path = destination_path.to_path_buf();
1447            new_path.push(entry_path.path.file_name()?);
1448            if new_path != entry_path.path.as_ref() {
1449                let task = project.rename_entry(entry_to_move, new_path, cx);
1450                cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1451            }
1452
1453            project.worktree_id_for_entry(destination, cx)
1454        });
1455
1456        if let Some(destination_worktree) = destination_worktree {
1457            self.expand_entry(destination_worktree, destination, cx);
1458        }
1459    }
1460
1461    fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
1462        let mut entry_index = 0;
1463        let mut visible_entries_index = 0;
1464        for (worktree_index, (worktree_id, worktree_entries)) in
1465            self.visible_entries.iter().enumerate()
1466        {
1467            if *worktree_id == selection.worktree_id {
1468                for entry in worktree_entries {
1469                    if entry.id == selection.entry_id {
1470                        return Some((worktree_index, entry_index, visible_entries_index));
1471                    } else {
1472                        visible_entries_index += 1;
1473                        entry_index += 1;
1474                    }
1475                }
1476                break;
1477            } else {
1478                visible_entries_index += worktree_entries.len();
1479            }
1480        }
1481        None
1482    }
1483
1484    // Returns list of entries that should be affected by an operation.
1485    // When currently selected entry is not marked, it's treated as the only marked entry.
1486    fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
1487        let Some(selection) = self.selection else {
1488            return Default::default();
1489        };
1490        if self.marked_entries.contains(&selection) {
1491            self.marked_entries.clone()
1492        } else {
1493            BTreeSet::from_iter([selection])
1494        }
1495    }
1496    pub fn selected_entry<'a>(
1497        &self,
1498        cx: &'a AppContext,
1499    ) -> Option<(&'a Worktree, &'a project::Entry)> {
1500        let (worktree, entry) = self.selected_entry_handle(cx)?;
1501        Some((worktree.read(cx), entry))
1502    }
1503
1504    fn selected_entry_handle<'a>(
1505        &self,
1506        cx: &'a AppContext,
1507    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1508        let selection = self.selection?;
1509        let project = self.project.read(cx);
1510        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1511        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1512        Some((worktree, entry))
1513    }
1514
1515    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1516        let (worktree, entry) = self.selected_entry(cx)?;
1517        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1518
1519        for path in entry.path.ancestors() {
1520            let Some(entry) = worktree.entry_for_path(path) else {
1521                continue;
1522            };
1523            if entry.is_dir() {
1524                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1525                    expanded_dir_ids.insert(idx, entry.id);
1526                }
1527            }
1528        }
1529
1530        Some(())
1531    }
1532
1533    fn update_visible_entries(
1534        &mut self,
1535        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1536        cx: &mut ViewContext<Self>,
1537    ) {
1538        let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1539        let project = self.project.read(cx);
1540        self.last_worktree_root_id = project
1541            .visible_worktrees(cx)
1542            .rev()
1543            .next()
1544            .and_then(|worktree| worktree.read(cx).root_entry())
1545            .map(|entry| entry.id);
1546
1547        self.visible_entries.clear();
1548        for worktree in project.visible_worktrees(cx) {
1549            let snapshot = worktree.read(cx).snapshot();
1550            let worktree_id = snapshot.id();
1551
1552            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1553                hash_map::Entry::Occupied(e) => e.into_mut(),
1554                hash_map::Entry::Vacant(e) => {
1555                    // The first time a worktree's root entry becomes available,
1556                    // mark that root entry as expanded.
1557                    if let Some(entry) = snapshot.root_entry() {
1558                        e.insert(vec![entry.id]).as_slice()
1559                    } else {
1560                        &[]
1561                    }
1562                }
1563            };
1564
1565            let mut new_entry_parent_id = None;
1566            let mut new_entry_kind = EntryKind::Dir;
1567            if let Some(edit_state) = &self.edit_state {
1568                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1569                    new_entry_parent_id = Some(edit_state.entry_id);
1570                    new_entry_kind = if edit_state.is_dir {
1571                        EntryKind::Dir
1572                    } else {
1573                        EntryKind::File(Default::default())
1574                    };
1575                }
1576            }
1577
1578            let mut visible_worktree_entries = Vec::new();
1579            let mut entry_iter = snapshot.entries(true);
1580            while let Some(entry) = entry_iter.entry() {
1581                if auto_collapse_dirs
1582                    && entry.kind.is_dir()
1583                    && !self.unfolded_dir_ids.contains(&entry.id)
1584                {
1585                    if let Some(root_path) = snapshot.root_entry() {
1586                        let mut child_entries = snapshot.child_entries(&entry.path);
1587                        if let Some(child) = child_entries.next() {
1588                            if entry.path != root_path.path
1589                                && child_entries.next().is_none()
1590                                && child.kind.is_dir()
1591                            {
1592                                entry_iter.advance();
1593                                continue;
1594                            }
1595                        }
1596                    }
1597                }
1598
1599                visible_worktree_entries.push(entry.clone());
1600                if Some(entry.id) == new_entry_parent_id {
1601                    visible_worktree_entries.push(Entry {
1602                        id: NEW_ENTRY_ID,
1603                        kind: new_entry_kind,
1604                        path: entry.path.join("\0").into(),
1605                        inode: 0,
1606                        mtime: entry.mtime,
1607                        is_ignored: entry.is_ignored,
1608                        is_external: false,
1609                        is_private: false,
1610                        git_status: entry.git_status,
1611                        canonical_path: entry.canonical_path.clone(),
1612                        is_symlink: entry.is_symlink,
1613                    });
1614                }
1615                if expanded_dir_ids.binary_search(&entry.id).is_err()
1616                    && entry_iter.advance_to_sibling()
1617                {
1618                    continue;
1619                }
1620                entry_iter.advance();
1621            }
1622
1623            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1624
1625            visible_worktree_entries.sort_by(|entry_a, entry_b| {
1626                let mut components_a = entry_a.path.components().peekable();
1627                let mut components_b = entry_b.path.components().peekable();
1628                loop {
1629                    match (components_a.next(), components_b.next()) {
1630                        (Some(component_a), Some(component_b)) => {
1631                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1632                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1633                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1634                                let maybe_numeric_ordering = maybe!({
1635                                    let num_and_remainder_a = Path::new(component_a.as_os_str())
1636                                        .file_stem()
1637                                        .and_then(|s| s.to_str())
1638                                        .and_then(
1639                                            NumericPrefixWithSuffix::from_numeric_prefixed_str,
1640                                        )?;
1641                                    let num_and_remainder_b = Path::new(component_b.as_os_str())
1642                                        .file_stem()
1643                                        .and_then(|s| s.to_str())
1644                                        .and_then(
1645                                            NumericPrefixWithSuffix::from_numeric_prefixed_str,
1646                                        )?;
1647
1648                                    num_and_remainder_a.partial_cmp(&num_and_remainder_b)
1649                                });
1650
1651                                maybe_numeric_ordering.unwrap_or_else(|| {
1652                                    let name_a =
1653                                        UniCase::new(component_a.as_os_str().to_string_lossy());
1654                                    let name_b =
1655                                        UniCase::new(component_b.as_os_str().to_string_lossy());
1656
1657                                    name_a.cmp(&name_b)
1658                                })
1659                            });
1660                            if !ordering.is_eq() {
1661                                return ordering;
1662                            }
1663                        }
1664                        (Some(_), None) => break Ordering::Greater,
1665                        (None, Some(_)) => break Ordering::Less,
1666                        (None, None) => break Ordering::Equal,
1667                    }
1668                }
1669            });
1670            self.visible_entries
1671                .push((worktree_id, visible_worktree_entries));
1672        }
1673
1674        if let Some((worktree_id, entry_id)) = new_selected_entry {
1675            self.selection = Some(SelectedEntry {
1676                worktree_id,
1677                entry_id,
1678            });
1679            if cx.modifiers().shift {
1680                self.marked_entries.insert(SelectedEntry {
1681                    worktree_id,
1682                    entry_id,
1683                });
1684            }
1685        }
1686    }
1687
1688    fn expand_entry(
1689        &mut self,
1690        worktree_id: WorktreeId,
1691        entry_id: ProjectEntryId,
1692        cx: &mut ViewContext<Self>,
1693    ) {
1694        self.project.update(cx, |project, cx| {
1695            if let Some((worktree, expanded_dir_ids)) = project
1696                .worktree_for_id(worktree_id, cx)
1697                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1698            {
1699                project.expand_entry(worktree_id, entry_id, cx);
1700                let worktree = worktree.read(cx);
1701
1702                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1703                    loop {
1704                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1705                            expanded_dir_ids.insert(ix, entry.id);
1706                        }
1707
1708                        if let Some(parent_entry) =
1709                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1710                        {
1711                            entry = parent_entry;
1712                        } else {
1713                            break;
1714                        }
1715                    }
1716                }
1717            }
1718        });
1719    }
1720
1721    fn drag_onto(
1722        &mut self,
1723        selections: &DraggedSelection,
1724        target_entry_id: ProjectEntryId,
1725        is_file: bool,
1726        cx: &mut ViewContext<Self>,
1727    ) {
1728        let should_copy = cx.modifiers().alt;
1729        if should_copy {
1730            let _ = maybe!({
1731                let project = self.project.read(cx);
1732                let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
1733                let target_entry = target_worktree
1734                    .read(cx)
1735                    .entry_for_id(target_entry_id)?
1736                    .clone();
1737                for selection in selections.items() {
1738                    let new_path = self.create_paste_path(
1739                        &selection,
1740                        (target_worktree.clone(), &target_entry),
1741                        cx,
1742                    )?;
1743                    self.project
1744                        .update(cx, |project, cx| {
1745                            project.copy_entry(selection.entry_id, new_path, cx)
1746                        })
1747                        .detach_and_log_err(cx)
1748                }
1749
1750                Some(())
1751            });
1752        } else {
1753            for selection in selections.items() {
1754                self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
1755            }
1756        }
1757    }
1758
1759    fn for_each_visible_entry(
1760        &self,
1761        range: Range<usize>,
1762        cx: &mut ViewContext<ProjectPanel>,
1763        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1764    ) {
1765        let mut ix = 0;
1766        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1767            if ix >= range.end {
1768                return;
1769            }
1770
1771            if ix + visible_worktree_entries.len() <= range.start {
1772                ix += visible_worktree_entries.len();
1773                continue;
1774            }
1775
1776            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1777            let (git_status_setting, show_file_icons, show_folder_icons) = {
1778                let settings = ProjectPanelSettings::get_global(cx);
1779                (
1780                    settings.git_status,
1781                    settings.file_icons,
1782                    settings.folder_icons,
1783                )
1784            };
1785            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1786                let snapshot = worktree.read(cx).snapshot();
1787                let root_name = OsStr::new(snapshot.root_name());
1788                let expanded_entry_ids = self
1789                    .expanded_dir_ids
1790                    .get(&snapshot.id())
1791                    .map(Vec::as_slice)
1792                    .unwrap_or(&[]);
1793
1794                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1795                for entry in visible_worktree_entries[entry_range].iter() {
1796                    let status = git_status_setting.then(|| entry.git_status).flatten();
1797                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1798                    let icon = match entry.kind {
1799                        EntryKind::File(_) => {
1800                            if show_file_icons {
1801                                FileIcons::get_icon(&entry.path, cx)
1802                            } else {
1803                                None
1804                            }
1805                        }
1806                        _ => {
1807                            if show_folder_icons {
1808                                FileIcons::get_folder_icon(is_expanded, cx)
1809                            } else {
1810                                FileIcons::get_chevron_icon(is_expanded, cx)
1811                            }
1812                        }
1813                    };
1814
1815                    let (depth, difference) = ProjectPanel::calculate_depth_and_difference(
1816                        entry,
1817                        visible_worktree_entries,
1818                    );
1819
1820                    let filename = match difference {
1821                        diff if diff > 1 => entry
1822                            .path
1823                            .iter()
1824                            .skip(entry.path.components().count() - diff)
1825                            .collect::<PathBuf>()
1826                            .to_str()
1827                            .unwrap_or_default()
1828                            .to_string(),
1829                        _ => entry
1830                            .path
1831                            .file_name()
1832                            .map(|name| name.to_string_lossy().into_owned())
1833                            .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
1834                    };
1835                    let selection = SelectedEntry {
1836                        worktree_id: snapshot.id(),
1837                        entry_id: entry.id,
1838                    };
1839                    let mut details = EntryDetails {
1840                        filename,
1841                        icon,
1842                        path: entry.path.clone(),
1843                        depth,
1844                        kind: entry.kind,
1845                        is_ignored: entry.is_ignored,
1846                        is_expanded,
1847                        is_selected: self.selection == Some(selection),
1848                        is_marked: self.marked_entries.contains(&selection),
1849                        is_editing: false,
1850                        is_processing: false,
1851                        is_cut: self
1852                            .clipboard
1853                            .as_ref()
1854                            .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
1855                        git_status: status,
1856                        is_private: entry.is_private,
1857                        worktree_id: *worktree_id,
1858                        canonical_path: entry.canonical_path.clone(),
1859                    };
1860
1861                    if let Some(edit_state) = &self.edit_state {
1862                        let is_edited_entry = if edit_state.is_new_entry {
1863                            entry.id == NEW_ENTRY_ID
1864                        } else {
1865                            entry.id == edit_state.entry_id
1866                        };
1867
1868                        if is_edited_entry {
1869                            if let Some(processing_filename) = &edit_state.processing_filename {
1870                                details.is_processing = true;
1871                                details.filename.clear();
1872                                details.filename.push_str(processing_filename);
1873                            } else {
1874                                if edit_state.is_new_entry {
1875                                    details.filename.clear();
1876                                }
1877                                details.is_editing = true;
1878                            }
1879                        }
1880                    }
1881
1882                    callback(entry.id, details, cx);
1883                }
1884            }
1885            ix = end_ix;
1886        }
1887    }
1888
1889    fn calculate_depth_and_difference(
1890        entry: &Entry,
1891        visible_worktree_entries: &Vec<Entry>,
1892    ) -> (usize, usize) {
1893        let visible_worktree_paths: HashSet<Arc<Path>> = visible_worktree_entries
1894            .iter()
1895            .map(|e| e.path.clone())
1896            .collect();
1897
1898        let (depth, difference) = entry
1899            .path
1900            .ancestors()
1901            .skip(1) // Skip the entry itself
1902            .find_map(|ancestor| {
1903                if visible_worktree_paths.contains(ancestor) {
1904                    let parent_entry = visible_worktree_entries
1905                        .iter()
1906                        .find(|&e| &*e.path == ancestor)
1907                        .unwrap();
1908
1909                    let entry_path_components_count = entry.path.components().count();
1910                    let parent_path_components_count = parent_entry.path.components().count();
1911                    let difference = entry_path_components_count - parent_path_components_count;
1912                    let depth = parent_entry
1913                        .path
1914                        .ancestors()
1915                        .skip(1)
1916                        .filter(|ancestor| visible_worktree_paths.contains(*ancestor))
1917                        .count();
1918                    Some((depth + 1, difference))
1919                } else {
1920                    None
1921                }
1922            })
1923            .unwrap_or((0, 0));
1924
1925        (depth, difference)
1926    }
1927
1928    fn render_entry(
1929        &self,
1930        entry_id: ProjectEntryId,
1931        details: EntryDetails,
1932        cx: &mut ViewContext<Self>,
1933    ) -> Stateful<Div> {
1934        let kind = details.kind;
1935        let settings = ProjectPanelSettings::get_global(cx);
1936        let show_editor = details.is_editing && !details.is_processing;
1937        let selection = SelectedEntry {
1938            worktree_id: details.worktree_id,
1939            entry_id,
1940        };
1941        let is_marked = self.marked_entries.contains(&selection);
1942        let is_active = self
1943            .selection
1944            .map_or(false, |selection| selection.entry_id == entry_id);
1945        let width = self.size(cx);
1946        let filename_text_color =
1947            entry_git_aware_label_color(details.git_status, details.is_ignored, is_marked);
1948        let file_name = details.filename.clone();
1949        let mut icon = details.icon.clone();
1950        if settings.file_icons && show_editor && details.kind.is_file() {
1951            let filename = self.filename_editor.read(cx).text(cx);
1952            if filename.len() > 2 {
1953                icon = FileIcons::get_icon(Path::new(&filename), cx);
1954            }
1955        }
1956
1957        let canonical_path = details
1958            .canonical_path
1959            .as_ref()
1960            .map(|f| f.to_string_lossy().to_string());
1961
1962        let depth = details.depth;
1963        let worktree_id = details.worktree_id;
1964        let selections = Arc::new(self.marked_entries.clone());
1965
1966        let dragged_selection = DraggedSelection {
1967            active_selection: selection,
1968            marked_selections: selections,
1969        };
1970        div()
1971            .id(entry_id.to_proto() as usize)
1972            .on_drag(dragged_selection, move |selection, cx| {
1973                cx.new_view(|_| DraggedProjectEntryView {
1974                    details: details.clone(),
1975                    width,
1976                    selection: selection.active_selection,
1977                    selections: selection.marked_selections.clone(),
1978                })
1979            })
1980            .drag_over::<DraggedSelection>(|style, _, cx| {
1981                style.bg(cx.theme().colors().drop_target_background)
1982            })
1983            .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
1984                this.drag_onto(selections, entry_id, kind.is_file(), cx);
1985            }))
1986            .child(
1987                ListItem::new(entry_id.to_proto() as usize)
1988                    .indent_level(depth)
1989                    .indent_step_size(px(settings.indent_size))
1990                    .selected(is_marked || is_active)
1991                    .when_some(canonical_path, |this, path| {
1992                        this.end_slot::<AnyElement>(
1993                            div()
1994                                .id("symlink_icon")
1995                                .tooltip(move |cx| {
1996                                    Tooltip::text(format!("{path} • Symbolic Link"), cx)
1997                                })
1998                                .child(
1999                                    Icon::new(IconName::ArrowUpRight)
2000                                        .size(IconSize::Indicator)
2001                                        .color(filename_text_color),
2002                                )
2003                                .into_any_element(),
2004                        )
2005                    })
2006                    .child(if let Some(icon) = &icon {
2007                        h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
2008                    } else {
2009                        h_flex()
2010                            .size(IconSize::default().rems())
2011                            .invisible()
2012                            .flex_none()
2013                    })
2014                    .child(
2015                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
2016                            h_flex().h_6().w_full().child(editor.clone())
2017                        } else {
2018                            h_flex().h_6().child(
2019                                Label::new(file_name)
2020                                    .single_line()
2021                                    .color(filename_text_color),
2022                            )
2023                        }
2024                        .ml_1(),
2025                    )
2026                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
2027                        if event.down.button == MouseButton::Right || event.down.first_mouse {
2028                            return;
2029                        }
2030                        if !show_editor {
2031                            if let Some(selection) =
2032                                this.selection.filter(|_| event.down.modifiers.shift)
2033                            {
2034                                let current_selection = this.index_for_selection(selection);
2035                                let target_selection = this.index_for_selection(SelectedEntry {
2036                                    entry_id,
2037                                    worktree_id,
2038                                });
2039                                if let Some(((_, _, source_index), (_, _, target_index))) =
2040                                    current_selection.zip(target_selection)
2041                                {
2042                                    let range_start = source_index.min(target_index);
2043                                    let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
2044                                    let mut new_selections = BTreeSet::new();
2045                                    this.for_each_visible_entry(
2046                                        range_start..range_end,
2047                                        cx,
2048                                        |entry_id, details, _| {
2049                                            new_selections.insert(SelectedEntry {
2050                                                entry_id,
2051                                                worktree_id: details.worktree_id,
2052                                            });
2053                                        },
2054                                    );
2055
2056                                    this.marked_entries = this
2057                                        .marked_entries
2058                                        .union(&new_selections)
2059                                        .cloned()
2060                                        .collect();
2061
2062                                    this.selection = Some(SelectedEntry {
2063                                        entry_id,
2064                                        worktree_id,
2065                                    });
2066                                    // Ensure that the current entry is selected.
2067                                    this.marked_entries.insert(SelectedEntry {
2068                                        entry_id,
2069                                        worktree_id,
2070                                    });
2071                                }
2072                            } else if event.down.modifiers.secondary() {
2073                                if !this.marked_entries.insert(selection) {
2074                                    this.marked_entries.remove(&selection);
2075                                }
2076                            } else if kind.is_dir() {
2077                                this.toggle_expanded(entry_id, cx);
2078                            } else {
2079                                let click_count = event.up.click_count;
2080                                if click_count > 1 && event.down.modifiers.secondary() {
2081                                    this.split_entry(entry_id, cx);
2082                                } else {
2083                                    this.open_entry(
2084                                        entry_id,
2085                                        cx.modifiers().secondary(),
2086                                        click_count > 1,
2087                                        click_count == 1,
2088                                        cx,
2089                                    );
2090                                }
2091                            }
2092                        }
2093                    }))
2094                    .on_secondary_mouse_down(cx.listener(
2095                        move |this, event: &MouseDownEvent, cx| {
2096                            // Stop propagation to prevent the catch-all context menu for the project
2097                            // panel from being deployed.
2098                            cx.stop_propagation();
2099                            this.deploy_context_menu(event.position, entry_id, cx);
2100                        },
2101                    )),
2102            )
2103            .border_1()
2104            .border_r_2()
2105            .rounded_none()
2106            .hover(|style| {
2107                if is_active {
2108                    style
2109                } else {
2110                    let hover_color = cx.theme().colors().ghost_element_hover;
2111                    style.bg(hover_color).border_color(hover_color)
2112                }
2113            })
2114            .when(is_marked || is_active, |this| {
2115                let colors = cx.theme().colors();
2116                this.when(is_marked, |this| this.bg(colors.ghost_element_selected))
2117                    .border_color(colors.ghost_element_selected)
2118            })
2119            .when(
2120                is_active && self.focus_handle.contains_focused(cx),
2121                |this| this.border_color(Color::Selected.color(cx)),
2122            )
2123    }
2124
2125    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
2126        let mut dispatch_context = KeyContext::new_with_defaults();
2127        dispatch_context.add("ProjectPanel");
2128        dispatch_context.add("menu");
2129
2130        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
2131            "editing"
2132        } else {
2133            "not_editing"
2134        };
2135
2136        dispatch_context.add(identifier);
2137        dispatch_context
2138    }
2139
2140    fn reveal_entry(
2141        &mut self,
2142        project: Model<Project>,
2143        entry_id: ProjectEntryId,
2144        skip_ignored: bool,
2145        cx: &mut ViewContext<'_, ProjectPanel>,
2146    ) {
2147        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
2148            let worktree = worktree.read(cx);
2149            if skip_ignored
2150                && worktree
2151                    .entry_for_id(entry_id)
2152                    .map_or(true, |entry| entry.is_ignored)
2153            {
2154                return;
2155            }
2156
2157            let worktree_id = worktree.id();
2158            self.marked_entries.clear();
2159            self.expand_entry(worktree_id, entry_id, cx);
2160            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
2161            self.autoscroll(cx);
2162            cx.notify();
2163        }
2164    }
2165}
2166
2167impl Render for ProjectPanel {
2168    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
2169        let has_worktree = self.visible_entries.len() != 0;
2170        let project = self.project.read(cx);
2171
2172        if has_worktree {
2173            h_flex()
2174                .id("project-panel")
2175                .size_full()
2176                .relative()
2177                .key_context(self.dispatch_context(cx))
2178                .on_action(cx.listener(Self::select_next))
2179                .on_action(cx.listener(Self::select_prev))
2180                .on_action(cx.listener(Self::select_first))
2181                .on_action(cx.listener(Self::select_last))
2182                .on_action(cx.listener(Self::select_parent))
2183                .on_action(cx.listener(Self::expand_selected_entry))
2184                .on_action(cx.listener(Self::collapse_selected_entry))
2185                .on_action(cx.listener(Self::collapse_all_entries))
2186                .on_action(cx.listener(Self::open))
2187                .on_action(cx.listener(Self::open_permanent))
2188                .on_action(cx.listener(Self::confirm))
2189                .on_action(cx.listener(Self::cancel))
2190                .on_action(cx.listener(Self::copy_path))
2191                .on_action(cx.listener(Self::copy_relative_path))
2192                .on_action(cx.listener(Self::new_search_in_directory))
2193                .on_action(cx.listener(Self::unfold_directory))
2194                .on_action(cx.listener(Self::fold_directory))
2195                .when(!project.is_read_only(), |el| {
2196                    el.on_action(cx.listener(Self::new_file))
2197                        .on_action(cx.listener(Self::new_directory))
2198                        .on_action(cx.listener(Self::rename))
2199                        .on_action(cx.listener(Self::delete))
2200                        .on_action(cx.listener(Self::trash))
2201                        .on_action(cx.listener(Self::cut))
2202                        .on_action(cx.listener(Self::copy))
2203                        .on_action(cx.listener(Self::paste))
2204                        .on_action(cx.listener(Self::duplicate))
2205                })
2206                .when(project.is_local(), |el| {
2207                    el.on_action(cx.listener(Self::reveal_in_finder))
2208                        .on_action(cx.listener(Self::open_in_terminal))
2209                })
2210                .on_mouse_down(
2211                    MouseButton::Right,
2212                    cx.listener(move |this, event: &MouseDownEvent, cx| {
2213                        // When deploying the context menu anywhere below the last project entry,
2214                        // act as if the user clicked the root of the last worktree.
2215                        if let Some(entry_id) = this.last_worktree_root_id {
2216                            this.deploy_context_menu(event.position, entry_id, cx);
2217                        }
2218                    }),
2219                )
2220                .track_focus(&self.focus_handle)
2221                .child(
2222                    uniform_list(
2223                        cx.view().clone(),
2224                        "entries",
2225                        self.visible_entries
2226                            .iter()
2227                            .map(|(_, worktree_entries)| worktree_entries.len())
2228                            .sum(),
2229                        {
2230                            |this, range, cx| {
2231                                let mut items = Vec::new();
2232                                this.for_each_visible_entry(range, cx, |id, details, cx| {
2233                                    items.push(this.render_entry(id, details, cx));
2234                                });
2235                                items
2236                            }
2237                        },
2238                    )
2239                    .size_full()
2240                    .with_sizing_behavior(ListSizingBehavior::Infer)
2241                    .track_scroll(self.scroll_handle.clone()),
2242                )
2243                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2244                    deferred(
2245                        anchored()
2246                            .position(*position)
2247                            .anchor(gpui::AnchorCorner::TopLeft)
2248                            .child(menu.clone()),
2249                    )
2250                    .with_priority(1)
2251                }))
2252        } else {
2253            v_flex()
2254                .id("empty-project_panel")
2255                .size_full()
2256                .p_4()
2257                .track_focus(&self.focus_handle)
2258                .child(
2259                    Button::new("open_project", "Open a project")
2260                        .style(ButtonStyle::Filled)
2261                        .full_width()
2262                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
2263                        .on_click(cx.listener(|this, _, cx| {
2264                            this.workspace
2265                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
2266                                .log_err();
2267                        })),
2268                )
2269        }
2270    }
2271}
2272
2273impl Render for DraggedProjectEntryView {
2274    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2275        let settings = ProjectPanelSettings::get_global(cx);
2276        let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
2277        h_flex().font(ui_font).map(|this| {
2278            if self.selections.contains(&self.selection) {
2279                this.flex_shrink()
2280                    .p_1()
2281                    .items_end()
2282                    .rounded_md()
2283                    .child(self.selections.len().to_string())
2284            } else {
2285                this.bg(cx.theme().colors().background).w(self.width).child(
2286                    ListItem::new(self.selection.entry_id.to_proto() as usize)
2287                        .indent_level(self.details.depth)
2288                        .indent_step_size(px(settings.indent_size))
2289                        .child(if let Some(icon) = &self.details.icon {
2290                            div().child(Icon::from_path(icon.to_string()))
2291                        } else {
2292                            div()
2293                        })
2294                        .child(Label::new(self.details.filename.clone())),
2295                )
2296            }
2297        })
2298    }
2299}
2300
2301impl EventEmitter<Event> for ProjectPanel {}
2302
2303impl EventEmitter<PanelEvent> for ProjectPanel {}
2304
2305impl Panel for ProjectPanel {
2306    fn position(&self, cx: &WindowContext) -> DockPosition {
2307        match ProjectPanelSettings::get_global(cx).dock {
2308            ProjectPanelDockPosition::Left => DockPosition::Left,
2309            ProjectPanelDockPosition::Right => DockPosition::Right,
2310        }
2311    }
2312
2313    fn position_is_valid(&self, position: DockPosition) -> bool {
2314        matches!(position, DockPosition::Left | DockPosition::Right)
2315    }
2316
2317    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2318        settings::update_settings_file::<ProjectPanelSettings>(
2319            self.fs.clone(),
2320            cx,
2321            move |settings| {
2322                let dock = match position {
2323                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
2324                    DockPosition::Right => ProjectPanelDockPosition::Right,
2325                };
2326                settings.dock = Some(dock);
2327            },
2328        );
2329    }
2330
2331    fn size(&self, cx: &WindowContext) -> Pixels {
2332        self.width
2333            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
2334    }
2335
2336    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2337        self.width = size;
2338        self.serialize(cx);
2339        cx.notify();
2340    }
2341
2342    fn icon(&self, cx: &WindowContext) -> Option<IconName> {
2343        ProjectPanelSettings::get_global(cx)
2344            .button
2345            .then(|| IconName::FileTree)
2346    }
2347
2348    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2349        Some("Project Panel")
2350    }
2351
2352    fn toggle_action(&self) -> Box<dyn Action> {
2353        Box::new(ToggleFocus)
2354    }
2355
2356    fn persistent_name() -> &'static str {
2357        "Project Panel"
2358    }
2359
2360    fn starts_open(&self, cx: &WindowContext) -> bool {
2361        let project = &self.project.read(cx);
2362        project.dev_server_project_id().is_some()
2363            || project.visible_worktrees(cx).any(|tree| {
2364                tree.read(cx)
2365                    .root_entry()
2366                    .map_or(false, |entry| entry.is_dir())
2367            })
2368    }
2369}
2370
2371impl FocusableView for ProjectPanel {
2372    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
2373        self.focus_handle.clone()
2374    }
2375}
2376
2377impl ClipboardEntry {
2378    fn is_cut(&self) -> bool {
2379        matches!(self, Self::Cut { .. })
2380    }
2381
2382    fn items(&self) -> &BTreeSet<SelectedEntry> {
2383        match self {
2384            ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
2385        }
2386    }
2387}
2388
2389#[cfg(test)]
2390mod tests {
2391    use super::*;
2392    use collections::HashSet;
2393    use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
2394    use pretty_assertions::assert_eq;
2395    use project::{FakeFs, WorktreeSettings};
2396    use serde_json::json;
2397    use settings::SettingsStore;
2398    use std::path::{Path, PathBuf};
2399    use workspace::{
2400        item::{Item, ProjectItem},
2401        register_project_item, AppState,
2402    };
2403
2404    #[gpui::test]
2405    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
2406        init_test(cx);
2407
2408        let fs = FakeFs::new(cx.executor().clone());
2409        fs.insert_tree(
2410            "/root1",
2411            json!({
2412                ".dockerignore": "",
2413                ".git": {
2414                    "HEAD": "",
2415                },
2416                "a": {
2417                    "0": { "q": "", "r": "", "s": "" },
2418                    "1": { "t": "", "u": "" },
2419                    "2": { "v": "", "w": "", "x": "", "y": "" },
2420                },
2421                "b": {
2422                    "3": { "Q": "" },
2423                    "4": { "R": "", "S": "", "T": "", "U": "" },
2424                },
2425                "C": {
2426                    "5": {},
2427                    "6": { "V": "", "W": "" },
2428                    "7": { "X": "" },
2429                    "8": { "Y": {}, "Z": "" }
2430                }
2431            }),
2432        )
2433        .await;
2434        fs.insert_tree(
2435            "/root2",
2436            json!({
2437                "d": {
2438                    "9": ""
2439                },
2440                "e": {}
2441            }),
2442        )
2443        .await;
2444
2445        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2446        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2447        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2448        let panel = workspace
2449            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2450            .unwrap();
2451        assert_eq!(
2452            visible_entries_as_strings(&panel, 0..50, cx),
2453            &[
2454                "v root1",
2455                "    > .git",
2456                "    > a",
2457                "    > b",
2458                "    > C",
2459                "      .dockerignore",
2460                "v root2",
2461                "    > d",
2462                "    > e",
2463            ]
2464        );
2465
2466        toggle_expand_dir(&panel, "root1/b", cx);
2467        assert_eq!(
2468            visible_entries_as_strings(&panel, 0..50, cx),
2469            &[
2470                "v root1",
2471                "    > .git",
2472                "    > a",
2473                "    v b  <== selected",
2474                "        > 3",
2475                "        > 4",
2476                "    > C",
2477                "      .dockerignore",
2478                "v root2",
2479                "    > d",
2480                "    > e",
2481            ]
2482        );
2483
2484        assert_eq!(
2485            visible_entries_as_strings(&panel, 6..9, cx),
2486            &[
2487                //
2488                "    > C",
2489                "      .dockerignore",
2490                "v root2",
2491            ]
2492        );
2493    }
2494
2495    #[gpui::test]
2496    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
2497        init_test(cx);
2498        cx.update(|cx| {
2499            cx.update_global::<SettingsStore, _>(|store, cx| {
2500                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
2501                    worktree_settings.file_scan_exclusions =
2502                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
2503                });
2504            });
2505        });
2506
2507        let fs = FakeFs::new(cx.background_executor.clone());
2508        fs.insert_tree(
2509            "/root1",
2510            json!({
2511                ".dockerignore": "",
2512                ".git": {
2513                    "HEAD": "",
2514                },
2515                "a": {
2516                    "0": { "q": "", "r": "", "s": "" },
2517                    "1": { "t": "", "u": "" },
2518                    "2": { "v": "", "w": "", "x": "", "y": "" },
2519                },
2520                "b": {
2521                    "3": { "Q": "" },
2522                    "4": { "R": "", "S": "", "T": "", "U": "" },
2523                },
2524                "C": {
2525                    "5": {},
2526                    "6": { "V": "", "W": "" },
2527                    "7": { "X": "" },
2528                    "8": { "Y": {}, "Z": "" }
2529                }
2530            }),
2531        )
2532        .await;
2533        fs.insert_tree(
2534            "/root2",
2535            json!({
2536                "d": {
2537                    "4": ""
2538                },
2539                "e": {}
2540            }),
2541        )
2542        .await;
2543
2544        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2545        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2546        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2547        let panel = workspace
2548            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2549            .unwrap();
2550        assert_eq!(
2551            visible_entries_as_strings(&panel, 0..50, cx),
2552            &[
2553                "v root1",
2554                "    > a",
2555                "    > b",
2556                "    > C",
2557                "      .dockerignore",
2558                "v root2",
2559                "    > d",
2560                "    > e",
2561            ]
2562        );
2563
2564        toggle_expand_dir(&panel, "root1/b", cx);
2565        assert_eq!(
2566            visible_entries_as_strings(&panel, 0..50, cx),
2567            &[
2568                "v root1",
2569                "    > a",
2570                "    v b  <== selected",
2571                "        > 3",
2572                "    > C",
2573                "      .dockerignore",
2574                "v root2",
2575                "    > d",
2576                "    > e",
2577            ]
2578        );
2579
2580        toggle_expand_dir(&panel, "root2/d", cx);
2581        assert_eq!(
2582            visible_entries_as_strings(&panel, 0..50, cx),
2583            &[
2584                "v root1",
2585                "    > a",
2586                "    v b",
2587                "        > 3",
2588                "    > C",
2589                "      .dockerignore",
2590                "v root2",
2591                "    v d  <== selected",
2592                "    > e",
2593            ]
2594        );
2595
2596        toggle_expand_dir(&panel, "root2/e", cx);
2597        assert_eq!(
2598            visible_entries_as_strings(&panel, 0..50, cx),
2599            &[
2600                "v root1",
2601                "    > a",
2602                "    v b",
2603                "        > 3",
2604                "    > C",
2605                "      .dockerignore",
2606                "v root2",
2607                "    v d",
2608                "    v e  <== selected",
2609            ]
2610        );
2611    }
2612
2613    #[gpui::test]
2614    async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
2615        init_test(cx);
2616
2617        let fs = FakeFs::new(cx.executor().clone());
2618        fs.insert_tree(
2619            "/root1",
2620            json!({
2621                "dir_1": {
2622                    "nested_dir_1": {
2623                        "nested_dir_2": {
2624                            "nested_dir_3": {
2625                                "file_a.java": "// File contents",
2626                                "file_b.java": "// File contents",
2627                                "file_c.java": "// File contents",
2628                                "nested_dir_4": {
2629                                    "nested_dir_5": {
2630                                        "file_d.java": "// File contents",
2631                                    }
2632                                }
2633                            }
2634                        }
2635                    }
2636                }
2637            }),
2638        )
2639        .await;
2640        fs.insert_tree(
2641            "/root2",
2642            json!({
2643                "dir_2": {
2644                    "file_1.java": "// File contents",
2645                }
2646            }),
2647        )
2648        .await;
2649
2650        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2651        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2652        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2653        cx.update(|cx| {
2654            let settings = *ProjectPanelSettings::get_global(cx);
2655            ProjectPanelSettings::override_global(
2656                ProjectPanelSettings {
2657                    auto_fold_dirs: true,
2658                    ..settings
2659                },
2660                cx,
2661            );
2662        });
2663        let panel = workspace
2664            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2665            .unwrap();
2666        assert_eq!(
2667            visible_entries_as_strings(&panel, 0..10, cx),
2668            &[
2669                "v root1",
2670                "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2671                "v root2",
2672                "    > dir_2",
2673            ]
2674        );
2675
2676        toggle_expand_dir(
2677            &panel,
2678            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2679            cx,
2680        );
2681        assert_eq!(
2682            visible_entries_as_strings(&panel, 0..10, cx),
2683            &[
2684                "v root1",
2685                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
2686                "        > nested_dir_4/nested_dir_5",
2687                "          file_a.java",
2688                "          file_b.java",
2689                "          file_c.java",
2690                "v root2",
2691                "    > dir_2",
2692            ]
2693        );
2694
2695        toggle_expand_dir(
2696            &panel,
2697            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
2698            cx,
2699        );
2700        assert_eq!(
2701            visible_entries_as_strings(&panel, 0..10, cx),
2702            &[
2703                "v root1",
2704                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2705                "        v nested_dir_4/nested_dir_5  <== selected",
2706                "              file_d.java",
2707                "          file_a.java",
2708                "          file_b.java",
2709                "          file_c.java",
2710                "v root2",
2711                "    > dir_2",
2712            ]
2713        );
2714        toggle_expand_dir(&panel, "root2/dir_2", cx);
2715        assert_eq!(
2716            visible_entries_as_strings(&panel, 0..10, cx),
2717            &[
2718                "v root1",
2719                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2720                "        v nested_dir_4/nested_dir_5",
2721                "              file_d.java",
2722                "          file_a.java",
2723                "          file_b.java",
2724                "          file_c.java",
2725                "v root2",
2726                "    v dir_2  <== selected",
2727                "          file_1.java",
2728            ]
2729        );
2730    }
2731
2732    #[gpui::test(iterations = 30)]
2733    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
2734        init_test(cx);
2735
2736        let fs = FakeFs::new(cx.executor().clone());
2737        fs.insert_tree(
2738            "/root1",
2739            json!({
2740                ".dockerignore": "",
2741                ".git": {
2742                    "HEAD": "",
2743                },
2744                "a": {
2745                    "0": { "q": "", "r": "", "s": "" },
2746                    "1": { "t": "", "u": "" },
2747                    "2": { "v": "", "w": "", "x": "", "y": "" },
2748                },
2749                "b": {
2750                    "3": { "Q": "" },
2751                    "4": { "R": "", "S": "", "T": "", "U": "" },
2752                },
2753                "C": {
2754                    "5": {},
2755                    "6": { "V": "", "W": "" },
2756                    "7": { "X": "" },
2757                    "8": { "Y": {}, "Z": "" }
2758                }
2759            }),
2760        )
2761        .await;
2762        fs.insert_tree(
2763            "/root2",
2764            json!({
2765                "d": {
2766                    "9": ""
2767                },
2768                "e": {}
2769            }),
2770        )
2771        .await;
2772
2773        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2774        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2775        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2776        let panel = workspace
2777            .update(cx, |workspace, cx| {
2778                let panel = ProjectPanel::new(workspace, cx);
2779                workspace.add_panel(panel.clone(), cx);
2780                panel
2781            })
2782            .unwrap();
2783
2784        select_path(&panel, "root1", cx);
2785        assert_eq!(
2786            visible_entries_as_strings(&panel, 0..10, cx),
2787            &[
2788                "v root1  <== selected",
2789                "    > .git",
2790                "    > a",
2791                "    > b",
2792                "    > C",
2793                "      .dockerignore",
2794                "v root2",
2795                "    > d",
2796                "    > e",
2797            ]
2798        );
2799
2800        // Add a file with the root folder selected. The filename editor is placed
2801        // before the first file in the root folder.
2802        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2803        panel.update(cx, |panel, cx| {
2804            assert!(panel.filename_editor.read(cx).is_focused(cx));
2805        });
2806        assert_eq!(
2807            visible_entries_as_strings(&panel, 0..10, cx),
2808            &[
2809                "v root1",
2810                "    > .git",
2811                "    > a",
2812                "    > b",
2813                "    > C",
2814                "      [EDITOR: '']  <== selected",
2815                "      .dockerignore",
2816                "v root2",
2817                "    > d",
2818                "    > e",
2819            ]
2820        );
2821
2822        let confirm = panel.update(cx, |panel, cx| {
2823            panel
2824                .filename_editor
2825                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2826            panel.confirm_edit(cx).unwrap()
2827        });
2828        assert_eq!(
2829            visible_entries_as_strings(&panel, 0..10, cx),
2830            &[
2831                "v root1",
2832                "    > .git",
2833                "    > a",
2834                "    > b",
2835                "    > C",
2836                "      [PROCESSING: 'the-new-filename']  <== selected",
2837                "      .dockerignore",
2838                "v root2",
2839                "    > d",
2840                "    > e",
2841            ]
2842        );
2843
2844        confirm.await.unwrap();
2845        assert_eq!(
2846            visible_entries_as_strings(&panel, 0..10, cx),
2847            &[
2848                "v root1",
2849                "    > .git",
2850                "    > a",
2851                "    > b",
2852                "    > C",
2853                "      .dockerignore",
2854                "      the-new-filename  <== selected  <== marked",
2855                "v root2",
2856                "    > d",
2857                "    > e",
2858            ]
2859        );
2860
2861        select_path(&panel, "root1/b", cx);
2862        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2863        assert_eq!(
2864            visible_entries_as_strings(&panel, 0..10, cx),
2865            &[
2866                "v root1",
2867                "    > .git",
2868                "    > a",
2869                "    v b",
2870                "        > 3",
2871                "        > 4",
2872                "          [EDITOR: '']  <== selected",
2873                "    > C",
2874                "      .dockerignore",
2875                "      the-new-filename",
2876            ]
2877        );
2878
2879        panel
2880            .update(cx, |panel, cx| {
2881                panel
2882                    .filename_editor
2883                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2884                panel.confirm_edit(cx).unwrap()
2885            })
2886            .await
2887            .unwrap();
2888        assert_eq!(
2889            visible_entries_as_strings(&panel, 0..10, cx),
2890            &[
2891                "v root1",
2892                "    > .git",
2893                "    > a",
2894                "    v b",
2895                "        > 3",
2896                "        > 4",
2897                "          another-filename.txt  <== selected  <== marked",
2898                "    > C",
2899                "      .dockerignore",
2900                "      the-new-filename",
2901            ]
2902        );
2903
2904        select_path(&panel, "root1/b/another-filename.txt", cx);
2905        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2906        assert_eq!(
2907            visible_entries_as_strings(&panel, 0..10, cx),
2908            &[
2909                "v root1",
2910                "    > .git",
2911                "    > a",
2912                "    v b",
2913                "        > 3",
2914                "        > 4",
2915                "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
2916                "    > C",
2917                "      .dockerignore",
2918                "      the-new-filename",
2919            ]
2920        );
2921
2922        let confirm = panel.update(cx, |panel, cx| {
2923            panel.filename_editor.update(cx, |editor, cx| {
2924                let file_name_selections = editor.selections.all::<usize>(cx);
2925                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2926                let file_name_selection = &file_name_selections[0];
2927                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2928                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2929
2930                editor.set_text("a-different-filename.tar.gz", cx)
2931            });
2932            panel.confirm_edit(cx).unwrap()
2933        });
2934        assert_eq!(
2935            visible_entries_as_strings(&panel, 0..10, cx),
2936            &[
2937                "v root1",
2938                "    > .git",
2939                "    > a",
2940                "    v b",
2941                "        > 3",
2942                "        > 4",
2943                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
2944                "    > C",
2945                "      .dockerignore",
2946                "      the-new-filename",
2947            ]
2948        );
2949
2950        confirm.await.unwrap();
2951        assert_eq!(
2952            visible_entries_as_strings(&panel, 0..10, cx),
2953            &[
2954                "v root1",
2955                "    > .git",
2956                "    > a",
2957                "    v b",
2958                "        > 3",
2959                "        > 4",
2960                "          a-different-filename.tar.gz  <== selected",
2961                "    > C",
2962                "      .dockerignore",
2963                "      the-new-filename",
2964            ]
2965        );
2966
2967        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2968        assert_eq!(
2969            visible_entries_as_strings(&panel, 0..10, cx),
2970            &[
2971                "v root1",
2972                "    > .git",
2973                "    > a",
2974                "    v b",
2975                "        > 3",
2976                "        > 4",
2977                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2978                "    > C",
2979                "      .dockerignore",
2980                "      the-new-filename",
2981            ]
2982        );
2983
2984        panel.update(cx, |panel, cx| {
2985            panel.filename_editor.update(cx, |editor, cx| {
2986                let file_name_selections = editor.selections.all::<usize>(cx);
2987                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2988                let file_name_selection = &file_name_selections[0];
2989                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2990                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..");
2991
2992            });
2993            panel.cancel(&menu::Cancel, cx)
2994        });
2995
2996        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2997        assert_eq!(
2998            visible_entries_as_strings(&panel, 0..10, cx),
2999            &[
3000                "v root1",
3001                "    > .git",
3002                "    > a",
3003                "    v b",
3004                "        > [EDITOR: '']  <== selected",
3005                "        > 3",
3006                "        > 4",
3007                "          a-different-filename.tar.gz",
3008                "    > C",
3009                "      .dockerignore",
3010            ]
3011        );
3012
3013        let confirm = panel.update(cx, |panel, cx| {
3014            panel
3015                .filename_editor
3016                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
3017            panel.confirm_edit(cx).unwrap()
3018        });
3019        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
3020        assert_eq!(
3021            visible_entries_as_strings(&panel, 0..10, cx),
3022            &[
3023                "v root1",
3024                "    > .git",
3025                "    > a",
3026                "    v b",
3027                "        > [PROCESSING: 'new-dir']",
3028                "        > 3  <== selected",
3029                "        > 4",
3030                "          a-different-filename.tar.gz",
3031                "    > C",
3032                "      .dockerignore",
3033            ]
3034        );
3035
3036        confirm.await.unwrap();
3037        assert_eq!(
3038            visible_entries_as_strings(&panel, 0..10, cx),
3039            &[
3040                "v root1",
3041                "    > .git",
3042                "    > a",
3043                "    v b",
3044                "        > 3  <== selected",
3045                "        > 4",
3046                "        > new-dir",
3047                "          a-different-filename.tar.gz",
3048                "    > C",
3049                "      .dockerignore",
3050            ]
3051        );
3052
3053        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
3054        assert_eq!(
3055            visible_entries_as_strings(&panel, 0..10, cx),
3056            &[
3057                "v root1",
3058                "    > .git",
3059                "    > a",
3060                "    v b",
3061                "        > [EDITOR: '3']  <== selected",
3062                "        > 4",
3063                "        > new-dir",
3064                "          a-different-filename.tar.gz",
3065                "    > C",
3066                "      .dockerignore",
3067            ]
3068        );
3069
3070        // Dismiss the rename editor when it loses focus.
3071        workspace.update(cx, |_, cx| cx.blur()).unwrap();
3072        assert_eq!(
3073            visible_entries_as_strings(&panel, 0..10, cx),
3074            &[
3075                "v root1",
3076                "    > .git",
3077                "    > a",
3078                "    v b",
3079                "        > 3  <== selected",
3080                "        > 4",
3081                "        > new-dir",
3082                "          a-different-filename.tar.gz",
3083                "    > C",
3084                "      .dockerignore",
3085            ]
3086        );
3087    }
3088
3089    #[gpui::test(iterations = 10)]
3090    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
3091        init_test(cx);
3092
3093        let fs = FakeFs::new(cx.executor().clone());
3094        fs.insert_tree(
3095            "/root1",
3096            json!({
3097                ".dockerignore": "",
3098                ".git": {
3099                    "HEAD": "",
3100                },
3101                "a": {
3102                    "0": { "q": "", "r": "", "s": "" },
3103                    "1": { "t": "", "u": "" },
3104                    "2": { "v": "", "w": "", "x": "", "y": "" },
3105                },
3106                "b": {
3107                    "3": { "Q": "" },
3108                    "4": { "R": "", "S": "", "T": "", "U": "" },
3109                },
3110                "C": {
3111                    "5": {},
3112                    "6": { "V": "", "W": "" },
3113                    "7": { "X": "" },
3114                    "8": { "Y": {}, "Z": "" }
3115                }
3116            }),
3117        )
3118        .await;
3119        fs.insert_tree(
3120            "/root2",
3121            json!({
3122                "d": {
3123                    "9": ""
3124                },
3125                "e": {}
3126            }),
3127        )
3128        .await;
3129
3130        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3131        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3132        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3133        let panel = workspace
3134            .update(cx, |workspace, cx| {
3135                let panel = ProjectPanel::new(workspace, cx);
3136                workspace.add_panel(panel.clone(), cx);
3137                panel
3138            })
3139            .unwrap();
3140
3141        select_path(&panel, "root1", cx);
3142        assert_eq!(
3143            visible_entries_as_strings(&panel, 0..10, cx),
3144            &[
3145                "v root1  <== selected",
3146                "    > .git",
3147                "    > a",
3148                "    > b",
3149                "    > C",
3150                "      .dockerignore",
3151                "v root2",
3152                "    > d",
3153                "    > e",
3154            ]
3155        );
3156
3157        // Add a file with the root folder selected. The filename editor is placed
3158        // before the first file in the root folder.
3159        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3160        panel.update(cx, |panel, cx| {
3161            assert!(panel.filename_editor.read(cx).is_focused(cx));
3162        });
3163        assert_eq!(
3164            visible_entries_as_strings(&panel, 0..10, cx),
3165            &[
3166                "v root1",
3167                "    > .git",
3168                "    > a",
3169                "    > b",
3170                "    > C",
3171                "      [EDITOR: '']  <== selected",
3172                "      .dockerignore",
3173                "v root2",
3174                "    > d",
3175                "    > e",
3176            ]
3177        );
3178
3179        let confirm = panel.update(cx, |panel, cx| {
3180            panel.filename_editor.update(cx, |editor, cx| {
3181                editor.set_text("/bdir1/dir2/the-new-filename", cx)
3182            });
3183            panel.confirm_edit(cx).unwrap()
3184        });
3185
3186        assert_eq!(
3187            visible_entries_as_strings(&panel, 0..10, cx),
3188            &[
3189                "v root1",
3190                "    > .git",
3191                "    > a",
3192                "    > b",
3193                "    > C",
3194                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
3195                "      .dockerignore",
3196                "v root2",
3197                "    > d",
3198                "    > e",
3199            ]
3200        );
3201
3202        confirm.await.unwrap();
3203        assert_eq!(
3204            visible_entries_as_strings(&panel, 0..13, cx),
3205            &[
3206                "v root1",
3207                "    > .git",
3208                "    > a",
3209                "    > b",
3210                "    v bdir1",
3211                "        v dir2",
3212                "              the-new-filename  <== selected  <== marked",
3213                "    > C",
3214                "      .dockerignore",
3215                "v root2",
3216                "    > d",
3217                "    > e",
3218            ]
3219        );
3220    }
3221
3222    #[gpui::test]
3223    async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
3224        init_test(cx);
3225
3226        let fs = FakeFs::new(cx.executor().clone());
3227        fs.insert_tree(
3228            "/root1",
3229            json!({
3230                ".dockerignore": "",
3231                ".git": {
3232                    "HEAD": "",
3233                },
3234            }),
3235        )
3236        .await;
3237
3238        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3239        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3240        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3241        let panel = workspace
3242            .update(cx, |workspace, cx| {
3243                let panel = ProjectPanel::new(workspace, cx);
3244                workspace.add_panel(panel.clone(), cx);
3245                panel
3246            })
3247            .unwrap();
3248
3249        select_path(&panel, "root1", cx);
3250        assert_eq!(
3251            visible_entries_as_strings(&panel, 0..10, cx),
3252            &["v root1  <== selected", "    > .git", "      .dockerignore",]
3253        );
3254
3255        // Add a file with the root folder selected. The filename editor is placed
3256        // before the first file in the root folder.
3257        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3258        panel.update(cx, |panel, cx| {
3259            assert!(panel.filename_editor.read(cx).is_focused(cx));
3260        });
3261        assert_eq!(
3262            visible_entries_as_strings(&panel, 0..10, cx),
3263            &[
3264                "v root1",
3265                "    > .git",
3266                "      [EDITOR: '']  <== selected",
3267                "      .dockerignore",
3268            ]
3269        );
3270
3271        let confirm = panel.update(cx, |panel, cx| {
3272            panel
3273                .filename_editor
3274                .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
3275            panel.confirm_edit(cx).unwrap()
3276        });
3277
3278        assert_eq!(
3279            visible_entries_as_strings(&panel, 0..10, cx),
3280            &[
3281                "v root1",
3282                "    > .git",
3283                "      [PROCESSING: '/new_dir/']  <== selected",
3284                "      .dockerignore",
3285            ]
3286        );
3287
3288        confirm.await.unwrap();
3289        assert_eq!(
3290            visible_entries_as_strings(&panel, 0..13, cx),
3291            &[
3292                "v root1",
3293                "    > .git",
3294                "    v new_dir  <== selected",
3295                "      .dockerignore",
3296            ]
3297        );
3298    }
3299
3300    #[gpui::test]
3301    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
3302        init_test(cx);
3303
3304        let fs = FakeFs::new(cx.executor().clone());
3305        fs.insert_tree(
3306            "/root1",
3307            json!({
3308                "one.two.txt": "",
3309                "one.txt": ""
3310            }),
3311        )
3312        .await;
3313
3314        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3315        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3316        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3317        let panel = workspace
3318            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3319            .unwrap();
3320
3321        panel.update(cx, |panel, cx| {
3322            panel.select_next(&Default::default(), cx);
3323            panel.select_next(&Default::default(), cx);
3324        });
3325
3326        assert_eq!(
3327            visible_entries_as_strings(&panel, 0..50, cx),
3328            &[
3329                //
3330                "v root1",
3331                "      one.two.txt  <== selected",
3332                "      one.txt",
3333            ]
3334        );
3335
3336        // Regression test - file name is created correctly when
3337        // the copied file's name contains multiple dots.
3338        panel.update(cx, |panel, cx| {
3339            panel.copy(&Default::default(), cx);
3340            panel.paste(&Default::default(), cx);
3341        });
3342        cx.executor().run_until_parked();
3343
3344        assert_eq!(
3345            visible_entries_as_strings(&panel, 0..50, cx),
3346            &[
3347                //
3348                "v root1",
3349                "      one.two copy.txt",
3350                "      one.two.txt  <== selected",
3351                "      one.txt",
3352            ]
3353        );
3354
3355        panel.update(cx, |panel, cx| {
3356            panel.paste(&Default::default(), cx);
3357        });
3358        cx.executor().run_until_parked();
3359
3360        assert_eq!(
3361            visible_entries_as_strings(&panel, 0..50, cx),
3362            &[
3363                //
3364                "v root1",
3365                "      one.two copy 1.txt",
3366                "      one.two copy.txt",
3367                "      one.two.txt  <== selected",
3368                "      one.txt",
3369            ]
3370        );
3371    }
3372
3373    #[gpui::test]
3374    async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
3375        init_test(cx);
3376
3377        let fs = FakeFs::new(cx.executor().clone());
3378        fs.insert_tree(
3379            "/root",
3380            json!({
3381                "a": {
3382                    "one.txt": "",
3383                    "two.txt": "",
3384                    "inner_dir": {
3385                        "three.txt": "",
3386                        "four.txt": "",
3387                    }
3388                },
3389                "b": {}
3390            }),
3391        )
3392        .await;
3393
3394        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3395        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3396        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3397        let panel = workspace
3398            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3399            .unwrap();
3400
3401        select_path(&panel, "root/a", cx);
3402        panel.update(cx, |panel, cx| {
3403            panel.copy(&Default::default(), cx);
3404            panel.select_next(&Default::default(), cx);
3405            panel.paste(&Default::default(), cx);
3406        });
3407        cx.executor().run_until_parked();
3408
3409        let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
3410        assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
3411
3412        let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
3413        assert_ne!(
3414            pasted_dir_file, None,
3415            "Pasted directory file should have an entry"
3416        );
3417
3418        let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
3419        assert_ne!(
3420            pasted_dir_inner_dir, None,
3421            "Directories inside pasted directory should have an entry"
3422        );
3423
3424        toggle_expand_dir(&panel, "root/b/a", cx);
3425        toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
3426
3427        assert_eq!(
3428            visible_entries_as_strings(&panel, 0..50, cx),
3429            &[
3430                //
3431                "v root",
3432                "    > a",
3433                "    v b",
3434                "        v a",
3435                "            v inner_dir  <== selected",
3436                "                  four.txt",
3437                "                  three.txt",
3438                "              one.txt",
3439                "              two.txt",
3440            ]
3441        );
3442
3443        select_path(&panel, "root", cx);
3444        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3445        cx.executor().run_until_parked();
3446        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3447        cx.executor().run_until_parked();
3448        assert_eq!(
3449            visible_entries_as_strings(&panel, 0..50, cx),
3450            &[
3451                //
3452                "v root  <== selected",
3453                "    > a",
3454                "    > a copy",
3455                "    > a copy 1",
3456                "    v b",
3457                "        v a",
3458                "            v inner_dir",
3459                "                  four.txt",
3460                "                  three.txt",
3461                "              one.txt",
3462                "              two.txt"
3463            ]
3464        );
3465    }
3466
3467    #[gpui::test]
3468    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
3469        init_test_with_editor(cx);
3470
3471        let fs = FakeFs::new(cx.executor().clone());
3472        fs.insert_tree(
3473            "/src",
3474            json!({
3475                "test": {
3476                    "first.rs": "// First Rust file",
3477                    "second.rs": "// Second Rust file",
3478                    "third.rs": "// Third Rust file",
3479                }
3480            }),
3481        )
3482        .await;
3483
3484        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3485        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3486        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3487        let panel = workspace
3488            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3489            .unwrap();
3490
3491        toggle_expand_dir(&panel, "src/test", cx);
3492        select_path(&panel, "src/test/first.rs", cx);
3493        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3494        cx.executor().run_until_parked();
3495        assert_eq!(
3496            visible_entries_as_strings(&panel, 0..10, cx),
3497            &[
3498                "v src",
3499                "    v test",
3500                "          first.rs  <== selected",
3501                "          second.rs",
3502                "          third.rs"
3503            ]
3504        );
3505        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
3506
3507        submit_deletion(&panel, cx);
3508        assert_eq!(
3509            visible_entries_as_strings(&panel, 0..10, cx),
3510            &[
3511                "v src",
3512                "    v test",
3513                "          second.rs",
3514                "          third.rs"
3515            ],
3516            "Project panel should have no deleted file, no other file is selected in it"
3517        );
3518        ensure_no_open_items_and_panes(&workspace, cx);
3519
3520        select_path(&panel, "src/test/second.rs", cx);
3521        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3522        cx.executor().run_until_parked();
3523        assert_eq!(
3524            visible_entries_as_strings(&panel, 0..10, cx),
3525            &[
3526                "v src",
3527                "    v test",
3528                "          second.rs  <== selected",
3529                "          third.rs"
3530            ]
3531        );
3532        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
3533
3534        workspace
3535            .update(cx, |workspace, cx| {
3536                let active_items = workspace
3537                    .panes()
3538                    .iter()
3539                    .filter_map(|pane| pane.read(cx).active_item())
3540                    .collect::<Vec<_>>();
3541                assert_eq!(active_items.len(), 1);
3542                let open_editor = active_items
3543                    .into_iter()
3544                    .next()
3545                    .unwrap()
3546                    .downcast::<Editor>()
3547                    .expect("Open item should be an editor");
3548                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
3549            })
3550            .unwrap();
3551        submit_deletion_skipping_prompt(&panel, cx);
3552        assert_eq!(
3553            visible_entries_as_strings(&panel, 0..10, cx),
3554            &["v src", "    v test", "          third.rs"],
3555            "Project panel should have no deleted file, with one last file remaining"
3556        );
3557        ensure_no_open_items_and_panes(&workspace, cx);
3558    }
3559
3560    #[gpui::test]
3561    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
3562        init_test_with_editor(cx);
3563
3564        let fs = FakeFs::new(cx.executor().clone());
3565        fs.insert_tree(
3566            "/src",
3567            json!({
3568                "test": {
3569                    "first.rs": "// First Rust file",
3570                    "second.rs": "// Second Rust file",
3571                    "third.rs": "// Third Rust file",
3572                }
3573            }),
3574        )
3575        .await;
3576
3577        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3578        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3579        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3580        let panel = workspace
3581            .update(cx, |workspace, cx| {
3582                let panel = ProjectPanel::new(workspace, cx);
3583                workspace.add_panel(panel.clone(), cx);
3584                panel
3585            })
3586            .unwrap();
3587
3588        select_path(&panel, "src/", cx);
3589        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3590        cx.executor().run_until_parked();
3591        assert_eq!(
3592            visible_entries_as_strings(&panel, 0..10, cx),
3593            &[
3594                //
3595                "v src  <== selected",
3596                "    > test"
3597            ]
3598        );
3599        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3600        panel.update(cx, |panel, cx| {
3601            assert!(panel.filename_editor.read(cx).is_focused(cx));
3602        });
3603        assert_eq!(
3604            visible_entries_as_strings(&panel, 0..10, cx),
3605            &[
3606                //
3607                "v src",
3608                "    > [EDITOR: '']  <== selected",
3609                "    > test"
3610            ]
3611        );
3612        panel.update(cx, |panel, cx| {
3613            panel
3614                .filename_editor
3615                .update(cx, |editor, cx| editor.set_text("test", cx));
3616            assert!(
3617                panel.confirm_edit(cx).is_none(),
3618                "Should not allow to confirm on conflicting new directory name"
3619            )
3620        });
3621        assert_eq!(
3622            visible_entries_as_strings(&panel, 0..10, cx),
3623            &[
3624                //
3625                "v src",
3626                "    > test"
3627            ],
3628            "File list should be unchanged after failed folder create confirmation"
3629        );
3630
3631        select_path(&panel, "src/test/", cx);
3632        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3633        cx.executor().run_until_parked();
3634        assert_eq!(
3635            visible_entries_as_strings(&panel, 0..10, cx),
3636            &[
3637                //
3638                "v src",
3639                "    > test  <== selected"
3640            ]
3641        );
3642        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3643        panel.update(cx, |panel, cx| {
3644            assert!(panel.filename_editor.read(cx).is_focused(cx));
3645        });
3646        assert_eq!(
3647            visible_entries_as_strings(&panel, 0..10, cx),
3648            &[
3649                "v src",
3650                "    v test",
3651                "          [EDITOR: '']  <== selected",
3652                "          first.rs",
3653                "          second.rs",
3654                "          third.rs"
3655            ]
3656        );
3657        panel.update(cx, |panel, cx| {
3658            panel
3659                .filename_editor
3660                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
3661            assert!(
3662                panel.confirm_edit(cx).is_none(),
3663                "Should not allow to confirm on conflicting new file name"
3664            )
3665        });
3666        assert_eq!(
3667            visible_entries_as_strings(&panel, 0..10, cx),
3668            &[
3669                "v src",
3670                "    v test",
3671                "          first.rs",
3672                "          second.rs",
3673                "          third.rs"
3674            ],
3675            "File list should be unchanged after failed file create confirmation"
3676        );
3677
3678        select_path(&panel, "src/test/first.rs", cx);
3679        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3680        cx.executor().run_until_parked();
3681        assert_eq!(
3682            visible_entries_as_strings(&panel, 0..10, cx),
3683            &[
3684                "v src",
3685                "    v test",
3686                "          first.rs  <== selected",
3687                "          second.rs",
3688                "          third.rs"
3689            ],
3690        );
3691        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3692        panel.update(cx, |panel, cx| {
3693            assert!(panel.filename_editor.read(cx).is_focused(cx));
3694        });
3695        assert_eq!(
3696            visible_entries_as_strings(&panel, 0..10, cx),
3697            &[
3698                "v src",
3699                "    v test",
3700                "          [EDITOR: 'first.rs']  <== selected",
3701                "          second.rs",
3702                "          third.rs"
3703            ]
3704        );
3705        panel.update(cx, |panel, cx| {
3706            panel
3707                .filename_editor
3708                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
3709            assert!(
3710                panel.confirm_edit(cx).is_none(),
3711                "Should not allow to confirm on conflicting file rename"
3712            )
3713        });
3714        assert_eq!(
3715            visible_entries_as_strings(&panel, 0..10, cx),
3716            &[
3717                "v src",
3718                "    v test",
3719                "          first.rs  <== selected",
3720                "          second.rs",
3721                "          third.rs"
3722            ],
3723            "File list should be unchanged after failed rename confirmation"
3724        );
3725    }
3726
3727    #[gpui::test]
3728    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3729        init_test_with_editor(cx);
3730
3731        let fs = FakeFs::new(cx.executor().clone());
3732        fs.insert_tree(
3733            "/project_root",
3734            json!({
3735                "dir_1": {
3736                    "nested_dir": {
3737                        "file_a.py": "# File contents",
3738                    }
3739                },
3740                "file_1.py": "# File contents",
3741            }),
3742        )
3743        .await;
3744
3745        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3746        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3747        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3748        let panel = workspace
3749            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3750            .unwrap();
3751
3752        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3753        cx.executor().run_until_parked();
3754        select_path(&panel, "project_root/dir_1", cx);
3755        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3756        select_path(&panel, "project_root/dir_1/nested_dir", cx);
3757        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3758        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3759        cx.executor().run_until_parked();
3760        assert_eq!(
3761            visible_entries_as_strings(&panel, 0..10, cx),
3762            &[
3763                "v project_root",
3764                "    v dir_1",
3765                "        > nested_dir  <== selected",
3766                "      file_1.py",
3767            ]
3768        );
3769    }
3770
3771    #[gpui::test]
3772    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3773        init_test_with_editor(cx);
3774
3775        let fs = FakeFs::new(cx.executor().clone());
3776        fs.insert_tree(
3777            "/project_root",
3778            json!({
3779                "dir_1": {
3780                    "nested_dir": {
3781                        "file_a.py": "# File contents",
3782                        "file_b.py": "# File contents",
3783                        "file_c.py": "# File contents",
3784                    },
3785                    "file_1.py": "# File contents",
3786                    "file_2.py": "# File contents",
3787                    "file_3.py": "# File contents",
3788                },
3789                "dir_2": {
3790                    "file_1.py": "# File contents",
3791                    "file_2.py": "# File contents",
3792                    "file_3.py": "# File contents",
3793                }
3794            }),
3795        )
3796        .await;
3797
3798        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3799        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3800        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3801        let panel = workspace
3802            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3803            .unwrap();
3804
3805        panel.update(cx, |panel, cx| {
3806            panel.collapse_all_entries(&CollapseAllEntries, cx)
3807        });
3808        cx.executor().run_until_parked();
3809        assert_eq!(
3810            visible_entries_as_strings(&panel, 0..10, cx),
3811            &["v project_root", "    > dir_1", "    > dir_2",]
3812        );
3813
3814        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3815        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3816        cx.executor().run_until_parked();
3817        assert_eq!(
3818            visible_entries_as_strings(&panel, 0..10, cx),
3819            &[
3820                "v project_root",
3821                "    v dir_1  <== selected",
3822                "        > nested_dir",
3823                "          file_1.py",
3824                "          file_2.py",
3825                "          file_3.py",
3826                "    > dir_2",
3827            ]
3828        );
3829    }
3830
3831    #[gpui::test]
3832    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
3833        init_test(cx);
3834
3835        let fs = FakeFs::new(cx.executor().clone());
3836        fs.as_fake().insert_tree("/root", json!({})).await;
3837        let project = Project::test(fs, ["/root".as_ref()], cx).await;
3838        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3839        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3840        let panel = workspace
3841            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3842            .unwrap();
3843
3844        // Make a new buffer with no backing file
3845        workspace
3846            .update(cx, |workspace, cx| {
3847                Editor::new_file(workspace, &Default::default(), cx)
3848            })
3849            .unwrap();
3850
3851        cx.executor().run_until_parked();
3852
3853        // "Save as" the buffer, creating a new backing file for it
3854        let save_task = workspace
3855            .update(cx, |workspace, cx| {
3856                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3857            })
3858            .unwrap();
3859
3860        cx.executor().run_until_parked();
3861        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3862        save_task.await.unwrap();
3863
3864        // Rename the file
3865        select_path(&panel, "root/new", cx);
3866        assert_eq!(
3867            visible_entries_as_strings(&panel, 0..10, cx),
3868            &["v root", "      new  <== selected"]
3869        );
3870        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3871        panel.update(cx, |panel, cx| {
3872            panel
3873                .filename_editor
3874                .update(cx, |editor, cx| editor.set_text("newer", cx));
3875        });
3876        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3877
3878        cx.executor().run_until_parked();
3879        assert_eq!(
3880            visible_entries_as_strings(&panel, 0..10, cx),
3881            &["v root", "      newer  <== selected"]
3882        );
3883
3884        workspace
3885            .update(cx, |workspace, cx| {
3886                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3887            })
3888            .unwrap()
3889            .await
3890            .unwrap();
3891
3892        cx.executor().run_until_parked();
3893        // assert that saving the file doesn't restore "new"
3894        assert_eq!(
3895            visible_entries_as_strings(&panel, 0..10, cx),
3896            &["v root", "      newer  <== selected"]
3897        );
3898    }
3899
3900    #[gpui::test]
3901    async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
3902        init_test_with_editor(cx);
3903        let fs = FakeFs::new(cx.executor().clone());
3904        fs.insert_tree(
3905            "/project_root",
3906            json!({
3907                "dir_1": {
3908                    "nested_dir": {
3909                        "file_a.py": "# File contents",
3910                    }
3911                },
3912                "file_1.py": "# File contents",
3913            }),
3914        )
3915        .await;
3916
3917        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3918        let worktree_id =
3919            cx.update(|cx| project.read(cx).worktrees().next().unwrap().read(cx).id());
3920        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3921        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3922        let panel = workspace
3923            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3924            .unwrap();
3925        cx.update(|cx| {
3926            panel.update(cx, |this, cx| {
3927                this.select_next(&Default::default(), cx);
3928                this.expand_selected_entry(&Default::default(), cx);
3929                this.expand_selected_entry(&Default::default(), cx);
3930                this.select_next(&Default::default(), cx);
3931                this.expand_selected_entry(&Default::default(), cx);
3932                this.select_next(&Default::default(), cx);
3933            })
3934        });
3935        assert_eq!(
3936            visible_entries_as_strings(&panel, 0..10, cx),
3937            &[
3938                "v project_root",
3939                "    v dir_1",
3940                "        v nested_dir",
3941                "              file_a.py  <== selected",
3942                "      file_1.py",
3943            ]
3944        );
3945        let modifiers_with_shift = gpui::Modifiers {
3946            shift: true,
3947            ..Default::default()
3948        };
3949        cx.simulate_modifiers_change(modifiers_with_shift);
3950        cx.update(|cx| {
3951            panel.update(cx, |this, cx| {
3952                this.select_next(&Default::default(), cx);
3953            })
3954        });
3955        assert_eq!(
3956            visible_entries_as_strings(&panel, 0..10, cx),
3957            &[
3958                "v project_root",
3959                "    v dir_1",
3960                "        v nested_dir",
3961                "              file_a.py",
3962                "      file_1.py  <== selected  <== marked",
3963            ]
3964        );
3965        cx.update(|cx| {
3966            panel.update(cx, |this, cx| {
3967                this.select_prev(&Default::default(), cx);
3968            })
3969        });
3970        assert_eq!(
3971            visible_entries_as_strings(&panel, 0..10, cx),
3972            &[
3973                "v project_root",
3974                "    v dir_1",
3975                "        v nested_dir",
3976                "              file_a.py  <== selected  <== marked",
3977                "      file_1.py  <== marked",
3978            ]
3979        );
3980        cx.update(|cx| {
3981            panel.update(cx, |this, cx| {
3982                let drag = DraggedSelection {
3983                    active_selection: this.selection.unwrap(),
3984                    marked_selections: Arc::new(this.marked_entries.clone()),
3985                };
3986                let target_entry = this
3987                    .project
3988                    .read(cx)
3989                    .entry_for_path(&(worktree_id, "").into(), cx)
3990                    .unwrap();
3991                this.drag_onto(&drag, target_entry.id, false, cx);
3992            });
3993        });
3994        cx.run_until_parked();
3995        assert_eq!(
3996            visible_entries_as_strings(&panel, 0..10, cx),
3997            &[
3998                "v project_root",
3999                "    v dir_1",
4000                "        v nested_dir",
4001                "      file_1.py  <== marked",
4002                "      file_a.py  <== selected  <== marked",
4003            ]
4004        );
4005        // ESC clears out all marks
4006        cx.update(|cx| {
4007            panel.update(cx, |this, cx| {
4008                this.cancel(&menu::Cancel, cx);
4009            })
4010        });
4011        assert_eq!(
4012            visible_entries_as_strings(&panel, 0..10, cx),
4013            &[
4014                "v project_root",
4015                "    v dir_1",
4016                "        v nested_dir",
4017                "      file_1.py",
4018                "      file_a.py  <== selected",
4019            ]
4020        );
4021        // ESC clears out all marks
4022        cx.update(|cx| {
4023            panel.update(cx, |this, cx| {
4024                this.select_prev(&SelectPrev, cx);
4025                this.select_next(&SelectNext, cx);
4026            })
4027        });
4028        assert_eq!(
4029            visible_entries_as_strings(&panel, 0..10, cx),
4030            &[
4031                "v project_root",
4032                "    v dir_1",
4033                "        v nested_dir",
4034                "      file_1.py  <== marked",
4035                "      file_a.py  <== selected  <== marked",
4036            ]
4037        );
4038        cx.simulate_modifiers_change(Default::default());
4039        cx.update(|cx| {
4040            panel.update(cx, |this, cx| {
4041                this.cut(&Cut, cx);
4042                this.select_prev(&SelectPrev, cx);
4043                this.select_prev(&SelectPrev, cx);
4044
4045                this.paste(&Paste, cx);
4046                // this.expand_selected_entry(&ExpandSelectedEntry, cx);
4047            })
4048        });
4049        cx.run_until_parked();
4050        assert_eq!(
4051            visible_entries_as_strings(&panel, 0..10, cx),
4052            &[
4053                "v project_root",
4054                "    v dir_1",
4055                "        v nested_dir  <== selected",
4056                "              file_1.py  <== marked",
4057                "              file_a.py  <== marked",
4058            ]
4059        );
4060        cx.simulate_modifiers_change(modifiers_with_shift);
4061        cx.update(|cx| {
4062            panel.update(cx, |this, cx| {
4063                this.expand_selected_entry(&Default::default(), cx);
4064                this.select_next(&SelectNext, cx);
4065                this.select_next(&SelectNext, cx);
4066            })
4067        });
4068        submit_deletion(&panel, cx);
4069        assert_eq!(
4070            visible_entries_as_strings(&panel, 0..10, cx),
4071            &["v project_root", "    v dir_1", "        v nested_dir",]
4072        );
4073    }
4074    #[gpui::test]
4075    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4076        init_test_with_editor(cx);
4077        cx.update(|cx| {
4078            cx.update_global::<SettingsStore, _>(|store, cx| {
4079                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4080                    worktree_settings.file_scan_exclusions = Some(Vec::new());
4081                });
4082                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4083                    project_panel_settings.auto_reveal_entries = Some(false)
4084                });
4085            })
4086        });
4087
4088        let fs = FakeFs::new(cx.background_executor.clone());
4089        fs.insert_tree(
4090            "/project_root",
4091            json!({
4092                ".git": {},
4093                ".gitignore": "**/gitignored_dir",
4094                "dir_1": {
4095                    "file_1.py": "# File 1_1 contents",
4096                    "file_2.py": "# File 1_2 contents",
4097                    "file_3.py": "# File 1_3 contents",
4098                    "gitignored_dir": {
4099                        "file_a.py": "# File contents",
4100                        "file_b.py": "# File contents",
4101                        "file_c.py": "# File contents",
4102                    },
4103                },
4104                "dir_2": {
4105                    "file_1.py": "# File 2_1 contents",
4106                    "file_2.py": "# File 2_2 contents",
4107                    "file_3.py": "# File 2_3 contents",
4108                }
4109            }),
4110        )
4111        .await;
4112
4113        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4114        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4115        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4116        let panel = workspace
4117            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4118            .unwrap();
4119
4120        assert_eq!(
4121            visible_entries_as_strings(&panel, 0..20, cx),
4122            &[
4123                "v project_root",
4124                "    > .git",
4125                "    > dir_1",
4126                "    > dir_2",
4127                "      .gitignore",
4128            ]
4129        );
4130
4131        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4132            .expect("dir 1 file is not ignored and should have an entry");
4133        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4134            .expect("dir 2 file is not ignored and should have an entry");
4135        let gitignored_dir_file =
4136            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4137        assert_eq!(
4138            gitignored_dir_file, None,
4139            "File in the gitignored dir should not have an entry before its dir is toggled"
4140        );
4141
4142        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4143        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4144        cx.executor().run_until_parked();
4145        assert_eq!(
4146            visible_entries_as_strings(&panel, 0..20, cx),
4147            &[
4148                "v project_root",
4149                "    > .git",
4150                "    v dir_1",
4151                "        v gitignored_dir  <== selected",
4152                "              file_a.py",
4153                "              file_b.py",
4154                "              file_c.py",
4155                "          file_1.py",
4156                "          file_2.py",
4157                "          file_3.py",
4158                "    > dir_2",
4159                "      .gitignore",
4160            ],
4161            "Should show gitignored dir file list in the project panel"
4162        );
4163        let gitignored_dir_file =
4164            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4165                .expect("after gitignored dir got opened, a file entry should be present");
4166
4167        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4168        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4169        assert_eq!(
4170            visible_entries_as_strings(&panel, 0..20, cx),
4171            &[
4172                "v project_root",
4173                "    > .git",
4174                "    > dir_1  <== selected",
4175                "    > dir_2",
4176                "      .gitignore",
4177            ],
4178            "Should hide all dir contents again and prepare for the auto reveal test"
4179        );
4180
4181        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4182            panel.update(cx, |panel, cx| {
4183                panel.project.update(cx, |_, cx| {
4184                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4185                })
4186            });
4187            cx.run_until_parked();
4188            assert_eq!(
4189                visible_entries_as_strings(&panel, 0..20, cx),
4190                &[
4191                    "v project_root",
4192                    "    > .git",
4193                    "    > dir_1  <== selected",
4194                    "    > dir_2",
4195                    "      .gitignore",
4196                ],
4197                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4198            );
4199        }
4200
4201        cx.update(|cx| {
4202            cx.update_global::<SettingsStore, _>(|store, cx| {
4203                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4204                    project_panel_settings.auto_reveal_entries = Some(true)
4205                });
4206            })
4207        });
4208
4209        panel.update(cx, |panel, cx| {
4210            panel.project.update(cx, |_, cx| {
4211                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4212            })
4213        });
4214        cx.run_until_parked();
4215        assert_eq!(
4216            visible_entries_as_strings(&panel, 0..20, cx),
4217            &[
4218                "v project_root",
4219                "    > .git",
4220                "    v dir_1",
4221                "        > gitignored_dir",
4222                "          file_1.py  <== selected",
4223                "          file_2.py",
4224                "          file_3.py",
4225                "    > dir_2",
4226                "      .gitignore",
4227            ],
4228            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4229        );
4230
4231        panel.update(cx, |panel, cx| {
4232            panel.project.update(cx, |_, cx| {
4233                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4234            })
4235        });
4236        cx.run_until_parked();
4237        assert_eq!(
4238            visible_entries_as_strings(&panel, 0..20, cx),
4239            &[
4240                "v project_root",
4241                "    > .git",
4242                "    v dir_1",
4243                "        > gitignored_dir",
4244                "          file_1.py",
4245                "          file_2.py",
4246                "          file_3.py",
4247                "    v dir_2",
4248                "          file_1.py  <== selected",
4249                "          file_2.py",
4250                "          file_3.py",
4251                "      .gitignore",
4252            ],
4253            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4254        );
4255
4256        panel.update(cx, |panel, cx| {
4257            panel.project.update(cx, |_, cx| {
4258                cx.emit(project::Event::ActiveEntryChanged(Some(
4259                    gitignored_dir_file,
4260                )))
4261            })
4262        });
4263        cx.run_until_parked();
4264        assert_eq!(
4265            visible_entries_as_strings(&panel, 0..20, cx),
4266            &[
4267                "v project_root",
4268                "    > .git",
4269                "    v dir_1",
4270                "        > gitignored_dir",
4271                "          file_1.py",
4272                "          file_2.py",
4273                "          file_3.py",
4274                "    v dir_2",
4275                "          file_1.py  <== selected",
4276                "          file_2.py",
4277                "          file_3.py",
4278                "      .gitignore",
4279            ],
4280            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4281        );
4282
4283        panel.update(cx, |panel, cx| {
4284            panel.project.update(cx, |_, cx| {
4285                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4286            })
4287        });
4288        cx.run_until_parked();
4289        assert_eq!(
4290            visible_entries_as_strings(&panel, 0..20, cx),
4291            &[
4292                "v project_root",
4293                "    > .git",
4294                "    v dir_1",
4295                "        v gitignored_dir",
4296                "              file_a.py  <== selected",
4297                "              file_b.py",
4298                "              file_c.py",
4299                "          file_1.py",
4300                "          file_2.py",
4301                "          file_3.py",
4302                "    v dir_2",
4303                "          file_1.py",
4304                "          file_2.py",
4305                "          file_3.py",
4306                "      .gitignore",
4307            ],
4308            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4309        );
4310    }
4311
4312    #[gpui::test]
4313    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
4314        init_test_with_editor(cx);
4315        cx.update(|cx| {
4316            cx.update_global::<SettingsStore, _>(|store, cx| {
4317                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4318                    worktree_settings.file_scan_exclusions = Some(Vec::new());
4319                });
4320                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4321                    project_panel_settings.auto_reveal_entries = Some(false)
4322                });
4323            })
4324        });
4325
4326        let fs = FakeFs::new(cx.background_executor.clone());
4327        fs.insert_tree(
4328            "/project_root",
4329            json!({
4330                ".git": {},
4331                ".gitignore": "**/gitignored_dir",
4332                "dir_1": {
4333                    "file_1.py": "# File 1_1 contents",
4334                    "file_2.py": "# File 1_2 contents",
4335                    "file_3.py": "# File 1_3 contents",
4336                    "gitignored_dir": {
4337                        "file_a.py": "# File contents",
4338                        "file_b.py": "# File contents",
4339                        "file_c.py": "# File contents",
4340                    },
4341                },
4342                "dir_2": {
4343                    "file_1.py": "# File 2_1 contents",
4344                    "file_2.py": "# File 2_2 contents",
4345                    "file_3.py": "# File 2_3 contents",
4346                }
4347            }),
4348        )
4349        .await;
4350
4351        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4352        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4353        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4354        let panel = workspace
4355            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4356            .unwrap();
4357
4358        assert_eq!(
4359            visible_entries_as_strings(&panel, 0..20, cx),
4360            &[
4361                "v project_root",
4362                "    > .git",
4363                "    > dir_1",
4364                "    > dir_2",
4365                "      .gitignore",
4366            ]
4367        );
4368
4369        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4370            .expect("dir 1 file is not ignored and should have an entry");
4371        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4372            .expect("dir 2 file is not ignored and should have an entry");
4373        let gitignored_dir_file =
4374            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4375        assert_eq!(
4376            gitignored_dir_file, None,
4377            "File in the gitignored dir should not have an entry before its dir is toggled"
4378        );
4379
4380        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4381        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4382        cx.run_until_parked();
4383        assert_eq!(
4384            visible_entries_as_strings(&panel, 0..20, cx),
4385            &[
4386                "v project_root",
4387                "    > .git",
4388                "    v dir_1",
4389                "        v gitignored_dir  <== selected",
4390                "              file_a.py",
4391                "              file_b.py",
4392                "              file_c.py",
4393                "          file_1.py",
4394                "          file_2.py",
4395                "          file_3.py",
4396                "    > dir_2",
4397                "      .gitignore",
4398            ],
4399            "Should show gitignored dir file list in the project panel"
4400        );
4401        let gitignored_dir_file =
4402            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4403                .expect("after gitignored dir got opened, a file entry should be present");
4404
4405        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4406        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4407        assert_eq!(
4408            visible_entries_as_strings(&panel, 0..20, cx),
4409            &[
4410                "v project_root",
4411                "    > .git",
4412                "    > dir_1  <== selected",
4413                "    > dir_2",
4414                "      .gitignore",
4415            ],
4416            "Should hide all dir contents again and prepare for the explicit reveal test"
4417        );
4418
4419        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4420            panel.update(cx, |panel, cx| {
4421                panel.project.update(cx, |_, cx| {
4422                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4423                })
4424            });
4425            cx.run_until_parked();
4426            assert_eq!(
4427                visible_entries_as_strings(&panel, 0..20, cx),
4428                &[
4429                    "v project_root",
4430                    "    > .git",
4431                    "    > dir_1  <== selected",
4432                    "    > dir_2",
4433                    "      .gitignore",
4434                ],
4435                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4436            );
4437        }
4438
4439        panel.update(cx, |panel, cx| {
4440            panel.project.update(cx, |_, cx| {
4441                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
4442            })
4443        });
4444        cx.run_until_parked();
4445        assert_eq!(
4446            visible_entries_as_strings(&panel, 0..20, cx),
4447            &[
4448                "v project_root",
4449                "    > .git",
4450                "    v dir_1",
4451                "        > gitignored_dir",
4452                "          file_1.py  <== selected",
4453                "          file_2.py",
4454                "          file_3.py",
4455                "    > dir_2",
4456                "      .gitignore",
4457            ],
4458            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
4459        );
4460
4461        panel.update(cx, |panel, cx| {
4462            panel.project.update(cx, |_, cx| {
4463                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
4464            })
4465        });
4466        cx.run_until_parked();
4467        assert_eq!(
4468            visible_entries_as_strings(&panel, 0..20, cx),
4469            &[
4470                "v project_root",
4471                "    > .git",
4472                "    v dir_1",
4473                "        > gitignored_dir",
4474                "          file_1.py",
4475                "          file_2.py",
4476                "          file_3.py",
4477                "    v dir_2",
4478                "          file_1.py  <== selected",
4479                "          file_2.py",
4480                "          file_3.py",
4481                "      .gitignore",
4482            ],
4483            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
4484        );
4485
4486        panel.update(cx, |panel, cx| {
4487            panel.project.update(cx, |_, cx| {
4488                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4489            })
4490        });
4491        cx.run_until_parked();
4492        assert_eq!(
4493            visible_entries_as_strings(&panel, 0..20, cx),
4494            &[
4495                "v project_root",
4496                "    > .git",
4497                "    v dir_1",
4498                "        v gitignored_dir",
4499                "              file_a.py  <== selected",
4500                "              file_b.py",
4501                "              file_c.py",
4502                "          file_1.py",
4503                "          file_2.py",
4504                "          file_3.py",
4505                "    v dir_2",
4506                "          file_1.py",
4507                "          file_2.py",
4508                "          file_3.py",
4509                "      .gitignore",
4510            ],
4511            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
4512        );
4513    }
4514
4515    #[gpui::test]
4516    async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
4517        init_test(cx);
4518        cx.update(|cx| {
4519            cx.update_global::<SettingsStore, _>(|store, cx| {
4520                store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
4521                    project_settings.file_scan_exclusions =
4522                        Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
4523                });
4524            });
4525        });
4526
4527        cx.update(|cx| {
4528            register_project_item::<TestProjectItemView>(cx);
4529        });
4530
4531        let fs = FakeFs::new(cx.executor().clone());
4532        fs.insert_tree(
4533            "/root1",
4534            json!({
4535                ".dockerignore": "",
4536                ".git": {
4537                    "HEAD": "",
4538                },
4539            }),
4540        )
4541        .await;
4542
4543        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4544        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4545        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4546        let panel = workspace
4547            .update(cx, |workspace, cx| {
4548                let panel = ProjectPanel::new(workspace, cx);
4549                workspace.add_panel(panel.clone(), cx);
4550                panel
4551            })
4552            .unwrap();
4553
4554        select_path(&panel, "root1", cx);
4555        assert_eq!(
4556            visible_entries_as_strings(&panel, 0..10, cx),
4557            &["v root1  <== selected", "      .dockerignore",]
4558        );
4559        workspace
4560            .update(cx, |workspace, cx| {
4561                assert!(
4562                    workspace.active_item(cx).is_none(),
4563                    "Should have no active items in the beginning"
4564                );
4565            })
4566            .unwrap();
4567
4568        let excluded_file_path = ".git/COMMIT_EDITMSG";
4569        let excluded_dir_path = "excluded_dir";
4570
4571        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4572        panel.update(cx, |panel, cx| {
4573            assert!(panel.filename_editor.read(cx).is_focused(cx));
4574        });
4575        panel
4576            .update(cx, |panel, cx| {
4577                panel
4578                    .filename_editor
4579                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4580                panel.confirm_edit(cx).unwrap()
4581            })
4582            .await
4583            .unwrap();
4584
4585        assert_eq!(
4586            visible_entries_as_strings(&panel, 0..13, cx),
4587            &["v root1", "      .dockerignore"],
4588            "Excluded dir should not be shown after opening a file in it"
4589        );
4590        panel.update(cx, |panel, cx| {
4591            assert!(
4592                !panel.filename_editor.read(cx).is_focused(cx),
4593                "Should have closed the file name editor"
4594            );
4595        });
4596        workspace
4597            .update(cx, |workspace, cx| {
4598                let active_entry_path = workspace
4599                    .active_item(cx)
4600                    .expect("should have opened and activated the excluded item")
4601                    .act_as::<TestProjectItemView>(cx)
4602                    .expect(
4603                        "should have opened the corresponding project item for the excluded item",
4604                    )
4605                    .read(cx)
4606                    .path
4607                    .clone();
4608                assert_eq!(
4609                    active_entry_path.path.as_ref(),
4610                    Path::new(excluded_file_path),
4611                    "Should open the excluded file"
4612                );
4613
4614                assert!(
4615                    workspace.notification_ids().is_empty(),
4616                    "Should have no notifications after opening an excluded file"
4617                );
4618            })
4619            .unwrap();
4620        assert!(
4621            fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
4622            "Should have created the excluded file"
4623        );
4624
4625        select_path(&panel, "root1", cx);
4626        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4627        panel.update(cx, |panel, cx| {
4628            assert!(panel.filename_editor.read(cx).is_focused(cx));
4629        });
4630        panel
4631            .update(cx, |panel, cx| {
4632                panel
4633                    .filename_editor
4634                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4635                panel.confirm_edit(cx).unwrap()
4636            })
4637            .await
4638            .unwrap();
4639
4640        assert_eq!(
4641            visible_entries_as_strings(&panel, 0..13, cx),
4642            &["v root1", "      .dockerignore"],
4643            "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
4644        );
4645        panel.update(cx, |panel, cx| {
4646            assert!(
4647                !panel.filename_editor.read(cx).is_focused(cx),
4648                "Should have closed the file name editor"
4649            );
4650        });
4651        workspace
4652            .update(cx, |workspace, cx| {
4653                let notifications = workspace.notification_ids();
4654                assert_eq!(
4655                    notifications.len(),
4656                    1,
4657                    "Should receive one notification with the error message"
4658                );
4659                workspace.dismiss_notification(notifications.first().unwrap(), cx);
4660                assert!(workspace.notification_ids().is_empty());
4661            })
4662            .unwrap();
4663
4664        select_path(&panel, "root1", cx);
4665        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4666        panel.update(cx, |panel, cx| {
4667            assert!(panel.filename_editor.read(cx).is_focused(cx));
4668        });
4669        panel
4670            .update(cx, |panel, cx| {
4671                panel
4672                    .filename_editor
4673                    .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
4674                panel.confirm_edit(cx).unwrap()
4675            })
4676            .await
4677            .unwrap();
4678
4679        assert_eq!(
4680            visible_entries_as_strings(&panel, 0..13, cx),
4681            &["v root1", "      .dockerignore"],
4682            "Should not change the project panel after trying to create an excluded directory"
4683        );
4684        panel.update(cx, |panel, cx| {
4685            assert!(
4686                !panel.filename_editor.read(cx).is_focused(cx),
4687                "Should have closed the file name editor"
4688            );
4689        });
4690        workspace
4691            .update(cx, |workspace, cx| {
4692                let notifications = workspace.notification_ids();
4693                assert_eq!(
4694                    notifications.len(),
4695                    1,
4696                    "Should receive one notification explaining that no directory is actually shown"
4697                );
4698                workspace.dismiss_notification(notifications.first().unwrap(), cx);
4699                assert!(workspace.notification_ids().is_empty());
4700            })
4701            .unwrap();
4702        assert!(
4703            fs.is_dir(Path::new("/root1/excluded_dir")).await,
4704            "Should have created the excluded directory"
4705        );
4706    }
4707
4708    fn toggle_expand_dir(
4709        panel: &View<ProjectPanel>,
4710        path: impl AsRef<Path>,
4711        cx: &mut VisualTestContext,
4712    ) {
4713        let path = path.as_ref();
4714        panel.update(cx, |panel, cx| {
4715            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4716                let worktree = worktree.read(cx);
4717                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4718                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4719                    panel.toggle_expanded(entry_id, cx);
4720                    return;
4721                }
4722            }
4723            panic!("no worktree for path {:?}", path);
4724        });
4725    }
4726
4727    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
4728        let path = path.as_ref();
4729        panel.update(cx, |panel, cx| {
4730            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4731                let worktree = worktree.read(cx);
4732                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4733                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4734                    panel.selection = Some(crate::SelectedEntry {
4735                        worktree_id: worktree.id(),
4736                        entry_id,
4737                    });
4738                    return;
4739                }
4740            }
4741            panic!("no worktree for path {:?}", path);
4742        });
4743    }
4744
4745    fn find_project_entry(
4746        panel: &View<ProjectPanel>,
4747        path: impl AsRef<Path>,
4748        cx: &mut VisualTestContext,
4749    ) -> Option<ProjectEntryId> {
4750        let path = path.as_ref();
4751        panel.update(cx, |panel, cx| {
4752            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4753                let worktree = worktree.read(cx);
4754                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4755                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
4756                }
4757            }
4758            panic!("no worktree for path {path:?}");
4759        })
4760    }
4761
4762    fn visible_entries_as_strings(
4763        panel: &View<ProjectPanel>,
4764        range: Range<usize>,
4765        cx: &mut VisualTestContext,
4766    ) -> Vec<String> {
4767        let mut result = Vec::new();
4768        let mut project_entries = HashSet::default();
4769        let mut has_editor = false;
4770
4771        panel.update(cx, |panel, cx| {
4772            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
4773                if details.is_editing {
4774                    assert!(!has_editor, "duplicate editor entry");
4775                    has_editor = true;
4776                } else {
4777                    assert!(
4778                        project_entries.insert(project_entry),
4779                        "duplicate project entry {:?} {:?}",
4780                        project_entry,
4781                        details
4782                    );
4783                }
4784
4785                let indent = "    ".repeat(details.depth);
4786                let icon = if details.kind.is_dir() {
4787                    if details.is_expanded {
4788                        "v "
4789                    } else {
4790                        "> "
4791                    }
4792                } else {
4793                    "  "
4794                };
4795                let name = if details.is_editing {
4796                    format!("[EDITOR: '{}']", details.filename)
4797                } else if details.is_processing {
4798                    format!("[PROCESSING: '{}']", details.filename)
4799                } else {
4800                    details.filename.clone()
4801                };
4802                let selected = if details.is_selected {
4803                    "  <== selected"
4804                } else {
4805                    ""
4806                };
4807                let marked = if details.is_marked {
4808                    "  <== marked"
4809                } else {
4810                    ""
4811                };
4812
4813                result.push(format!("{indent}{icon}{name}{selected}{marked}"));
4814            });
4815        });
4816
4817        result
4818    }
4819
4820    fn init_test(cx: &mut TestAppContext) {
4821        cx.update(|cx| {
4822            let settings_store = SettingsStore::test(cx);
4823            cx.set_global(settings_store);
4824            init_settings(cx);
4825            theme::init(theme::LoadThemes::JustBase, cx);
4826            language::init(cx);
4827            editor::init_settings(cx);
4828            crate::init((), cx);
4829            workspace::init_settings(cx);
4830            client::init_settings(cx);
4831            Project::init_settings(cx);
4832
4833            cx.update_global::<SettingsStore, _>(|store, cx| {
4834                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4835                    worktree_settings.file_scan_exclusions = Some(Vec::new());
4836                });
4837            });
4838        });
4839    }
4840
4841    fn init_test_with_editor(cx: &mut TestAppContext) {
4842        cx.update(|cx| {
4843            let app_state = AppState::test(cx);
4844            theme::init(theme::LoadThemes::JustBase, cx);
4845            init_settings(cx);
4846            language::init(cx);
4847            editor::init(cx);
4848            crate::init((), cx);
4849            workspace::init(app_state.clone(), cx);
4850            Project::init_settings(cx);
4851        });
4852    }
4853
4854    fn ensure_single_file_is_opened(
4855        window: &WindowHandle<Workspace>,
4856        expected_path: &str,
4857        cx: &mut TestAppContext,
4858    ) {
4859        window
4860            .update(cx, |workspace, cx| {
4861                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
4862                assert_eq!(worktrees.len(), 1);
4863                let worktree_id = worktrees[0].read(cx).id();
4864
4865                let open_project_paths = workspace
4866                    .panes()
4867                    .iter()
4868                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4869                    .collect::<Vec<_>>();
4870                assert_eq!(
4871                    open_project_paths,
4872                    vec![ProjectPath {
4873                        worktree_id,
4874                        path: Arc::from(Path::new(expected_path))
4875                    }],
4876                    "Should have opened file, selected in project panel"
4877                );
4878            })
4879            .unwrap();
4880    }
4881
4882    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4883        assert!(
4884            !cx.has_pending_prompt(),
4885            "Should have no prompts before the deletion"
4886        );
4887        panel.update(cx, |panel, cx| {
4888            panel.delete(&Delete { skip_prompt: false }, cx)
4889        });
4890        assert!(
4891            cx.has_pending_prompt(),
4892            "Should have a prompt after the deletion"
4893        );
4894        cx.simulate_prompt_answer(0);
4895        assert!(
4896            !cx.has_pending_prompt(),
4897            "Should have no prompts after prompt was replied to"
4898        );
4899        cx.executor().run_until_parked();
4900    }
4901
4902    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4903        assert!(
4904            !cx.has_pending_prompt(),
4905            "Should have no prompts before the deletion"
4906        );
4907        panel.update(cx, |panel, cx| {
4908            panel.delete(&Delete { skip_prompt: true }, cx)
4909        });
4910        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
4911        cx.executor().run_until_parked();
4912    }
4913
4914    fn ensure_no_open_items_and_panes(
4915        workspace: &WindowHandle<Workspace>,
4916        cx: &mut VisualTestContext,
4917    ) {
4918        assert!(
4919            !cx.has_pending_prompt(),
4920            "Should have no prompts after deletion operation closes the file"
4921        );
4922        workspace
4923            .read_with(cx, |workspace, cx| {
4924                let open_project_paths = workspace
4925                    .panes()
4926                    .iter()
4927                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4928                    .collect::<Vec<_>>();
4929                assert!(
4930                    open_project_paths.is_empty(),
4931                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
4932                );
4933            })
4934            .unwrap();
4935    }
4936
4937    struct TestProjectItemView {
4938        focus_handle: FocusHandle,
4939        path: ProjectPath,
4940    }
4941
4942    struct TestProjectItem {
4943        path: ProjectPath,
4944    }
4945
4946    impl project::Item for TestProjectItem {
4947        fn try_open(
4948            _project: &Model<Project>,
4949            path: &ProjectPath,
4950            cx: &mut AppContext,
4951        ) -> Option<Task<gpui::Result<Model<Self>>>> {
4952            let path = path.clone();
4953            Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
4954        }
4955
4956        fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
4957            None
4958        }
4959
4960        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
4961            Some(self.path.clone())
4962        }
4963    }
4964
4965    impl ProjectItem for TestProjectItemView {
4966        type Item = TestProjectItem;
4967
4968        fn for_project_item(
4969            _: Model<Project>,
4970            project_item: Model<Self::Item>,
4971            cx: &mut ViewContext<Self>,
4972        ) -> Self
4973        where
4974            Self: Sized,
4975        {
4976            Self {
4977                path: project_item.update(cx, |project_item, _| project_item.path.clone()),
4978                focus_handle: cx.focus_handle(),
4979            }
4980        }
4981    }
4982
4983    impl Item for TestProjectItemView {
4984        type Event = ();
4985    }
4986
4987    impl EventEmitter<()> for TestProjectItemView {}
4988
4989    impl FocusableView for TestProjectItemView {
4990        fn focus_handle(&self, _: &AppContext) -> FocusHandle {
4991            self.focus_handle.clone()
4992        }
4993    }
4994
4995    impl Render for TestProjectItemView {
4996        fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
4997            Empty
4998        }
4999    }
5000}