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