project_panel.rs

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