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::text(format!("{path} • 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                        .style(ButtonStyle::Filled)
2771                        .full_width()
2772                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
2773                        .on_click(cx.listener(|this, _, cx| {
2774                            this.workspace
2775                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
2776                                .log_err();
2777                        })),
2778                )
2779                .drag_over::<ExternalPaths>(|style, _, cx| {
2780                    style.bg(cx.theme().colors().drop_target_background)
2781                })
2782                .on_drop(
2783                    cx.listener(move |this, external_paths: &ExternalPaths, cx| {
2784                        this.last_external_paths_drag_over_entry = None;
2785                        this.marked_entries.clear();
2786                        if let Some(task) = this
2787                            .workspace
2788                            .update(cx, |workspace, cx| {
2789                                workspace.open_workspace_for_paths(
2790                                    true,
2791                                    external_paths.paths().to_owned(),
2792                                    cx,
2793                                )
2794                            })
2795                            .log_err()
2796                        {
2797                            task.detach_and_log_err(cx);
2798                        }
2799                        cx.stop_propagation();
2800                    }),
2801                )
2802        }
2803    }
2804}
2805
2806impl Render for DraggedProjectEntryView {
2807    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2808        let settings = ProjectPanelSettings::get_global(cx);
2809        let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
2810        h_flex().font(ui_font).map(|this| {
2811            if self.selections.contains(&self.selection) {
2812                this.flex_shrink()
2813                    .p_1()
2814                    .items_end()
2815                    .rounded_md()
2816                    .child(self.selections.len().to_string())
2817            } else {
2818                this.bg(cx.theme().colors().background).w(self.width).child(
2819                    ListItem::new(self.selection.entry_id.to_proto() as usize)
2820                        .indent_level(self.details.depth)
2821                        .indent_step_size(px(settings.indent_size))
2822                        .child(if let Some(icon) = &self.details.icon {
2823                            div().child(Icon::from_path(icon.clone()))
2824                        } else {
2825                            div()
2826                        })
2827                        .child(Label::new(self.details.filename.clone())),
2828                )
2829            }
2830        })
2831    }
2832}
2833
2834impl EventEmitter<Event> for ProjectPanel {}
2835
2836impl EventEmitter<PanelEvent> for ProjectPanel {}
2837
2838impl Panel for ProjectPanel {
2839    fn position(&self, cx: &WindowContext) -> DockPosition {
2840        match ProjectPanelSettings::get_global(cx).dock {
2841            ProjectPanelDockPosition::Left => DockPosition::Left,
2842            ProjectPanelDockPosition::Right => DockPosition::Right,
2843        }
2844    }
2845
2846    fn position_is_valid(&self, position: DockPosition) -> bool {
2847        matches!(position, DockPosition::Left | DockPosition::Right)
2848    }
2849
2850    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2851        settings::update_settings_file::<ProjectPanelSettings>(
2852            self.fs.clone(),
2853            cx,
2854            move |settings, _| {
2855                let dock = match position {
2856                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
2857                    DockPosition::Right => ProjectPanelDockPosition::Right,
2858                };
2859                settings.dock = Some(dock);
2860            },
2861        );
2862    }
2863
2864    fn size(&self, cx: &WindowContext) -> Pixels {
2865        self.width
2866            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
2867    }
2868
2869    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2870        self.width = size;
2871        self.serialize(cx);
2872        cx.notify();
2873    }
2874
2875    fn icon(&self, cx: &WindowContext) -> Option<IconName> {
2876        ProjectPanelSettings::get_global(cx)
2877            .button
2878            .then_some(IconName::FileTree)
2879    }
2880
2881    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2882        Some("Project Panel")
2883    }
2884
2885    fn toggle_action(&self) -> Box<dyn Action> {
2886        Box::new(ToggleFocus)
2887    }
2888
2889    fn persistent_name() -> &'static str {
2890        "Project Panel"
2891    }
2892
2893    fn starts_open(&self, cx: &WindowContext) -> bool {
2894        let project = &self.project.read(cx);
2895        project.dev_server_project_id().is_some()
2896            || project.visible_worktrees(cx).any(|tree| {
2897                tree.read(cx)
2898                    .root_entry()
2899                    .map_or(false, |entry| entry.is_dir())
2900            })
2901    }
2902}
2903
2904impl FocusableView for ProjectPanel {
2905    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
2906        self.focus_handle.clone()
2907    }
2908}
2909
2910impl ClipboardEntry {
2911    fn is_cut(&self) -> bool {
2912        matches!(self, Self::Cut { .. })
2913    }
2914
2915    fn items(&self) -> &BTreeSet<SelectedEntry> {
2916        match self {
2917            ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
2918        }
2919    }
2920}
2921
2922#[cfg(test)]
2923mod tests {
2924    use super::*;
2925    use collections::HashSet;
2926    use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
2927    use pretty_assertions::assert_eq;
2928    use project::{FakeFs, WorktreeSettings};
2929    use serde_json::json;
2930    use settings::SettingsStore;
2931    use std::path::{Path, PathBuf};
2932    use workspace::{
2933        item::{Item, ProjectItem},
2934        register_project_item, AppState,
2935    };
2936
2937    #[gpui::test]
2938    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
2939        init_test(cx);
2940
2941        let fs = FakeFs::new(cx.executor().clone());
2942        fs.insert_tree(
2943            "/root1",
2944            json!({
2945                ".dockerignore": "",
2946                ".git": {
2947                    "HEAD": "",
2948                },
2949                "a": {
2950                    "0": { "q": "", "r": "", "s": "" },
2951                    "1": { "t": "", "u": "" },
2952                    "2": { "v": "", "w": "", "x": "", "y": "" },
2953                },
2954                "b": {
2955                    "3": { "Q": "" },
2956                    "4": { "R": "", "S": "", "T": "", "U": "" },
2957                },
2958                "C": {
2959                    "5": {},
2960                    "6": { "V": "", "W": "" },
2961                    "7": { "X": "" },
2962                    "8": { "Y": {}, "Z": "" }
2963                }
2964            }),
2965        )
2966        .await;
2967        fs.insert_tree(
2968            "/root2",
2969            json!({
2970                "d": {
2971                    "9": ""
2972                },
2973                "e": {}
2974            }),
2975        )
2976        .await;
2977
2978        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2979        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2980        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2981        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
2982        assert_eq!(
2983            visible_entries_as_strings(&panel, 0..50, cx),
2984            &[
2985                "v root1",
2986                "    > .git",
2987                "    > a",
2988                "    > b",
2989                "    > C",
2990                "      .dockerignore",
2991                "v root2",
2992                "    > d",
2993                "    > e",
2994            ]
2995        );
2996
2997        toggle_expand_dir(&panel, "root1/b", cx);
2998        assert_eq!(
2999            visible_entries_as_strings(&panel, 0..50, cx),
3000            &[
3001                "v root1",
3002                "    > .git",
3003                "    > a",
3004                "    v b  <== selected",
3005                "        > 3",
3006                "        > 4",
3007                "    > C",
3008                "      .dockerignore",
3009                "v root2",
3010                "    > d",
3011                "    > e",
3012            ]
3013        );
3014
3015        assert_eq!(
3016            visible_entries_as_strings(&panel, 6..9, cx),
3017            &[
3018                //
3019                "    > C",
3020                "      .dockerignore",
3021                "v root2",
3022            ]
3023        );
3024    }
3025
3026    #[gpui::test]
3027    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
3028        init_test(cx);
3029        cx.update(|cx| {
3030            cx.update_global::<SettingsStore, _>(|store, cx| {
3031                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3032                    worktree_settings.file_scan_exclusions =
3033                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
3034                });
3035            });
3036        });
3037
3038        let fs = FakeFs::new(cx.background_executor.clone());
3039        fs.insert_tree(
3040            "/root1",
3041            json!({
3042                ".dockerignore": "",
3043                ".git": {
3044                    "HEAD": "",
3045                },
3046                "a": {
3047                    "0": { "q": "", "r": "", "s": "" },
3048                    "1": { "t": "", "u": "" },
3049                    "2": { "v": "", "w": "", "x": "", "y": "" },
3050                },
3051                "b": {
3052                    "3": { "Q": "" },
3053                    "4": { "R": "", "S": "", "T": "", "U": "" },
3054                },
3055                "C": {
3056                    "5": {},
3057                    "6": { "V": "", "W": "" },
3058                    "7": { "X": "" },
3059                    "8": { "Y": {}, "Z": "" }
3060                }
3061            }),
3062        )
3063        .await;
3064        fs.insert_tree(
3065            "/root2",
3066            json!({
3067                "d": {
3068                    "4": ""
3069                },
3070                "e": {}
3071            }),
3072        )
3073        .await;
3074
3075        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3076        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3077        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3078        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3079        assert_eq!(
3080            visible_entries_as_strings(&panel, 0..50, cx),
3081            &[
3082                "v root1",
3083                "    > a",
3084                "    > b",
3085                "    > C",
3086                "      .dockerignore",
3087                "v root2",
3088                "    > d",
3089                "    > e",
3090            ]
3091        );
3092
3093        toggle_expand_dir(&panel, "root1/b", cx);
3094        assert_eq!(
3095            visible_entries_as_strings(&panel, 0..50, cx),
3096            &[
3097                "v root1",
3098                "    > a",
3099                "    v b  <== selected",
3100                "        > 3",
3101                "    > C",
3102                "      .dockerignore",
3103                "v root2",
3104                "    > d",
3105                "    > e",
3106            ]
3107        );
3108
3109        toggle_expand_dir(&panel, "root2/d", cx);
3110        assert_eq!(
3111            visible_entries_as_strings(&panel, 0..50, cx),
3112            &[
3113                "v root1",
3114                "    > a",
3115                "    v b",
3116                "        > 3",
3117                "    > C",
3118                "      .dockerignore",
3119                "v root2",
3120                "    v d  <== selected",
3121                "    > e",
3122            ]
3123        );
3124
3125        toggle_expand_dir(&panel, "root2/e", cx);
3126        assert_eq!(
3127            visible_entries_as_strings(&panel, 0..50, cx),
3128            &[
3129                "v root1",
3130                "    > a",
3131                "    v b",
3132                "        > 3",
3133                "    > C",
3134                "      .dockerignore",
3135                "v root2",
3136                "    v d",
3137                "    v e  <== selected",
3138            ]
3139        );
3140    }
3141
3142    #[gpui::test]
3143    async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
3144        init_test(cx);
3145
3146        let fs = FakeFs::new(cx.executor().clone());
3147        fs.insert_tree(
3148            "/root1",
3149            json!({
3150                "dir_1": {
3151                    "nested_dir_1": {
3152                        "nested_dir_2": {
3153                            "nested_dir_3": {
3154                                "file_a.java": "// File contents",
3155                                "file_b.java": "// File contents",
3156                                "file_c.java": "// File contents",
3157                                "nested_dir_4": {
3158                                    "nested_dir_5": {
3159                                        "file_d.java": "// File contents",
3160                                    }
3161                                }
3162                            }
3163                        }
3164                    }
3165                }
3166            }),
3167        )
3168        .await;
3169        fs.insert_tree(
3170            "/root2",
3171            json!({
3172                "dir_2": {
3173                    "file_1.java": "// File contents",
3174                }
3175            }),
3176        )
3177        .await;
3178
3179        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3180        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3181        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3182        cx.update(|cx| {
3183            let settings = *ProjectPanelSettings::get_global(cx);
3184            ProjectPanelSettings::override_global(
3185                ProjectPanelSettings {
3186                    auto_fold_dirs: true,
3187                    ..settings
3188                },
3189                cx,
3190            );
3191        });
3192        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3193        assert_eq!(
3194            visible_entries_as_strings(&panel, 0..10, cx),
3195            &[
3196                "v root1",
3197                "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3198                "v root2",
3199                "    > dir_2",
3200            ]
3201        );
3202
3203        toggle_expand_dir(
3204            &panel,
3205            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3206            cx,
3207        );
3208        assert_eq!(
3209            visible_entries_as_strings(&panel, 0..10, cx),
3210            &[
3211                "v root1",
3212                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
3213                "        > nested_dir_4/nested_dir_5",
3214                "          file_a.java",
3215                "          file_b.java",
3216                "          file_c.java",
3217                "v root2",
3218                "    > dir_2",
3219            ]
3220        );
3221
3222        toggle_expand_dir(
3223            &panel,
3224            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
3225            cx,
3226        );
3227        assert_eq!(
3228            visible_entries_as_strings(&panel, 0..10, cx),
3229            &[
3230                "v root1",
3231                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3232                "        v nested_dir_4/nested_dir_5  <== selected",
3233                "              file_d.java",
3234                "          file_a.java",
3235                "          file_b.java",
3236                "          file_c.java",
3237                "v root2",
3238                "    > dir_2",
3239            ]
3240        );
3241        toggle_expand_dir(&panel, "root2/dir_2", cx);
3242        assert_eq!(
3243            visible_entries_as_strings(&panel, 0..10, cx),
3244            &[
3245                "v root1",
3246                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3247                "        v nested_dir_4/nested_dir_5",
3248                "              file_d.java",
3249                "          file_a.java",
3250                "          file_b.java",
3251                "          file_c.java",
3252                "v root2",
3253                "    v dir_2  <== selected",
3254                "          file_1.java",
3255            ]
3256        );
3257    }
3258
3259    #[gpui::test(iterations = 30)]
3260    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
3261        init_test(cx);
3262
3263        let fs = FakeFs::new(cx.executor().clone());
3264        fs.insert_tree(
3265            "/root1",
3266            json!({
3267                ".dockerignore": "",
3268                ".git": {
3269                    "HEAD": "",
3270                },
3271                "a": {
3272                    "0": { "q": "", "r": "", "s": "" },
3273                    "1": { "t": "", "u": "" },
3274                    "2": { "v": "", "w": "", "x": "", "y": "" },
3275                },
3276                "b": {
3277                    "3": { "Q": "" },
3278                    "4": { "R": "", "S": "", "T": "", "U": "" },
3279                },
3280                "C": {
3281                    "5": {},
3282                    "6": { "V": "", "W": "" },
3283                    "7": { "X": "" },
3284                    "8": { "Y": {}, "Z": "" }
3285                }
3286            }),
3287        )
3288        .await;
3289        fs.insert_tree(
3290            "/root2",
3291            json!({
3292                "d": {
3293                    "9": ""
3294                },
3295                "e": {}
3296            }),
3297        )
3298        .await;
3299
3300        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3301        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3302        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3303        let panel = workspace
3304            .update(cx, |workspace, cx| {
3305                let panel = ProjectPanel::new(workspace, cx);
3306                workspace.add_panel(panel.clone(), cx);
3307                panel
3308            })
3309            .unwrap();
3310
3311        select_path(&panel, "root1", cx);
3312        assert_eq!(
3313            visible_entries_as_strings(&panel, 0..10, cx),
3314            &[
3315                "v root1  <== selected",
3316                "    > .git",
3317                "    > a",
3318                "    > b",
3319                "    > C",
3320                "      .dockerignore",
3321                "v root2",
3322                "    > d",
3323                "    > e",
3324            ]
3325        );
3326
3327        // Add a file with the root folder selected. The filename editor is placed
3328        // before the first file in the root folder.
3329        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3330        panel.update(cx, |panel, cx| {
3331            assert!(panel.filename_editor.read(cx).is_focused(cx));
3332        });
3333        assert_eq!(
3334            visible_entries_as_strings(&panel, 0..10, cx),
3335            &[
3336                "v root1",
3337                "    > .git",
3338                "    > a",
3339                "    > b",
3340                "    > C",
3341                "      [EDITOR: '']  <== selected",
3342                "      .dockerignore",
3343                "v root2",
3344                "    > d",
3345                "    > e",
3346            ]
3347        );
3348
3349        let confirm = panel.update(cx, |panel, cx| {
3350            panel
3351                .filename_editor
3352                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
3353            panel.confirm_edit(cx).unwrap()
3354        });
3355        assert_eq!(
3356            visible_entries_as_strings(&panel, 0..10, cx),
3357            &[
3358                "v root1",
3359                "    > .git",
3360                "    > a",
3361                "    > b",
3362                "    > C",
3363                "      [PROCESSING: 'the-new-filename']  <== selected",
3364                "      .dockerignore",
3365                "v root2",
3366                "    > d",
3367                "    > e",
3368            ]
3369        );
3370
3371        confirm.await.unwrap();
3372        assert_eq!(
3373            visible_entries_as_strings(&panel, 0..10, cx),
3374            &[
3375                "v root1",
3376                "    > .git",
3377                "    > a",
3378                "    > b",
3379                "    > C",
3380                "      .dockerignore",
3381                "      the-new-filename  <== selected  <== marked",
3382                "v root2",
3383                "    > d",
3384                "    > e",
3385            ]
3386        );
3387
3388        select_path(&panel, "root1/b", cx);
3389        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3390        assert_eq!(
3391            visible_entries_as_strings(&panel, 0..10, cx),
3392            &[
3393                "v root1",
3394                "    > .git",
3395                "    > a",
3396                "    v b",
3397                "        > 3",
3398                "        > 4",
3399                "          [EDITOR: '']  <== selected",
3400                "    > C",
3401                "      .dockerignore",
3402                "      the-new-filename",
3403            ]
3404        );
3405
3406        panel
3407            .update(cx, |panel, cx| {
3408                panel
3409                    .filename_editor
3410                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
3411                panel.confirm_edit(cx).unwrap()
3412            })
3413            .await
3414            .unwrap();
3415        assert_eq!(
3416            visible_entries_as_strings(&panel, 0..10, cx),
3417            &[
3418                "v root1",
3419                "    > .git",
3420                "    > a",
3421                "    v b",
3422                "        > 3",
3423                "        > 4",
3424                "          another-filename.txt  <== selected  <== marked",
3425                "    > C",
3426                "      .dockerignore",
3427                "      the-new-filename",
3428            ]
3429        );
3430
3431        select_path(&panel, "root1/b/another-filename.txt", cx);
3432        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3433        assert_eq!(
3434            visible_entries_as_strings(&panel, 0..10, cx),
3435            &[
3436                "v root1",
3437                "    > .git",
3438                "    > a",
3439                "    v b",
3440                "        > 3",
3441                "        > 4",
3442                "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
3443                "    > C",
3444                "      .dockerignore",
3445                "      the-new-filename",
3446            ]
3447        );
3448
3449        let confirm = panel.update(cx, |panel, cx| {
3450            panel.filename_editor.update(cx, |editor, cx| {
3451                let file_name_selections = editor.selections.all::<usize>(cx);
3452                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3453                let file_name_selection = &file_name_selections[0];
3454                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3455                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
3456
3457                editor.set_text("a-different-filename.tar.gz", cx)
3458            });
3459            panel.confirm_edit(cx).unwrap()
3460        });
3461        assert_eq!(
3462            visible_entries_as_strings(&panel, 0..10, cx),
3463            &[
3464                "v root1",
3465                "    > .git",
3466                "    > a",
3467                "    v b",
3468                "        > 3",
3469                "        > 4",
3470                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
3471                "    > C",
3472                "      .dockerignore",
3473                "      the-new-filename",
3474            ]
3475        );
3476
3477        confirm.await.unwrap();
3478        assert_eq!(
3479            visible_entries_as_strings(&panel, 0..10, cx),
3480            &[
3481                "v root1",
3482                "    > .git",
3483                "    > a",
3484                "    v b",
3485                "        > 3",
3486                "        > 4",
3487                "          a-different-filename.tar.gz  <== selected",
3488                "    > C",
3489                "      .dockerignore",
3490                "      the-new-filename",
3491            ]
3492        );
3493
3494        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3495        assert_eq!(
3496            visible_entries_as_strings(&panel, 0..10, cx),
3497            &[
3498                "v root1",
3499                "    > .git",
3500                "    > a",
3501                "    v b",
3502                "        > 3",
3503                "        > 4",
3504                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
3505                "    > C",
3506                "      .dockerignore",
3507                "      the-new-filename",
3508            ]
3509        );
3510
3511        panel.update(cx, |panel, cx| {
3512            panel.filename_editor.update(cx, |editor, cx| {
3513                let file_name_selections = editor.selections.all::<usize>(cx);
3514                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3515                let file_name_selection = &file_name_selections[0];
3516                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3517                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..");
3518
3519            });
3520            panel.cancel(&menu::Cancel, cx)
3521        });
3522
3523        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3524        assert_eq!(
3525            visible_entries_as_strings(&panel, 0..10, cx),
3526            &[
3527                "v root1",
3528                "    > .git",
3529                "    > a",
3530                "    v b",
3531                "        > 3",
3532                "        > 4",
3533                "        > [EDITOR: '']  <== selected",
3534                "          a-different-filename.tar.gz",
3535                "    > C",
3536                "      .dockerignore",
3537            ]
3538        );
3539
3540        let confirm = panel.update(cx, |panel, cx| {
3541            panel
3542                .filename_editor
3543                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
3544            panel.confirm_edit(cx).unwrap()
3545        });
3546        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
3547        assert_eq!(
3548            visible_entries_as_strings(&panel, 0..10, cx),
3549            &[
3550                "v root1",
3551                "    > .git",
3552                "    > a",
3553                "    v b",
3554                "        > 3",
3555                "        > 4",
3556                "        > [PROCESSING: 'new-dir']",
3557                "          a-different-filename.tar.gz  <== selected",
3558                "    > C",
3559                "      .dockerignore",
3560            ]
3561        );
3562
3563        confirm.await.unwrap();
3564        assert_eq!(
3565            visible_entries_as_strings(&panel, 0..10, cx),
3566            &[
3567                "v root1",
3568                "    > .git",
3569                "    > a",
3570                "    v b",
3571                "        > 3",
3572                "        > 4",
3573                "        > new-dir",
3574                "          a-different-filename.tar.gz  <== selected",
3575                "    > C",
3576                "      .dockerignore",
3577            ]
3578        );
3579
3580        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
3581        assert_eq!(
3582            visible_entries_as_strings(&panel, 0..10, cx),
3583            &[
3584                "v root1",
3585                "    > .git",
3586                "    > a",
3587                "    v b",
3588                "        > 3",
3589                "        > 4",
3590                "        > new-dir",
3591                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
3592                "    > C",
3593                "      .dockerignore",
3594            ]
3595        );
3596
3597        // Dismiss the rename editor when it loses focus.
3598        workspace.update(cx, |_, cx| cx.blur()).unwrap();
3599        assert_eq!(
3600            visible_entries_as_strings(&panel, 0..10, cx),
3601            &[
3602                "v root1",
3603                "    > .git",
3604                "    > a",
3605                "    v b",
3606                "        > 3",
3607                "        > 4",
3608                "        > new-dir",
3609                "          a-different-filename.tar.gz  <== selected",
3610                "    > C",
3611                "      .dockerignore",
3612            ]
3613        );
3614    }
3615
3616    #[gpui::test(iterations = 10)]
3617    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
3618        init_test(cx);
3619
3620        let fs = FakeFs::new(cx.executor().clone());
3621        fs.insert_tree(
3622            "/root1",
3623            json!({
3624                ".dockerignore": "",
3625                ".git": {
3626                    "HEAD": "",
3627                },
3628                "a": {
3629                    "0": { "q": "", "r": "", "s": "" },
3630                    "1": { "t": "", "u": "" },
3631                    "2": { "v": "", "w": "", "x": "", "y": "" },
3632                },
3633                "b": {
3634                    "3": { "Q": "" },
3635                    "4": { "R": "", "S": "", "T": "", "U": "" },
3636                },
3637                "C": {
3638                    "5": {},
3639                    "6": { "V": "", "W": "" },
3640                    "7": { "X": "" },
3641                    "8": { "Y": {}, "Z": "" }
3642                }
3643            }),
3644        )
3645        .await;
3646        fs.insert_tree(
3647            "/root2",
3648            json!({
3649                "d": {
3650                    "9": ""
3651                },
3652                "e": {}
3653            }),
3654        )
3655        .await;
3656
3657        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3658        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3659        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3660        let panel = workspace
3661            .update(cx, |workspace, cx| {
3662                let panel = ProjectPanel::new(workspace, cx);
3663                workspace.add_panel(panel.clone(), cx);
3664                panel
3665            })
3666            .unwrap();
3667
3668        select_path(&panel, "root1", cx);
3669        assert_eq!(
3670            visible_entries_as_strings(&panel, 0..10, cx),
3671            &[
3672                "v root1  <== selected",
3673                "    > .git",
3674                "    > a",
3675                "    > b",
3676                "    > C",
3677                "      .dockerignore",
3678                "v root2",
3679                "    > d",
3680                "    > e",
3681            ]
3682        );
3683
3684        // Add a file with the root folder selected. The filename editor is placed
3685        // before the first file in the root folder.
3686        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3687        panel.update(cx, |panel, cx| {
3688            assert!(panel.filename_editor.read(cx).is_focused(cx));
3689        });
3690        assert_eq!(
3691            visible_entries_as_strings(&panel, 0..10, cx),
3692            &[
3693                "v root1",
3694                "    > .git",
3695                "    > a",
3696                "    > b",
3697                "    > C",
3698                "      [EDITOR: '']  <== selected",
3699                "      .dockerignore",
3700                "v root2",
3701                "    > d",
3702                "    > e",
3703            ]
3704        );
3705
3706        let confirm = panel.update(cx, |panel, cx| {
3707            panel.filename_editor.update(cx, |editor, cx| {
3708                editor.set_text("/bdir1/dir2/the-new-filename", cx)
3709            });
3710            panel.confirm_edit(cx).unwrap()
3711        });
3712
3713        assert_eq!(
3714            visible_entries_as_strings(&panel, 0..10, cx),
3715            &[
3716                "v root1",
3717                "    > .git",
3718                "    > a",
3719                "    > b",
3720                "    > C",
3721                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
3722                "      .dockerignore",
3723                "v root2",
3724                "    > d",
3725                "    > e",
3726            ]
3727        );
3728
3729        confirm.await.unwrap();
3730        assert_eq!(
3731            visible_entries_as_strings(&panel, 0..13, cx),
3732            &[
3733                "v root1",
3734                "    > .git",
3735                "    > a",
3736                "    > b",
3737                "    v bdir1",
3738                "        v dir2",
3739                "              the-new-filename  <== selected  <== marked",
3740                "    > C",
3741                "      .dockerignore",
3742                "v root2",
3743                "    > d",
3744                "    > e",
3745            ]
3746        );
3747    }
3748
3749    #[gpui::test]
3750    async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
3751        init_test(cx);
3752
3753        let fs = FakeFs::new(cx.executor().clone());
3754        fs.insert_tree(
3755            "/root1",
3756            json!({
3757                ".dockerignore": "",
3758                ".git": {
3759                    "HEAD": "",
3760                },
3761            }),
3762        )
3763        .await;
3764
3765        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3766        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3767        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3768        let panel = workspace
3769            .update(cx, |workspace, cx| {
3770                let panel = ProjectPanel::new(workspace, cx);
3771                workspace.add_panel(panel.clone(), cx);
3772                panel
3773            })
3774            .unwrap();
3775
3776        select_path(&panel, "root1", cx);
3777        assert_eq!(
3778            visible_entries_as_strings(&panel, 0..10, cx),
3779            &["v root1  <== selected", "    > .git", "      .dockerignore",]
3780        );
3781
3782        // Add a file with the root folder selected. The filename editor is placed
3783        // before the first file in the root folder.
3784        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3785        panel.update(cx, |panel, cx| {
3786            assert!(panel.filename_editor.read(cx).is_focused(cx));
3787        });
3788        assert_eq!(
3789            visible_entries_as_strings(&panel, 0..10, cx),
3790            &[
3791                "v root1",
3792                "    > .git",
3793                "      [EDITOR: '']  <== selected",
3794                "      .dockerignore",
3795            ]
3796        );
3797
3798        let confirm = panel.update(cx, |panel, cx| {
3799            panel
3800                .filename_editor
3801                .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
3802            panel.confirm_edit(cx).unwrap()
3803        });
3804
3805        assert_eq!(
3806            visible_entries_as_strings(&panel, 0..10, cx),
3807            &[
3808                "v root1",
3809                "    > .git",
3810                "      [PROCESSING: '/new_dir/']  <== selected",
3811                "      .dockerignore",
3812            ]
3813        );
3814
3815        confirm.await.unwrap();
3816        assert_eq!(
3817            visible_entries_as_strings(&panel, 0..13, cx),
3818            &[
3819                "v root1",
3820                "    > .git",
3821                "    v new_dir  <== selected",
3822                "      .dockerignore",
3823            ]
3824        );
3825    }
3826
3827    #[gpui::test]
3828    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
3829        init_test(cx);
3830
3831        let fs = FakeFs::new(cx.executor().clone());
3832        fs.insert_tree(
3833            "/root1",
3834            json!({
3835                "one.two.txt": "",
3836                "one.txt": ""
3837            }),
3838        )
3839        .await;
3840
3841        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3842        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3843        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3844        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3845
3846        panel.update(cx, |panel, cx| {
3847            panel.select_next(&Default::default(), cx);
3848            panel.select_next(&Default::default(), cx);
3849        });
3850
3851        assert_eq!(
3852            visible_entries_as_strings(&panel, 0..50, cx),
3853            &[
3854                //
3855                "v root1",
3856                "      one.txt  <== selected",
3857                "      one.two.txt",
3858            ]
3859        );
3860
3861        // Regression test - file name is created correctly when
3862        // the copied file's name contains multiple dots.
3863        panel.update(cx, |panel, cx| {
3864            panel.copy(&Default::default(), cx);
3865            panel.paste(&Default::default(), cx);
3866        });
3867        cx.executor().run_until_parked();
3868
3869        assert_eq!(
3870            visible_entries_as_strings(&panel, 0..50, cx),
3871            &[
3872                //
3873                "v root1",
3874                "      one.txt",
3875                "      one copy.txt  <== selected",
3876                "      one.two.txt",
3877            ]
3878        );
3879
3880        panel.update(cx, |panel, cx| {
3881            panel.paste(&Default::default(), cx);
3882        });
3883        cx.executor().run_until_parked();
3884
3885        assert_eq!(
3886            visible_entries_as_strings(&panel, 0..50, cx),
3887            &[
3888                //
3889                "v root1",
3890                "      one.txt",
3891                "      one copy.txt",
3892                "      one copy 1.txt  <== selected",
3893                "      one.two.txt",
3894            ]
3895        );
3896    }
3897
3898    #[gpui::test]
3899    async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
3900        init_test(cx);
3901
3902        let fs = FakeFs::new(cx.executor().clone());
3903        fs.insert_tree(
3904            "/root1",
3905            json!({
3906                "one.txt": "",
3907                "two.txt": "",
3908                "three.txt": "",
3909                "a": {
3910                    "0": { "q": "", "r": "", "s": "" },
3911                    "1": { "t": "", "u": "" },
3912                    "2": { "v": "", "w": "", "x": "", "y": "" },
3913                },
3914            }),
3915        )
3916        .await;
3917
3918        fs.insert_tree(
3919            "/root2",
3920            json!({
3921                "one.txt": "",
3922                "two.txt": "",
3923                "four.txt": "",
3924                "b": {
3925                    "3": { "Q": "" },
3926                    "4": { "R": "", "S": "", "T": "", "U": "" },
3927                },
3928            }),
3929        )
3930        .await;
3931
3932        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3933        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3934        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3935        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3936
3937        select_path(&panel, "root1/three.txt", cx);
3938        panel.update(cx, |panel, cx| {
3939            panel.cut(&Default::default(), cx);
3940        });
3941
3942        select_path(&panel, "root2/one.txt", cx);
3943        panel.update(cx, |panel, cx| {
3944            panel.select_next(&Default::default(), cx);
3945            panel.paste(&Default::default(), cx);
3946        });
3947        cx.executor().run_until_parked();
3948        assert_eq!(
3949            visible_entries_as_strings(&panel, 0..50, cx),
3950            &[
3951                //
3952                "v root1",
3953                "    > a",
3954                "      one.txt",
3955                "      two.txt",
3956                "v root2",
3957                "    > b",
3958                "      four.txt",
3959                "      one.txt",
3960                "      three.txt  <== selected",
3961                "      two.txt",
3962            ]
3963        );
3964
3965        select_path(&panel, "root1/a", cx);
3966        panel.update(cx, |panel, cx| {
3967            panel.cut(&Default::default(), cx);
3968        });
3969        select_path(&panel, "root2/two.txt", cx);
3970        panel.update(cx, |panel, cx| {
3971            panel.select_next(&Default::default(), cx);
3972            panel.paste(&Default::default(), cx);
3973        });
3974
3975        cx.executor().run_until_parked();
3976        assert_eq!(
3977            visible_entries_as_strings(&panel, 0..50, cx),
3978            &[
3979                //
3980                "v root1",
3981                "      one.txt",
3982                "      two.txt",
3983                "v root2",
3984                "    > a  <== selected",
3985                "    > b",
3986                "      four.txt",
3987                "      one.txt",
3988                "      three.txt",
3989                "      two.txt",
3990            ]
3991        );
3992    }
3993
3994    #[gpui::test]
3995    async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
3996        init_test(cx);
3997
3998        let fs = FakeFs::new(cx.executor().clone());
3999        fs.insert_tree(
4000            "/root1",
4001            json!({
4002                "one.txt": "",
4003                "two.txt": "",
4004                "three.txt": "",
4005                "a": {
4006                    "0": { "q": "", "r": "", "s": "" },
4007                    "1": { "t": "", "u": "" },
4008                    "2": { "v": "", "w": "", "x": "", "y": "" },
4009                },
4010            }),
4011        )
4012        .await;
4013
4014        fs.insert_tree(
4015            "/root2",
4016            json!({
4017                "one.txt": "",
4018                "two.txt": "",
4019                "four.txt": "",
4020                "b": {
4021                    "3": { "Q": "" },
4022                    "4": { "R": "", "S": "", "T": "", "U": "" },
4023                },
4024            }),
4025        )
4026        .await;
4027
4028        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4029        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4030        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4031        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4032
4033        select_path(&panel, "root1/three.txt", cx);
4034        panel.update(cx, |panel, cx| {
4035            panel.copy(&Default::default(), cx);
4036        });
4037
4038        select_path(&panel, "root2/one.txt", cx);
4039        panel.update(cx, |panel, cx| {
4040            panel.select_next(&Default::default(), cx);
4041            panel.paste(&Default::default(), cx);
4042        });
4043        cx.executor().run_until_parked();
4044        assert_eq!(
4045            visible_entries_as_strings(&panel, 0..50, cx),
4046            &[
4047                //
4048                "v root1",
4049                "    > a",
4050                "      one.txt",
4051                "      three.txt",
4052                "      two.txt",
4053                "v root2",
4054                "    > b",
4055                "      four.txt",
4056                "      one.txt",
4057                "      three.txt  <== selected",
4058                "      two.txt",
4059            ]
4060        );
4061
4062        select_path(&panel, "root1/three.txt", cx);
4063        panel.update(cx, |panel, cx| {
4064            panel.copy(&Default::default(), cx);
4065        });
4066        select_path(&panel, "root2/two.txt", cx);
4067        panel.update(cx, |panel, cx| {
4068            panel.select_next(&Default::default(), cx);
4069            panel.paste(&Default::default(), cx);
4070        });
4071
4072        cx.executor().run_until_parked();
4073        assert_eq!(
4074            visible_entries_as_strings(&panel, 0..50, cx),
4075            &[
4076                //
4077                "v root1",
4078                "    > a",
4079                "      one.txt",
4080                "      three.txt",
4081                "      two.txt",
4082                "v root2",
4083                "    > b",
4084                "      four.txt",
4085                "      one.txt",
4086                "      three.txt",
4087                "      three copy.txt  <== selected",
4088                "      two.txt",
4089            ]
4090        );
4091
4092        select_path(&panel, "root1/a", cx);
4093        panel.update(cx, |panel, cx| {
4094            panel.copy(&Default::default(), cx);
4095        });
4096        select_path(&panel, "root2/two.txt", cx);
4097        panel.update(cx, |panel, cx| {
4098            panel.select_next(&Default::default(), cx);
4099            panel.paste(&Default::default(), cx);
4100        });
4101
4102        cx.executor().run_until_parked();
4103        assert_eq!(
4104            visible_entries_as_strings(&panel, 0..50, cx),
4105            &[
4106                //
4107                "v root1",
4108                "    > a",
4109                "      one.txt",
4110                "      three.txt",
4111                "      two.txt",
4112                "v root2",
4113                "    > a  <== selected",
4114                "    > b",
4115                "      four.txt",
4116                "      one.txt",
4117                "      three.txt",
4118                "      three copy.txt",
4119                "      two.txt",
4120            ]
4121        );
4122    }
4123
4124    #[gpui::test]
4125    async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
4126        init_test(cx);
4127
4128        let fs = FakeFs::new(cx.executor().clone());
4129        fs.insert_tree(
4130            "/root",
4131            json!({
4132                "a": {
4133                    "one.txt": "",
4134                    "two.txt": "",
4135                    "inner_dir": {
4136                        "three.txt": "",
4137                        "four.txt": "",
4138                    }
4139                },
4140                "b": {}
4141            }),
4142        )
4143        .await;
4144
4145        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4146        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4147        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4148        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4149
4150        select_path(&panel, "root/a", cx);
4151        panel.update(cx, |panel, cx| {
4152            panel.copy(&Default::default(), cx);
4153            panel.select_next(&Default::default(), cx);
4154            panel.paste(&Default::default(), cx);
4155        });
4156        cx.executor().run_until_parked();
4157
4158        let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
4159        assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
4160
4161        let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
4162        assert_ne!(
4163            pasted_dir_file, None,
4164            "Pasted directory file should have an entry"
4165        );
4166
4167        let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
4168        assert_ne!(
4169            pasted_dir_inner_dir, None,
4170            "Directories inside pasted directory should have an entry"
4171        );
4172
4173        toggle_expand_dir(&panel, "root/b/a", cx);
4174        toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
4175
4176        assert_eq!(
4177            visible_entries_as_strings(&panel, 0..50, cx),
4178            &[
4179                //
4180                "v root",
4181                "    > a",
4182                "    v b",
4183                "        v a",
4184                "            v inner_dir  <== selected",
4185                "                  four.txt",
4186                "                  three.txt",
4187                "              one.txt",
4188                "              two.txt",
4189            ]
4190        );
4191
4192        select_path(&panel, "root", cx);
4193        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
4194        cx.executor().run_until_parked();
4195        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
4196        cx.executor().run_until_parked();
4197        assert_eq!(
4198            visible_entries_as_strings(&panel, 0..50, cx),
4199            &[
4200                //
4201                "v root",
4202                "    > a",
4203                "    v a copy",
4204                "        > a  <== selected",
4205                "        > inner_dir",
4206                "          one.txt",
4207                "          two.txt",
4208                "    v b",
4209                "        v a",
4210                "            v inner_dir",
4211                "                  four.txt",
4212                "                  three.txt",
4213                "              one.txt",
4214                "              two.txt"
4215            ]
4216        );
4217    }
4218
4219    #[gpui::test]
4220    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
4221        init_test_with_editor(cx);
4222
4223        let fs = FakeFs::new(cx.executor().clone());
4224        fs.insert_tree(
4225            "/src",
4226            json!({
4227                "test": {
4228                    "first.rs": "// First Rust file",
4229                    "second.rs": "// Second Rust file",
4230                    "third.rs": "// Third Rust file",
4231                }
4232            }),
4233        )
4234        .await;
4235
4236        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4237        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4238        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4239        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4240
4241        toggle_expand_dir(&panel, "src/test", cx);
4242        select_path(&panel, "src/test/first.rs", cx);
4243        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4244        cx.executor().run_until_parked();
4245        assert_eq!(
4246            visible_entries_as_strings(&panel, 0..10, cx),
4247            &[
4248                "v src",
4249                "    v test",
4250                "          first.rs  <== selected",
4251                "          second.rs",
4252                "          third.rs"
4253            ]
4254        );
4255        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4256
4257        submit_deletion(&panel, cx);
4258        assert_eq!(
4259            visible_entries_as_strings(&panel, 0..10, cx),
4260            &[
4261                "v src",
4262                "    v test",
4263                "          second.rs",
4264                "          third.rs"
4265            ],
4266            "Project panel should have no deleted file, no other file is selected in it"
4267        );
4268        ensure_no_open_items_and_panes(&workspace, cx);
4269
4270        select_path(&panel, "src/test/second.rs", cx);
4271        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4272        cx.executor().run_until_parked();
4273        assert_eq!(
4274            visible_entries_as_strings(&panel, 0..10, cx),
4275            &[
4276                "v src",
4277                "    v test",
4278                "          second.rs  <== selected",
4279                "          third.rs"
4280            ]
4281        );
4282        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4283
4284        workspace
4285            .update(cx, |workspace, cx| {
4286                let active_items = workspace
4287                    .panes()
4288                    .iter()
4289                    .filter_map(|pane| pane.read(cx).active_item())
4290                    .collect::<Vec<_>>();
4291                assert_eq!(active_items.len(), 1);
4292                let open_editor = active_items
4293                    .into_iter()
4294                    .next()
4295                    .unwrap()
4296                    .downcast::<Editor>()
4297                    .expect("Open item should be an editor");
4298                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
4299            })
4300            .unwrap();
4301        submit_deletion_skipping_prompt(&panel, cx);
4302        assert_eq!(
4303            visible_entries_as_strings(&panel, 0..10, cx),
4304            &["v src", "    v test", "          third.rs"],
4305            "Project panel should have no deleted file, with one last file remaining"
4306        );
4307        ensure_no_open_items_and_panes(&workspace, cx);
4308    }
4309
4310    #[gpui::test]
4311    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
4312        init_test_with_editor(cx);
4313
4314        let fs = FakeFs::new(cx.executor().clone());
4315        fs.insert_tree(
4316            "/src",
4317            json!({
4318                "test": {
4319                    "first.rs": "// First Rust file",
4320                    "second.rs": "// Second Rust file",
4321                    "third.rs": "// Third Rust file",
4322                }
4323            }),
4324        )
4325        .await;
4326
4327        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4328        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4329        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4330        let panel = workspace
4331            .update(cx, |workspace, cx| {
4332                let panel = ProjectPanel::new(workspace, cx);
4333                workspace.add_panel(panel.clone(), cx);
4334                panel
4335            })
4336            .unwrap();
4337
4338        select_path(&panel, "src/", cx);
4339        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4340        cx.executor().run_until_parked();
4341        assert_eq!(
4342            visible_entries_as_strings(&panel, 0..10, cx),
4343            &[
4344                //
4345                "v src  <== selected",
4346                "    > test"
4347            ]
4348        );
4349        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4350        panel.update(cx, |panel, cx| {
4351            assert!(panel.filename_editor.read(cx).is_focused(cx));
4352        });
4353        assert_eq!(
4354            visible_entries_as_strings(&panel, 0..10, cx),
4355            &[
4356                //
4357                "v src",
4358                "    > [EDITOR: '']  <== selected",
4359                "    > test"
4360            ]
4361        );
4362        panel.update(cx, |panel, cx| {
4363            panel
4364                .filename_editor
4365                .update(cx, |editor, cx| editor.set_text("test", cx));
4366            assert!(
4367                panel.confirm_edit(cx).is_none(),
4368                "Should not allow to confirm on conflicting new directory name"
4369            )
4370        });
4371        assert_eq!(
4372            visible_entries_as_strings(&panel, 0..10, cx),
4373            &[
4374                //
4375                "v src",
4376                "    > test"
4377            ],
4378            "File list should be unchanged after failed folder create confirmation"
4379        );
4380
4381        select_path(&panel, "src/test/", cx);
4382        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4383        cx.executor().run_until_parked();
4384        assert_eq!(
4385            visible_entries_as_strings(&panel, 0..10, cx),
4386            &[
4387                //
4388                "v src",
4389                "    > test  <== selected"
4390            ]
4391        );
4392        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4393        panel.update(cx, |panel, cx| {
4394            assert!(panel.filename_editor.read(cx).is_focused(cx));
4395        });
4396        assert_eq!(
4397            visible_entries_as_strings(&panel, 0..10, cx),
4398            &[
4399                "v src",
4400                "    v test",
4401                "          [EDITOR: '']  <== selected",
4402                "          first.rs",
4403                "          second.rs",
4404                "          third.rs"
4405            ]
4406        );
4407        panel.update(cx, |panel, cx| {
4408            panel
4409                .filename_editor
4410                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
4411            assert!(
4412                panel.confirm_edit(cx).is_none(),
4413                "Should not allow to confirm on conflicting new file name"
4414            )
4415        });
4416        assert_eq!(
4417            visible_entries_as_strings(&panel, 0..10, cx),
4418            &[
4419                "v src",
4420                "    v test",
4421                "          first.rs",
4422                "          second.rs",
4423                "          third.rs"
4424            ],
4425            "File list should be unchanged after failed file create confirmation"
4426        );
4427
4428        select_path(&panel, "src/test/first.rs", cx);
4429        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4430        cx.executor().run_until_parked();
4431        assert_eq!(
4432            visible_entries_as_strings(&panel, 0..10, cx),
4433            &[
4434                "v src",
4435                "    v test",
4436                "          first.rs  <== selected",
4437                "          second.rs",
4438                "          third.rs"
4439            ],
4440        );
4441        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4442        panel.update(cx, |panel, cx| {
4443            assert!(panel.filename_editor.read(cx).is_focused(cx));
4444        });
4445        assert_eq!(
4446            visible_entries_as_strings(&panel, 0..10, cx),
4447            &[
4448                "v src",
4449                "    v test",
4450                "          [EDITOR: 'first.rs']  <== selected",
4451                "          second.rs",
4452                "          third.rs"
4453            ]
4454        );
4455        panel.update(cx, |panel, cx| {
4456            panel
4457                .filename_editor
4458                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
4459            assert!(
4460                panel.confirm_edit(cx).is_none(),
4461                "Should not allow to confirm on conflicting file rename"
4462            )
4463        });
4464        assert_eq!(
4465            visible_entries_as_strings(&panel, 0..10, cx),
4466            &[
4467                "v src",
4468                "    v test",
4469                "          first.rs  <== selected",
4470                "          second.rs",
4471                "          third.rs"
4472            ],
4473            "File list should be unchanged after failed rename confirmation"
4474        );
4475    }
4476
4477    #[gpui::test]
4478    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
4479        init_test_with_editor(cx);
4480
4481        let fs = FakeFs::new(cx.executor().clone());
4482        fs.insert_tree(
4483            "/project_root",
4484            json!({
4485                "dir_1": {
4486                    "nested_dir": {
4487                        "file_a.py": "# File contents",
4488                    }
4489                },
4490                "file_1.py": "# File contents",
4491            }),
4492        )
4493        .await;
4494
4495        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4496        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4497        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4498        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4499
4500        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4501        cx.executor().run_until_parked();
4502        select_path(&panel, "project_root/dir_1", cx);
4503        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4504        select_path(&panel, "project_root/dir_1/nested_dir", cx);
4505        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4506        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4507        cx.executor().run_until_parked();
4508        assert_eq!(
4509            visible_entries_as_strings(&panel, 0..10, cx),
4510            &[
4511                "v project_root",
4512                "    v dir_1",
4513                "        > nested_dir  <== selected",
4514                "      file_1.py",
4515            ]
4516        );
4517    }
4518
4519    #[gpui::test]
4520    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
4521        init_test_with_editor(cx);
4522
4523        let fs = FakeFs::new(cx.executor().clone());
4524        fs.insert_tree(
4525            "/project_root",
4526            json!({
4527                "dir_1": {
4528                    "nested_dir": {
4529                        "file_a.py": "# File contents",
4530                        "file_b.py": "# File contents",
4531                        "file_c.py": "# File contents",
4532                    },
4533                    "file_1.py": "# File contents",
4534                    "file_2.py": "# File contents",
4535                    "file_3.py": "# File contents",
4536                },
4537                "dir_2": {
4538                    "file_1.py": "# File contents",
4539                    "file_2.py": "# File contents",
4540                    "file_3.py": "# File contents",
4541                }
4542            }),
4543        )
4544        .await;
4545
4546        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4547        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4548        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4549        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4550
4551        panel.update(cx, |panel, cx| {
4552            panel.collapse_all_entries(&CollapseAllEntries, cx)
4553        });
4554        cx.executor().run_until_parked();
4555        assert_eq!(
4556            visible_entries_as_strings(&panel, 0..10, cx),
4557            &["v project_root", "    > dir_1", "    > dir_2",]
4558        );
4559
4560        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
4561        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4562        cx.executor().run_until_parked();
4563        assert_eq!(
4564            visible_entries_as_strings(&panel, 0..10, cx),
4565            &[
4566                "v project_root",
4567                "    v dir_1  <== selected",
4568                "        > nested_dir",
4569                "          file_1.py",
4570                "          file_2.py",
4571                "          file_3.py",
4572                "    > dir_2",
4573            ]
4574        );
4575    }
4576
4577    #[gpui::test]
4578    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
4579        init_test(cx);
4580
4581        let fs = FakeFs::new(cx.executor().clone());
4582        fs.as_fake().insert_tree("/root", json!({})).await;
4583        let project = Project::test(fs, ["/root".as_ref()], cx).await;
4584        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4585        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4586        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4587
4588        // Make a new buffer with no backing file
4589        workspace
4590            .update(cx, |workspace, cx| {
4591                Editor::new_file(workspace, &Default::default(), cx)
4592            })
4593            .unwrap();
4594
4595        cx.executor().run_until_parked();
4596
4597        // "Save as" the buffer, creating a new backing file for it
4598        let save_task = workspace
4599            .update(cx, |workspace, cx| {
4600                workspace.save_active_item(workspace::SaveIntent::Save, cx)
4601            })
4602            .unwrap();
4603
4604        cx.executor().run_until_parked();
4605        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
4606        save_task.await.unwrap();
4607
4608        // Rename the file
4609        select_path(&panel, "root/new", cx);
4610        assert_eq!(
4611            visible_entries_as_strings(&panel, 0..10, cx),
4612            &["v root", "      new  <== selected"]
4613        );
4614        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4615        panel.update(cx, |panel, cx| {
4616            panel
4617                .filename_editor
4618                .update(cx, |editor, cx| editor.set_text("newer", cx));
4619        });
4620        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4621
4622        cx.executor().run_until_parked();
4623        assert_eq!(
4624            visible_entries_as_strings(&panel, 0..10, cx),
4625            &["v root", "      newer  <== selected"]
4626        );
4627
4628        workspace
4629            .update(cx, |workspace, cx| {
4630                workspace.save_active_item(workspace::SaveIntent::Save, cx)
4631            })
4632            .unwrap()
4633            .await
4634            .unwrap();
4635
4636        cx.executor().run_until_parked();
4637        // assert that saving the file doesn't restore "new"
4638        assert_eq!(
4639            visible_entries_as_strings(&panel, 0..10, cx),
4640            &["v root", "      newer  <== selected"]
4641        );
4642    }
4643
4644    #[gpui::test]
4645    async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
4646        init_test_with_editor(cx);
4647        let fs = FakeFs::new(cx.executor().clone());
4648        fs.insert_tree(
4649            "/project_root",
4650            json!({
4651                "dir_1": {
4652                    "nested_dir": {
4653                        "file_a.py": "# File contents",
4654                    }
4655                },
4656                "file_1.py": "# File contents",
4657            }),
4658        )
4659        .await;
4660
4661        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4662        let worktree_id =
4663            cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
4664        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4665        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4666        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4667        cx.update(|cx| {
4668            panel.update(cx, |this, cx| {
4669                this.select_next(&Default::default(), cx);
4670                this.expand_selected_entry(&Default::default(), cx);
4671                this.expand_selected_entry(&Default::default(), cx);
4672                this.select_next(&Default::default(), cx);
4673                this.expand_selected_entry(&Default::default(), cx);
4674                this.select_next(&Default::default(), cx);
4675            })
4676        });
4677        assert_eq!(
4678            visible_entries_as_strings(&panel, 0..10, cx),
4679            &[
4680                "v project_root",
4681                "    v dir_1",
4682                "        v nested_dir",
4683                "              file_a.py  <== selected",
4684                "      file_1.py",
4685            ]
4686        );
4687        let modifiers_with_shift = gpui::Modifiers {
4688            shift: true,
4689            ..Default::default()
4690        };
4691        cx.simulate_modifiers_change(modifiers_with_shift);
4692        cx.update(|cx| {
4693            panel.update(cx, |this, cx| {
4694                this.select_next(&Default::default(), cx);
4695            })
4696        });
4697        assert_eq!(
4698            visible_entries_as_strings(&panel, 0..10, cx),
4699            &[
4700                "v project_root",
4701                "    v dir_1",
4702                "        v nested_dir",
4703                "              file_a.py",
4704                "      file_1.py  <== selected  <== marked",
4705            ]
4706        );
4707        cx.update(|cx| {
4708            panel.update(cx, |this, cx| {
4709                this.select_prev(&Default::default(), cx);
4710            })
4711        });
4712        assert_eq!(
4713            visible_entries_as_strings(&panel, 0..10, cx),
4714            &[
4715                "v project_root",
4716                "    v dir_1",
4717                "        v nested_dir",
4718                "              file_a.py  <== selected  <== marked",
4719                "      file_1.py  <== marked",
4720            ]
4721        );
4722        cx.update(|cx| {
4723            panel.update(cx, |this, cx| {
4724                let drag = DraggedSelection {
4725                    active_selection: this.selection.unwrap(),
4726                    marked_selections: Arc::new(this.marked_entries.clone()),
4727                };
4728                let target_entry = this
4729                    .project
4730                    .read(cx)
4731                    .entry_for_path(&(worktree_id, "").into(), cx)
4732                    .unwrap();
4733                this.drag_onto(&drag, target_entry.id, false, cx);
4734            });
4735        });
4736        cx.run_until_parked();
4737        assert_eq!(
4738            visible_entries_as_strings(&panel, 0..10, cx),
4739            &[
4740                "v project_root",
4741                "    v dir_1",
4742                "        v nested_dir",
4743                "      file_1.py  <== marked",
4744                "      file_a.py  <== selected  <== marked",
4745            ]
4746        );
4747        // ESC clears out all marks
4748        cx.update(|cx| {
4749            panel.update(cx, |this, cx| {
4750                this.cancel(&menu::Cancel, cx);
4751            })
4752        });
4753        assert_eq!(
4754            visible_entries_as_strings(&panel, 0..10, cx),
4755            &[
4756                "v project_root",
4757                "    v dir_1",
4758                "        v nested_dir",
4759                "      file_1.py",
4760                "      file_a.py  <== selected",
4761            ]
4762        );
4763        // ESC clears out all marks
4764        cx.update(|cx| {
4765            panel.update(cx, |this, cx| {
4766                this.select_prev(&SelectPrev, cx);
4767                this.select_next(&SelectNext, cx);
4768            })
4769        });
4770        assert_eq!(
4771            visible_entries_as_strings(&panel, 0..10, cx),
4772            &[
4773                "v project_root",
4774                "    v dir_1",
4775                "        v nested_dir",
4776                "      file_1.py  <== marked",
4777                "      file_a.py  <== selected  <== marked",
4778            ]
4779        );
4780        cx.simulate_modifiers_change(Default::default());
4781        cx.update(|cx| {
4782            panel.update(cx, |this, cx| {
4783                this.cut(&Cut, cx);
4784                this.select_prev(&SelectPrev, cx);
4785                this.select_prev(&SelectPrev, cx);
4786
4787                this.paste(&Paste, cx);
4788                // this.expand_selected_entry(&ExpandSelectedEntry, cx);
4789            })
4790        });
4791        cx.run_until_parked();
4792        assert_eq!(
4793            visible_entries_as_strings(&panel, 0..10, cx),
4794            &[
4795                "v project_root",
4796                "    v dir_1",
4797                "        v nested_dir",
4798                "              file_1.py  <== marked",
4799                "              file_a.py  <== selected  <== marked",
4800            ]
4801        );
4802        cx.simulate_modifiers_change(modifiers_with_shift);
4803        cx.update(|cx| {
4804            panel.update(cx, |this, cx| {
4805                this.expand_selected_entry(&Default::default(), cx);
4806                this.select_next(&SelectNext, cx);
4807                this.select_next(&SelectNext, cx);
4808            })
4809        });
4810        submit_deletion(&panel, cx);
4811        assert_eq!(
4812            visible_entries_as_strings(&panel, 0..10, cx),
4813            &["v project_root", "    v dir_1", "        v nested_dir",]
4814        );
4815    }
4816    #[gpui::test]
4817    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4818        init_test_with_editor(cx);
4819        cx.update(|cx| {
4820            cx.update_global::<SettingsStore, _>(|store, cx| {
4821                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4822                    worktree_settings.file_scan_exclusions = Some(Vec::new());
4823                });
4824                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4825                    project_panel_settings.auto_reveal_entries = Some(false)
4826                });
4827            })
4828        });
4829
4830        let fs = FakeFs::new(cx.background_executor.clone());
4831        fs.insert_tree(
4832            "/project_root",
4833            json!({
4834                ".git": {},
4835                ".gitignore": "**/gitignored_dir",
4836                "dir_1": {
4837                    "file_1.py": "# File 1_1 contents",
4838                    "file_2.py": "# File 1_2 contents",
4839                    "file_3.py": "# File 1_3 contents",
4840                    "gitignored_dir": {
4841                        "file_a.py": "# File contents",
4842                        "file_b.py": "# File contents",
4843                        "file_c.py": "# File contents",
4844                    },
4845                },
4846                "dir_2": {
4847                    "file_1.py": "# File 2_1 contents",
4848                    "file_2.py": "# File 2_2 contents",
4849                    "file_3.py": "# File 2_3 contents",
4850                }
4851            }),
4852        )
4853        .await;
4854
4855        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4856        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4857        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4858        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4859
4860        assert_eq!(
4861            visible_entries_as_strings(&panel, 0..20, cx),
4862            &[
4863                "v project_root",
4864                "    > .git",
4865                "    > dir_1",
4866                "    > dir_2",
4867                "      .gitignore",
4868            ]
4869        );
4870
4871        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4872            .expect("dir 1 file is not ignored and should have an entry");
4873        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4874            .expect("dir 2 file is not ignored and should have an entry");
4875        let gitignored_dir_file =
4876            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4877        assert_eq!(
4878            gitignored_dir_file, None,
4879            "File in the gitignored dir should not have an entry before its dir is toggled"
4880        );
4881
4882        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4883        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4884        cx.executor().run_until_parked();
4885        assert_eq!(
4886            visible_entries_as_strings(&panel, 0..20, cx),
4887            &[
4888                "v project_root",
4889                "    > .git",
4890                "    v dir_1",
4891                "        v gitignored_dir  <== selected",
4892                "              file_a.py",
4893                "              file_b.py",
4894                "              file_c.py",
4895                "          file_1.py",
4896                "          file_2.py",
4897                "          file_3.py",
4898                "    > dir_2",
4899                "      .gitignore",
4900            ],
4901            "Should show gitignored dir file list in the project panel"
4902        );
4903        let gitignored_dir_file =
4904            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4905                .expect("after gitignored dir got opened, a file entry should be present");
4906
4907        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4908        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4909        assert_eq!(
4910            visible_entries_as_strings(&panel, 0..20, cx),
4911            &[
4912                "v project_root",
4913                "    > .git",
4914                "    > dir_1  <== selected",
4915                "    > dir_2",
4916                "      .gitignore",
4917            ],
4918            "Should hide all dir contents again and prepare for the auto reveal test"
4919        );
4920
4921        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4922            panel.update(cx, |panel, cx| {
4923                panel.project.update(cx, |_, cx| {
4924                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4925                })
4926            });
4927            cx.run_until_parked();
4928            assert_eq!(
4929                visible_entries_as_strings(&panel, 0..20, cx),
4930                &[
4931                    "v project_root",
4932                    "    > .git",
4933                    "    > dir_1  <== selected",
4934                    "    > dir_2",
4935                    "      .gitignore",
4936                ],
4937                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4938            );
4939        }
4940
4941        cx.update(|cx| {
4942            cx.update_global::<SettingsStore, _>(|store, cx| {
4943                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4944                    project_panel_settings.auto_reveal_entries = Some(true)
4945                });
4946            })
4947        });
4948
4949        panel.update(cx, |panel, cx| {
4950            panel.project.update(cx, |_, cx| {
4951                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4952            })
4953        });
4954        cx.run_until_parked();
4955        assert_eq!(
4956            visible_entries_as_strings(&panel, 0..20, cx),
4957            &[
4958                "v project_root",
4959                "    > .git",
4960                "    v dir_1",
4961                "        > gitignored_dir",
4962                "          file_1.py  <== selected",
4963                "          file_2.py",
4964                "          file_3.py",
4965                "    > dir_2",
4966                "      .gitignore",
4967            ],
4968            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4969        );
4970
4971        panel.update(cx, |panel, cx| {
4972            panel.project.update(cx, |_, cx| {
4973                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4974            })
4975        });
4976        cx.run_until_parked();
4977        assert_eq!(
4978            visible_entries_as_strings(&panel, 0..20, cx),
4979            &[
4980                "v project_root",
4981                "    > .git",
4982                "    v dir_1",
4983                "        > gitignored_dir",
4984                "          file_1.py",
4985                "          file_2.py",
4986                "          file_3.py",
4987                "    v dir_2",
4988                "          file_1.py  <== selected",
4989                "          file_2.py",
4990                "          file_3.py",
4991                "      .gitignore",
4992            ],
4993            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4994        );
4995
4996        panel.update(cx, |panel, cx| {
4997            panel.project.update(cx, |_, cx| {
4998                cx.emit(project::Event::ActiveEntryChanged(Some(
4999                    gitignored_dir_file,
5000                )))
5001            })
5002        });
5003        cx.run_until_parked();
5004        assert_eq!(
5005            visible_entries_as_strings(&panel, 0..20, cx),
5006            &[
5007                "v project_root",
5008                "    > .git",
5009                "    v dir_1",
5010                "        > gitignored_dir",
5011                "          file_1.py",
5012                "          file_2.py",
5013                "          file_3.py",
5014                "    v dir_2",
5015                "          file_1.py  <== selected",
5016                "          file_2.py",
5017                "          file_3.py",
5018                "      .gitignore",
5019            ],
5020            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
5021        );
5022
5023        panel.update(cx, |panel, cx| {
5024            panel.project.update(cx, |_, cx| {
5025                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5026            })
5027        });
5028        cx.run_until_parked();
5029        assert_eq!(
5030            visible_entries_as_strings(&panel, 0..20, cx),
5031            &[
5032                "v project_root",
5033                "    > .git",
5034                "    v dir_1",
5035                "        v gitignored_dir",
5036                "              file_a.py  <== selected",
5037                "              file_b.py",
5038                "              file_c.py",
5039                "          file_1.py",
5040                "          file_2.py",
5041                "          file_3.py",
5042                "    v dir_2",
5043                "          file_1.py",
5044                "          file_2.py",
5045                "          file_3.py",
5046                "      .gitignore",
5047            ],
5048            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
5049        );
5050    }
5051
5052    #[gpui::test]
5053    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
5054        init_test_with_editor(cx);
5055        cx.update(|cx| {
5056            cx.update_global::<SettingsStore, _>(|store, cx| {
5057                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5058                    worktree_settings.file_scan_exclusions = Some(Vec::new());
5059                });
5060                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5061                    project_panel_settings.auto_reveal_entries = Some(false)
5062                });
5063            })
5064        });
5065
5066        let fs = FakeFs::new(cx.background_executor.clone());
5067        fs.insert_tree(
5068            "/project_root",
5069            json!({
5070                ".git": {},
5071                ".gitignore": "**/gitignored_dir",
5072                "dir_1": {
5073                    "file_1.py": "# File 1_1 contents",
5074                    "file_2.py": "# File 1_2 contents",
5075                    "file_3.py": "# File 1_3 contents",
5076                    "gitignored_dir": {
5077                        "file_a.py": "# File contents",
5078                        "file_b.py": "# File contents",
5079                        "file_c.py": "# File contents",
5080                    },
5081                },
5082                "dir_2": {
5083                    "file_1.py": "# File 2_1 contents",
5084                    "file_2.py": "# File 2_2 contents",
5085                    "file_3.py": "# File 2_3 contents",
5086                }
5087            }),
5088        )
5089        .await;
5090
5091        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5092        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5093        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5094        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5095
5096        assert_eq!(
5097            visible_entries_as_strings(&panel, 0..20, cx),
5098            &[
5099                "v project_root",
5100                "    > .git",
5101                "    > dir_1",
5102                "    > dir_2",
5103                "      .gitignore",
5104            ]
5105        );
5106
5107        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5108            .expect("dir 1 file is not ignored and should have an entry");
5109        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5110            .expect("dir 2 file is not ignored and should have an entry");
5111        let gitignored_dir_file =
5112            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5113        assert_eq!(
5114            gitignored_dir_file, None,
5115            "File in the gitignored dir should not have an entry before its dir is toggled"
5116        );
5117
5118        toggle_expand_dir(&panel, "project_root/dir_1", cx);
5119        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5120        cx.run_until_parked();
5121        assert_eq!(
5122            visible_entries_as_strings(&panel, 0..20, cx),
5123            &[
5124                "v project_root",
5125                "    > .git",
5126                "    v dir_1",
5127                "        v gitignored_dir  <== selected",
5128                "              file_a.py",
5129                "              file_b.py",
5130                "              file_c.py",
5131                "          file_1.py",
5132                "          file_2.py",
5133                "          file_3.py",
5134                "    > dir_2",
5135                "      .gitignore",
5136            ],
5137            "Should show gitignored dir file list in the project panel"
5138        );
5139        let gitignored_dir_file =
5140            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5141                .expect("after gitignored dir got opened, a file entry should be present");
5142
5143        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5144        toggle_expand_dir(&panel, "project_root/dir_1", cx);
5145        assert_eq!(
5146            visible_entries_as_strings(&panel, 0..20, cx),
5147            &[
5148                "v project_root",
5149                "    > .git",
5150                "    > dir_1  <== selected",
5151                "    > dir_2",
5152                "      .gitignore",
5153            ],
5154            "Should hide all dir contents again and prepare for the explicit reveal test"
5155        );
5156
5157        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5158            panel.update(cx, |panel, cx| {
5159                panel.project.update(cx, |_, cx| {
5160                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5161                })
5162            });
5163            cx.run_until_parked();
5164            assert_eq!(
5165                visible_entries_as_strings(&panel, 0..20, cx),
5166                &[
5167                    "v project_root",
5168                    "    > .git",
5169                    "    > dir_1  <== selected",
5170                    "    > dir_2",
5171                    "      .gitignore",
5172                ],
5173                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5174            );
5175        }
5176
5177        panel.update(cx, |panel, cx| {
5178            panel.project.update(cx, |_, cx| {
5179                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
5180            })
5181        });
5182        cx.run_until_parked();
5183        assert_eq!(
5184            visible_entries_as_strings(&panel, 0..20, cx),
5185            &[
5186                "v project_root",
5187                "    > .git",
5188                "    v dir_1",
5189                "        > gitignored_dir",
5190                "          file_1.py  <== selected",
5191                "          file_2.py",
5192                "          file_3.py",
5193                "    > dir_2",
5194                "      .gitignore",
5195            ],
5196            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
5197        );
5198
5199        panel.update(cx, |panel, cx| {
5200            panel.project.update(cx, |_, cx| {
5201                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
5202            })
5203        });
5204        cx.run_until_parked();
5205        assert_eq!(
5206            visible_entries_as_strings(&panel, 0..20, cx),
5207            &[
5208                "v project_root",
5209                "    > .git",
5210                "    v dir_1",
5211                "        > gitignored_dir",
5212                "          file_1.py",
5213                "          file_2.py",
5214                "          file_3.py",
5215                "    v dir_2",
5216                "          file_1.py  <== selected",
5217                "          file_2.py",
5218                "          file_3.py",
5219                "      .gitignore",
5220            ],
5221            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
5222        );
5223
5224        panel.update(cx, |panel, cx| {
5225            panel.project.update(cx, |_, cx| {
5226                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5227            })
5228        });
5229        cx.run_until_parked();
5230        assert_eq!(
5231            visible_entries_as_strings(&panel, 0..20, cx),
5232            &[
5233                "v project_root",
5234                "    > .git",
5235                "    v dir_1",
5236                "        v gitignored_dir",
5237                "              file_a.py  <== selected",
5238                "              file_b.py",
5239                "              file_c.py",
5240                "          file_1.py",
5241                "          file_2.py",
5242                "          file_3.py",
5243                "    v dir_2",
5244                "          file_1.py",
5245                "          file_2.py",
5246                "          file_3.py",
5247                "      .gitignore",
5248            ],
5249            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
5250        );
5251    }
5252
5253    #[gpui::test]
5254    async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
5255        init_test(cx);
5256        cx.update(|cx| {
5257            cx.update_global::<SettingsStore, _>(|store, cx| {
5258                store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
5259                    project_settings.file_scan_exclusions =
5260                        Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
5261                });
5262            });
5263        });
5264
5265        cx.update(|cx| {
5266            register_project_item::<TestProjectItemView>(cx);
5267        });
5268
5269        let fs = FakeFs::new(cx.executor().clone());
5270        fs.insert_tree(
5271            "/root1",
5272            json!({
5273                ".dockerignore": "",
5274                ".git": {
5275                    "HEAD": "",
5276                },
5277            }),
5278        )
5279        .await;
5280
5281        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5282        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5283        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5284        let panel = workspace
5285            .update(cx, |workspace, cx| {
5286                let panel = ProjectPanel::new(workspace, cx);
5287                workspace.add_panel(panel.clone(), cx);
5288                panel
5289            })
5290            .unwrap();
5291
5292        select_path(&panel, "root1", cx);
5293        assert_eq!(
5294            visible_entries_as_strings(&panel, 0..10, cx),
5295            &["v root1  <== selected", "      .dockerignore",]
5296        );
5297        workspace
5298            .update(cx, |workspace, cx| {
5299                assert!(
5300                    workspace.active_item(cx).is_none(),
5301                    "Should have no active items in the beginning"
5302                );
5303            })
5304            .unwrap();
5305
5306        let excluded_file_path = ".git/COMMIT_EDITMSG";
5307        let excluded_dir_path = "excluded_dir";
5308
5309        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5310        panel.update(cx, |panel, cx| {
5311            assert!(panel.filename_editor.read(cx).is_focused(cx));
5312        });
5313        panel
5314            .update(cx, |panel, cx| {
5315                panel
5316                    .filename_editor
5317                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
5318                panel.confirm_edit(cx).unwrap()
5319            })
5320            .await
5321            .unwrap();
5322
5323        assert_eq!(
5324            visible_entries_as_strings(&panel, 0..13, cx),
5325            &["v root1", "      .dockerignore"],
5326            "Excluded dir should not be shown after opening a file in it"
5327        );
5328        panel.update(cx, |panel, cx| {
5329            assert!(
5330                !panel.filename_editor.read(cx).is_focused(cx),
5331                "Should have closed the file name editor"
5332            );
5333        });
5334        workspace
5335            .update(cx, |workspace, cx| {
5336                let active_entry_path = workspace
5337                    .active_item(cx)
5338                    .expect("should have opened and activated the excluded item")
5339                    .act_as::<TestProjectItemView>(cx)
5340                    .expect(
5341                        "should have opened the corresponding project item for the excluded item",
5342                    )
5343                    .read(cx)
5344                    .path
5345                    .clone();
5346                assert_eq!(
5347                    active_entry_path.path.as_ref(),
5348                    Path::new(excluded_file_path),
5349                    "Should open the excluded file"
5350                );
5351
5352                assert!(
5353                    workspace.notification_ids().is_empty(),
5354                    "Should have no notifications after opening an excluded file"
5355                );
5356            })
5357            .unwrap();
5358        assert!(
5359            fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
5360            "Should have created the excluded file"
5361        );
5362
5363        select_path(&panel, "root1", cx);
5364        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5365        panel.update(cx, |panel, cx| {
5366            assert!(panel.filename_editor.read(cx).is_focused(cx));
5367        });
5368        panel
5369            .update(cx, |panel, cx| {
5370                panel
5371                    .filename_editor
5372                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
5373                panel.confirm_edit(cx).unwrap()
5374            })
5375            .await
5376            .unwrap();
5377
5378        assert_eq!(
5379            visible_entries_as_strings(&panel, 0..13, cx),
5380            &["v root1", "      .dockerignore"],
5381            "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
5382        );
5383        panel.update(cx, |panel, cx| {
5384            assert!(
5385                !panel.filename_editor.read(cx).is_focused(cx),
5386                "Should have closed the file name editor"
5387            );
5388        });
5389        workspace
5390            .update(cx, |workspace, cx| {
5391                let notifications = workspace.notification_ids();
5392                assert_eq!(
5393                    notifications.len(),
5394                    1,
5395                    "Should receive one notification with the error message"
5396                );
5397                workspace.dismiss_notification(notifications.first().unwrap(), cx);
5398                assert!(workspace.notification_ids().is_empty());
5399            })
5400            .unwrap();
5401
5402        select_path(&panel, "root1", cx);
5403        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5404        panel.update(cx, |panel, cx| {
5405            assert!(panel.filename_editor.read(cx).is_focused(cx));
5406        });
5407        panel
5408            .update(cx, |panel, cx| {
5409                panel
5410                    .filename_editor
5411                    .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
5412                panel.confirm_edit(cx).unwrap()
5413            })
5414            .await
5415            .unwrap();
5416
5417        assert_eq!(
5418            visible_entries_as_strings(&panel, 0..13, cx),
5419            &["v root1", "      .dockerignore"],
5420            "Should not change the project panel after trying to create an excluded directory"
5421        );
5422        panel.update(cx, |panel, cx| {
5423            assert!(
5424                !panel.filename_editor.read(cx).is_focused(cx),
5425                "Should have closed the file name editor"
5426            );
5427        });
5428        workspace
5429            .update(cx, |workspace, cx| {
5430                let notifications = workspace.notification_ids();
5431                assert_eq!(
5432                    notifications.len(),
5433                    1,
5434                    "Should receive one notification explaining that no directory is actually shown"
5435                );
5436                workspace.dismiss_notification(notifications.first().unwrap(), cx);
5437                assert!(workspace.notification_ids().is_empty());
5438            })
5439            .unwrap();
5440        assert!(
5441            fs.is_dir(Path::new("/root1/excluded_dir")).await,
5442            "Should have created the excluded directory"
5443        );
5444    }
5445
5446    fn toggle_expand_dir(
5447        panel: &View<ProjectPanel>,
5448        path: impl AsRef<Path>,
5449        cx: &mut VisualTestContext,
5450    ) {
5451        let path = path.as_ref();
5452        panel.update(cx, |panel, cx| {
5453            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5454                let worktree = worktree.read(cx);
5455                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5456                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5457                    panel.toggle_expanded(entry_id, cx);
5458                    return;
5459                }
5460            }
5461            panic!("no worktree for path {:?}", path);
5462        });
5463    }
5464
5465    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
5466        let path = path.as_ref();
5467        panel.update(cx, |panel, cx| {
5468            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5469                let worktree = worktree.read(cx);
5470                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5471                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5472                    panel.selection = Some(crate::SelectedEntry {
5473                        worktree_id: worktree.id(),
5474                        entry_id,
5475                    });
5476                    return;
5477                }
5478            }
5479            panic!("no worktree for path {:?}", path);
5480        });
5481    }
5482
5483    fn find_project_entry(
5484        panel: &View<ProjectPanel>,
5485        path: impl AsRef<Path>,
5486        cx: &mut VisualTestContext,
5487    ) -> Option<ProjectEntryId> {
5488        let path = path.as_ref();
5489        panel.update(cx, |panel, cx| {
5490            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5491                let worktree = worktree.read(cx);
5492                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5493                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
5494                }
5495            }
5496            panic!("no worktree for path {path:?}");
5497        })
5498    }
5499
5500    fn visible_entries_as_strings(
5501        panel: &View<ProjectPanel>,
5502        range: Range<usize>,
5503        cx: &mut VisualTestContext,
5504    ) -> Vec<String> {
5505        let mut result = Vec::new();
5506        let mut project_entries = HashSet::default();
5507        let mut has_editor = false;
5508
5509        panel.update(cx, |panel, cx| {
5510            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
5511                if details.is_editing {
5512                    assert!(!has_editor, "duplicate editor entry");
5513                    has_editor = true;
5514                } else {
5515                    assert!(
5516                        project_entries.insert(project_entry),
5517                        "duplicate project entry {:?} {:?}",
5518                        project_entry,
5519                        details
5520                    );
5521                }
5522
5523                let indent = "    ".repeat(details.depth);
5524                let icon = if details.kind.is_dir() {
5525                    if details.is_expanded {
5526                        "v "
5527                    } else {
5528                        "> "
5529                    }
5530                } else {
5531                    "  "
5532                };
5533                let name = if details.is_editing {
5534                    format!("[EDITOR: '{}']", details.filename)
5535                } else if details.is_processing {
5536                    format!("[PROCESSING: '{}']", details.filename)
5537                } else {
5538                    details.filename.clone()
5539                };
5540                let selected = if details.is_selected {
5541                    "  <== selected"
5542                } else {
5543                    ""
5544                };
5545                let marked = if details.is_marked {
5546                    "  <== marked"
5547                } else {
5548                    ""
5549                };
5550
5551                result.push(format!("{indent}{icon}{name}{selected}{marked}"));
5552            });
5553        });
5554
5555        result
5556    }
5557
5558    fn init_test(cx: &mut TestAppContext) {
5559        cx.update(|cx| {
5560            let settings_store = SettingsStore::test(cx);
5561            cx.set_global(settings_store);
5562            init_settings(cx);
5563            theme::init(theme::LoadThemes::JustBase, cx);
5564            language::init(cx);
5565            editor::init_settings(cx);
5566            crate::init((), cx);
5567            workspace::init_settings(cx);
5568            client::init_settings(cx);
5569            Project::init_settings(cx);
5570
5571            cx.update_global::<SettingsStore, _>(|store, cx| {
5572                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5573                    project_panel_settings.auto_fold_dirs = Some(false);
5574                });
5575                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5576                    worktree_settings.file_scan_exclusions = Some(Vec::new());
5577                });
5578            });
5579        });
5580    }
5581
5582    fn init_test_with_editor(cx: &mut TestAppContext) {
5583        cx.update(|cx| {
5584            let app_state = AppState::test(cx);
5585            theme::init(theme::LoadThemes::JustBase, cx);
5586            init_settings(cx);
5587            language::init(cx);
5588            editor::init(cx);
5589            crate::init((), cx);
5590            workspace::init(app_state.clone(), cx);
5591            Project::init_settings(cx);
5592
5593            cx.update_global::<SettingsStore, _>(|store, cx| {
5594                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5595                    project_panel_settings.auto_fold_dirs = Some(false);
5596                });
5597                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5598                    worktree_settings.file_scan_exclusions = Some(Vec::new());
5599                });
5600            });
5601        });
5602    }
5603
5604    fn ensure_single_file_is_opened(
5605        window: &WindowHandle<Workspace>,
5606        expected_path: &str,
5607        cx: &mut TestAppContext,
5608    ) {
5609        window
5610            .update(cx, |workspace, cx| {
5611                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
5612                assert_eq!(worktrees.len(), 1);
5613                let worktree_id = worktrees[0].read(cx).id();
5614
5615                let open_project_paths = workspace
5616                    .panes()
5617                    .iter()
5618                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5619                    .collect::<Vec<_>>();
5620                assert_eq!(
5621                    open_project_paths,
5622                    vec![ProjectPath {
5623                        worktree_id,
5624                        path: Arc::from(Path::new(expected_path))
5625                    }],
5626                    "Should have opened file, selected in project panel"
5627                );
5628            })
5629            .unwrap();
5630    }
5631
5632    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
5633        assert!(
5634            !cx.has_pending_prompt(),
5635            "Should have no prompts before the deletion"
5636        );
5637        panel.update(cx, |panel, cx| {
5638            panel.delete(&Delete { skip_prompt: false }, cx)
5639        });
5640        assert!(
5641            cx.has_pending_prompt(),
5642            "Should have a prompt after the deletion"
5643        );
5644        cx.simulate_prompt_answer(0);
5645        assert!(
5646            !cx.has_pending_prompt(),
5647            "Should have no prompts after prompt was replied to"
5648        );
5649        cx.executor().run_until_parked();
5650    }
5651
5652    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
5653        assert!(
5654            !cx.has_pending_prompt(),
5655            "Should have no prompts before the deletion"
5656        );
5657        panel.update(cx, |panel, cx| {
5658            panel.delete(&Delete { skip_prompt: true }, cx)
5659        });
5660        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
5661        cx.executor().run_until_parked();
5662    }
5663
5664    fn ensure_no_open_items_and_panes(
5665        workspace: &WindowHandle<Workspace>,
5666        cx: &mut VisualTestContext,
5667    ) {
5668        assert!(
5669            !cx.has_pending_prompt(),
5670            "Should have no prompts after deletion operation closes the file"
5671        );
5672        workspace
5673            .read_with(cx, |workspace, cx| {
5674                let open_project_paths = workspace
5675                    .panes()
5676                    .iter()
5677                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5678                    .collect::<Vec<_>>();
5679                assert!(
5680                    open_project_paths.is_empty(),
5681                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
5682                );
5683            })
5684            .unwrap();
5685    }
5686
5687    struct TestProjectItemView {
5688        focus_handle: FocusHandle,
5689        path: ProjectPath,
5690    }
5691
5692    struct TestProjectItem {
5693        path: ProjectPath,
5694    }
5695
5696    impl project::Item for TestProjectItem {
5697        fn try_open(
5698            _project: &Model<Project>,
5699            path: &ProjectPath,
5700            cx: &mut AppContext,
5701        ) -> Option<Task<gpui::Result<Model<Self>>>> {
5702            let path = path.clone();
5703            Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
5704        }
5705
5706        fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
5707            None
5708        }
5709
5710        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
5711            Some(self.path.clone())
5712        }
5713    }
5714
5715    impl ProjectItem for TestProjectItemView {
5716        type Item = TestProjectItem;
5717
5718        fn for_project_item(
5719            _: Model<Project>,
5720            project_item: Model<Self::Item>,
5721            cx: &mut ViewContext<Self>,
5722        ) -> Self
5723        where
5724            Self: Sized,
5725        {
5726            Self {
5727                path: project_item.update(cx, |project_item, _| project_item.path.clone()),
5728                focus_handle: cx.focus_handle(),
5729            }
5730        }
5731    }
5732
5733    impl Item for TestProjectItemView {
5734        type Event = ();
5735    }
5736
5737    impl EventEmitter<()> for TestProjectItemView {}
5738
5739    impl FocusableView for TestProjectItemView {
5740        fn focus_handle(&self, _: &AppContext) -> FocusHandle {
5741            self.focus_handle.clone()
5742        }
5743    }
5744
5745    impl Render for TestProjectItemView {
5746        fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
5747            Empty
5748        }
5749    }
5750}