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                let entries = visible_worktree_entries
1796                    .iter()
1797                    .map(|e| (e.path.clone()))
1798                    .collect();
1799                for entry in visible_worktree_entries[entry_range].iter() {
1800                    let status = git_status_setting.then(|| entry.git_status).flatten();
1801                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1802                    let icon = match entry.kind {
1803                        EntryKind::File(_) => {
1804                            if show_file_icons {
1805                                FileIcons::get_icon(&entry.path, cx)
1806                            } else {
1807                                None
1808                            }
1809                        }
1810                        _ => {
1811                            if show_folder_icons {
1812                                FileIcons::get_folder_icon(is_expanded, cx)
1813                            } else {
1814                                FileIcons::get_chevron_icon(is_expanded, cx)
1815                            }
1816                        }
1817                    };
1818
1819                    let (depth, difference) =
1820                        ProjectPanel::calculate_depth_and_difference(entry, &entries);
1821
1822                    let filename = match difference {
1823                        diff if diff > 1 => entry
1824                            .path
1825                            .iter()
1826                            .skip(entry.path.components().count() - diff)
1827                            .collect::<PathBuf>()
1828                            .to_str()
1829                            .unwrap_or_default()
1830                            .to_string(),
1831                        _ => entry
1832                            .path
1833                            .file_name()
1834                            .map(|name| name.to_string_lossy().into_owned())
1835                            .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
1836                    };
1837                    let selection = SelectedEntry {
1838                        worktree_id: snapshot.id(),
1839                        entry_id: entry.id,
1840                    };
1841                    let mut details = EntryDetails {
1842                        filename,
1843                        icon,
1844                        path: entry.path.clone(),
1845                        depth,
1846                        kind: entry.kind,
1847                        is_ignored: entry.is_ignored,
1848                        is_expanded,
1849                        is_selected: self.selection == Some(selection),
1850                        is_marked: self.marked_entries.contains(&selection),
1851                        is_editing: false,
1852                        is_processing: false,
1853                        is_cut: self
1854                            .clipboard
1855                            .as_ref()
1856                            .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
1857                        git_status: status,
1858                        is_private: entry.is_private,
1859                        worktree_id: *worktree_id,
1860                        canonical_path: entry.canonical_path.clone(),
1861                    };
1862
1863                    if let Some(edit_state) = &self.edit_state {
1864                        let is_edited_entry = if edit_state.is_new_entry {
1865                            entry.id == NEW_ENTRY_ID
1866                        } else {
1867                            entry.id == edit_state.entry_id
1868                        };
1869
1870                        if is_edited_entry {
1871                            if let Some(processing_filename) = &edit_state.processing_filename {
1872                                details.is_processing = true;
1873                                details.filename.clear();
1874                                details.filename.push_str(processing_filename);
1875                            } else {
1876                                if edit_state.is_new_entry {
1877                                    details.filename.clear();
1878                                }
1879                                details.is_editing = true;
1880                            }
1881                        }
1882                    }
1883
1884                    callback(entry.id, details, cx);
1885                }
1886            }
1887            ix = end_ix;
1888        }
1889    }
1890
1891    fn calculate_depth_and_difference(
1892        entry: &Entry,
1893        visible_worktree_entries: &HashSet<Arc<Path>>,
1894    ) -> (usize, usize) {
1895        let (depth, difference) = entry
1896            .path
1897            .ancestors()
1898            .skip(1) // Skip the entry itself
1899            .find_map(|ancestor| {
1900                if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
1901                    let entry_path_components_count = entry.path.components().count();
1902                    let parent_path_components_count = parent_entry.components().count();
1903                    let difference = entry_path_components_count - parent_path_components_count;
1904                    let depth = parent_entry
1905                        .ancestors()
1906                        .skip(1)
1907                        .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
1908                        .count();
1909                    Some((depth + 1, difference))
1910                } else {
1911                    None
1912                }
1913            })
1914            .unwrap_or((0, 0));
1915
1916        (depth, difference)
1917    }
1918
1919    fn render_entry(
1920        &self,
1921        entry_id: ProjectEntryId,
1922        details: EntryDetails,
1923        cx: &mut ViewContext<Self>,
1924    ) -> Stateful<Div> {
1925        let kind = details.kind;
1926        let settings = ProjectPanelSettings::get_global(cx);
1927        let show_editor = details.is_editing && !details.is_processing;
1928        let selection = SelectedEntry {
1929            worktree_id: details.worktree_id,
1930            entry_id,
1931        };
1932        let is_marked = self.marked_entries.contains(&selection);
1933        let is_active = self
1934            .selection
1935            .map_or(false, |selection| selection.entry_id == entry_id);
1936        let width = self.size(cx);
1937        let filename_text_color =
1938            entry_git_aware_label_color(details.git_status, details.is_ignored, is_marked);
1939        let file_name = details.filename.clone();
1940        let mut icon = details.icon.clone();
1941        if settings.file_icons && show_editor && details.kind.is_file() {
1942            let filename = self.filename_editor.read(cx).text(cx);
1943            if filename.len() > 2 {
1944                icon = FileIcons::get_icon(Path::new(&filename), cx);
1945            }
1946        }
1947
1948        let canonical_path = details
1949            .canonical_path
1950            .as_ref()
1951            .map(|f| f.to_string_lossy().to_string());
1952
1953        let depth = details.depth;
1954        let worktree_id = details.worktree_id;
1955        let selections = Arc::new(self.marked_entries.clone());
1956
1957        let dragged_selection = DraggedSelection {
1958            active_selection: selection,
1959            marked_selections: selections,
1960        };
1961        div()
1962            .id(entry_id.to_proto() as usize)
1963            .on_drag(dragged_selection, move |selection, cx| {
1964                cx.new_view(|_| DraggedProjectEntryView {
1965                    details: details.clone(),
1966                    width,
1967                    selection: selection.active_selection,
1968                    selections: selection.marked_selections.clone(),
1969                })
1970            })
1971            .drag_over::<DraggedSelection>(|style, _, cx| {
1972                style.bg(cx.theme().colors().drop_target_background)
1973            })
1974            .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
1975                this.drag_onto(selections, entry_id, kind.is_file(), cx);
1976            }))
1977            .child(
1978                ListItem::new(entry_id.to_proto() as usize)
1979                    .indent_level(depth)
1980                    .indent_step_size(px(settings.indent_size))
1981                    .selected(is_marked || is_active)
1982                    .when_some(canonical_path, |this, path| {
1983                        this.end_slot::<AnyElement>(
1984                            div()
1985                                .id("symlink_icon")
1986                                .tooltip(move |cx| {
1987                                    Tooltip::text(format!("{path} • Symbolic Link"), cx)
1988                                })
1989                                .child(
1990                                    Icon::new(IconName::ArrowUpRight)
1991                                        .size(IconSize::Indicator)
1992                                        .color(filename_text_color),
1993                                )
1994                                .into_any_element(),
1995                        )
1996                    })
1997                    .child(if let Some(icon) = &icon {
1998                        h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
1999                    } else {
2000                        h_flex()
2001                            .size(IconSize::default().rems())
2002                            .invisible()
2003                            .flex_none()
2004                    })
2005                    .child(
2006                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
2007                            h_flex().h_6().w_full().child(editor.clone())
2008                        } else {
2009                            h_flex().h_6().child(
2010                                Label::new(file_name)
2011                                    .single_line()
2012                                    .color(filename_text_color),
2013                            )
2014                        }
2015                        .ml_1(),
2016                    )
2017                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
2018                        if event.down.button == MouseButton::Right || event.down.first_mouse {
2019                            return;
2020                        }
2021                        if !show_editor {
2022                            if let Some(selection) =
2023                                this.selection.filter(|_| event.down.modifiers.shift)
2024                            {
2025                                let current_selection = this.index_for_selection(selection);
2026                                let target_selection = this.index_for_selection(SelectedEntry {
2027                                    entry_id,
2028                                    worktree_id,
2029                                });
2030                                if let Some(((_, _, source_index), (_, _, target_index))) =
2031                                    current_selection.zip(target_selection)
2032                                {
2033                                    let range_start = source_index.min(target_index);
2034                                    let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
2035                                    let mut new_selections = BTreeSet::new();
2036                                    this.for_each_visible_entry(
2037                                        range_start..range_end,
2038                                        cx,
2039                                        |entry_id, details, _| {
2040                                            new_selections.insert(SelectedEntry {
2041                                                entry_id,
2042                                                worktree_id: details.worktree_id,
2043                                            });
2044                                        },
2045                                    );
2046
2047                                    this.marked_entries = this
2048                                        .marked_entries
2049                                        .union(&new_selections)
2050                                        .cloned()
2051                                        .collect();
2052
2053                                    this.selection = Some(SelectedEntry {
2054                                        entry_id,
2055                                        worktree_id,
2056                                    });
2057                                    // Ensure that the current entry is selected.
2058                                    this.marked_entries.insert(SelectedEntry {
2059                                        entry_id,
2060                                        worktree_id,
2061                                    });
2062                                }
2063                            } else if event.down.modifiers.secondary() {
2064                                if !this.marked_entries.insert(selection) {
2065                                    this.marked_entries.remove(&selection);
2066                                }
2067                            } else if kind.is_dir() {
2068                                this.toggle_expanded(entry_id, cx);
2069                            } else {
2070                                let click_count = event.up.click_count;
2071                                if click_count > 1 && event.down.modifiers.secondary() {
2072                                    this.split_entry(entry_id, cx);
2073                                } else {
2074                                    this.open_entry(
2075                                        entry_id,
2076                                        cx.modifiers().secondary(),
2077                                        click_count > 1,
2078                                        click_count == 1,
2079                                        cx,
2080                                    );
2081                                }
2082                            }
2083                        }
2084                    }))
2085                    .on_secondary_mouse_down(cx.listener(
2086                        move |this, event: &MouseDownEvent, cx| {
2087                            // Stop propagation to prevent the catch-all context menu for the project
2088                            // panel from being deployed.
2089                            cx.stop_propagation();
2090                            this.deploy_context_menu(event.position, entry_id, cx);
2091                        },
2092                    )),
2093            )
2094            .border_1()
2095            .border_r_2()
2096            .rounded_none()
2097            .hover(|style| {
2098                if is_active {
2099                    style
2100                } else {
2101                    let hover_color = cx.theme().colors().ghost_element_hover;
2102                    style.bg(hover_color).border_color(hover_color)
2103                }
2104            })
2105            .when(is_marked || is_active, |this| {
2106                let colors = cx.theme().colors();
2107                this.when(is_marked, |this| this.bg(colors.ghost_element_selected))
2108                    .border_color(colors.ghost_element_selected)
2109            })
2110            .when(
2111                is_active && self.focus_handle.contains_focused(cx),
2112                |this| this.border_color(Color::Selected.color(cx)),
2113            )
2114    }
2115
2116    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
2117        let mut dispatch_context = KeyContext::new_with_defaults();
2118        dispatch_context.add("ProjectPanel");
2119        dispatch_context.add("menu");
2120
2121        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
2122            "editing"
2123        } else {
2124            "not_editing"
2125        };
2126
2127        dispatch_context.add(identifier);
2128        dispatch_context
2129    }
2130
2131    fn reveal_entry(
2132        &mut self,
2133        project: Model<Project>,
2134        entry_id: ProjectEntryId,
2135        skip_ignored: bool,
2136        cx: &mut ViewContext<'_, ProjectPanel>,
2137    ) {
2138        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
2139            let worktree = worktree.read(cx);
2140            if skip_ignored
2141                && worktree
2142                    .entry_for_id(entry_id)
2143                    .map_or(true, |entry| entry.is_ignored)
2144            {
2145                return;
2146            }
2147
2148            let worktree_id = worktree.id();
2149            self.marked_entries.clear();
2150            self.expand_entry(worktree_id, entry_id, cx);
2151            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
2152            self.autoscroll(cx);
2153            cx.notify();
2154        }
2155    }
2156}
2157
2158impl Render for ProjectPanel {
2159    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
2160        let has_worktree = self.visible_entries.len() != 0;
2161        let project = self.project.read(cx);
2162
2163        if has_worktree {
2164            h_flex()
2165                .id("project-panel")
2166                .size_full()
2167                .relative()
2168                .key_context(self.dispatch_context(cx))
2169                .on_action(cx.listener(Self::select_next))
2170                .on_action(cx.listener(Self::select_prev))
2171                .on_action(cx.listener(Self::select_first))
2172                .on_action(cx.listener(Self::select_last))
2173                .on_action(cx.listener(Self::select_parent))
2174                .on_action(cx.listener(Self::expand_selected_entry))
2175                .on_action(cx.listener(Self::collapse_selected_entry))
2176                .on_action(cx.listener(Self::collapse_all_entries))
2177                .on_action(cx.listener(Self::open))
2178                .on_action(cx.listener(Self::open_permanent))
2179                .on_action(cx.listener(Self::confirm))
2180                .on_action(cx.listener(Self::cancel))
2181                .on_action(cx.listener(Self::copy_path))
2182                .on_action(cx.listener(Self::copy_relative_path))
2183                .on_action(cx.listener(Self::new_search_in_directory))
2184                .on_action(cx.listener(Self::unfold_directory))
2185                .on_action(cx.listener(Self::fold_directory))
2186                .when(!project.is_read_only(), |el| {
2187                    el.on_action(cx.listener(Self::new_file))
2188                        .on_action(cx.listener(Self::new_directory))
2189                        .on_action(cx.listener(Self::rename))
2190                        .on_action(cx.listener(Self::delete))
2191                        .on_action(cx.listener(Self::trash))
2192                        .on_action(cx.listener(Self::cut))
2193                        .on_action(cx.listener(Self::copy))
2194                        .on_action(cx.listener(Self::paste))
2195                        .on_action(cx.listener(Self::duplicate))
2196                })
2197                .when(project.is_local(), |el| {
2198                    el.on_action(cx.listener(Self::reveal_in_finder))
2199                        .on_action(cx.listener(Self::open_in_terminal))
2200                })
2201                .on_mouse_down(
2202                    MouseButton::Right,
2203                    cx.listener(move |this, event: &MouseDownEvent, cx| {
2204                        // When deploying the context menu anywhere below the last project entry,
2205                        // act as if the user clicked the root of the last worktree.
2206                        if let Some(entry_id) = this.last_worktree_root_id {
2207                            this.deploy_context_menu(event.position, entry_id, cx);
2208                        }
2209                    }),
2210                )
2211                .track_focus(&self.focus_handle)
2212                .child(
2213                    uniform_list(
2214                        cx.view().clone(),
2215                        "entries",
2216                        self.visible_entries
2217                            .iter()
2218                            .map(|(_, worktree_entries)| worktree_entries.len())
2219                            .sum(),
2220                        {
2221                            |this, range, cx| {
2222                                let mut items = Vec::new();
2223                                this.for_each_visible_entry(range, cx, |id, details, cx| {
2224                                    items.push(this.render_entry(id, details, cx));
2225                                });
2226                                items
2227                            }
2228                        },
2229                    )
2230                    .size_full()
2231                    .with_sizing_behavior(ListSizingBehavior::Infer)
2232                    .track_scroll(self.scroll_handle.clone()),
2233                )
2234                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2235                    deferred(
2236                        anchored()
2237                            .position(*position)
2238                            .anchor(gpui::AnchorCorner::TopLeft)
2239                            .child(menu.clone()),
2240                    )
2241                    .with_priority(1)
2242                }))
2243        } else {
2244            v_flex()
2245                .id("empty-project_panel")
2246                .size_full()
2247                .p_4()
2248                .track_focus(&self.focus_handle)
2249                .child(
2250                    Button::new("open_project", "Open a project")
2251                        .style(ButtonStyle::Filled)
2252                        .full_width()
2253                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
2254                        .on_click(cx.listener(|this, _, cx| {
2255                            this.workspace
2256                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
2257                                .log_err();
2258                        })),
2259                )
2260        }
2261    }
2262}
2263
2264impl Render for DraggedProjectEntryView {
2265    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2266        let settings = ProjectPanelSettings::get_global(cx);
2267        let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
2268        h_flex().font(ui_font).map(|this| {
2269            if self.selections.contains(&self.selection) {
2270                this.flex_shrink()
2271                    .p_1()
2272                    .items_end()
2273                    .rounded_md()
2274                    .child(self.selections.len().to_string())
2275            } else {
2276                this.bg(cx.theme().colors().background).w(self.width).child(
2277                    ListItem::new(self.selection.entry_id.to_proto() as usize)
2278                        .indent_level(self.details.depth)
2279                        .indent_step_size(px(settings.indent_size))
2280                        .child(if let Some(icon) = &self.details.icon {
2281                            div().child(Icon::from_path(icon.to_string()))
2282                        } else {
2283                            div()
2284                        })
2285                        .child(Label::new(self.details.filename.clone())),
2286                )
2287            }
2288        })
2289    }
2290}
2291
2292impl EventEmitter<Event> for ProjectPanel {}
2293
2294impl EventEmitter<PanelEvent> for ProjectPanel {}
2295
2296impl Panel for ProjectPanel {
2297    fn position(&self, cx: &WindowContext) -> DockPosition {
2298        match ProjectPanelSettings::get_global(cx).dock {
2299            ProjectPanelDockPosition::Left => DockPosition::Left,
2300            ProjectPanelDockPosition::Right => DockPosition::Right,
2301        }
2302    }
2303
2304    fn position_is_valid(&self, position: DockPosition) -> bool {
2305        matches!(position, DockPosition::Left | DockPosition::Right)
2306    }
2307
2308    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2309        settings::update_settings_file::<ProjectPanelSettings>(
2310            self.fs.clone(),
2311            cx,
2312            move |settings| {
2313                let dock = match position {
2314                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
2315                    DockPosition::Right => ProjectPanelDockPosition::Right,
2316                };
2317                settings.dock = Some(dock);
2318            },
2319        );
2320    }
2321
2322    fn size(&self, cx: &WindowContext) -> Pixels {
2323        self.width
2324            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
2325    }
2326
2327    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2328        self.width = size;
2329        self.serialize(cx);
2330        cx.notify();
2331    }
2332
2333    fn icon(&self, cx: &WindowContext) -> Option<IconName> {
2334        ProjectPanelSettings::get_global(cx)
2335            .button
2336            .then(|| IconName::FileTree)
2337    }
2338
2339    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2340        Some("Project Panel")
2341    }
2342
2343    fn toggle_action(&self) -> Box<dyn Action> {
2344        Box::new(ToggleFocus)
2345    }
2346
2347    fn persistent_name() -> &'static str {
2348        "Project Panel"
2349    }
2350
2351    fn starts_open(&self, cx: &WindowContext) -> bool {
2352        let project = &self.project.read(cx);
2353        project.dev_server_project_id().is_some()
2354            || project.visible_worktrees(cx).any(|tree| {
2355                tree.read(cx)
2356                    .root_entry()
2357                    .map_or(false, |entry| entry.is_dir())
2358            })
2359    }
2360}
2361
2362impl FocusableView for ProjectPanel {
2363    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
2364        self.focus_handle.clone()
2365    }
2366}
2367
2368impl ClipboardEntry {
2369    fn is_cut(&self) -> bool {
2370        matches!(self, Self::Cut { .. })
2371    }
2372
2373    fn items(&self) -> &BTreeSet<SelectedEntry> {
2374        match self {
2375            ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
2376        }
2377    }
2378}
2379
2380#[cfg(test)]
2381mod tests {
2382    use super::*;
2383    use collections::HashSet;
2384    use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
2385    use pretty_assertions::assert_eq;
2386    use project::{FakeFs, WorktreeSettings};
2387    use serde_json::json;
2388    use settings::SettingsStore;
2389    use std::path::{Path, PathBuf};
2390    use workspace::{
2391        item::{Item, ProjectItem},
2392        register_project_item, AppState,
2393    };
2394
2395    #[gpui::test]
2396    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
2397        init_test(cx);
2398
2399        let fs = FakeFs::new(cx.executor().clone());
2400        fs.insert_tree(
2401            "/root1",
2402            json!({
2403                ".dockerignore": "",
2404                ".git": {
2405                    "HEAD": "",
2406                },
2407                "a": {
2408                    "0": { "q": "", "r": "", "s": "" },
2409                    "1": { "t": "", "u": "" },
2410                    "2": { "v": "", "w": "", "x": "", "y": "" },
2411                },
2412                "b": {
2413                    "3": { "Q": "" },
2414                    "4": { "R": "", "S": "", "T": "", "U": "" },
2415                },
2416                "C": {
2417                    "5": {},
2418                    "6": { "V": "", "W": "" },
2419                    "7": { "X": "" },
2420                    "8": { "Y": {}, "Z": "" }
2421                }
2422            }),
2423        )
2424        .await;
2425        fs.insert_tree(
2426            "/root2",
2427            json!({
2428                "d": {
2429                    "9": ""
2430                },
2431                "e": {}
2432            }),
2433        )
2434        .await;
2435
2436        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2437        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2438        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2439        let panel = workspace
2440            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2441            .unwrap();
2442        assert_eq!(
2443            visible_entries_as_strings(&panel, 0..50, cx),
2444            &[
2445                "v root1",
2446                "    > .git",
2447                "    > a",
2448                "    > b",
2449                "    > C",
2450                "      .dockerignore",
2451                "v root2",
2452                "    > d",
2453                "    > e",
2454            ]
2455        );
2456
2457        toggle_expand_dir(&panel, "root1/b", cx);
2458        assert_eq!(
2459            visible_entries_as_strings(&panel, 0..50, cx),
2460            &[
2461                "v root1",
2462                "    > .git",
2463                "    > a",
2464                "    v b  <== selected",
2465                "        > 3",
2466                "        > 4",
2467                "    > C",
2468                "      .dockerignore",
2469                "v root2",
2470                "    > d",
2471                "    > e",
2472            ]
2473        );
2474
2475        assert_eq!(
2476            visible_entries_as_strings(&panel, 6..9, cx),
2477            &[
2478                //
2479                "    > C",
2480                "      .dockerignore",
2481                "v root2",
2482            ]
2483        );
2484    }
2485
2486    #[gpui::test]
2487    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
2488        init_test(cx);
2489        cx.update(|cx| {
2490            cx.update_global::<SettingsStore, _>(|store, cx| {
2491                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
2492                    worktree_settings.file_scan_exclusions =
2493                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
2494                });
2495            });
2496        });
2497
2498        let fs = FakeFs::new(cx.background_executor.clone());
2499        fs.insert_tree(
2500            "/root1",
2501            json!({
2502                ".dockerignore": "",
2503                ".git": {
2504                    "HEAD": "",
2505                },
2506                "a": {
2507                    "0": { "q": "", "r": "", "s": "" },
2508                    "1": { "t": "", "u": "" },
2509                    "2": { "v": "", "w": "", "x": "", "y": "" },
2510                },
2511                "b": {
2512                    "3": { "Q": "" },
2513                    "4": { "R": "", "S": "", "T": "", "U": "" },
2514                },
2515                "C": {
2516                    "5": {},
2517                    "6": { "V": "", "W": "" },
2518                    "7": { "X": "" },
2519                    "8": { "Y": {}, "Z": "" }
2520                }
2521            }),
2522        )
2523        .await;
2524        fs.insert_tree(
2525            "/root2",
2526            json!({
2527                "d": {
2528                    "4": ""
2529                },
2530                "e": {}
2531            }),
2532        )
2533        .await;
2534
2535        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2536        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2537        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2538        let panel = workspace
2539            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2540            .unwrap();
2541        assert_eq!(
2542            visible_entries_as_strings(&panel, 0..50, cx),
2543            &[
2544                "v root1",
2545                "    > a",
2546                "    > b",
2547                "    > C",
2548                "      .dockerignore",
2549                "v root2",
2550                "    > d",
2551                "    > e",
2552            ]
2553        );
2554
2555        toggle_expand_dir(&panel, "root1/b", cx);
2556        assert_eq!(
2557            visible_entries_as_strings(&panel, 0..50, cx),
2558            &[
2559                "v root1",
2560                "    > a",
2561                "    v b  <== selected",
2562                "        > 3",
2563                "    > C",
2564                "      .dockerignore",
2565                "v root2",
2566                "    > d",
2567                "    > e",
2568            ]
2569        );
2570
2571        toggle_expand_dir(&panel, "root2/d", cx);
2572        assert_eq!(
2573            visible_entries_as_strings(&panel, 0..50, cx),
2574            &[
2575                "v root1",
2576                "    > a",
2577                "    v b",
2578                "        > 3",
2579                "    > C",
2580                "      .dockerignore",
2581                "v root2",
2582                "    v d  <== selected",
2583                "    > e",
2584            ]
2585        );
2586
2587        toggle_expand_dir(&panel, "root2/e", cx);
2588        assert_eq!(
2589            visible_entries_as_strings(&panel, 0..50, cx),
2590            &[
2591                "v root1",
2592                "    > a",
2593                "    v b",
2594                "        > 3",
2595                "    > C",
2596                "      .dockerignore",
2597                "v root2",
2598                "    v d",
2599                "    v e  <== selected",
2600            ]
2601        );
2602    }
2603
2604    #[gpui::test]
2605    async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
2606        init_test(cx);
2607
2608        let fs = FakeFs::new(cx.executor().clone());
2609        fs.insert_tree(
2610            "/root1",
2611            json!({
2612                "dir_1": {
2613                    "nested_dir_1": {
2614                        "nested_dir_2": {
2615                            "nested_dir_3": {
2616                                "file_a.java": "// File contents",
2617                                "file_b.java": "// File contents",
2618                                "file_c.java": "// File contents",
2619                                "nested_dir_4": {
2620                                    "nested_dir_5": {
2621                                        "file_d.java": "// File contents",
2622                                    }
2623                                }
2624                            }
2625                        }
2626                    }
2627                }
2628            }),
2629        )
2630        .await;
2631        fs.insert_tree(
2632            "/root2",
2633            json!({
2634                "dir_2": {
2635                    "file_1.java": "// File contents",
2636                }
2637            }),
2638        )
2639        .await;
2640
2641        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2642        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2643        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2644        cx.update(|cx| {
2645            let settings = *ProjectPanelSettings::get_global(cx);
2646            ProjectPanelSettings::override_global(
2647                ProjectPanelSettings {
2648                    auto_fold_dirs: true,
2649                    ..settings
2650                },
2651                cx,
2652            );
2653        });
2654        let panel = workspace
2655            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2656            .unwrap();
2657        assert_eq!(
2658            visible_entries_as_strings(&panel, 0..10, cx),
2659            &[
2660                "v root1",
2661                "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2662                "v root2",
2663                "    > dir_2",
2664            ]
2665        );
2666
2667        toggle_expand_dir(
2668            &panel,
2669            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2670            cx,
2671        );
2672        assert_eq!(
2673            visible_entries_as_strings(&panel, 0..10, cx),
2674            &[
2675                "v root1",
2676                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
2677                "        > nested_dir_4/nested_dir_5",
2678                "          file_a.java",
2679                "          file_b.java",
2680                "          file_c.java",
2681                "v root2",
2682                "    > dir_2",
2683            ]
2684        );
2685
2686        toggle_expand_dir(
2687            &panel,
2688            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
2689            cx,
2690        );
2691        assert_eq!(
2692            visible_entries_as_strings(&panel, 0..10, cx),
2693            &[
2694                "v root1",
2695                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2696                "        v nested_dir_4/nested_dir_5  <== selected",
2697                "              file_d.java",
2698                "          file_a.java",
2699                "          file_b.java",
2700                "          file_c.java",
2701                "v root2",
2702                "    > dir_2",
2703            ]
2704        );
2705        toggle_expand_dir(&panel, "root2/dir_2", cx);
2706        assert_eq!(
2707            visible_entries_as_strings(&panel, 0..10, cx),
2708            &[
2709                "v root1",
2710                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2711                "        v nested_dir_4/nested_dir_5",
2712                "              file_d.java",
2713                "          file_a.java",
2714                "          file_b.java",
2715                "          file_c.java",
2716                "v root2",
2717                "    v dir_2  <== selected",
2718                "          file_1.java",
2719            ]
2720        );
2721    }
2722
2723    #[gpui::test(iterations = 30)]
2724    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
2725        init_test(cx);
2726
2727        let fs = FakeFs::new(cx.executor().clone());
2728        fs.insert_tree(
2729            "/root1",
2730            json!({
2731                ".dockerignore": "",
2732                ".git": {
2733                    "HEAD": "",
2734                },
2735                "a": {
2736                    "0": { "q": "", "r": "", "s": "" },
2737                    "1": { "t": "", "u": "" },
2738                    "2": { "v": "", "w": "", "x": "", "y": "" },
2739                },
2740                "b": {
2741                    "3": { "Q": "" },
2742                    "4": { "R": "", "S": "", "T": "", "U": "" },
2743                },
2744                "C": {
2745                    "5": {},
2746                    "6": { "V": "", "W": "" },
2747                    "7": { "X": "" },
2748                    "8": { "Y": {}, "Z": "" }
2749                }
2750            }),
2751        )
2752        .await;
2753        fs.insert_tree(
2754            "/root2",
2755            json!({
2756                "d": {
2757                    "9": ""
2758                },
2759                "e": {}
2760            }),
2761        )
2762        .await;
2763
2764        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2765        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2766        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2767        let panel = workspace
2768            .update(cx, |workspace, cx| {
2769                let panel = ProjectPanel::new(workspace, cx);
2770                workspace.add_panel(panel.clone(), cx);
2771                panel
2772            })
2773            .unwrap();
2774
2775        select_path(&panel, "root1", cx);
2776        assert_eq!(
2777            visible_entries_as_strings(&panel, 0..10, cx),
2778            &[
2779                "v root1  <== selected",
2780                "    > .git",
2781                "    > a",
2782                "    > b",
2783                "    > C",
2784                "      .dockerignore",
2785                "v root2",
2786                "    > d",
2787                "    > e",
2788            ]
2789        );
2790
2791        // Add a file with the root folder selected. The filename editor is placed
2792        // before the first file in the root folder.
2793        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2794        panel.update(cx, |panel, cx| {
2795            assert!(panel.filename_editor.read(cx).is_focused(cx));
2796        });
2797        assert_eq!(
2798            visible_entries_as_strings(&panel, 0..10, cx),
2799            &[
2800                "v root1",
2801                "    > .git",
2802                "    > a",
2803                "    > b",
2804                "    > C",
2805                "      [EDITOR: '']  <== selected",
2806                "      .dockerignore",
2807                "v root2",
2808                "    > d",
2809                "    > e",
2810            ]
2811        );
2812
2813        let confirm = panel.update(cx, |panel, cx| {
2814            panel
2815                .filename_editor
2816                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2817            panel.confirm_edit(cx).unwrap()
2818        });
2819        assert_eq!(
2820            visible_entries_as_strings(&panel, 0..10, cx),
2821            &[
2822                "v root1",
2823                "    > .git",
2824                "    > a",
2825                "    > b",
2826                "    > C",
2827                "      [PROCESSING: 'the-new-filename']  <== selected",
2828                "      .dockerignore",
2829                "v root2",
2830                "    > d",
2831                "    > e",
2832            ]
2833        );
2834
2835        confirm.await.unwrap();
2836        assert_eq!(
2837            visible_entries_as_strings(&panel, 0..10, cx),
2838            &[
2839                "v root1",
2840                "    > .git",
2841                "    > a",
2842                "    > b",
2843                "    > C",
2844                "      .dockerignore",
2845                "      the-new-filename  <== selected  <== marked",
2846                "v root2",
2847                "    > d",
2848                "    > e",
2849            ]
2850        );
2851
2852        select_path(&panel, "root1/b", cx);
2853        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2854        assert_eq!(
2855            visible_entries_as_strings(&panel, 0..10, cx),
2856            &[
2857                "v root1",
2858                "    > .git",
2859                "    > a",
2860                "    v b",
2861                "        > 3",
2862                "        > 4",
2863                "          [EDITOR: '']  <== selected",
2864                "    > C",
2865                "      .dockerignore",
2866                "      the-new-filename",
2867            ]
2868        );
2869
2870        panel
2871            .update(cx, |panel, cx| {
2872                panel
2873                    .filename_editor
2874                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2875                panel.confirm_edit(cx).unwrap()
2876            })
2877            .await
2878            .unwrap();
2879        assert_eq!(
2880            visible_entries_as_strings(&panel, 0..10, cx),
2881            &[
2882                "v root1",
2883                "    > .git",
2884                "    > a",
2885                "    v b",
2886                "        > 3",
2887                "        > 4",
2888                "          another-filename.txt  <== selected  <== marked",
2889                "    > C",
2890                "      .dockerignore",
2891                "      the-new-filename",
2892            ]
2893        );
2894
2895        select_path(&panel, "root1/b/another-filename.txt", cx);
2896        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2897        assert_eq!(
2898            visible_entries_as_strings(&panel, 0..10, cx),
2899            &[
2900                "v root1",
2901                "    > .git",
2902                "    > a",
2903                "    v b",
2904                "        > 3",
2905                "        > 4",
2906                "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
2907                "    > C",
2908                "      .dockerignore",
2909                "      the-new-filename",
2910            ]
2911        );
2912
2913        let confirm = panel.update(cx, |panel, cx| {
2914            panel.filename_editor.update(cx, |editor, cx| {
2915                let file_name_selections = editor.selections.all::<usize>(cx);
2916                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2917                let file_name_selection = &file_name_selections[0];
2918                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2919                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2920
2921                editor.set_text("a-different-filename.tar.gz", cx)
2922            });
2923            panel.confirm_edit(cx).unwrap()
2924        });
2925        assert_eq!(
2926            visible_entries_as_strings(&panel, 0..10, cx),
2927            &[
2928                "v root1",
2929                "    > .git",
2930                "    > a",
2931                "    v b",
2932                "        > 3",
2933                "        > 4",
2934                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
2935                "    > C",
2936                "      .dockerignore",
2937                "      the-new-filename",
2938            ]
2939        );
2940
2941        confirm.await.unwrap();
2942        assert_eq!(
2943            visible_entries_as_strings(&panel, 0..10, cx),
2944            &[
2945                "v root1",
2946                "    > .git",
2947                "    > a",
2948                "    v b",
2949                "        > 3",
2950                "        > 4",
2951                "          a-different-filename.tar.gz  <== selected",
2952                "    > C",
2953                "      .dockerignore",
2954                "      the-new-filename",
2955            ]
2956        );
2957
2958        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2959        assert_eq!(
2960            visible_entries_as_strings(&panel, 0..10, cx),
2961            &[
2962                "v root1",
2963                "    > .git",
2964                "    > a",
2965                "    v b",
2966                "        > 3",
2967                "        > 4",
2968                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2969                "    > C",
2970                "      .dockerignore",
2971                "      the-new-filename",
2972            ]
2973        );
2974
2975        panel.update(cx, |panel, cx| {
2976            panel.filename_editor.update(cx, |editor, cx| {
2977                let file_name_selections = editor.selections.all::<usize>(cx);
2978                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2979                let file_name_selection = &file_name_selections[0];
2980                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2981                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..");
2982
2983            });
2984            panel.cancel(&menu::Cancel, cx)
2985        });
2986
2987        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2988        assert_eq!(
2989            visible_entries_as_strings(&panel, 0..10, cx),
2990            &[
2991                "v root1",
2992                "    > .git",
2993                "    > a",
2994                "    v b",
2995                "        > [EDITOR: '']  <== selected",
2996                "        > 3",
2997                "        > 4",
2998                "          a-different-filename.tar.gz",
2999                "    > C",
3000                "      .dockerignore",
3001            ]
3002        );
3003
3004        let confirm = panel.update(cx, |panel, cx| {
3005            panel
3006                .filename_editor
3007                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
3008            panel.confirm_edit(cx).unwrap()
3009        });
3010        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
3011        assert_eq!(
3012            visible_entries_as_strings(&panel, 0..10, cx),
3013            &[
3014                "v root1",
3015                "    > .git",
3016                "    > a",
3017                "    v b",
3018                "        > [PROCESSING: 'new-dir']",
3019                "        > 3  <== selected",
3020                "        > 4",
3021                "          a-different-filename.tar.gz",
3022                "    > C",
3023                "      .dockerignore",
3024            ]
3025        );
3026
3027        confirm.await.unwrap();
3028        assert_eq!(
3029            visible_entries_as_strings(&panel, 0..10, cx),
3030            &[
3031                "v root1",
3032                "    > .git",
3033                "    > a",
3034                "    v b",
3035                "        > 3  <== selected",
3036                "        > 4",
3037                "        > new-dir",
3038                "          a-different-filename.tar.gz",
3039                "    > C",
3040                "      .dockerignore",
3041            ]
3042        );
3043
3044        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
3045        assert_eq!(
3046            visible_entries_as_strings(&panel, 0..10, cx),
3047            &[
3048                "v root1",
3049                "    > .git",
3050                "    > a",
3051                "    v b",
3052                "        > [EDITOR: '3']  <== selected",
3053                "        > 4",
3054                "        > new-dir",
3055                "          a-different-filename.tar.gz",
3056                "    > C",
3057                "      .dockerignore",
3058            ]
3059        );
3060
3061        // Dismiss the rename editor when it loses focus.
3062        workspace.update(cx, |_, cx| cx.blur()).unwrap();
3063        assert_eq!(
3064            visible_entries_as_strings(&panel, 0..10, cx),
3065            &[
3066                "v root1",
3067                "    > .git",
3068                "    > a",
3069                "    v b",
3070                "        > 3  <== selected",
3071                "        > 4",
3072                "        > new-dir",
3073                "          a-different-filename.tar.gz",
3074                "    > C",
3075                "      .dockerignore",
3076            ]
3077        );
3078    }
3079
3080    #[gpui::test(iterations = 10)]
3081    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
3082        init_test(cx);
3083
3084        let fs = FakeFs::new(cx.executor().clone());
3085        fs.insert_tree(
3086            "/root1",
3087            json!({
3088                ".dockerignore": "",
3089                ".git": {
3090                    "HEAD": "",
3091                },
3092                "a": {
3093                    "0": { "q": "", "r": "", "s": "" },
3094                    "1": { "t": "", "u": "" },
3095                    "2": { "v": "", "w": "", "x": "", "y": "" },
3096                },
3097                "b": {
3098                    "3": { "Q": "" },
3099                    "4": { "R": "", "S": "", "T": "", "U": "" },
3100                },
3101                "C": {
3102                    "5": {},
3103                    "6": { "V": "", "W": "" },
3104                    "7": { "X": "" },
3105                    "8": { "Y": {}, "Z": "" }
3106                }
3107            }),
3108        )
3109        .await;
3110        fs.insert_tree(
3111            "/root2",
3112            json!({
3113                "d": {
3114                    "9": ""
3115                },
3116                "e": {}
3117            }),
3118        )
3119        .await;
3120
3121        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3122        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3123        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3124        let panel = workspace
3125            .update(cx, |workspace, cx| {
3126                let panel = ProjectPanel::new(workspace, cx);
3127                workspace.add_panel(panel.clone(), cx);
3128                panel
3129            })
3130            .unwrap();
3131
3132        select_path(&panel, "root1", cx);
3133        assert_eq!(
3134            visible_entries_as_strings(&panel, 0..10, cx),
3135            &[
3136                "v root1  <== selected",
3137                "    > .git",
3138                "    > a",
3139                "    > b",
3140                "    > C",
3141                "      .dockerignore",
3142                "v root2",
3143                "    > d",
3144                "    > e",
3145            ]
3146        );
3147
3148        // Add a file with the root folder selected. The filename editor is placed
3149        // before the first file in the root folder.
3150        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3151        panel.update(cx, |panel, cx| {
3152            assert!(panel.filename_editor.read(cx).is_focused(cx));
3153        });
3154        assert_eq!(
3155            visible_entries_as_strings(&panel, 0..10, cx),
3156            &[
3157                "v root1",
3158                "    > .git",
3159                "    > a",
3160                "    > b",
3161                "    > C",
3162                "      [EDITOR: '']  <== selected",
3163                "      .dockerignore",
3164                "v root2",
3165                "    > d",
3166                "    > e",
3167            ]
3168        );
3169
3170        let confirm = panel.update(cx, |panel, cx| {
3171            panel.filename_editor.update(cx, |editor, cx| {
3172                editor.set_text("/bdir1/dir2/the-new-filename", cx)
3173            });
3174            panel.confirm_edit(cx).unwrap()
3175        });
3176
3177        assert_eq!(
3178            visible_entries_as_strings(&panel, 0..10, cx),
3179            &[
3180                "v root1",
3181                "    > .git",
3182                "    > a",
3183                "    > b",
3184                "    > C",
3185                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
3186                "      .dockerignore",
3187                "v root2",
3188                "    > d",
3189                "    > e",
3190            ]
3191        );
3192
3193        confirm.await.unwrap();
3194        assert_eq!(
3195            visible_entries_as_strings(&panel, 0..13, cx),
3196            &[
3197                "v root1",
3198                "    > .git",
3199                "    > a",
3200                "    > b",
3201                "    v bdir1",
3202                "        v dir2",
3203                "              the-new-filename  <== selected  <== marked",
3204                "    > C",
3205                "      .dockerignore",
3206                "v root2",
3207                "    > d",
3208                "    > e",
3209            ]
3210        );
3211    }
3212
3213    #[gpui::test]
3214    async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
3215        init_test(cx);
3216
3217        let fs = FakeFs::new(cx.executor().clone());
3218        fs.insert_tree(
3219            "/root1",
3220            json!({
3221                ".dockerignore": "",
3222                ".git": {
3223                    "HEAD": "",
3224                },
3225            }),
3226        )
3227        .await;
3228
3229        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3230        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3231        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3232        let panel = workspace
3233            .update(cx, |workspace, cx| {
3234                let panel = ProjectPanel::new(workspace, cx);
3235                workspace.add_panel(panel.clone(), cx);
3236                panel
3237            })
3238            .unwrap();
3239
3240        select_path(&panel, "root1", cx);
3241        assert_eq!(
3242            visible_entries_as_strings(&panel, 0..10, cx),
3243            &["v root1  <== selected", "    > .git", "      .dockerignore",]
3244        );
3245
3246        // Add a file with the root folder selected. The filename editor is placed
3247        // before the first file in the root folder.
3248        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3249        panel.update(cx, |panel, cx| {
3250            assert!(panel.filename_editor.read(cx).is_focused(cx));
3251        });
3252        assert_eq!(
3253            visible_entries_as_strings(&panel, 0..10, cx),
3254            &[
3255                "v root1",
3256                "    > .git",
3257                "      [EDITOR: '']  <== selected",
3258                "      .dockerignore",
3259            ]
3260        );
3261
3262        let confirm = panel.update(cx, |panel, cx| {
3263            panel
3264                .filename_editor
3265                .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
3266            panel.confirm_edit(cx).unwrap()
3267        });
3268
3269        assert_eq!(
3270            visible_entries_as_strings(&panel, 0..10, cx),
3271            &[
3272                "v root1",
3273                "    > .git",
3274                "      [PROCESSING: '/new_dir/']  <== selected",
3275                "      .dockerignore",
3276            ]
3277        );
3278
3279        confirm.await.unwrap();
3280        assert_eq!(
3281            visible_entries_as_strings(&panel, 0..13, cx),
3282            &[
3283                "v root1",
3284                "    > .git",
3285                "    v new_dir  <== selected",
3286                "      .dockerignore",
3287            ]
3288        );
3289    }
3290
3291    #[gpui::test]
3292    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
3293        init_test(cx);
3294
3295        let fs = FakeFs::new(cx.executor().clone());
3296        fs.insert_tree(
3297            "/root1",
3298            json!({
3299                "one.two.txt": "",
3300                "one.txt": ""
3301            }),
3302        )
3303        .await;
3304
3305        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3306        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3307        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3308        let panel = workspace
3309            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3310            .unwrap();
3311
3312        panel.update(cx, |panel, cx| {
3313            panel.select_next(&Default::default(), cx);
3314            panel.select_next(&Default::default(), cx);
3315        });
3316
3317        assert_eq!(
3318            visible_entries_as_strings(&panel, 0..50, cx),
3319            &[
3320                //
3321                "v root1",
3322                "      one.two.txt  <== selected",
3323                "      one.txt",
3324            ]
3325        );
3326
3327        // Regression test - file name is created correctly when
3328        // the copied file's name contains multiple dots.
3329        panel.update(cx, |panel, cx| {
3330            panel.copy(&Default::default(), cx);
3331            panel.paste(&Default::default(), cx);
3332        });
3333        cx.executor().run_until_parked();
3334
3335        assert_eq!(
3336            visible_entries_as_strings(&panel, 0..50, cx),
3337            &[
3338                //
3339                "v root1",
3340                "      one.two copy.txt",
3341                "      one.two.txt  <== selected",
3342                "      one.txt",
3343            ]
3344        );
3345
3346        panel.update(cx, |panel, cx| {
3347            panel.paste(&Default::default(), cx);
3348        });
3349        cx.executor().run_until_parked();
3350
3351        assert_eq!(
3352            visible_entries_as_strings(&panel, 0..50, cx),
3353            &[
3354                //
3355                "v root1",
3356                "      one.two copy 1.txt",
3357                "      one.two copy.txt",
3358                "      one.two.txt  <== selected",
3359                "      one.txt",
3360            ]
3361        );
3362    }
3363
3364    #[gpui::test]
3365    async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
3366        init_test(cx);
3367
3368        let fs = FakeFs::new(cx.executor().clone());
3369        fs.insert_tree(
3370            "/root",
3371            json!({
3372                "a": {
3373                    "one.txt": "",
3374                    "two.txt": "",
3375                    "inner_dir": {
3376                        "three.txt": "",
3377                        "four.txt": "",
3378                    }
3379                },
3380                "b": {}
3381            }),
3382        )
3383        .await;
3384
3385        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3386        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3387        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3388        let panel = workspace
3389            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3390            .unwrap();
3391
3392        select_path(&panel, "root/a", cx);
3393        panel.update(cx, |panel, cx| {
3394            panel.copy(&Default::default(), cx);
3395            panel.select_next(&Default::default(), cx);
3396            panel.paste(&Default::default(), cx);
3397        });
3398        cx.executor().run_until_parked();
3399
3400        let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
3401        assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
3402
3403        let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
3404        assert_ne!(
3405            pasted_dir_file, None,
3406            "Pasted directory file should have an entry"
3407        );
3408
3409        let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
3410        assert_ne!(
3411            pasted_dir_inner_dir, None,
3412            "Directories inside pasted directory should have an entry"
3413        );
3414
3415        toggle_expand_dir(&panel, "root/b/a", cx);
3416        toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
3417
3418        assert_eq!(
3419            visible_entries_as_strings(&panel, 0..50, cx),
3420            &[
3421                //
3422                "v root",
3423                "    > a",
3424                "    v b",
3425                "        v a",
3426                "            v inner_dir  <== selected",
3427                "                  four.txt",
3428                "                  three.txt",
3429                "              one.txt",
3430                "              two.txt",
3431            ]
3432        );
3433
3434        select_path(&panel, "root", cx);
3435        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3436        cx.executor().run_until_parked();
3437        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3438        cx.executor().run_until_parked();
3439        assert_eq!(
3440            visible_entries_as_strings(&panel, 0..50, cx),
3441            &[
3442                //
3443                "v root  <== selected",
3444                "    > a",
3445                "    > a copy",
3446                "    > a copy 1",
3447                "    v b",
3448                "        v a",
3449                "            v inner_dir",
3450                "                  four.txt",
3451                "                  three.txt",
3452                "              one.txt",
3453                "              two.txt"
3454            ]
3455        );
3456    }
3457
3458    #[gpui::test]
3459    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
3460        init_test_with_editor(cx);
3461
3462        let fs = FakeFs::new(cx.executor().clone());
3463        fs.insert_tree(
3464            "/src",
3465            json!({
3466                "test": {
3467                    "first.rs": "// First Rust file",
3468                    "second.rs": "// Second Rust file",
3469                    "third.rs": "// Third Rust file",
3470                }
3471            }),
3472        )
3473        .await;
3474
3475        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3476        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3477        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3478        let panel = workspace
3479            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3480            .unwrap();
3481
3482        toggle_expand_dir(&panel, "src/test", cx);
3483        select_path(&panel, "src/test/first.rs", cx);
3484        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3485        cx.executor().run_until_parked();
3486        assert_eq!(
3487            visible_entries_as_strings(&panel, 0..10, cx),
3488            &[
3489                "v src",
3490                "    v test",
3491                "          first.rs  <== selected",
3492                "          second.rs",
3493                "          third.rs"
3494            ]
3495        );
3496        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
3497
3498        submit_deletion(&panel, cx);
3499        assert_eq!(
3500            visible_entries_as_strings(&panel, 0..10, cx),
3501            &[
3502                "v src",
3503                "    v test",
3504                "          second.rs",
3505                "          third.rs"
3506            ],
3507            "Project panel should have no deleted file, no other file is selected in it"
3508        );
3509        ensure_no_open_items_and_panes(&workspace, cx);
3510
3511        select_path(&panel, "src/test/second.rs", cx);
3512        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3513        cx.executor().run_until_parked();
3514        assert_eq!(
3515            visible_entries_as_strings(&panel, 0..10, cx),
3516            &[
3517                "v src",
3518                "    v test",
3519                "          second.rs  <== selected",
3520                "          third.rs"
3521            ]
3522        );
3523        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
3524
3525        workspace
3526            .update(cx, |workspace, cx| {
3527                let active_items = workspace
3528                    .panes()
3529                    .iter()
3530                    .filter_map(|pane| pane.read(cx).active_item())
3531                    .collect::<Vec<_>>();
3532                assert_eq!(active_items.len(), 1);
3533                let open_editor = active_items
3534                    .into_iter()
3535                    .next()
3536                    .unwrap()
3537                    .downcast::<Editor>()
3538                    .expect("Open item should be an editor");
3539                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
3540            })
3541            .unwrap();
3542        submit_deletion_skipping_prompt(&panel, cx);
3543        assert_eq!(
3544            visible_entries_as_strings(&panel, 0..10, cx),
3545            &["v src", "    v test", "          third.rs"],
3546            "Project panel should have no deleted file, with one last file remaining"
3547        );
3548        ensure_no_open_items_and_panes(&workspace, cx);
3549    }
3550
3551    #[gpui::test]
3552    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
3553        init_test_with_editor(cx);
3554
3555        let fs = FakeFs::new(cx.executor().clone());
3556        fs.insert_tree(
3557            "/src",
3558            json!({
3559                "test": {
3560                    "first.rs": "// First Rust file",
3561                    "second.rs": "// Second Rust file",
3562                    "third.rs": "// Third Rust file",
3563                }
3564            }),
3565        )
3566        .await;
3567
3568        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3569        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3570        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3571        let panel = workspace
3572            .update(cx, |workspace, cx| {
3573                let panel = ProjectPanel::new(workspace, cx);
3574                workspace.add_panel(panel.clone(), cx);
3575                panel
3576            })
3577            .unwrap();
3578
3579        select_path(&panel, "src/", cx);
3580        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3581        cx.executor().run_until_parked();
3582        assert_eq!(
3583            visible_entries_as_strings(&panel, 0..10, cx),
3584            &[
3585                //
3586                "v src  <== selected",
3587                "    > test"
3588            ]
3589        );
3590        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3591        panel.update(cx, |panel, cx| {
3592            assert!(panel.filename_editor.read(cx).is_focused(cx));
3593        });
3594        assert_eq!(
3595            visible_entries_as_strings(&panel, 0..10, cx),
3596            &[
3597                //
3598                "v src",
3599                "    > [EDITOR: '']  <== selected",
3600                "    > test"
3601            ]
3602        );
3603        panel.update(cx, |panel, cx| {
3604            panel
3605                .filename_editor
3606                .update(cx, |editor, cx| editor.set_text("test", cx));
3607            assert!(
3608                panel.confirm_edit(cx).is_none(),
3609                "Should not allow to confirm on conflicting new directory name"
3610            )
3611        });
3612        assert_eq!(
3613            visible_entries_as_strings(&panel, 0..10, cx),
3614            &[
3615                //
3616                "v src",
3617                "    > test"
3618            ],
3619            "File list should be unchanged after failed folder create confirmation"
3620        );
3621
3622        select_path(&panel, "src/test/", cx);
3623        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3624        cx.executor().run_until_parked();
3625        assert_eq!(
3626            visible_entries_as_strings(&panel, 0..10, cx),
3627            &[
3628                //
3629                "v src",
3630                "    > test  <== selected"
3631            ]
3632        );
3633        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3634        panel.update(cx, |panel, cx| {
3635            assert!(panel.filename_editor.read(cx).is_focused(cx));
3636        });
3637        assert_eq!(
3638            visible_entries_as_strings(&panel, 0..10, cx),
3639            &[
3640                "v src",
3641                "    v test",
3642                "          [EDITOR: '']  <== selected",
3643                "          first.rs",
3644                "          second.rs",
3645                "          third.rs"
3646            ]
3647        );
3648        panel.update(cx, |panel, cx| {
3649            panel
3650                .filename_editor
3651                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
3652            assert!(
3653                panel.confirm_edit(cx).is_none(),
3654                "Should not allow to confirm on conflicting new file name"
3655            )
3656        });
3657        assert_eq!(
3658            visible_entries_as_strings(&panel, 0..10, cx),
3659            &[
3660                "v src",
3661                "    v test",
3662                "          first.rs",
3663                "          second.rs",
3664                "          third.rs"
3665            ],
3666            "File list should be unchanged after failed file create confirmation"
3667        );
3668
3669        select_path(&panel, "src/test/first.rs", cx);
3670        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3671        cx.executor().run_until_parked();
3672        assert_eq!(
3673            visible_entries_as_strings(&panel, 0..10, cx),
3674            &[
3675                "v src",
3676                "    v test",
3677                "          first.rs  <== selected",
3678                "          second.rs",
3679                "          third.rs"
3680            ],
3681        );
3682        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3683        panel.update(cx, |panel, cx| {
3684            assert!(panel.filename_editor.read(cx).is_focused(cx));
3685        });
3686        assert_eq!(
3687            visible_entries_as_strings(&panel, 0..10, cx),
3688            &[
3689                "v src",
3690                "    v test",
3691                "          [EDITOR: 'first.rs']  <== selected",
3692                "          second.rs",
3693                "          third.rs"
3694            ]
3695        );
3696        panel.update(cx, |panel, cx| {
3697            panel
3698                .filename_editor
3699                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
3700            assert!(
3701                panel.confirm_edit(cx).is_none(),
3702                "Should not allow to confirm on conflicting file rename"
3703            )
3704        });
3705        assert_eq!(
3706            visible_entries_as_strings(&panel, 0..10, cx),
3707            &[
3708                "v src",
3709                "    v test",
3710                "          first.rs  <== selected",
3711                "          second.rs",
3712                "          third.rs"
3713            ],
3714            "File list should be unchanged after failed rename confirmation"
3715        );
3716    }
3717
3718    #[gpui::test]
3719    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3720        init_test_with_editor(cx);
3721
3722        let fs = FakeFs::new(cx.executor().clone());
3723        fs.insert_tree(
3724            "/project_root",
3725            json!({
3726                "dir_1": {
3727                    "nested_dir": {
3728                        "file_a.py": "# File contents",
3729                    }
3730                },
3731                "file_1.py": "# File contents",
3732            }),
3733        )
3734        .await;
3735
3736        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3737        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3738        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3739        let panel = workspace
3740            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3741            .unwrap();
3742
3743        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3744        cx.executor().run_until_parked();
3745        select_path(&panel, "project_root/dir_1", cx);
3746        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3747        select_path(&panel, "project_root/dir_1/nested_dir", cx);
3748        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3749        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3750        cx.executor().run_until_parked();
3751        assert_eq!(
3752            visible_entries_as_strings(&panel, 0..10, cx),
3753            &[
3754                "v project_root",
3755                "    v dir_1",
3756                "        > nested_dir  <== selected",
3757                "      file_1.py",
3758            ]
3759        );
3760    }
3761
3762    #[gpui::test]
3763    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3764        init_test_with_editor(cx);
3765
3766        let fs = FakeFs::new(cx.executor().clone());
3767        fs.insert_tree(
3768            "/project_root",
3769            json!({
3770                "dir_1": {
3771                    "nested_dir": {
3772                        "file_a.py": "# File contents",
3773                        "file_b.py": "# File contents",
3774                        "file_c.py": "# File contents",
3775                    },
3776                    "file_1.py": "# File contents",
3777                    "file_2.py": "# File contents",
3778                    "file_3.py": "# File contents",
3779                },
3780                "dir_2": {
3781                    "file_1.py": "# File contents",
3782                    "file_2.py": "# File contents",
3783                    "file_3.py": "# File contents",
3784                }
3785            }),
3786        )
3787        .await;
3788
3789        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3790        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3791        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3792        let panel = workspace
3793            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3794            .unwrap();
3795
3796        panel.update(cx, |panel, cx| {
3797            panel.collapse_all_entries(&CollapseAllEntries, cx)
3798        });
3799        cx.executor().run_until_parked();
3800        assert_eq!(
3801            visible_entries_as_strings(&panel, 0..10, cx),
3802            &["v project_root", "    > dir_1", "    > dir_2",]
3803        );
3804
3805        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3806        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3807        cx.executor().run_until_parked();
3808        assert_eq!(
3809            visible_entries_as_strings(&panel, 0..10, cx),
3810            &[
3811                "v project_root",
3812                "    v dir_1  <== selected",
3813                "        > nested_dir",
3814                "          file_1.py",
3815                "          file_2.py",
3816                "          file_3.py",
3817                "    > dir_2",
3818            ]
3819        );
3820    }
3821
3822    #[gpui::test]
3823    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
3824        init_test(cx);
3825
3826        let fs = FakeFs::new(cx.executor().clone());
3827        fs.as_fake().insert_tree("/root", json!({})).await;
3828        let project = Project::test(fs, ["/root".as_ref()], cx).await;
3829        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3830        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3831        let panel = workspace
3832            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3833            .unwrap();
3834
3835        // Make a new buffer with no backing file
3836        workspace
3837            .update(cx, |workspace, cx| {
3838                Editor::new_file(workspace, &Default::default(), cx)
3839            })
3840            .unwrap();
3841
3842        cx.executor().run_until_parked();
3843
3844        // "Save as" the buffer, creating a new backing file for it
3845        let save_task = workspace
3846            .update(cx, |workspace, cx| {
3847                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3848            })
3849            .unwrap();
3850
3851        cx.executor().run_until_parked();
3852        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3853        save_task.await.unwrap();
3854
3855        // Rename the file
3856        select_path(&panel, "root/new", cx);
3857        assert_eq!(
3858            visible_entries_as_strings(&panel, 0..10, cx),
3859            &["v root", "      new  <== selected"]
3860        );
3861        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3862        panel.update(cx, |panel, cx| {
3863            panel
3864                .filename_editor
3865                .update(cx, |editor, cx| editor.set_text("newer", cx));
3866        });
3867        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3868
3869        cx.executor().run_until_parked();
3870        assert_eq!(
3871            visible_entries_as_strings(&panel, 0..10, cx),
3872            &["v root", "      newer  <== selected"]
3873        );
3874
3875        workspace
3876            .update(cx, |workspace, cx| {
3877                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3878            })
3879            .unwrap()
3880            .await
3881            .unwrap();
3882
3883        cx.executor().run_until_parked();
3884        // assert that saving the file doesn't restore "new"
3885        assert_eq!(
3886            visible_entries_as_strings(&panel, 0..10, cx),
3887            &["v root", "      newer  <== selected"]
3888        );
3889    }
3890
3891    #[gpui::test]
3892    async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
3893        init_test_with_editor(cx);
3894        let fs = FakeFs::new(cx.executor().clone());
3895        fs.insert_tree(
3896            "/project_root",
3897            json!({
3898                "dir_1": {
3899                    "nested_dir": {
3900                        "file_a.py": "# File contents",
3901                    }
3902                },
3903                "file_1.py": "# File contents",
3904            }),
3905        )
3906        .await;
3907
3908        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3909        let worktree_id =
3910            cx.update(|cx| project.read(cx).worktrees().next().unwrap().read(cx).id());
3911        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3912        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3913        let panel = workspace
3914            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3915            .unwrap();
3916        cx.update(|cx| {
3917            panel.update(cx, |this, cx| {
3918                this.select_next(&Default::default(), cx);
3919                this.expand_selected_entry(&Default::default(), cx);
3920                this.expand_selected_entry(&Default::default(), cx);
3921                this.select_next(&Default::default(), cx);
3922                this.expand_selected_entry(&Default::default(), cx);
3923                this.select_next(&Default::default(), cx);
3924            })
3925        });
3926        assert_eq!(
3927            visible_entries_as_strings(&panel, 0..10, cx),
3928            &[
3929                "v project_root",
3930                "    v dir_1",
3931                "        v nested_dir",
3932                "              file_a.py  <== selected",
3933                "      file_1.py",
3934            ]
3935        );
3936        let modifiers_with_shift = gpui::Modifiers {
3937            shift: true,
3938            ..Default::default()
3939        };
3940        cx.simulate_modifiers_change(modifiers_with_shift);
3941        cx.update(|cx| {
3942            panel.update(cx, |this, cx| {
3943                this.select_next(&Default::default(), cx);
3944            })
3945        });
3946        assert_eq!(
3947            visible_entries_as_strings(&panel, 0..10, cx),
3948            &[
3949                "v project_root",
3950                "    v dir_1",
3951                "        v nested_dir",
3952                "              file_a.py",
3953                "      file_1.py  <== selected  <== marked",
3954            ]
3955        );
3956        cx.update(|cx| {
3957            panel.update(cx, |this, cx| {
3958                this.select_prev(&Default::default(), cx);
3959            })
3960        });
3961        assert_eq!(
3962            visible_entries_as_strings(&panel, 0..10, cx),
3963            &[
3964                "v project_root",
3965                "    v dir_1",
3966                "        v nested_dir",
3967                "              file_a.py  <== selected  <== marked",
3968                "      file_1.py  <== marked",
3969            ]
3970        );
3971        cx.update(|cx| {
3972            panel.update(cx, |this, cx| {
3973                let drag = DraggedSelection {
3974                    active_selection: this.selection.unwrap(),
3975                    marked_selections: Arc::new(this.marked_entries.clone()),
3976                };
3977                let target_entry = this
3978                    .project
3979                    .read(cx)
3980                    .entry_for_path(&(worktree_id, "").into(), cx)
3981                    .unwrap();
3982                this.drag_onto(&drag, target_entry.id, false, cx);
3983            });
3984        });
3985        cx.run_until_parked();
3986        assert_eq!(
3987            visible_entries_as_strings(&panel, 0..10, cx),
3988            &[
3989                "v project_root",
3990                "    v dir_1",
3991                "        v nested_dir",
3992                "      file_1.py  <== marked",
3993                "      file_a.py  <== selected  <== marked",
3994            ]
3995        );
3996        // ESC clears out all marks
3997        cx.update(|cx| {
3998            panel.update(cx, |this, cx| {
3999                this.cancel(&menu::Cancel, cx);
4000            })
4001        });
4002        assert_eq!(
4003            visible_entries_as_strings(&panel, 0..10, cx),
4004            &[
4005                "v project_root",
4006                "    v dir_1",
4007                "        v nested_dir",
4008                "      file_1.py",
4009                "      file_a.py  <== selected",
4010            ]
4011        );
4012        // ESC clears out all marks
4013        cx.update(|cx| {
4014            panel.update(cx, |this, cx| {
4015                this.select_prev(&SelectPrev, cx);
4016                this.select_next(&SelectNext, cx);
4017            })
4018        });
4019        assert_eq!(
4020            visible_entries_as_strings(&panel, 0..10, cx),
4021            &[
4022                "v project_root",
4023                "    v dir_1",
4024                "        v nested_dir",
4025                "      file_1.py  <== marked",
4026                "      file_a.py  <== selected  <== marked",
4027            ]
4028        );
4029        cx.simulate_modifiers_change(Default::default());
4030        cx.update(|cx| {
4031            panel.update(cx, |this, cx| {
4032                this.cut(&Cut, cx);
4033                this.select_prev(&SelectPrev, cx);
4034                this.select_prev(&SelectPrev, cx);
4035
4036                this.paste(&Paste, cx);
4037                // this.expand_selected_entry(&ExpandSelectedEntry, cx);
4038            })
4039        });
4040        cx.run_until_parked();
4041        assert_eq!(
4042            visible_entries_as_strings(&panel, 0..10, cx),
4043            &[
4044                "v project_root",
4045                "    v dir_1",
4046                "        v nested_dir  <== selected",
4047                "              file_1.py  <== marked",
4048                "              file_a.py  <== marked",
4049            ]
4050        );
4051        cx.simulate_modifiers_change(modifiers_with_shift);
4052        cx.update(|cx| {
4053            panel.update(cx, |this, cx| {
4054                this.expand_selected_entry(&Default::default(), cx);
4055                this.select_next(&SelectNext, cx);
4056                this.select_next(&SelectNext, cx);
4057            })
4058        });
4059        submit_deletion(&panel, cx);
4060        assert_eq!(
4061            visible_entries_as_strings(&panel, 0..10, cx),
4062            &["v project_root", "    v dir_1", "        v nested_dir",]
4063        );
4064    }
4065    #[gpui::test]
4066    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4067        init_test_with_editor(cx);
4068        cx.update(|cx| {
4069            cx.update_global::<SettingsStore, _>(|store, cx| {
4070                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4071                    worktree_settings.file_scan_exclusions = Some(Vec::new());
4072                });
4073                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4074                    project_panel_settings.auto_reveal_entries = Some(false)
4075                });
4076            })
4077        });
4078
4079        let fs = FakeFs::new(cx.background_executor.clone());
4080        fs.insert_tree(
4081            "/project_root",
4082            json!({
4083                ".git": {},
4084                ".gitignore": "**/gitignored_dir",
4085                "dir_1": {
4086                    "file_1.py": "# File 1_1 contents",
4087                    "file_2.py": "# File 1_2 contents",
4088                    "file_3.py": "# File 1_3 contents",
4089                    "gitignored_dir": {
4090                        "file_a.py": "# File contents",
4091                        "file_b.py": "# File contents",
4092                        "file_c.py": "# File contents",
4093                    },
4094                },
4095                "dir_2": {
4096                    "file_1.py": "# File 2_1 contents",
4097                    "file_2.py": "# File 2_2 contents",
4098                    "file_3.py": "# File 2_3 contents",
4099                }
4100            }),
4101        )
4102        .await;
4103
4104        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4105        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4106        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4107        let panel = workspace
4108            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4109            .unwrap();
4110
4111        assert_eq!(
4112            visible_entries_as_strings(&panel, 0..20, cx),
4113            &[
4114                "v project_root",
4115                "    > .git",
4116                "    > dir_1",
4117                "    > dir_2",
4118                "      .gitignore",
4119            ]
4120        );
4121
4122        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4123            .expect("dir 1 file is not ignored and should have an entry");
4124        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4125            .expect("dir 2 file is not ignored and should have an entry");
4126        let gitignored_dir_file =
4127            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4128        assert_eq!(
4129            gitignored_dir_file, None,
4130            "File in the gitignored dir should not have an entry before its dir is toggled"
4131        );
4132
4133        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4134        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4135        cx.executor().run_until_parked();
4136        assert_eq!(
4137            visible_entries_as_strings(&panel, 0..20, cx),
4138            &[
4139                "v project_root",
4140                "    > .git",
4141                "    v dir_1",
4142                "        v gitignored_dir  <== selected",
4143                "              file_a.py",
4144                "              file_b.py",
4145                "              file_c.py",
4146                "          file_1.py",
4147                "          file_2.py",
4148                "          file_3.py",
4149                "    > dir_2",
4150                "      .gitignore",
4151            ],
4152            "Should show gitignored dir file list in the project panel"
4153        );
4154        let gitignored_dir_file =
4155            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4156                .expect("after gitignored dir got opened, a file entry should be present");
4157
4158        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4159        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4160        assert_eq!(
4161            visible_entries_as_strings(&panel, 0..20, cx),
4162            &[
4163                "v project_root",
4164                "    > .git",
4165                "    > dir_1  <== selected",
4166                "    > dir_2",
4167                "      .gitignore",
4168            ],
4169            "Should hide all dir contents again and prepare for the auto reveal test"
4170        );
4171
4172        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4173            panel.update(cx, |panel, cx| {
4174                panel.project.update(cx, |_, cx| {
4175                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4176                })
4177            });
4178            cx.run_until_parked();
4179            assert_eq!(
4180                visible_entries_as_strings(&panel, 0..20, cx),
4181                &[
4182                    "v project_root",
4183                    "    > .git",
4184                    "    > dir_1  <== selected",
4185                    "    > dir_2",
4186                    "      .gitignore",
4187                ],
4188                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4189            );
4190        }
4191
4192        cx.update(|cx| {
4193            cx.update_global::<SettingsStore, _>(|store, cx| {
4194                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4195                    project_panel_settings.auto_reveal_entries = Some(true)
4196                });
4197            })
4198        });
4199
4200        panel.update(cx, |panel, cx| {
4201            panel.project.update(cx, |_, cx| {
4202                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4203            })
4204        });
4205        cx.run_until_parked();
4206        assert_eq!(
4207            visible_entries_as_strings(&panel, 0..20, cx),
4208            &[
4209                "v project_root",
4210                "    > .git",
4211                "    v dir_1",
4212                "        > gitignored_dir",
4213                "          file_1.py  <== selected",
4214                "          file_2.py",
4215                "          file_3.py",
4216                "    > dir_2",
4217                "      .gitignore",
4218            ],
4219            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4220        );
4221
4222        panel.update(cx, |panel, cx| {
4223            panel.project.update(cx, |_, cx| {
4224                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4225            })
4226        });
4227        cx.run_until_parked();
4228        assert_eq!(
4229            visible_entries_as_strings(&panel, 0..20, cx),
4230            &[
4231                "v project_root",
4232                "    > .git",
4233                "    v dir_1",
4234                "        > gitignored_dir",
4235                "          file_1.py",
4236                "          file_2.py",
4237                "          file_3.py",
4238                "    v dir_2",
4239                "          file_1.py  <== selected",
4240                "          file_2.py",
4241                "          file_3.py",
4242                "      .gitignore",
4243            ],
4244            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4245        );
4246
4247        panel.update(cx, |panel, cx| {
4248            panel.project.update(cx, |_, cx| {
4249                cx.emit(project::Event::ActiveEntryChanged(Some(
4250                    gitignored_dir_file,
4251                )))
4252            })
4253        });
4254        cx.run_until_parked();
4255        assert_eq!(
4256            visible_entries_as_strings(&panel, 0..20, cx),
4257            &[
4258                "v project_root",
4259                "    > .git",
4260                "    v dir_1",
4261                "        > gitignored_dir",
4262                "          file_1.py",
4263                "          file_2.py",
4264                "          file_3.py",
4265                "    v dir_2",
4266                "          file_1.py  <== selected",
4267                "          file_2.py",
4268                "          file_3.py",
4269                "      .gitignore",
4270            ],
4271            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4272        );
4273
4274        panel.update(cx, |panel, cx| {
4275            panel.project.update(cx, |_, cx| {
4276                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4277            })
4278        });
4279        cx.run_until_parked();
4280        assert_eq!(
4281            visible_entries_as_strings(&panel, 0..20, cx),
4282            &[
4283                "v project_root",
4284                "    > .git",
4285                "    v dir_1",
4286                "        v gitignored_dir",
4287                "              file_a.py  <== selected",
4288                "              file_b.py",
4289                "              file_c.py",
4290                "          file_1.py",
4291                "          file_2.py",
4292                "          file_3.py",
4293                "    v dir_2",
4294                "          file_1.py",
4295                "          file_2.py",
4296                "          file_3.py",
4297                "      .gitignore",
4298            ],
4299            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4300        );
4301    }
4302
4303    #[gpui::test]
4304    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
4305        init_test_with_editor(cx);
4306        cx.update(|cx| {
4307            cx.update_global::<SettingsStore, _>(|store, cx| {
4308                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4309                    worktree_settings.file_scan_exclusions = Some(Vec::new());
4310                });
4311                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4312                    project_panel_settings.auto_reveal_entries = Some(false)
4313                });
4314            })
4315        });
4316
4317        let fs = FakeFs::new(cx.background_executor.clone());
4318        fs.insert_tree(
4319            "/project_root",
4320            json!({
4321                ".git": {},
4322                ".gitignore": "**/gitignored_dir",
4323                "dir_1": {
4324                    "file_1.py": "# File 1_1 contents",
4325                    "file_2.py": "# File 1_2 contents",
4326                    "file_3.py": "# File 1_3 contents",
4327                    "gitignored_dir": {
4328                        "file_a.py": "# File contents",
4329                        "file_b.py": "# File contents",
4330                        "file_c.py": "# File contents",
4331                    },
4332                },
4333                "dir_2": {
4334                    "file_1.py": "# File 2_1 contents",
4335                    "file_2.py": "# File 2_2 contents",
4336                    "file_3.py": "# File 2_3 contents",
4337                }
4338            }),
4339        )
4340        .await;
4341
4342        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4343        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4344        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4345        let panel = workspace
4346            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4347            .unwrap();
4348
4349        assert_eq!(
4350            visible_entries_as_strings(&panel, 0..20, cx),
4351            &[
4352                "v project_root",
4353                "    > .git",
4354                "    > dir_1",
4355                "    > dir_2",
4356                "      .gitignore",
4357            ]
4358        );
4359
4360        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4361            .expect("dir 1 file is not ignored and should have an entry");
4362        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4363            .expect("dir 2 file is not ignored and should have an entry");
4364        let gitignored_dir_file =
4365            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4366        assert_eq!(
4367            gitignored_dir_file, None,
4368            "File in the gitignored dir should not have an entry before its dir is toggled"
4369        );
4370
4371        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4372        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4373        cx.run_until_parked();
4374        assert_eq!(
4375            visible_entries_as_strings(&panel, 0..20, cx),
4376            &[
4377                "v project_root",
4378                "    > .git",
4379                "    v dir_1",
4380                "        v gitignored_dir  <== selected",
4381                "              file_a.py",
4382                "              file_b.py",
4383                "              file_c.py",
4384                "          file_1.py",
4385                "          file_2.py",
4386                "          file_3.py",
4387                "    > dir_2",
4388                "      .gitignore",
4389            ],
4390            "Should show gitignored dir file list in the project panel"
4391        );
4392        let gitignored_dir_file =
4393            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4394                .expect("after gitignored dir got opened, a file entry should be present");
4395
4396        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4397        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4398        assert_eq!(
4399            visible_entries_as_strings(&panel, 0..20, cx),
4400            &[
4401                "v project_root",
4402                "    > .git",
4403                "    > dir_1  <== selected",
4404                "    > dir_2",
4405                "      .gitignore",
4406            ],
4407            "Should hide all dir contents again and prepare for the explicit reveal test"
4408        );
4409
4410        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4411            panel.update(cx, |panel, cx| {
4412                panel.project.update(cx, |_, cx| {
4413                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4414                })
4415            });
4416            cx.run_until_parked();
4417            assert_eq!(
4418                visible_entries_as_strings(&panel, 0..20, cx),
4419                &[
4420                    "v project_root",
4421                    "    > .git",
4422                    "    > dir_1  <== selected",
4423                    "    > dir_2",
4424                    "      .gitignore",
4425                ],
4426                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4427            );
4428        }
4429
4430        panel.update(cx, |panel, cx| {
4431            panel.project.update(cx, |_, cx| {
4432                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
4433            })
4434        });
4435        cx.run_until_parked();
4436        assert_eq!(
4437            visible_entries_as_strings(&panel, 0..20, cx),
4438            &[
4439                "v project_root",
4440                "    > .git",
4441                "    v dir_1",
4442                "        > gitignored_dir",
4443                "          file_1.py  <== selected",
4444                "          file_2.py",
4445                "          file_3.py",
4446                "    > dir_2",
4447                "      .gitignore",
4448            ],
4449            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
4450        );
4451
4452        panel.update(cx, |panel, cx| {
4453            panel.project.update(cx, |_, cx| {
4454                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
4455            })
4456        });
4457        cx.run_until_parked();
4458        assert_eq!(
4459            visible_entries_as_strings(&panel, 0..20, cx),
4460            &[
4461                "v project_root",
4462                "    > .git",
4463                "    v dir_1",
4464                "        > gitignored_dir",
4465                "          file_1.py",
4466                "          file_2.py",
4467                "          file_3.py",
4468                "    v dir_2",
4469                "          file_1.py  <== selected",
4470                "          file_2.py",
4471                "          file_3.py",
4472                "      .gitignore",
4473            ],
4474            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
4475        );
4476
4477        panel.update(cx, |panel, cx| {
4478            panel.project.update(cx, |_, cx| {
4479                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4480            })
4481        });
4482        cx.run_until_parked();
4483        assert_eq!(
4484            visible_entries_as_strings(&panel, 0..20, cx),
4485            &[
4486                "v project_root",
4487                "    > .git",
4488                "    v dir_1",
4489                "        v gitignored_dir",
4490                "              file_a.py  <== selected",
4491                "              file_b.py",
4492                "              file_c.py",
4493                "          file_1.py",
4494                "          file_2.py",
4495                "          file_3.py",
4496                "    v dir_2",
4497                "          file_1.py",
4498                "          file_2.py",
4499                "          file_3.py",
4500                "      .gitignore",
4501            ],
4502            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
4503        );
4504    }
4505
4506    #[gpui::test]
4507    async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
4508        init_test(cx);
4509        cx.update(|cx| {
4510            cx.update_global::<SettingsStore, _>(|store, cx| {
4511                store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
4512                    project_settings.file_scan_exclusions =
4513                        Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
4514                });
4515            });
4516        });
4517
4518        cx.update(|cx| {
4519            register_project_item::<TestProjectItemView>(cx);
4520        });
4521
4522        let fs = FakeFs::new(cx.executor().clone());
4523        fs.insert_tree(
4524            "/root1",
4525            json!({
4526                ".dockerignore": "",
4527                ".git": {
4528                    "HEAD": "",
4529                },
4530            }),
4531        )
4532        .await;
4533
4534        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4535        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4536        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4537        let panel = workspace
4538            .update(cx, |workspace, cx| {
4539                let panel = ProjectPanel::new(workspace, cx);
4540                workspace.add_panel(panel.clone(), cx);
4541                panel
4542            })
4543            .unwrap();
4544
4545        select_path(&panel, "root1", cx);
4546        assert_eq!(
4547            visible_entries_as_strings(&panel, 0..10, cx),
4548            &["v root1  <== selected", "      .dockerignore",]
4549        );
4550        workspace
4551            .update(cx, |workspace, cx| {
4552                assert!(
4553                    workspace.active_item(cx).is_none(),
4554                    "Should have no active items in the beginning"
4555                );
4556            })
4557            .unwrap();
4558
4559        let excluded_file_path = ".git/COMMIT_EDITMSG";
4560        let excluded_dir_path = "excluded_dir";
4561
4562        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4563        panel.update(cx, |panel, cx| {
4564            assert!(panel.filename_editor.read(cx).is_focused(cx));
4565        });
4566        panel
4567            .update(cx, |panel, cx| {
4568                panel
4569                    .filename_editor
4570                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4571                panel.confirm_edit(cx).unwrap()
4572            })
4573            .await
4574            .unwrap();
4575
4576        assert_eq!(
4577            visible_entries_as_strings(&panel, 0..13, cx),
4578            &["v root1", "      .dockerignore"],
4579            "Excluded dir should not be shown after opening a file in it"
4580        );
4581        panel.update(cx, |panel, cx| {
4582            assert!(
4583                !panel.filename_editor.read(cx).is_focused(cx),
4584                "Should have closed the file name editor"
4585            );
4586        });
4587        workspace
4588            .update(cx, |workspace, cx| {
4589                let active_entry_path = workspace
4590                    .active_item(cx)
4591                    .expect("should have opened and activated the excluded item")
4592                    .act_as::<TestProjectItemView>(cx)
4593                    .expect(
4594                        "should have opened the corresponding project item for the excluded item",
4595                    )
4596                    .read(cx)
4597                    .path
4598                    .clone();
4599                assert_eq!(
4600                    active_entry_path.path.as_ref(),
4601                    Path::new(excluded_file_path),
4602                    "Should open the excluded file"
4603                );
4604
4605                assert!(
4606                    workspace.notification_ids().is_empty(),
4607                    "Should have no notifications after opening an excluded file"
4608                );
4609            })
4610            .unwrap();
4611        assert!(
4612            fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
4613            "Should have created the excluded file"
4614        );
4615
4616        select_path(&panel, "root1", cx);
4617        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4618        panel.update(cx, |panel, cx| {
4619            assert!(panel.filename_editor.read(cx).is_focused(cx));
4620        });
4621        panel
4622            .update(cx, |panel, cx| {
4623                panel
4624                    .filename_editor
4625                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4626                panel.confirm_edit(cx).unwrap()
4627            })
4628            .await
4629            .unwrap();
4630
4631        assert_eq!(
4632            visible_entries_as_strings(&panel, 0..13, cx),
4633            &["v root1", "      .dockerignore"],
4634            "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
4635        );
4636        panel.update(cx, |panel, cx| {
4637            assert!(
4638                !panel.filename_editor.read(cx).is_focused(cx),
4639                "Should have closed the file name editor"
4640            );
4641        });
4642        workspace
4643            .update(cx, |workspace, cx| {
4644                let notifications = workspace.notification_ids();
4645                assert_eq!(
4646                    notifications.len(),
4647                    1,
4648                    "Should receive one notification with the error message"
4649                );
4650                workspace.dismiss_notification(notifications.first().unwrap(), cx);
4651                assert!(workspace.notification_ids().is_empty());
4652            })
4653            .unwrap();
4654
4655        select_path(&panel, "root1", cx);
4656        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4657        panel.update(cx, |panel, cx| {
4658            assert!(panel.filename_editor.read(cx).is_focused(cx));
4659        });
4660        panel
4661            .update(cx, |panel, cx| {
4662                panel
4663                    .filename_editor
4664                    .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
4665                panel.confirm_edit(cx).unwrap()
4666            })
4667            .await
4668            .unwrap();
4669
4670        assert_eq!(
4671            visible_entries_as_strings(&panel, 0..13, cx),
4672            &["v root1", "      .dockerignore"],
4673            "Should not change the project panel after trying to create an excluded directory"
4674        );
4675        panel.update(cx, |panel, cx| {
4676            assert!(
4677                !panel.filename_editor.read(cx).is_focused(cx),
4678                "Should have closed the file name editor"
4679            );
4680        });
4681        workspace
4682            .update(cx, |workspace, cx| {
4683                let notifications = workspace.notification_ids();
4684                assert_eq!(
4685                    notifications.len(),
4686                    1,
4687                    "Should receive one notification explaining that no directory is actually shown"
4688                );
4689                workspace.dismiss_notification(notifications.first().unwrap(), cx);
4690                assert!(workspace.notification_ids().is_empty());
4691            })
4692            .unwrap();
4693        assert!(
4694            fs.is_dir(Path::new("/root1/excluded_dir")).await,
4695            "Should have created the excluded directory"
4696        );
4697    }
4698
4699    fn toggle_expand_dir(
4700        panel: &View<ProjectPanel>,
4701        path: impl AsRef<Path>,
4702        cx: &mut VisualTestContext,
4703    ) {
4704        let path = path.as_ref();
4705        panel.update(cx, |panel, cx| {
4706            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4707                let worktree = worktree.read(cx);
4708                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4709                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4710                    panel.toggle_expanded(entry_id, cx);
4711                    return;
4712                }
4713            }
4714            panic!("no worktree for path {:?}", path);
4715        });
4716    }
4717
4718    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
4719        let path = path.as_ref();
4720        panel.update(cx, |panel, cx| {
4721            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4722                let worktree = worktree.read(cx);
4723                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4724                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4725                    panel.selection = Some(crate::SelectedEntry {
4726                        worktree_id: worktree.id(),
4727                        entry_id,
4728                    });
4729                    return;
4730                }
4731            }
4732            panic!("no worktree for path {:?}", path);
4733        });
4734    }
4735
4736    fn find_project_entry(
4737        panel: &View<ProjectPanel>,
4738        path: impl AsRef<Path>,
4739        cx: &mut VisualTestContext,
4740    ) -> Option<ProjectEntryId> {
4741        let path = path.as_ref();
4742        panel.update(cx, |panel, cx| {
4743            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4744                let worktree = worktree.read(cx);
4745                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4746                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
4747                }
4748            }
4749            panic!("no worktree for path {path:?}");
4750        })
4751    }
4752
4753    fn visible_entries_as_strings(
4754        panel: &View<ProjectPanel>,
4755        range: Range<usize>,
4756        cx: &mut VisualTestContext,
4757    ) -> Vec<String> {
4758        let mut result = Vec::new();
4759        let mut project_entries = HashSet::default();
4760        let mut has_editor = false;
4761
4762        panel.update(cx, |panel, cx| {
4763            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
4764                if details.is_editing {
4765                    assert!(!has_editor, "duplicate editor entry");
4766                    has_editor = true;
4767                } else {
4768                    assert!(
4769                        project_entries.insert(project_entry),
4770                        "duplicate project entry {:?} {:?}",
4771                        project_entry,
4772                        details
4773                    );
4774                }
4775
4776                let indent = "    ".repeat(details.depth);
4777                let icon = if details.kind.is_dir() {
4778                    if details.is_expanded {
4779                        "v "
4780                    } else {
4781                        "> "
4782                    }
4783                } else {
4784                    "  "
4785                };
4786                let name = if details.is_editing {
4787                    format!("[EDITOR: '{}']", details.filename)
4788                } else if details.is_processing {
4789                    format!("[PROCESSING: '{}']", details.filename)
4790                } else {
4791                    details.filename.clone()
4792                };
4793                let selected = if details.is_selected {
4794                    "  <== selected"
4795                } else {
4796                    ""
4797                };
4798                let marked = if details.is_marked {
4799                    "  <== marked"
4800                } else {
4801                    ""
4802                };
4803
4804                result.push(format!("{indent}{icon}{name}{selected}{marked}"));
4805            });
4806        });
4807
4808        result
4809    }
4810
4811    fn init_test(cx: &mut TestAppContext) {
4812        cx.update(|cx| {
4813            let settings_store = SettingsStore::test(cx);
4814            cx.set_global(settings_store);
4815            init_settings(cx);
4816            theme::init(theme::LoadThemes::JustBase, cx);
4817            language::init(cx);
4818            editor::init_settings(cx);
4819            crate::init((), cx);
4820            workspace::init_settings(cx);
4821            client::init_settings(cx);
4822            Project::init_settings(cx);
4823
4824            cx.update_global::<SettingsStore, _>(|store, cx| {
4825                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4826                    worktree_settings.file_scan_exclusions = Some(Vec::new());
4827                });
4828            });
4829        });
4830    }
4831
4832    fn init_test_with_editor(cx: &mut TestAppContext) {
4833        cx.update(|cx| {
4834            let app_state = AppState::test(cx);
4835            theme::init(theme::LoadThemes::JustBase, cx);
4836            init_settings(cx);
4837            language::init(cx);
4838            editor::init(cx);
4839            crate::init((), cx);
4840            workspace::init(app_state.clone(), cx);
4841            Project::init_settings(cx);
4842        });
4843    }
4844
4845    fn ensure_single_file_is_opened(
4846        window: &WindowHandle<Workspace>,
4847        expected_path: &str,
4848        cx: &mut TestAppContext,
4849    ) {
4850        window
4851            .update(cx, |workspace, cx| {
4852                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
4853                assert_eq!(worktrees.len(), 1);
4854                let worktree_id = worktrees[0].read(cx).id();
4855
4856                let open_project_paths = workspace
4857                    .panes()
4858                    .iter()
4859                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4860                    .collect::<Vec<_>>();
4861                assert_eq!(
4862                    open_project_paths,
4863                    vec![ProjectPath {
4864                        worktree_id,
4865                        path: Arc::from(Path::new(expected_path))
4866                    }],
4867                    "Should have opened file, selected in project panel"
4868                );
4869            })
4870            .unwrap();
4871    }
4872
4873    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4874        assert!(
4875            !cx.has_pending_prompt(),
4876            "Should have no prompts before the deletion"
4877        );
4878        panel.update(cx, |panel, cx| {
4879            panel.delete(&Delete { skip_prompt: false }, cx)
4880        });
4881        assert!(
4882            cx.has_pending_prompt(),
4883            "Should have a prompt after the deletion"
4884        );
4885        cx.simulate_prompt_answer(0);
4886        assert!(
4887            !cx.has_pending_prompt(),
4888            "Should have no prompts after prompt was replied to"
4889        );
4890        cx.executor().run_until_parked();
4891    }
4892
4893    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4894        assert!(
4895            !cx.has_pending_prompt(),
4896            "Should have no prompts before the deletion"
4897        );
4898        panel.update(cx, |panel, cx| {
4899            panel.delete(&Delete { skip_prompt: true }, cx)
4900        });
4901        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
4902        cx.executor().run_until_parked();
4903    }
4904
4905    fn ensure_no_open_items_and_panes(
4906        workspace: &WindowHandle<Workspace>,
4907        cx: &mut VisualTestContext,
4908    ) {
4909        assert!(
4910            !cx.has_pending_prompt(),
4911            "Should have no prompts after deletion operation closes the file"
4912        );
4913        workspace
4914            .read_with(cx, |workspace, cx| {
4915                let open_project_paths = workspace
4916                    .panes()
4917                    .iter()
4918                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4919                    .collect::<Vec<_>>();
4920                assert!(
4921                    open_project_paths.is_empty(),
4922                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
4923                );
4924            })
4925            .unwrap();
4926    }
4927
4928    struct TestProjectItemView {
4929        focus_handle: FocusHandle,
4930        path: ProjectPath,
4931    }
4932
4933    struct TestProjectItem {
4934        path: ProjectPath,
4935    }
4936
4937    impl project::Item for TestProjectItem {
4938        fn try_open(
4939            _project: &Model<Project>,
4940            path: &ProjectPath,
4941            cx: &mut AppContext,
4942        ) -> Option<Task<gpui::Result<Model<Self>>>> {
4943            let path = path.clone();
4944            Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
4945        }
4946
4947        fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
4948            None
4949        }
4950
4951        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
4952            Some(self.path.clone())
4953        }
4954    }
4955
4956    impl ProjectItem for TestProjectItemView {
4957        type Item = TestProjectItem;
4958
4959        fn for_project_item(
4960            _: Model<Project>,
4961            project_item: Model<Self::Item>,
4962            cx: &mut ViewContext<Self>,
4963        ) -> Self
4964        where
4965            Self: Sized,
4966        {
4967            Self {
4968                path: project_item.update(cx, |project_item, _| project_item.path.clone()),
4969                focus_handle: cx.focus_handle(),
4970            }
4971        }
4972    }
4973
4974    impl Item for TestProjectItemView {
4975        type Event = ();
4976    }
4977
4978    impl EventEmitter<()> for TestProjectItemView {}
4979
4980    impl FocusableView for TestProjectItemView {
4981        fn focus_handle(&self, _: &AppContext) -> FocusHandle {
4982            self.focus_handle.clone()
4983        }
4984    }
4985
4986    impl Render for TestProjectItemView {
4987        fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
4988            Empty
4989        }
4990    }
4991}