project_panel.rs

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