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