project_panel.rs

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