project_panel.rs

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