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