project_panel.rs

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