project_panel.rs

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