project_panel.rs

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