project_panel.rs

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