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