project_panel.rs

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