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