project_panel.rs

   1mod project_panel_settings;
   2mod utils;
   3
   4use client::{ErrorCode, ErrorExt};
   5use language::DiagnosticSeverity;
   6use settings::{Settings, SettingsStore};
   7
   8use db::kvp::KEY_VALUE_STORE;
   9use editor::{
  10    items::{
  11        entry_diagnostic_aware_icon_decoration_and_color,
  12        entry_diagnostic_aware_icon_name_and_color, entry_git_aware_label_color,
  13    },
  14    scroll::{Autoscroll, ScrollbarAutoHide},
  15    Editor, EditorEvent, EditorSettings, ShowScrollbar,
  16};
  17use file_icons::FileIcons;
  18
  19use anyhow::{anyhow, Context as _, Result};
  20use collections::{hash_map, BTreeSet, HashMap};
  21use command_palette_hooks::CommandPaletteFilter;
  22use git::repository::GitFileStatus;
  23use gpui::{
  24    actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action,
  25    AnyElement, AppContext, AssetSource, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent,
  26    Div, DragMoveEvent, EventEmitter, ExternalPaths, FocusHandle, FocusableView, Hsla,
  27    InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model,
  28    MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, ScrollStrategy,
  29    Stateful, Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext,
  30    VisualContext as _, WeakView, WindowContext,
  31};
  32use indexmap::IndexMap;
  33use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
  34use project::{
  35    relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree,
  36    WorktreeId,
  37};
  38use project_panel_settings::{
  39    ProjectPanelDockPosition, ProjectPanelSettings, ShowDiagnostics, ShowIndentGuides,
  40};
  41use serde::{Deserialize, Serialize};
  42use smallvec::SmallVec;
  43use std::any::TypeId;
  44use std::{
  45    cell::OnceCell,
  46    cmp,
  47    collections::HashSet,
  48    ffi::OsStr,
  49    ops::Range,
  50    path::{Path, PathBuf},
  51    sync::Arc,
  52    time::Duration,
  53};
  54use theme::ThemeSettings;
  55use ui::{
  56    prelude::*, v_flex, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind,
  57    IndentGuideColors, IndentGuideLayout, KeyBinding, Label, ListItem, ListItemSpacing, Scrollbar,
  58    ScrollbarState, Tooltip,
  59};
  60use util::{maybe, paths::compare_paths, ResultExt, TakeUntilExt, TryFutureExt};
  61use workspace::{
  62    dock::{DockPosition, Panel, PanelEvent},
  63    notifications::{DetachAndPromptErr, NotifyTaskExt},
  64    DraggedSelection, OpenInTerminal, PreviewTabsSettings, SelectedEntry, Workspace,
  65};
  66use worktree::{CreatedEntry, GitEntry, GitEntryRef};
  67
  68const PROJECT_PANEL_KEY: &str = "ProjectPanel";
  69const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  70
  71pub struct ProjectPanel {
  72    project: Model<Project>,
  73    fs: Arc<dyn Fs>,
  74    focus_handle: FocusHandle,
  75    scroll_handle: UniformListScrollHandle,
  76    // An update loop that keeps incrementing/decrementing scroll offset while there is a dragged entry that's
  77    // hovered over the start/end of a list.
  78    hover_scroll_task: Option<Task<()>>,
  79    visible_entries: Vec<(WorktreeId, Vec<GitEntry>, OnceCell<HashSet<Arc<Path>>>)>,
  80    /// Maps from leaf project entry ID to the currently selected ancestor.
  81    /// Relevant only for auto-fold dirs, where a single project panel entry may actually consist of several
  82    /// project entries (and all non-leaf nodes are guaranteed to be directories).
  83    ancestors: HashMap<ProjectEntryId, FoldedAncestors>,
  84    last_worktree_root_id: Option<ProjectEntryId>,
  85    last_external_paths_drag_over_entry: Option<ProjectEntryId>,
  86    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  87    unfolded_dir_ids: HashSet<ProjectEntryId>,
  88    // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
  89    selection: Option<SelectedEntry>,
  90    marked_entries: BTreeSet<SelectedEntry>,
  91    context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
  92    edit_state: Option<EditState>,
  93    filename_editor: View<Editor>,
  94    clipboard: Option<ClipboardEntry>,
  95    _dragged_entry_destination: Option<Arc<Path>>,
  96    workspace: WeakView<Workspace>,
  97    width: Option<Pixels>,
  98    pending_serialization: Task<Option<()>>,
  99    show_scrollbar: bool,
 100    vertical_scrollbar_state: ScrollbarState,
 101    horizontal_scrollbar_state: ScrollbarState,
 102    hide_scrollbar_task: Option<Task<()>>,
 103    diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
 104    max_width_item_index: Option<usize>,
 105    // We keep track of the mouse down state on entries so we don't flash the UI
 106    // in case a user clicks to open a file.
 107    mouse_down: bool,
 108}
 109
 110#[derive(Clone, Debug)]
 111struct EditState {
 112    worktree_id: WorktreeId,
 113    entry_id: ProjectEntryId,
 114    leaf_entry_id: Option<ProjectEntryId>,
 115    is_dir: bool,
 116    depth: usize,
 117    processing_filename: Option<String>,
 118    previously_focused: Option<SelectedEntry>,
 119}
 120
 121impl EditState {
 122    fn is_new_entry(&self) -> bool {
 123        self.leaf_entry_id.is_none()
 124    }
 125}
 126
 127#[derive(Clone, Debug)]
 128enum ClipboardEntry {
 129    Copied(BTreeSet<SelectedEntry>),
 130    Cut(BTreeSet<SelectedEntry>),
 131}
 132
 133#[derive(Debug, PartialEq, Eq, Clone)]
 134struct EntryDetails {
 135    filename: String,
 136    icon: Option<SharedString>,
 137    path: Arc<Path>,
 138    depth: usize,
 139    kind: EntryKind,
 140    is_ignored: bool,
 141    is_expanded: bool,
 142    is_selected: bool,
 143    is_marked: bool,
 144    is_editing: bool,
 145    is_processing: bool,
 146    is_cut: bool,
 147    filename_text_color: Color,
 148    diagnostic_severity: Option<DiagnosticSeverity>,
 149    git_status: Option<GitFileStatus>,
 150    is_private: bool,
 151    worktree_id: WorktreeId,
 152    canonical_path: Option<Box<Path>>,
 153}
 154
 155#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
 156struct Delete {
 157    #[serde(default)]
 158    pub skip_prompt: bool,
 159}
 160
 161#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
 162struct Trash {
 163    #[serde(default)]
 164    pub skip_prompt: bool,
 165}
 166
 167impl_actions!(project_panel, [Delete, Trash]);
 168
 169actions!(
 170    project_panel,
 171    [
 172        ExpandSelectedEntry,
 173        CollapseSelectedEntry,
 174        CollapseAllEntries,
 175        NewDirectory,
 176        NewFile,
 177        Copy,
 178        CopyPath,
 179        CopyRelativePath,
 180        Duplicate,
 181        RevealInFileManager,
 182        RemoveFromProject,
 183        OpenWithSystem,
 184        Cut,
 185        Paste,
 186        Rename,
 187        Open,
 188        OpenPermanent,
 189        ToggleFocus,
 190        NewSearchInDirectory,
 191        UnfoldDirectory,
 192        FoldDirectory,
 193        SelectParent,
 194        SelectNextGitEntry,
 195        SelectPrevGitEntry,
 196        SelectNextDiagnostic,
 197        SelectPrevDiagnostic,
 198        SelectNextDirectory,
 199        SelectPrevDirectory,
 200    ]
 201);
 202
 203#[derive(Debug, Default)]
 204struct FoldedAncestors {
 205    current_ancestor_depth: usize,
 206    ancestors: Vec<ProjectEntryId>,
 207}
 208
 209impl FoldedAncestors {
 210    fn max_ancestor_depth(&self) -> usize {
 211        self.ancestors.len()
 212    }
 213}
 214
 215pub fn init_settings(cx: &mut AppContext) {
 216    ProjectPanelSettings::register(cx);
 217}
 218
 219pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
 220    init_settings(cx);
 221    file_icons::init(assets, cx);
 222
 223    cx.observe_new_views(|workspace: &mut Workspace, _| {
 224        workspace.register_action(|workspace, _: &ToggleFocus, cx| {
 225            workspace.toggle_panel_focus::<ProjectPanel>(cx);
 226        });
 227    })
 228    .detach();
 229}
 230
 231#[derive(Debug)]
 232pub enum Event {
 233    OpenedEntry {
 234        entry_id: ProjectEntryId,
 235        focus_opened_item: bool,
 236        allow_preview: bool,
 237    },
 238    SplitEntry {
 239        entry_id: ProjectEntryId,
 240    },
 241    Focus,
 242}
 243
 244#[derive(Serialize, Deserialize)]
 245struct SerializedProjectPanel {
 246    width: Option<Pixels>,
 247}
 248
 249struct DraggedProjectEntryView {
 250    selection: SelectedEntry,
 251    details: EntryDetails,
 252    width: Pixels,
 253    click_offset: Point<Pixels>,
 254    selections: Arc<BTreeSet<SelectedEntry>>,
 255}
 256
 257struct ItemColors {
 258    default: Hsla,
 259    hover: Hsla,
 260    drag_over: Hsla,
 261    marked_active: Hsla,
 262    focused: Hsla,
 263}
 264
 265fn get_item_color(cx: &ViewContext<ProjectPanel>) -> ItemColors {
 266    let colors = cx.theme().colors();
 267
 268    ItemColors {
 269        default: colors.panel_background,
 270        hover: colors.ghost_element_hover,
 271        drag_over: colors.drop_target_background,
 272        marked_active: colors.ghost_element_selected,
 273        focused: colors.panel_focused_border,
 274    }
 275}
 276
 277impl ProjectPanel {
 278    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 279        let project = workspace.project().clone();
 280        let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
 281            let focus_handle = cx.focus_handle();
 282            cx.on_focus(&focus_handle, Self::focus_in).detach();
 283            cx.on_focus_out(&focus_handle, |this, _, cx| {
 284                this.focus_out(cx);
 285                this.hide_scrollbar(cx);
 286            })
 287            .detach();
 288            cx.subscribe(&project, |this, project, event, cx| match event {
 289                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 290                    if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
 291                        this.reveal_entry(project, *entry_id, true, cx);
 292                    }
 293                }
 294                project::Event::RevealInProjectPanel(entry_id) => {
 295                    this.reveal_entry(project, *entry_id, false, cx);
 296                    cx.emit(PanelEvent::Activate);
 297                }
 298                project::Event::ActivateProjectPanel => {
 299                    cx.emit(PanelEvent::Activate);
 300                }
 301                project::Event::DiskBasedDiagnosticsFinished { .. }
 302                | project::Event::DiagnosticsUpdated { .. } => {
 303                    if ProjectPanelSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off
 304                    {
 305                        this.update_diagnostics(cx);
 306                        cx.notify();
 307                    }
 308                }
 309                project::Event::WorktreeRemoved(id) => {
 310                    this.expanded_dir_ids.remove(id);
 311                    this.update_visible_entries(None, cx);
 312                    cx.notify();
 313                }
 314                project::Event::WorktreeUpdatedGitRepositories(_)
 315                | project::Event::WorktreeUpdatedEntries(_, _)
 316                | project::Event::WorktreeAdded(_)
 317                | project::Event::WorktreeOrderChanged => {
 318                    this.update_visible_entries(None, cx);
 319                    cx.notify();
 320                }
 321                _ => {}
 322            })
 323            .detach();
 324
 325            let trash_action = [TypeId::of::<Trash>()];
 326            let is_remote = project.read(cx).is_via_collab();
 327
 328            if is_remote {
 329                CommandPaletteFilter::update_global(cx, |filter, _cx| {
 330                    filter.hide_action_types(&trash_action);
 331                });
 332            }
 333
 334            let filename_editor = cx.new_view(Editor::single_line);
 335
 336            cx.subscribe(
 337                &filename_editor,
 338                |project_panel, _, editor_event, cx| match editor_event {
 339                    EditorEvent::BufferEdited | EditorEvent::SelectionsChanged { .. } => {
 340                        project_panel.autoscroll(cx);
 341                    }
 342                    EditorEvent::Blurred => {
 343                        if project_panel
 344                            .edit_state
 345                            .as_ref()
 346                            .map_or(false, |state| state.processing_filename.is_none())
 347                        {
 348                            project_panel.edit_state = None;
 349                            project_panel.update_visible_entries(None, cx);
 350                            cx.notify();
 351                        }
 352                    }
 353                    _ => {}
 354                },
 355            )
 356            .detach();
 357
 358            cx.observe_global::<FileIcons>(|_, cx| {
 359                cx.notify();
 360            })
 361            .detach();
 362
 363            let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
 364            cx.observe_global::<SettingsStore>(move |this, cx| {
 365                let new_settings = *ProjectPanelSettings::get_global(cx);
 366                if project_panel_settings != new_settings {
 367                    project_panel_settings = new_settings;
 368                    this.update_diagnostics(cx);
 369                    cx.notify();
 370                }
 371            })
 372            .detach();
 373
 374            let scroll_handle = UniformListScrollHandle::new();
 375            let mut this = Self {
 376                project: project.clone(),
 377                hover_scroll_task: None,
 378                fs: workspace.app_state().fs.clone(),
 379                focus_handle,
 380                visible_entries: Default::default(),
 381                ancestors: Default::default(),
 382                last_worktree_root_id: Default::default(),
 383                last_external_paths_drag_over_entry: None,
 384                expanded_dir_ids: Default::default(),
 385                unfolded_dir_ids: Default::default(),
 386                selection: None,
 387                marked_entries: Default::default(),
 388                edit_state: None,
 389                context_menu: None,
 390                filename_editor,
 391                clipboard: None,
 392                _dragged_entry_destination: None,
 393                workspace: workspace.weak_handle(),
 394                width: None,
 395                pending_serialization: Task::ready(None),
 396                show_scrollbar: !Self::should_autohide_scrollbar(cx),
 397                hide_scrollbar_task: None,
 398                vertical_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
 399                    .parent_view(cx.view()),
 400                horizontal_scrollbar_state: ScrollbarState::new(scroll_handle.clone())
 401                    .parent_view(cx.view()),
 402                max_width_item_index: None,
 403                diagnostics: Default::default(),
 404                scroll_handle,
 405                mouse_down: false,
 406            };
 407            this.update_visible_entries(None, cx);
 408
 409            this
 410        });
 411
 412        cx.subscribe(&project_panel, {
 413            let project_panel = project_panel.downgrade();
 414            move |workspace, _, event, cx| match event {
 415                &Event::OpenedEntry {
 416                    entry_id,
 417                    focus_opened_item,
 418                    allow_preview,
 419                } => {
 420                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 421                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 422                            let file_path = entry.path.clone();
 423                            let worktree_id = worktree.read(cx).id();
 424                            let entry_id = entry.id;
 425                            let is_via_ssh = project.read(cx).is_via_ssh();
 426
 427                            workspace
 428                                .open_path_preview(
 429                                    ProjectPath {
 430                                        worktree_id,
 431                                        path: file_path.clone(),
 432                                    },
 433                                    None,
 434                                    focus_opened_item,
 435                                    allow_preview,
 436                                    cx,
 437                                )
 438                                .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
 439                                    match e.error_code() {
 440                                        ErrorCode::Disconnected => if is_via_ssh {
 441                                            Some("Disconnected from SSH host".to_string())
 442                                        } else {
 443                                            Some("Disconnected from remote project".to_string())
 444                                        },
 445                                        ErrorCode::UnsharedItem => Some(format!(
 446                                            "{} is not shared by the host. This could be because it has been marked as `private`",
 447                                            file_path.display()
 448                                        )),
 449                                        _ => None,
 450                                    }
 451                                });
 452
 453                            if let Some(project_panel) = project_panel.upgrade() {
 454                                // Always select and mark the entry, regardless of whether it is opened or not.
 455                                project_panel.update(cx, |project_panel, _| {
 456                                    let entry = SelectedEntry { worktree_id, entry_id };
 457                                    project_panel.marked_entries.clear();
 458                                    project_panel.marked_entries.insert(entry);
 459                                    project_panel.selection = Some(entry);
 460                                });
 461                                if !focus_opened_item {
 462                                    let focus_handle = project_panel.read(cx).focus_handle.clone();
 463                                    cx.focus(&focus_handle);
 464                                }
 465                            }
 466                        }
 467                    }
 468                }
 469                &Event::SplitEntry { entry_id } => {
 470                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 471                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 472                            workspace
 473                                .split_path(
 474                                    ProjectPath {
 475                                        worktree_id: worktree.read(cx).id(),
 476                                        path: entry.path.clone(),
 477                                    },
 478                                    cx,
 479                                )
 480                                .detach_and_log_err(cx);
 481                        }
 482                    }
 483                }
 484                _ => {}
 485            }
 486        })
 487        .detach();
 488
 489        project_panel
 490    }
 491
 492    pub async fn load(
 493        workspace: WeakView<Workspace>,
 494        mut cx: AsyncWindowContext,
 495    ) -> Result<View<Self>> {
 496        let serialized_panel = cx
 497            .background_executor()
 498            .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
 499            .await
 500            .map_err(|e| anyhow!("Failed to load project panel: {}", e))
 501            .log_err()
 502            .flatten()
 503            .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
 504            .transpose()
 505            .log_err()
 506            .flatten();
 507
 508        workspace.update(&mut cx, |workspace, cx| {
 509            let panel = ProjectPanel::new(workspace, cx);
 510            if let Some(serialized_panel) = serialized_panel {
 511                panel.update(cx, |panel, cx| {
 512                    panel.width = serialized_panel.width.map(|px| px.round());
 513                    cx.notify();
 514                });
 515            }
 516            panel
 517        })
 518    }
 519
 520    fn update_diagnostics(&mut self, cx: &mut ViewContext<Self>) {
 521        let mut diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity> =
 522            Default::default();
 523        let show_diagnostics_setting = ProjectPanelSettings::get_global(cx).show_diagnostics;
 524
 525        if show_diagnostics_setting != ShowDiagnostics::Off {
 526            self.project
 527                .read(cx)
 528                .diagnostic_summaries(false, cx)
 529                .filter_map(|(path, _, diagnostic_summary)| {
 530                    if diagnostic_summary.error_count > 0 {
 531                        Some((path, DiagnosticSeverity::ERROR))
 532                    } else if show_diagnostics_setting == ShowDiagnostics::All
 533                        && diagnostic_summary.warning_count > 0
 534                    {
 535                        Some((path, DiagnosticSeverity::WARNING))
 536                    } else {
 537                        None
 538                    }
 539                })
 540                .for_each(|(project_path, diagnostic_severity)| {
 541                    let mut path_buffer = PathBuf::new();
 542                    Self::update_strongest_diagnostic_severity(
 543                        &mut diagnostics,
 544                        &project_path,
 545                        path_buffer.clone(),
 546                        diagnostic_severity,
 547                    );
 548
 549                    for component in project_path.path.components() {
 550                        path_buffer.push(component);
 551                        Self::update_strongest_diagnostic_severity(
 552                            &mut diagnostics,
 553                            &project_path,
 554                            path_buffer.clone(),
 555                            diagnostic_severity,
 556                        );
 557                    }
 558                });
 559        }
 560        self.diagnostics = diagnostics;
 561    }
 562
 563    fn update_strongest_diagnostic_severity(
 564        diagnostics: &mut HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>,
 565        project_path: &ProjectPath,
 566        path_buffer: PathBuf,
 567        diagnostic_severity: DiagnosticSeverity,
 568    ) {
 569        diagnostics
 570            .entry((project_path.worktree_id, path_buffer.clone()))
 571            .and_modify(|strongest_diagnostic_severity| {
 572                *strongest_diagnostic_severity =
 573                    cmp::min(*strongest_diagnostic_severity, diagnostic_severity);
 574            })
 575            .or_insert(diagnostic_severity);
 576    }
 577
 578    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 579        let width = self.width;
 580        self.pending_serialization = cx.background_executor().spawn(
 581            async move {
 582                KEY_VALUE_STORE
 583                    .write_kvp(
 584                        PROJECT_PANEL_KEY.into(),
 585                        serde_json::to_string(&SerializedProjectPanel { width })?,
 586                    )
 587                    .await?;
 588                anyhow::Ok(())
 589            }
 590            .log_err(),
 591        );
 592    }
 593
 594    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 595        if !self.focus_handle.contains_focused(cx) {
 596            cx.emit(Event::Focus);
 597        }
 598    }
 599
 600    fn focus_out(&mut self, cx: &mut ViewContext<Self>) {
 601        if !self.focus_handle.is_focused(cx) {
 602            self.confirm(&Confirm, cx);
 603        }
 604    }
 605
 606    fn deploy_context_menu(
 607        &mut self,
 608        position: Point<Pixels>,
 609        entry_id: ProjectEntryId,
 610        cx: &mut ViewContext<Self>,
 611    ) {
 612        let project = self.project.read(cx);
 613
 614        let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 615            id
 616        } else {
 617            return;
 618        };
 619
 620        self.selection = Some(SelectedEntry {
 621            worktree_id,
 622            entry_id,
 623        });
 624
 625        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
 626            let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
 627            let worktree = worktree.read(cx);
 628            let is_root = Some(entry) == worktree.root_entry();
 629            let is_dir = entry.is_dir();
 630            let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
 631            let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
 632            let is_read_only = project.is_read_only(cx);
 633            let is_remote = project.is_via_collab();
 634            let is_local = project.is_local();
 635
 636            let context_menu = ContextMenu::build(cx, |menu, _| {
 637                menu.context(self.focus_handle.clone()).map(|menu| {
 638                    if is_read_only {
 639                        menu.when(is_dir, |menu| {
 640                            menu.action("Search Inside", Box::new(NewSearchInDirectory))
 641                        })
 642                    } else {
 643                        menu.action("New File", Box::new(NewFile))
 644                            .action("New Folder", Box::new(NewDirectory))
 645                            .separator()
 646                            .when(is_local && cfg!(target_os = "macos"), |menu| {
 647                                menu.action("Reveal in Finder", Box::new(RevealInFileManager))
 648                            })
 649                            .when(is_local && cfg!(not(target_os = "macos")), |menu| {
 650                                menu.action("Reveal in File Manager", Box::new(RevealInFileManager))
 651                            })
 652                            .when(is_local, |menu| {
 653                                menu.action("Open in Default App", Box::new(OpenWithSystem))
 654                            })
 655                            .action("Open in Terminal", Box::new(OpenInTerminal))
 656                            .when(is_dir, |menu| {
 657                                menu.separator()
 658                                    .action("Find in Folder…", Box::new(NewSearchInDirectory))
 659                            })
 660                            .when(is_unfoldable, |menu| {
 661                                menu.action("Unfold Directory", Box::new(UnfoldDirectory))
 662                            })
 663                            .when(is_foldable, |menu| {
 664                                menu.action("Fold Directory", Box::new(FoldDirectory))
 665                            })
 666                            .separator()
 667                            .action("Cut", Box::new(Cut))
 668                            .action("Copy", Box::new(Copy))
 669                            .action("Duplicate", Box::new(Duplicate))
 670                            // TODO: Paste should always be visible, cbut disabled when clipboard is empty
 671                            .map(|menu| {
 672                                if self.clipboard.as_ref().is_some() {
 673                                    menu.action("Paste", Box::new(Paste))
 674                                } else {
 675                                    menu.disabled_action("Paste", Box::new(Paste))
 676                                }
 677                            })
 678                            .separator()
 679                            .action("Copy Path", Box::new(CopyPath))
 680                            .action("Copy Relative Path", Box::new(CopyRelativePath))
 681                            .separator()
 682                            .action("Rename", Box::new(Rename))
 683                            .when(!is_root & !is_remote, |menu| {
 684                                menu.action("Trash", Box::new(Trash { skip_prompt: false }))
 685                            })
 686                            .when(!is_root, |menu| {
 687                                menu.action("Delete", Box::new(Delete { skip_prompt: false }))
 688                            })
 689                            .when(!is_remote & is_root, |menu| {
 690                                menu.separator()
 691                                    .action(
 692                                        "Add Folder to Project…",
 693                                        Box::new(workspace::AddFolderToProject),
 694                                    )
 695                                    .action("Remove from Project", Box::new(RemoveFromProject))
 696                            })
 697                            .when(is_root, |menu| {
 698                                menu.separator()
 699                                    .action("Collapse All", Box::new(CollapseAllEntries))
 700                            })
 701                    }
 702                })
 703            });
 704
 705            cx.focus_view(&context_menu);
 706            let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
 707                this.context_menu.take();
 708                cx.notify();
 709            });
 710            self.context_menu = Some((context_menu, position, subscription));
 711        }
 712
 713        cx.notify();
 714    }
 715
 716    fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
 717        if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
 718            return false;
 719        }
 720
 721        if let Some(parent_path) = entry.path.parent() {
 722            let snapshot = worktree.snapshot();
 723            let mut child_entries = snapshot.child_entries(parent_path);
 724            if let Some(child) = child_entries.next() {
 725                if child_entries.next().is_none() {
 726                    return child.kind.is_dir();
 727                }
 728            }
 729        };
 730        false
 731    }
 732
 733    fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
 734        if entry.is_dir() {
 735            let snapshot = worktree.snapshot();
 736
 737            let mut child_entries = snapshot.child_entries(&entry.path);
 738            if let Some(child) = child_entries.next() {
 739                if child_entries.next().is_none() {
 740                    return child.kind.is_dir();
 741                }
 742            }
 743        }
 744        false
 745    }
 746
 747    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 748        if let Some((worktree, entry)) = self.selected_entry(cx) {
 749            if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
 750                if folded_ancestors.current_ancestor_depth > 0 {
 751                    folded_ancestors.current_ancestor_depth -= 1;
 752                    cx.notify();
 753                    return;
 754                }
 755            }
 756            if entry.is_dir() {
 757                let worktree_id = worktree.id();
 758                let entry_id = entry.id;
 759                let expanded_dir_ids =
 760                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 761                        expanded_dir_ids
 762                    } else {
 763                        return;
 764                    };
 765
 766                match expanded_dir_ids.binary_search(&entry_id) {
 767                    Ok(_) => self.select_next(&SelectNext, cx),
 768                    Err(ix) => {
 769                        self.project.update(cx, |project, cx| {
 770                            project.expand_entry(worktree_id, entry_id, cx);
 771                        });
 772
 773                        expanded_dir_ids.insert(ix, entry_id);
 774                        self.update_visible_entries(None, cx);
 775                        cx.notify();
 776                    }
 777                }
 778            }
 779        }
 780    }
 781
 782    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 783        let Some((worktree, entry)) = self.selected_entry_handle(cx) else {
 784            return;
 785        };
 786        self.collapse_entry(entry.clone(), worktree, cx)
 787    }
 788
 789    fn collapse_entry(
 790        &mut self,
 791        entry: Entry,
 792        worktree: Model<Worktree>,
 793        cx: &mut ViewContext<Self>,
 794    ) {
 795        let worktree = worktree.read(cx);
 796        if let Some(folded_ancestors) = self.ancestors.get_mut(&entry.id) {
 797            if folded_ancestors.current_ancestor_depth + 1 < folded_ancestors.max_ancestor_depth() {
 798                folded_ancestors.current_ancestor_depth += 1;
 799                cx.notify();
 800                return;
 801            }
 802        }
 803        let worktree_id = worktree.id();
 804        let expanded_dir_ids =
 805            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 806                expanded_dir_ids
 807            } else {
 808                return;
 809            };
 810
 811        let mut entry = &entry;
 812        loop {
 813            let entry_id = entry.id;
 814            match expanded_dir_ids.binary_search(&entry_id) {
 815                Ok(ix) => {
 816                    expanded_dir_ids.remove(ix);
 817                    self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 818                    cx.notify();
 819                    break;
 820                }
 821                Err(_) => {
 822                    if let Some(parent_entry) =
 823                        entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 824                    {
 825                        entry = parent_entry;
 826                    } else {
 827                        break;
 828                    }
 829                }
 830            }
 831        }
 832    }
 833
 834    pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
 835        // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
 836        // (which is it's default behavior when there's no entry for a worktree in expanded_dir_ids).
 837        self.expanded_dir_ids
 838            .retain(|_, expanded_entries| expanded_entries.is_empty());
 839        self.update_visible_entries(None, cx);
 840        cx.notify();
 841    }
 842
 843    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 844        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 845            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 846                self.project.update(cx, |project, cx| {
 847                    match expanded_dir_ids.binary_search(&entry_id) {
 848                        Ok(ix) => {
 849                            expanded_dir_ids.remove(ix);
 850                        }
 851                        Err(ix) => {
 852                            project.expand_entry(worktree_id, entry_id, cx);
 853                            expanded_dir_ids.insert(ix, entry_id);
 854                        }
 855                    }
 856                });
 857                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 858                cx.focus(&self.focus_handle);
 859                cx.notify();
 860            }
 861        }
 862    }
 863
 864    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 865        if let Some(edit_state) = &self.edit_state {
 866            if edit_state.processing_filename.is_none() {
 867                self.filename_editor.update(cx, |editor, cx| {
 868                    editor.move_to_beginning_of_line(
 869                        &editor::actions::MoveToBeginningOfLine {
 870                            stop_at_soft_wraps: false,
 871                        },
 872                        cx,
 873                    );
 874                });
 875                return;
 876            }
 877        }
 878        if let Some(selection) = self.selection {
 879            let (mut worktree_ix, mut entry_ix, _) =
 880                self.index_for_selection(selection).unwrap_or_default();
 881            if entry_ix > 0 {
 882                entry_ix -= 1;
 883            } else if worktree_ix > 0 {
 884                worktree_ix -= 1;
 885                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 886            } else {
 887                return;
 888            }
 889
 890            let (worktree_id, worktree_entries, _) = &self.visible_entries[worktree_ix];
 891            let selection = SelectedEntry {
 892                worktree_id: *worktree_id,
 893                entry_id: worktree_entries[entry_ix].id,
 894            };
 895            self.selection = Some(selection);
 896            if cx.modifiers().shift {
 897                self.marked_entries.insert(selection);
 898            }
 899            self.autoscroll(cx);
 900            cx.notify();
 901        } else {
 902            self.select_first(&SelectFirst {}, cx);
 903        }
 904    }
 905
 906    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 907        if let Some(task) = self.confirm_edit(cx) {
 908            task.detach_and_notify_err(cx);
 909        }
 910    }
 911
 912    fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
 913        let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
 914        self.open_internal(true, !preview_tabs_enabled, cx);
 915    }
 916
 917    fn open_permanent(&mut self, _: &OpenPermanent, cx: &mut ViewContext<Self>) {
 918        self.open_internal(false, true, cx);
 919    }
 920
 921    fn open_internal(
 922        &mut self,
 923        allow_preview: bool,
 924        focus_opened_item: bool,
 925        cx: &mut ViewContext<Self>,
 926    ) {
 927        if let Some((_, entry)) = self.selected_entry(cx) {
 928            if entry.is_file() {
 929                self.open_entry(entry.id, focus_opened_item, allow_preview, cx);
 930            } else {
 931                self.toggle_expanded(entry.id, cx);
 932            }
 933        }
 934    }
 935
 936    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 937        let edit_state = self.edit_state.as_mut()?;
 938        cx.focus(&self.focus_handle);
 939
 940        let worktree_id = edit_state.worktree_id;
 941        let is_new_entry = edit_state.is_new_entry();
 942        let filename = self.filename_editor.read(cx).text(cx);
 943        edit_state.is_dir = edit_state.is_dir
 944            || (edit_state.is_new_entry() && filename.ends_with(std::path::MAIN_SEPARATOR));
 945        let is_dir = edit_state.is_dir;
 946        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 947        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 948
 949        let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
 950        let edit_task;
 951        let edited_entry_id;
 952        if is_new_entry {
 953            self.selection = Some(SelectedEntry {
 954                worktree_id,
 955                entry_id: NEW_ENTRY_ID,
 956            });
 957            let new_path = entry.path.join(filename.trim_start_matches('/'));
 958            if path_already_exists(new_path.as_path()) {
 959                return None;
 960            }
 961
 962            edited_entry_id = NEW_ENTRY_ID;
 963            edit_task = self.project.update(cx, |project, cx| {
 964                project.create_entry((worktree_id, &new_path), is_dir, cx)
 965            });
 966        } else {
 967            let new_path = if let Some(parent) = entry.path.clone().parent() {
 968                parent.join(&filename)
 969            } else {
 970                filename.clone().into()
 971            };
 972            if path_already_exists(new_path.as_path()) {
 973                return None;
 974            }
 975            edited_entry_id = entry.id;
 976            edit_task = self.project.update(cx, |project, cx| {
 977                project.rename_entry(entry.id, new_path.as_path(), cx)
 978            });
 979        };
 980
 981        edit_state.processing_filename = Some(filename);
 982        cx.notify();
 983
 984        Some(cx.spawn(|project_panel, mut cx| async move {
 985            let new_entry = edit_task.await;
 986            project_panel.update(&mut cx, |project_panel, cx| {
 987                project_panel.edit_state = None;
 988                cx.notify();
 989            })?;
 990
 991            match new_entry {
 992                Err(e) => {
 993                    project_panel.update(&mut cx, |project_panel, cx| {
 994                        project_panel.marked_entries.clear();
 995                        project_panel.update_visible_entries(None, cx);
 996                    }).ok();
 997                    Err(e)?;
 998                }
 999                Ok(CreatedEntry::Included(new_entry)) => {
1000                    project_panel.update(&mut cx, |project_panel, cx| {
1001                        if let Some(selection) = &mut project_panel.selection {
1002                            if selection.entry_id == edited_entry_id {
1003                                selection.worktree_id = worktree_id;
1004                                selection.entry_id = new_entry.id;
1005                                project_panel.marked_entries.clear();
1006                                project_panel.expand_to_selection(cx);
1007                            }
1008                        }
1009                        project_panel.update_visible_entries(None, cx);
1010                        if is_new_entry && !is_dir {
1011                            project_panel.open_entry(new_entry.id, true, false, cx);
1012                        }
1013                        cx.notify();
1014                    })?;
1015                }
1016                Ok(CreatedEntry::Excluded { abs_path }) => {
1017                    if let Some(open_task) = project_panel
1018                        .update(&mut cx, |project_panel, cx| {
1019                            project_panel.marked_entries.clear();
1020                            project_panel.update_visible_entries(None, cx);
1021
1022                            if is_dir {
1023                                project_panel.project.update(cx, |_, cx| {
1024                                    cx.emit(project::Event::Toast {
1025                                        notification_id: "excluded-directory".into(),
1026                                        message: format!("Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel")
1027                                    })
1028                                });
1029                                None
1030                            } else {
1031                                project_panel
1032                                    .workspace
1033                                    .update(cx, |workspace, cx| {
1034                                        workspace.open_abs_path(abs_path, true, cx)
1035                                    })
1036                                    .ok()
1037                            }
1038                        })
1039                        .ok()
1040                        .flatten()
1041                    {
1042                        let _ = open_task.await?;
1043                    }
1044                }
1045            }
1046            Ok(())
1047        }))
1048    }
1049
1050    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
1051        let previous_edit_state = self.edit_state.take();
1052        self.update_visible_entries(None, cx);
1053        self.marked_entries.clear();
1054
1055        if let Some(previously_focused) =
1056            previous_edit_state.and_then(|edit_state| edit_state.previously_focused)
1057        {
1058            self.selection = Some(previously_focused);
1059            self.autoscroll(cx);
1060        }
1061
1062        cx.focus(&self.focus_handle);
1063        cx.notify();
1064    }
1065
1066    fn open_entry(
1067        &mut self,
1068        entry_id: ProjectEntryId,
1069        focus_opened_item: bool,
1070        allow_preview: bool,
1071        cx: &mut ViewContext<Self>,
1072    ) {
1073        cx.emit(Event::OpenedEntry {
1074            entry_id,
1075            focus_opened_item,
1076            allow_preview,
1077        });
1078    }
1079
1080    fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
1081        cx.emit(Event::SplitEntry { entry_id });
1082    }
1083
1084    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
1085        self.add_entry(false, cx)
1086    }
1087
1088    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
1089        self.add_entry(true, cx)
1090    }
1091
1092    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
1093        if let Some(SelectedEntry {
1094            worktree_id,
1095            entry_id,
1096        }) = self.selection
1097        {
1098            let directory_id;
1099            let new_entry_id = self.resolve_entry(entry_id);
1100            if let Some((worktree, expanded_dir_ids)) = self
1101                .project
1102                .read(cx)
1103                .worktree_for_id(worktree_id, cx)
1104                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1105            {
1106                let worktree = worktree.read(cx);
1107                if let Some(mut entry) = worktree.entry_for_id(new_entry_id) {
1108                    loop {
1109                        if entry.is_dir() {
1110                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1111                                expanded_dir_ids.insert(ix, entry.id);
1112                            }
1113                            directory_id = entry.id;
1114                            break;
1115                        } else {
1116                            if let Some(parent_path) = entry.path.parent() {
1117                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
1118                                    entry = parent_entry;
1119                                    continue;
1120                                }
1121                            }
1122                            return;
1123                        }
1124                    }
1125                } else {
1126                    return;
1127                };
1128            } else {
1129                return;
1130            };
1131            self.marked_entries.clear();
1132            self.edit_state = Some(EditState {
1133                worktree_id,
1134                entry_id: directory_id,
1135                leaf_entry_id: None,
1136                is_dir,
1137                processing_filename: None,
1138                previously_focused: self.selection,
1139                depth: 0,
1140            });
1141            self.filename_editor.update(cx, |editor, cx| {
1142                editor.clear(cx);
1143                editor.focus(cx);
1144            });
1145            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
1146            self.autoscroll(cx);
1147            cx.notify();
1148        }
1149    }
1150
1151    fn unflatten_entry_id(&self, leaf_entry_id: ProjectEntryId) -> ProjectEntryId {
1152        if let Some(ancestors) = self.ancestors.get(&leaf_entry_id) {
1153            ancestors
1154                .ancestors
1155                .get(ancestors.current_ancestor_depth)
1156                .copied()
1157                .unwrap_or(leaf_entry_id)
1158        } else {
1159            leaf_entry_id
1160        }
1161    }
1162
1163    fn rename_impl(&mut self, selection: Option<Range<usize>>, cx: &mut ViewContext<Self>) {
1164        if let Some(SelectedEntry {
1165            worktree_id,
1166            entry_id,
1167        }) = self.selection
1168        {
1169            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
1170                let sub_entry_id = self.unflatten_entry_id(entry_id);
1171                if let Some(entry) = worktree.read(cx).entry_for_id(sub_entry_id) {
1172                    self.edit_state = Some(EditState {
1173                        worktree_id,
1174                        entry_id: sub_entry_id,
1175                        leaf_entry_id: Some(entry_id),
1176                        is_dir: entry.is_dir(),
1177                        processing_filename: None,
1178                        previously_focused: None,
1179                        depth: 0,
1180                    });
1181                    let file_name = entry
1182                        .path
1183                        .file_name()
1184                        .map(|s| s.to_string_lossy())
1185                        .unwrap_or_default()
1186                        .to_string();
1187                    let selection = selection.unwrap_or_else(|| {
1188                        let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
1189                        let selection_end =
1190                            file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
1191                        0..selection_end
1192                    });
1193                    self.filename_editor.update(cx, |editor, cx| {
1194                        editor.set_text(file_name, cx);
1195                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1196                            s.select_ranges([selection])
1197                        });
1198                        editor.focus(cx);
1199                    });
1200                    self.update_visible_entries(None, cx);
1201                    self.autoscroll(cx);
1202                    cx.notify();
1203                }
1204            }
1205        }
1206    }
1207
1208    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
1209        self.rename_impl(None, cx);
1210    }
1211
1212    fn trash(&mut self, action: &Trash, cx: &mut ViewContext<Self>) {
1213        self.remove(true, action.skip_prompt, cx);
1214    }
1215
1216    fn delete(&mut self, action: &Delete, cx: &mut ViewContext<Self>) {
1217        self.remove(false, action.skip_prompt, cx);
1218    }
1219
1220    fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<ProjectPanel>) {
1221        maybe!({
1222            let items_to_delete = self.disjoint_entries(cx);
1223            if items_to_delete.is_empty() {
1224                return None;
1225            }
1226            let project = self.project.read(cx);
1227
1228            let mut dirty_buffers = 0;
1229            let file_paths = items_to_delete
1230                .iter()
1231                .filter_map(|selection| {
1232                    let project_path = project.path_for_entry(selection.entry_id, cx)?;
1233                    dirty_buffers +=
1234                        project.dirty_buffers(cx).any(|path| path == project_path) as usize;
1235                    Some((
1236                        selection.entry_id,
1237                        project_path
1238                            .path
1239                            .file_name()?
1240                            .to_string_lossy()
1241                            .into_owned(),
1242                    ))
1243                })
1244                .collect::<Vec<_>>();
1245            if file_paths.is_empty() {
1246                return None;
1247            }
1248            let answer = if !skip_prompt {
1249                let operation = if trash { "Trash" } else { "Delete" };
1250                let prompt = match file_paths.first() {
1251                    Some((_, path)) if file_paths.len() == 1 => {
1252                        let unsaved_warning = if dirty_buffers > 0 {
1253                            "\n\nIt has unsaved changes, which will be lost."
1254                        } else {
1255                            ""
1256                        };
1257
1258                        format!("{operation} {path}?{unsaved_warning}")
1259                    }
1260                    _ => {
1261                        const CUTOFF_POINT: usize = 10;
1262                        let names = if file_paths.len() > CUTOFF_POINT {
1263                            let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
1264                            let mut paths = file_paths
1265                                .iter()
1266                                .map(|(_, path)| path.clone())
1267                                .take(CUTOFF_POINT)
1268                                .collect::<Vec<_>>();
1269                            paths.truncate(CUTOFF_POINT);
1270                            if truncated_path_counts == 1 {
1271                                paths.push(".. 1 file not shown".into());
1272                            } else {
1273                                paths.push(format!(".. {} files not shown", truncated_path_counts));
1274                            }
1275                            paths
1276                        } else {
1277                            file_paths.iter().map(|(_, path)| path.clone()).collect()
1278                        };
1279                        let unsaved_warning = if dirty_buffers == 0 {
1280                            String::new()
1281                        } else if dirty_buffers == 1 {
1282                            "\n\n1 of these has unsaved changes, which will be lost.".to_string()
1283                        } else {
1284                            format!("\n\n{dirty_buffers} of these have unsaved changes, which will be lost.")
1285                        };
1286
1287                        format!(
1288                            "Do you want to {} the following {} files?\n{}{unsaved_warning}",
1289                            operation.to_lowercase(),
1290                            file_paths.len(),
1291                            names.join("\n")
1292                        )
1293                    }
1294                };
1295                Some(cx.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"]))
1296            } else {
1297                None
1298            };
1299            let next_selection = self.find_next_selection_after_deletion(items_to_delete, cx);
1300            cx.spawn(|panel, mut cx| async move {
1301                if let Some(answer) = answer {
1302                    if answer.await != Ok(0) {
1303                        return anyhow::Ok(());
1304                    }
1305                }
1306                for (entry_id, _) in file_paths {
1307                    panel
1308                        .update(&mut cx, |panel, cx| {
1309                            panel
1310                                .project
1311                                .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
1312                                .context("no such entry")
1313                        })??
1314                        .await?;
1315                }
1316                panel.update(&mut cx, |panel, cx| {
1317                    if let Some(next_selection) = next_selection {
1318                        panel.selection = Some(next_selection);
1319                        panel.autoscroll(cx);
1320                    } else {
1321                        panel.select_last(&SelectLast {}, cx);
1322                    }
1323                })?;
1324                Ok(())
1325            })
1326            .detach_and_log_err(cx);
1327            Some(())
1328        });
1329    }
1330
1331    fn find_next_selection_after_deletion(
1332        &self,
1333        sanitized_entries: BTreeSet<SelectedEntry>,
1334        cx: &mut ViewContext<Self>,
1335    ) -> Option<SelectedEntry> {
1336        if sanitized_entries.is_empty() {
1337            return None;
1338        }
1339
1340        let project = self.project.read(cx);
1341        let (worktree_id, worktree) = sanitized_entries
1342            .iter()
1343            .map(|entry| entry.worktree_id)
1344            .filter_map(|id| project.worktree_for_id(id, cx).map(|w| (id, w.read(cx))))
1345            .max_by(|(_, a), (_, b)| a.root_name().cmp(b.root_name()))?;
1346
1347        let marked_entries_in_worktree = sanitized_entries
1348            .iter()
1349            .filter(|e| e.worktree_id == worktree_id)
1350            .collect::<HashSet<_>>();
1351        let latest_entry = marked_entries_in_worktree
1352            .iter()
1353            .max_by(|a, b| {
1354                match (
1355                    worktree.entry_for_id(a.entry_id),
1356                    worktree.entry_for_id(b.entry_id),
1357                ) {
1358                    (Some(a), Some(b)) => {
1359                        compare_paths((&a.path, a.is_file()), (&b.path, b.is_file()))
1360                    }
1361                    _ => cmp::Ordering::Equal,
1362                }
1363            })
1364            .and_then(|e| worktree.entry_for_id(e.entry_id))?;
1365
1366        let parent_path = latest_entry.path.parent()?;
1367        let parent_entry = worktree.entry_for_path(parent_path)?;
1368
1369        // Remove all siblings that are being deleted except the last marked entry
1370        let mut siblings: Vec<_> = worktree
1371            .snapshot()
1372            .child_entries(parent_path)
1373            .with_git_statuses()
1374            .filter(|sibling| {
1375                sibling.id == latest_entry.id
1376                    || !marked_entries_in_worktree.contains(&&SelectedEntry {
1377                        worktree_id,
1378                        entry_id: sibling.id,
1379                    })
1380            })
1381            .map(|entry| entry.to_owned())
1382            .collect();
1383
1384        project::sort_worktree_entries(&mut siblings);
1385        let sibling_entry_index = siblings
1386            .iter()
1387            .position(|sibling| sibling.id == latest_entry.id)?;
1388
1389        if let Some(next_sibling) = sibling_entry_index
1390            .checked_add(1)
1391            .and_then(|i| siblings.get(i))
1392        {
1393            return Some(SelectedEntry {
1394                worktree_id,
1395                entry_id: next_sibling.id,
1396            });
1397        }
1398        if let Some(prev_sibling) = sibling_entry_index
1399            .checked_sub(1)
1400            .and_then(|i| siblings.get(i))
1401        {
1402            return Some(SelectedEntry {
1403                worktree_id,
1404                entry_id: prev_sibling.id,
1405            });
1406        }
1407        // No neighbour sibling found, fall back to parent
1408        Some(SelectedEntry {
1409            worktree_id,
1410            entry_id: parent_entry.id,
1411        })
1412    }
1413
1414    fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
1415        if let Some((worktree, entry)) = self.selected_entry(cx) {
1416            self.unfolded_dir_ids.insert(entry.id);
1417
1418            let snapshot = worktree.snapshot();
1419            let mut parent_path = entry.path.parent();
1420            while let Some(path) = parent_path {
1421                if let Some(parent_entry) = worktree.entry_for_path(path) {
1422                    let mut children_iter = snapshot.child_entries(path);
1423
1424                    if children_iter.by_ref().take(2).count() > 1 {
1425                        break;
1426                    }
1427
1428                    self.unfolded_dir_ids.insert(parent_entry.id);
1429                    parent_path = path.parent();
1430                } else {
1431                    break;
1432                }
1433            }
1434
1435            self.update_visible_entries(None, cx);
1436            self.autoscroll(cx);
1437            cx.notify();
1438        }
1439    }
1440
1441    fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext<Self>) {
1442        if let Some((worktree, entry)) = self.selected_entry(cx) {
1443            self.unfolded_dir_ids.remove(&entry.id);
1444
1445            let snapshot = worktree.snapshot();
1446            let mut path = &*entry.path;
1447            loop {
1448                let mut child_entries_iter = snapshot.child_entries(path);
1449                if let Some(child) = child_entries_iter.next() {
1450                    if child_entries_iter.next().is_none() && child.is_dir() {
1451                        self.unfolded_dir_ids.remove(&child.id);
1452                        path = &*child.path;
1453                    } else {
1454                        break;
1455                    }
1456                } else {
1457                    break;
1458                }
1459            }
1460
1461            self.update_visible_entries(None, cx);
1462            self.autoscroll(cx);
1463            cx.notify();
1464        }
1465    }
1466
1467    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1468        if let Some(edit_state) = &self.edit_state {
1469            if edit_state.processing_filename.is_none() {
1470                self.filename_editor.update(cx, |editor, cx| {
1471                    editor.move_to_end_of_line(
1472                        &editor::actions::MoveToEndOfLine {
1473                            stop_at_soft_wraps: false,
1474                        },
1475                        cx,
1476                    );
1477                });
1478                return;
1479            }
1480        }
1481        if let Some(selection) = self.selection {
1482            let (mut worktree_ix, mut entry_ix, _) =
1483                self.index_for_selection(selection).unwrap_or_default();
1484            if let Some((_, worktree_entries, _)) = self.visible_entries.get(worktree_ix) {
1485                if entry_ix + 1 < worktree_entries.len() {
1486                    entry_ix += 1;
1487                } else {
1488                    worktree_ix += 1;
1489                    entry_ix = 0;
1490                }
1491            }
1492
1493            if let Some((worktree_id, worktree_entries, _)) = self.visible_entries.get(worktree_ix)
1494            {
1495                if let Some(entry) = worktree_entries.get(entry_ix) {
1496                    let selection = SelectedEntry {
1497                        worktree_id: *worktree_id,
1498                        entry_id: entry.id,
1499                    };
1500                    self.selection = Some(selection);
1501                    if cx.modifiers().shift {
1502                        self.marked_entries.insert(selection);
1503                    }
1504
1505                    self.autoscroll(cx);
1506                    cx.notify();
1507                }
1508            }
1509        } else {
1510            self.select_first(&SelectFirst {}, cx);
1511        }
1512    }
1513
1514    fn select_prev_diagnostic(&mut self, _: &SelectPrevDiagnostic, cx: &mut ViewContext<Self>) {
1515        let selection = self.find_entry(
1516            self.selection.as_ref(),
1517            true,
1518            |entry, worktree_id| {
1519                (self.selection.is_none()
1520                    || self.selection.is_some_and(|selection| {
1521                        if selection.worktree_id == worktree_id {
1522                            selection.entry_id != entry.id
1523                        } else {
1524                            true
1525                        }
1526                    }))
1527                    && entry.is_file()
1528                    && self
1529                        .diagnostics
1530                        .contains_key(&(worktree_id, entry.path.to_path_buf()))
1531            },
1532            cx,
1533        );
1534
1535        if let Some(selection) = selection {
1536            self.selection = Some(selection);
1537            self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1538            self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1539            self.autoscroll(cx);
1540            cx.notify();
1541        }
1542    }
1543
1544    fn select_next_diagnostic(&mut self, _: &SelectNextDiagnostic, cx: &mut ViewContext<Self>) {
1545        let selection = self.find_entry(
1546            self.selection.as_ref(),
1547            false,
1548            |entry, worktree_id| {
1549                (self.selection.is_none()
1550                    || self.selection.is_some_and(|selection| {
1551                        if selection.worktree_id == worktree_id {
1552                            selection.entry_id != entry.id
1553                        } else {
1554                            true
1555                        }
1556                    }))
1557                    && entry.is_file()
1558                    && self
1559                        .diagnostics
1560                        .contains_key(&(worktree_id, entry.path.to_path_buf()))
1561            },
1562            cx,
1563        );
1564
1565        if let Some(selection) = selection {
1566            self.selection = Some(selection);
1567            self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1568            self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1569            self.autoscroll(cx);
1570            cx.notify();
1571        }
1572    }
1573
1574    fn select_prev_git_entry(&mut self, _: &SelectPrevGitEntry, cx: &mut ViewContext<Self>) {
1575        let selection = self.find_entry(
1576            self.selection.as_ref(),
1577            true,
1578            |entry, worktree_id| {
1579                (self.selection.is_none()
1580                    || self.selection.is_some_and(|selection| {
1581                        if selection.worktree_id == worktree_id {
1582                            selection.entry_id != entry.id
1583                        } else {
1584                            true
1585                        }
1586                    }))
1587                    && entry.is_file()
1588                    && entry
1589                        .git_status
1590                        .is_some_and(|status| matches!(status, GitFileStatus::Modified))
1591            },
1592            cx,
1593        );
1594
1595        if let Some(selection) = selection {
1596            self.selection = Some(selection);
1597            self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1598            self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1599            self.autoscroll(cx);
1600            cx.notify();
1601        }
1602    }
1603
1604    fn select_prev_directory(&mut self, _: &SelectPrevDirectory, cx: &mut ViewContext<Self>) {
1605        let selection = self.find_visible_entry(
1606            self.selection.as_ref(),
1607            true,
1608            |entry, worktree_id| {
1609                (self.selection.is_none()
1610                    || self.selection.is_some_and(|selection| {
1611                        if selection.worktree_id == worktree_id {
1612                            selection.entry_id != entry.id
1613                        } else {
1614                            true
1615                        }
1616                    }))
1617                    && entry.is_dir()
1618            },
1619            cx,
1620        );
1621
1622        if let Some(selection) = selection {
1623            self.selection = Some(selection);
1624            self.autoscroll(cx);
1625            cx.notify();
1626        }
1627    }
1628
1629    fn select_next_directory(&mut self, _: &SelectNextDirectory, cx: &mut ViewContext<Self>) {
1630        let selection = self.find_visible_entry(
1631            self.selection.as_ref(),
1632            false,
1633            |entry, worktree_id| {
1634                (self.selection.is_none()
1635                    || self.selection.is_some_and(|selection| {
1636                        if selection.worktree_id == worktree_id {
1637                            selection.entry_id != entry.id
1638                        } else {
1639                            true
1640                        }
1641                    }))
1642                    && entry.is_dir()
1643            },
1644            cx,
1645        );
1646
1647        if let Some(selection) = selection {
1648            self.selection = Some(selection);
1649            self.autoscroll(cx);
1650            cx.notify();
1651        }
1652    }
1653
1654    fn select_next_git_entry(&mut self, _: &SelectNextGitEntry, cx: &mut ViewContext<Self>) {
1655        let selection = self.find_entry(
1656            self.selection.as_ref(),
1657            true,
1658            |entry, worktree_id| {
1659                (self.selection.is_none()
1660                    || self.selection.is_some_and(|selection| {
1661                        if selection.worktree_id == worktree_id {
1662                            selection.entry_id != entry.id
1663                        } else {
1664                            true
1665                        }
1666                    }))
1667                    && entry.is_file()
1668                    && entry
1669                        .git_status
1670                        .is_some_and(|status| matches!(status, GitFileStatus::Modified))
1671            },
1672            cx,
1673        );
1674
1675        if let Some(selection) = selection {
1676            self.selection = Some(selection);
1677            self.expand_entry(selection.worktree_id, selection.entry_id, cx);
1678            self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
1679            self.autoscroll(cx);
1680            cx.notify();
1681        }
1682    }
1683
1684    fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
1685        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
1686            if let Some(parent) = entry.path.parent() {
1687                let worktree = worktree.read(cx);
1688                if let Some(parent_entry) = worktree.entry_for_path(parent) {
1689                    self.selection = Some(SelectedEntry {
1690                        worktree_id: worktree.id(),
1691                        entry_id: parent_entry.id,
1692                    });
1693                    self.autoscroll(cx);
1694                    cx.notify();
1695                }
1696            }
1697        } else {
1698            self.select_first(&SelectFirst {}, cx);
1699        }
1700    }
1701
1702    fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
1703        let worktree = self
1704            .visible_entries
1705            .first()
1706            .and_then(|(worktree_id, _, _)| {
1707                self.project.read(cx).worktree_for_id(*worktree_id, cx)
1708            });
1709        if let Some(worktree) = worktree {
1710            let worktree = worktree.read(cx);
1711            let worktree_id = worktree.id();
1712            if let Some(root_entry) = worktree.root_entry() {
1713                let selection = SelectedEntry {
1714                    worktree_id,
1715                    entry_id: root_entry.id,
1716                };
1717                self.selection = Some(selection);
1718                if cx.modifiers().shift {
1719                    self.marked_entries.insert(selection);
1720                }
1721                self.autoscroll(cx);
1722                cx.notify();
1723            }
1724        }
1725    }
1726
1727    fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
1728        let worktree = self.visible_entries.last().and_then(|(worktree_id, _, _)| {
1729            self.project.read(cx).worktree_for_id(*worktree_id, cx)
1730        });
1731        if let Some(worktree) = worktree {
1732            let worktree = worktree.read(cx);
1733            let worktree_id = worktree.id();
1734            if let Some(last_entry) = worktree.entries(true, 0).last() {
1735                self.selection = Some(SelectedEntry {
1736                    worktree_id,
1737                    entry_id: last_entry.id,
1738                });
1739                self.autoscroll(cx);
1740                cx.notify();
1741            }
1742        }
1743    }
1744
1745    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
1746        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
1747            self.scroll_handle
1748                .scroll_to_item(index, ScrollStrategy::Center);
1749            cx.notify();
1750        }
1751    }
1752
1753    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
1754        let entries = self.disjoint_entries(cx);
1755        if !entries.is_empty() {
1756            self.clipboard = Some(ClipboardEntry::Cut(entries));
1757            cx.notify();
1758        }
1759    }
1760
1761    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
1762        let entries = self.disjoint_entries(cx);
1763        if !entries.is_empty() {
1764            self.clipboard = Some(ClipboardEntry::Copied(entries));
1765            cx.notify();
1766        }
1767    }
1768
1769    fn create_paste_path(
1770        &self,
1771        source: &SelectedEntry,
1772        (worktree, target_entry): (Model<Worktree>, &Entry),
1773        cx: &AppContext,
1774    ) -> Option<(PathBuf, Option<Range<usize>>)> {
1775        let mut new_path = target_entry.path.to_path_buf();
1776        // If we're pasting into a file, or a directory into itself, go up one level.
1777        if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
1778            new_path.pop();
1779        }
1780        let clipboard_entry_file_name = self
1781            .project
1782            .read(cx)
1783            .path_for_entry(source.entry_id, cx)?
1784            .path
1785            .file_name()?
1786            .to_os_string();
1787        new_path.push(&clipboard_entry_file_name);
1788        let extension = new_path.extension().map(|e| e.to_os_string());
1789        let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
1790        let file_name_len = file_name_without_extension.to_string_lossy().len();
1791        let mut disambiguation_range = None;
1792        let mut ix = 0;
1793        {
1794            let worktree = worktree.read(cx);
1795            while worktree.entry_for_path(&new_path).is_some() {
1796                new_path.pop();
1797
1798                let mut new_file_name = file_name_without_extension.to_os_string();
1799
1800                let disambiguation = " copy";
1801                let mut disambiguation_len = disambiguation.len();
1802
1803                new_file_name.push(disambiguation);
1804
1805                if ix > 0 {
1806                    let extra_disambiguation = format!(" {}", ix);
1807                    disambiguation_len += extra_disambiguation.len();
1808
1809                    new_file_name.push(extra_disambiguation);
1810                }
1811                if let Some(extension) = extension.as_ref() {
1812                    new_file_name.push(".");
1813                    new_file_name.push(extension);
1814                }
1815
1816                new_path.push(new_file_name);
1817                disambiguation_range = Some(file_name_len..(file_name_len + disambiguation_len));
1818                ix += 1;
1819            }
1820        }
1821        Some((new_path, disambiguation_range))
1822    }
1823
1824    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
1825        maybe!({
1826            let (worktree, entry) = self.selected_entry_handle(cx)?;
1827            let entry = entry.clone();
1828            let worktree_id = worktree.read(cx).id();
1829            let clipboard_entries = self
1830                .clipboard
1831                .as_ref()
1832                .filter(|clipboard| !clipboard.items().is_empty())?;
1833            enum PasteTask {
1834                Rename(Task<Result<CreatedEntry>>),
1835                Copy(Task<Result<Option<Entry>>>),
1836            }
1837            let mut paste_entry_tasks: IndexMap<(ProjectEntryId, bool), PasteTask> =
1838                IndexMap::default();
1839            let mut disambiguation_range = None;
1840            let clip_is_cut = clipboard_entries.is_cut();
1841            for clipboard_entry in clipboard_entries.items() {
1842                let (new_path, new_disambiguation_range) =
1843                    self.create_paste_path(clipboard_entry, self.selected_sub_entry(cx)?, cx)?;
1844                let clip_entry_id = clipboard_entry.entry_id;
1845                let is_same_worktree = clipboard_entry.worktree_id == worktree_id;
1846                let relative_worktree_source_path = if !is_same_worktree {
1847                    let target_base_path = worktree.read(cx).abs_path();
1848                    let clipboard_project_path =
1849                        self.project.read(cx).path_for_entry(clip_entry_id, cx)?;
1850                    let clipboard_abs_path = self
1851                        .project
1852                        .read(cx)
1853                        .absolute_path(&clipboard_project_path, cx)?;
1854                    Some(relativize_path(
1855                        &target_base_path,
1856                        clipboard_abs_path.as_path(),
1857                    ))
1858                } else {
1859                    None
1860                };
1861                let task = if clip_is_cut && is_same_worktree {
1862                    let task = self.project.update(cx, |project, cx| {
1863                        project.rename_entry(clip_entry_id, new_path, cx)
1864                    });
1865                    PasteTask::Rename(task)
1866                } else {
1867                    let entry_id = if is_same_worktree {
1868                        clip_entry_id
1869                    } else {
1870                        entry.id
1871                    };
1872                    let task = self.project.update(cx, |project, cx| {
1873                        project.copy_entry(entry_id, relative_worktree_source_path, new_path, cx)
1874                    });
1875                    PasteTask::Copy(task)
1876                };
1877                let needs_delete = !is_same_worktree && clip_is_cut;
1878                paste_entry_tasks.insert((clip_entry_id, needs_delete), task);
1879                disambiguation_range = new_disambiguation_range.or(disambiguation_range);
1880            }
1881
1882            let item_count = paste_entry_tasks.len();
1883
1884            cx.spawn(|project_panel, mut cx| async move {
1885                let mut last_succeed = None;
1886                let mut need_delete_ids = Vec::new();
1887                for ((entry_id, need_delete), task) in paste_entry_tasks.into_iter() {
1888                    match task {
1889                        PasteTask::Rename(task) => {
1890                            if let Some(CreatedEntry::Included(entry)) = task.await.log_err() {
1891                                last_succeed = Some(entry.id);
1892                            }
1893                        }
1894                        PasteTask::Copy(task) => {
1895                            if let Some(Some(entry)) = task.await.log_err() {
1896                                last_succeed = Some(entry.id);
1897                                if need_delete {
1898                                    need_delete_ids.push(entry_id);
1899                                }
1900                            }
1901                        }
1902                    }
1903                }
1904                // remove entry for cut in difference worktree
1905                for entry_id in need_delete_ids {
1906                    project_panel
1907                        .update(&mut cx, |project_panel, cx| {
1908                            project_panel
1909                                .project
1910                                .update(cx, |project, cx| project.delete_entry(entry_id, true, cx))
1911                                .ok_or_else(|| anyhow!("no such entry"))
1912                        })??
1913                        .await?;
1914                }
1915                // update selection
1916                if let Some(entry_id) = last_succeed {
1917                    project_panel
1918                        .update(&mut cx, |project_panel, cx| {
1919                            project_panel.selection = Some(SelectedEntry {
1920                                worktree_id,
1921                                entry_id,
1922                            });
1923
1924                            // if only one entry was pasted and it was disambiguated, open the rename editor
1925                            if item_count == 1 && disambiguation_range.is_some() {
1926                                project_panel.rename_impl(disambiguation_range, cx);
1927                            }
1928                        })
1929                        .ok();
1930                }
1931
1932                anyhow::Ok(())
1933            })
1934            .detach_and_log_err(cx);
1935
1936            self.expand_entry(worktree_id, entry.id, cx);
1937            Some(())
1938        });
1939    }
1940
1941    fn duplicate(&mut self, _: &Duplicate, cx: &mut ViewContext<Self>) {
1942        self.copy(&Copy {}, cx);
1943        self.paste(&Paste {}, cx);
1944    }
1945
1946    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
1947        let abs_file_paths = {
1948            let project = self.project.read(cx);
1949            self.effective_entries()
1950                .into_iter()
1951                .filter_map(|entry| {
1952                    let entry_path = project.path_for_entry(entry.entry_id, cx)?.path;
1953                    Some(
1954                        project
1955                            .worktree_for_id(entry.worktree_id, cx)?
1956                            .read(cx)
1957                            .abs_path()
1958                            .join(entry_path)
1959                            .to_string_lossy()
1960                            .to_string(),
1961                    )
1962                })
1963                .collect::<Vec<_>>()
1964        };
1965        if !abs_file_paths.is_empty() {
1966            cx.write_to_clipboard(ClipboardItem::new_string(abs_file_paths.join("\n")));
1967        }
1968    }
1969
1970    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
1971        let file_paths = {
1972            let project = self.project.read(cx);
1973            self.effective_entries()
1974                .into_iter()
1975                .filter_map(|entry| {
1976                    Some(
1977                        project
1978                            .path_for_entry(entry.entry_id, cx)?
1979                            .path
1980                            .to_string_lossy()
1981                            .to_string(),
1982                    )
1983                })
1984                .collect::<Vec<_>>()
1985        };
1986        if !file_paths.is_empty() {
1987            cx.write_to_clipboard(ClipboardItem::new_string(file_paths.join("\n")));
1988        }
1989    }
1990
1991    fn reveal_in_finder(&mut self, _: &RevealInFileManager, cx: &mut ViewContext<Self>) {
1992        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
1993            cx.reveal_path(&worktree.read(cx).abs_path().join(&entry.path));
1994        }
1995    }
1996
1997    fn remove_from_project(&mut self, _: &RemoveFromProject, cx: &mut ViewContext<Self>) {
1998        for entry in self.effective_entries().iter() {
1999            let worktree_id = entry.worktree_id;
2000            self.project
2001                .update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
2002        }
2003    }
2004
2005    fn open_system(&mut self, _: &OpenWithSystem, cx: &mut ViewContext<Self>) {
2006        if let Some((worktree, entry)) = self.selected_entry(cx) {
2007            let abs_path = worktree.abs_path().join(&entry.path);
2008            cx.open_with_system(&abs_path);
2009        }
2010    }
2011
2012    fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
2013        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2014            let abs_path = match &entry.canonical_path {
2015                Some(canonical_path) => Some(canonical_path.to_path_buf()),
2016                None => worktree.read(cx).absolutize(&entry.path).ok(),
2017            };
2018
2019            let working_directory = if entry.is_dir() {
2020                abs_path
2021            } else {
2022                abs_path.and_then(|path| Some(path.parent()?.to_path_buf()))
2023            };
2024            if let Some(working_directory) = working_directory {
2025                cx.dispatch_action(workspace::OpenTerminal { working_directory }.boxed_clone())
2026            }
2027        }
2028    }
2029
2030    pub fn new_search_in_directory(
2031        &mut self,
2032        _: &NewSearchInDirectory,
2033        cx: &mut ViewContext<Self>,
2034    ) {
2035        if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
2036            if entry.is_dir() {
2037                let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
2038                let dir_path = if include_root {
2039                    let mut full_path = PathBuf::from(worktree.read(cx).root_name());
2040                    full_path.push(&entry.path);
2041                    Arc::from(full_path)
2042                } else {
2043                    entry.path.clone()
2044                };
2045
2046                self.workspace
2047                    .update(cx, |workspace, cx| {
2048                        search::ProjectSearchView::new_search_in_directory(
2049                            workspace, &dir_path, cx,
2050                        );
2051                    })
2052                    .ok();
2053            }
2054        }
2055    }
2056
2057    fn move_entry(
2058        &mut self,
2059        entry_to_move: ProjectEntryId,
2060        destination: ProjectEntryId,
2061        destination_is_file: bool,
2062        cx: &mut ViewContext<Self>,
2063    ) {
2064        if self
2065            .project
2066            .read(cx)
2067            .entry_is_worktree_root(entry_to_move, cx)
2068        {
2069            self.move_worktree_root(entry_to_move, destination, cx)
2070        } else {
2071            self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
2072        }
2073    }
2074
2075    fn move_worktree_root(
2076        &mut self,
2077        entry_to_move: ProjectEntryId,
2078        destination: ProjectEntryId,
2079        cx: &mut ViewContext<Self>,
2080    ) {
2081        self.project.update(cx, |project, cx| {
2082            let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
2083                return;
2084            };
2085            let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
2086                return;
2087            };
2088
2089            let worktree_id = worktree_to_move.read(cx).id();
2090            let destination_id = destination_worktree.read(cx).id();
2091
2092            project
2093                .move_worktree(worktree_id, destination_id, cx)
2094                .log_err();
2095        });
2096    }
2097
2098    fn move_worktree_entry(
2099        &mut self,
2100        entry_to_move: ProjectEntryId,
2101        destination: ProjectEntryId,
2102        destination_is_file: bool,
2103        cx: &mut ViewContext<Self>,
2104    ) {
2105        if entry_to_move == destination {
2106            return;
2107        }
2108
2109        let destination_worktree = self.project.update(cx, |project, cx| {
2110            let entry_path = project.path_for_entry(entry_to_move, cx)?;
2111            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
2112
2113            let mut destination_path = destination_entry_path.as_ref();
2114            if destination_is_file {
2115                destination_path = destination_path.parent()?;
2116            }
2117
2118            let mut new_path = destination_path.to_path_buf();
2119            new_path.push(entry_path.path.file_name()?);
2120            if new_path != entry_path.path.as_ref() {
2121                let task = project.rename_entry(entry_to_move, new_path, cx);
2122                cx.foreground_executor().spawn(task).detach_and_log_err(cx);
2123            }
2124
2125            project.worktree_id_for_entry(destination, cx)
2126        });
2127
2128        if let Some(destination_worktree) = destination_worktree {
2129            self.expand_entry(destination_worktree, destination, cx);
2130        }
2131    }
2132
2133    fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
2134        let mut entry_index = 0;
2135        let mut visible_entries_index = 0;
2136        for (worktree_index, (worktree_id, worktree_entries, _)) in
2137            self.visible_entries.iter().enumerate()
2138        {
2139            if *worktree_id == selection.worktree_id {
2140                for entry in worktree_entries {
2141                    if entry.id == selection.entry_id {
2142                        return Some((worktree_index, entry_index, visible_entries_index));
2143                    } else {
2144                        visible_entries_index += 1;
2145                        entry_index += 1;
2146                    }
2147                }
2148                break;
2149            } else {
2150                visible_entries_index += worktree_entries.len();
2151            }
2152        }
2153        None
2154    }
2155
2156    fn disjoint_entries(&self, cx: &AppContext) -> BTreeSet<SelectedEntry> {
2157        let marked_entries = self.effective_entries();
2158        let mut sanitized_entries = BTreeSet::new();
2159        if marked_entries.is_empty() {
2160            return sanitized_entries;
2161        }
2162
2163        let project = self.project.read(cx);
2164        let marked_entries_by_worktree: HashMap<WorktreeId, Vec<SelectedEntry>> = marked_entries
2165            .into_iter()
2166            .filter(|entry| !project.entry_is_worktree_root(entry.entry_id, cx))
2167            .fold(HashMap::default(), |mut map, entry| {
2168                map.entry(entry.worktree_id).or_default().push(entry);
2169                map
2170            });
2171
2172        for (worktree_id, marked_entries) in marked_entries_by_worktree {
2173            if let Some(worktree) = project.worktree_for_id(worktree_id, cx) {
2174                let worktree = worktree.read(cx);
2175                let marked_dir_paths = marked_entries
2176                    .iter()
2177                    .filter_map(|entry| {
2178                        worktree.entry_for_id(entry.entry_id).and_then(|entry| {
2179                            if entry.is_dir() {
2180                                Some(entry.path.as_ref())
2181                            } else {
2182                                None
2183                            }
2184                        })
2185                    })
2186                    .collect::<BTreeSet<_>>();
2187
2188                sanitized_entries.extend(marked_entries.into_iter().filter(|entry| {
2189                    let Some(entry_info) = worktree.entry_for_id(entry.entry_id) else {
2190                        return false;
2191                    };
2192                    let entry_path = entry_info.path.as_ref();
2193                    let inside_marked_dir = marked_dir_paths.iter().any(|&marked_dir_path| {
2194                        entry_path != marked_dir_path && entry_path.starts_with(marked_dir_path)
2195                    });
2196                    !inside_marked_dir
2197                }));
2198            }
2199        }
2200
2201        sanitized_entries
2202    }
2203
2204    fn effective_entries(&self) -> BTreeSet<SelectedEntry> {
2205        if let Some(selection) = self.selection {
2206            let selection = SelectedEntry {
2207                entry_id: self.resolve_entry(selection.entry_id),
2208                worktree_id: selection.worktree_id,
2209            };
2210
2211            // Default to using just the selected item when nothing is marked.
2212            if self.marked_entries.is_empty() {
2213                return BTreeSet::from([selection]);
2214            }
2215
2216            // Allow operating on the selected item even when something else is marked,
2217            // making it easier to perform one-off actions without clearing a mark.
2218            if self.marked_entries.len() == 1 && !self.marked_entries.contains(&selection) {
2219                return BTreeSet::from([selection]);
2220            }
2221        }
2222
2223        // Return only marked entries since we've already handled special cases where
2224        // only selection should take precedence. At this point, marked entries may or
2225        // may not include the current selection, which is intentional.
2226        self.marked_entries
2227            .iter()
2228            .map(|entry| SelectedEntry {
2229                entry_id: self.resolve_entry(entry.entry_id),
2230                worktree_id: entry.worktree_id,
2231            })
2232            .collect::<BTreeSet<_>>()
2233    }
2234
2235    /// Finds the currently selected subentry for a given leaf entry id. If a given entry
2236    /// has no ancestors, the project entry ID that's passed in is returned as-is.
2237    fn resolve_entry(&self, id: ProjectEntryId) -> ProjectEntryId {
2238        self.ancestors
2239            .get(&id)
2240            .and_then(|ancestors| {
2241                if ancestors.current_ancestor_depth == 0 {
2242                    return None;
2243                }
2244                ancestors.ancestors.get(ancestors.current_ancestor_depth)
2245            })
2246            .copied()
2247            .unwrap_or(id)
2248    }
2249
2250    pub fn selected_entry<'a>(
2251        &self,
2252        cx: &'a AppContext,
2253    ) -> Option<(&'a Worktree, &'a project::Entry)> {
2254        let (worktree, entry) = self.selected_entry_handle(cx)?;
2255        Some((worktree.read(cx), entry))
2256    }
2257
2258    /// Compared to selected_entry, this function resolves to the currently
2259    /// selected subentry if dir auto-folding is enabled.
2260    fn selected_sub_entry<'a>(
2261        &self,
2262        cx: &'a AppContext,
2263    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
2264        let (worktree, mut entry) = self.selected_entry_handle(cx)?;
2265
2266        let resolved_id = self.resolve_entry(entry.id);
2267        if resolved_id != entry.id {
2268            let worktree = worktree.read(cx);
2269            entry = worktree.entry_for_id(resolved_id)?;
2270        }
2271        Some((worktree, entry))
2272    }
2273    fn selected_entry_handle<'a>(
2274        &self,
2275        cx: &'a AppContext,
2276    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
2277        let selection = self.selection?;
2278        let project = self.project.read(cx);
2279        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
2280        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
2281        Some((worktree, entry))
2282    }
2283
2284    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
2285        let (worktree, entry) = self.selected_entry(cx)?;
2286        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
2287
2288        for path in entry.path.ancestors() {
2289            let Some(entry) = worktree.entry_for_path(path) else {
2290                continue;
2291            };
2292            if entry.is_dir() {
2293                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
2294                    expanded_dir_ids.insert(idx, entry.id);
2295                }
2296            }
2297        }
2298
2299        Some(())
2300    }
2301
2302    fn update_visible_entries(
2303        &mut self,
2304        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
2305        cx: &mut ViewContext<Self>,
2306    ) {
2307        let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
2308        let project = self.project.read(cx);
2309        self.last_worktree_root_id = project
2310            .visible_worktrees(cx)
2311            .next_back()
2312            .and_then(|worktree| worktree.read(cx).root_entry())
2313            .map(|entry| entry.id);
2314
2315        let old_ancestors = std::mem::take(&mut self.ancestors);
2316        self.visible_entries.clear();
2317        let mut max_width_item = None;
2318        for worktree in project.visible_worktrees(cx) {
2319            let snapshot = worktree.read(cx).snapshot();
2320            let worktree_id = snapshot.id();
2321
2322            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
2323                hash_map::Entry::Occupied(e) => e.into_mut(),
2324                hash_map::Entry::Vacant(e) => {
2325                    // The first time a worktree's root entry becomes available,
2326                    // mark that root entry as expanded.
2327                    if let Some(entry) = snapshot.root_entry() {
2328                        e.insert(vec![entry.id]).as_slice()
2329                    } else {
2330                        &[]
2331                    }
2332                }
2333            };
2334
2335            let mut new_entry_parent_id = None;
2336            let mut new_entry_kind = EntryKind::Dir;
2337            if let Some(edit_state) = &self.edit_state {
2338                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry() {
2339                    new_entry_parent_id = Some(edit_state.entry_id);
2340                    new_entry_kind = if edit_state.is_dir {
2341                        EntryKind::Dir
2342                    } else {
2343                        EntryKind::File
2344                    };
2345                }
2346            }
2347
2348            let mut visible_worktree_entries = Vec::new();
2349            let mut entry_iter = snapshot.entries(true, 0).with_git_statuses();
2350            let mut auto_folded_ancestors = vec![];
2351            while let Some(entry) = entry_iter.entry() {
2352                if auto_collapse_dirs && entry.kind.is_dir() {
2353                    auto_folded_ancestors.push(entry.id);
2354                    if !self.unfolded_dir_ids.contains(&entry.id) {
2355                        if let Some(root_path) = snapshot.root_entry() {
2356                            let mut child_entries = snapshot.child_entries(&entry.path);
2357                            if let Some(child) = child_entries.next() {
2358                                if entry.path != root_path.path
2359                                    && child_entries.next().is_none()
2360                                    && child.kind.is_dir()
2361                                {
2362                                    entry_iter.advance();
2363
2364                                    continue;
2365                                }
2366                            }
2367                        }
2368                    }
2369                    let depth = old_ancestors
2370                        .get(&entry.id)
2371                        .map(|ancestor| ancestor.current_ancestor_depth)
2372                        .unwrap_or_default();
2373                    if let Some(edit_state) = &mut self.edit_state {
2374                        if edit_state.entry_id == entry.id {
2375                            edit_state.depth = depth;
2376                        }
2377                    }
2378                    let mut ancestors = std::mem::take(&mut auto_folded_ancestors);
2379                    if ancestors.len() > 1 {
2380                        ancestors.reverse();
2381                        self.ancestors.insert(
2382                            entry.id,
2383                            FoldedAncestors {
2384                                current_ancestor_depth: depth,
2385                                ancestors,
2386                            },
2387                        );
2388                    }
2389                }
2390                auto_folded_ancestors.clear();
2391                visible_worktree_entries.push(entry.to_owned());
2392                let precedes_new_entry = if let Some(new_entry_id) = new_entry_parent_id {
2393                    entry.id == new_entry_id || {
2394                        self.ancestors.get(&entry.id).map_or(false, |entries| {
2395                            entries
2396                                .ancestors
2397                                .iter()
2398                                .any(|entry_id| *entry_id == new_entry_id)
2399                        })
2400                    }
2401                } else {
2402                    false
2403                };
2404                if precedes_new_entry {
2405                    visible_worktree_entries.push(GitEntry {
2406                        entry: Entry {
2407                            id: NEW_ENTRY_ID,
2408                            kind: new_entry_kind,
2409                            path: entry.path.join("\0").into(),
2410                            inode: 0,
2411                            mtime: entry.mtime,
2412                            size: entry.size,
2413                            is_ignored: entry.is_ignored,
2414                            is_external: false,
2415                            is_private: false,
2416                            is_always_included: entry.is_always_included,
2417                            canonical_path: entry.canonical_path.clone(),
2418                            char_bag: entry.char_bag,
2419                            is_fifo: entry.is_fifo,
2420                        },
2421                        git_status: entry.git_status,
2422                    });
2423                }
2424                let worktree_abs_path = worktree.read(cx).abs_path();
2425                let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() {
2426                    let Some(path_name) = worktree_abs_path
2427                        .file_name()
2428                        .with_context(|| {
2429                            format!("Worktree abs path has no file name, root entry: {entry:?}")
2430                        })
2431                        .log_err()
2432                    else {
2433                        continue;
2434                    };
2435                    let path = Arc::from(Path::new(path_name));
2436                    let depth = 0;
2437                    (depth, path)
2438                } else if entry.is_file() {
2439                    let Some(path_name) = entry
2440                        .path
2441                        .file_name()
2442                        .with_context(|| format!("Non-root entry has no file name: {entry:?}"))
2443                        .log_err()
2444                    else {
2445                        continue;
2446                    };
2447                    let path = Arc::from(Path::new(path_name));
2448                    let depth = entry.path.ancestors().count() - 1;
2449                    (depth, path)
2450                } else {
2451                    let path = self
2452                        .ancestors
2453                        .get(&entry.id)
2454                        .and_then(|ancestors| {
2455                            let outermost_ancestor = ancestors.ancestors.last()?;
2456                            let root_folded_entry = worktree
2457                                .read(cx)
2458                                .entry_for_id(*outermost_ancestor)?
2459                                .path
2460                                .as_ref();
2461                            entry
2462                                .path
2463                                .strip_prefix(root_folded_entry)
2464                                .ok()
2465                                .and_then(|suffix| {
2466                                    let full_path = Path::new(root_folded_entry.file_name()?);
2467                                    Some(Arc::<Path>::from(full_path.join(suffix)))
2468                                })
2469                        })
2470                        .or_else(|| entry.path.file_name().map(Path::new).map(Arc::from))
2471                        .unwrap_or_else(|| entry.path.clone());
2472                    let depth = path.components().count();
2473                    (depth, path)
2474                };
2475                let width_estimate = item_width_estimate(
2476                    depth,
2477                    path.to_string_lossy().chars().count(),
2478                    entry.canonical_path.is_some(),
2479                );
2480
2481                match max_width_item.as_mut() {
2482                    Some((id, worktree_id, width)) => {
2483                        if *width < width_estimate {
2484                            *id = entry.id;
2485                            *worktree_id = worktree.read(cx).id();
2486                            *width = width_estimate;
2487                        }
2488                    }
2489                    None => {
2490                        max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate))
2491                    }
2492                }
2493
2494                if expanded_dir_ids.binary_search(&entry.id).is_err()
2495                    && entry_iter.advance_to_sibling()
2496                {
2497                    continue;
2498                }
2499                entry_iter.advance();
2500            }
2501
2502            project::sort_worktree_entries(&mut visible_worktree_entries);
2503
2504            self.visible_entries
2505                .push((worktree_id, visible_worktree_entries, OnceCell::new()));
2506        }
2507
2508        if let Some((project_entry_id, worktree_id, _)) = max_width_item {
2509            let mut visited_worktrees_length = 0;
2510            let index = self.visible_entries.iter().find_map(|(id, entries, _)| {
2511                if worktree_id == *id {
2512                    entries
2513                        .iter()
2514                        .position(|entry| entry.id == project_entry_id)
2515                } else {
2516                    visited_worktrees_length += entries.len();
2517                    None
2518                }
2519            });
2520            if let Some(index) = index {
2521                self.max_width_item_index = Some(visited_worktrees_length + index);
2522            }
2523        }
2524        if let Some((worktree_id, entry_id)) = new_selected_entry {
2525            self.selection = Some(SelectedEntry {
2526                worktree_id,
2527                entry_id,
2528            });
2529        }
2530    }
2531
2532    fn expand_entry(
2533        &mut self,
2534        worktree_id: WorktreeId,
2535        entry_id: ProjectEntryId,
2536        cx: &mut ViewContext<Self>,
2537    ) {
2538        self.project.update(cx, |project, cx| {
2539            if let Some((worktree, expanded_dir_ids)) = project
2540                .worktree_for_id(worktree_id, cx)
2541                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
2542            {
2543                project.expand_entry(worktree_id, entry_id, cx);
2544                let worktree = worktree.read(cx);
2545
2546                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
2547                    loop {
2548                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
2549                            expanded_dir_ids.insert(ix, entry.id);
2550                        }
2551
2552                        if let Some(parent_entry) =
2553                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
2554                        {
2555                            entry = parent_entry;
2556                        } else {
2557                            break;
2558                        }
2559                    }
2560                }
2561            }
2562        });
2563    }
2564
2565    fn drop_external_files(
2566        &mut self,
2567        paths: &[PathBuf],
2568        entry_id: ProjectEntryId,
2569        cx: &mut ViewContext<Self>,
2570    ) {
2571        let mut paths: Vec<Arc<Path>> = paths.iter().map(|path| Arc::from(path.clone())).collect();
2572
2573        let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
2574
2575        let Some((target_directory, worktree)) = maybe!({
2576            let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
2577            let entry = worktree.read(cx).entry_for_id(entry_id)?;
2578            let path = worktree.read(cx).absolutize(&entry.path).ok()?;
2579            let target_directory = if path.is_dir() {
2580                path
2581            } else {
2582                path.parent()?.to_path_buf()
2583            };
2584            Some((target_directory, worktree))
2585        }) else {
2586            return;
2587        };
2588
2589        let mut paths_to_replace = Vec::new();
2590        for path in &paths {
2591            if let Some(name) = path.file_name() {
2592                let mut target_path = target_directory.clone();
2593                target_path.push(name);
2594                if target_path.exists() {
2595                    paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
2596                }
2597            }
2598        }
2599
2600        cx.spawn(|this, mut cx| {
2601            async move {
2602                for (filename, original_path) in &paths_to_replace {
2603                    let answer = cx
2604                        .prompt(
2605                            PromptLevel::Info,
2606                            format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
2607                            None,
2608                            &["Replace", "Cancel"],
2609                        )
2610                        .await?;
2611                    if answer == 1 {
2612                        if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
2613                            paths.remove(item_idx);
2614                        }
2615                    }
2616                }
2617
2618                if paths.is_empty() {
2619                    return Ok(());
2620                }
2621
2622                let task = worktree.update(&mut cx, |worktree, cx| {
2623                    worktree.copy_external_entries(target_directory, paths, true, cx)
2624                })?;
2625
2626                let opened_entries = task.await?;
2627                this.update(&mut cx, |this, cx| {
2628                    if open_file_after_drop && !opened_entries.is_empty() {
2629                        this.open_entry(opened_entries[0], true, false, cx);
2630                    }
2631                })
2632            }
2633            .log_err()
2634        })
2635        .detach();
2636    }
2637
2638    fn drag_onto(
2639        &mut self,
2640        selections: &DraggedSelection,
2641        target_entry_id: ProjectEntryId,
2642        is_file: bool,
2643        cx: &mut ViewContext<Self>,
2644    ) {
2645        let should_copy = cx.modifiers().alt;
2646        if should_copy {
2647            let _ = maybe!({
2648                let project = self.project.read(cx);
2649                let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
2650                let worktree_id = target_worktree.read(cx).id();
2651                let target_entry = target_worktree
2652                    .read(cx)
2653                    .entry_for_id(target_entry_id)?
2654                    .clone();
2655
2656                let mut copy_tasks = Vec::new();
2657                let mut disambiguation_range = None;
2658                for selection in selections.items() {
2659                    let (new_path, new_disambiguation_range) = self.create_paste_path(
2660                        selection,
2661                        (target_worktree.clone(), &target_entry),
2662                        cx,
2663                    )?;
2664
2665                    let task = self.project.update(cx, |project, cx| {
2666                        project.copy_entry(selection.entry_id, None, new_path, cx)
2667                    });
2668                    copy_tasks.push(task);
2669                    disambiguation_range = new_disambiguation_range.or(disambiguation_range);
2670                }
2671
2672                let item_count = copy_tasks.len();
2673
2674                cx.spawn(|project_panel, mut cx| async move {
2675                    let mut last_succeed = None;
2676                    for task in copy_tasks.into_iter() {
2677                        if let Some(Some(entry)) = task.await.log_err() {
2678                            last_succeed = Some(entry.id);
2679                        }
2680                    }
2681                    // update selection
2682                    if let Some(entry_id) = last_succeed {
2683                        project_panel
2684                            .update(&mut cx, |project_panel, cx| {
2685                                project_panel.selection = Some(SelectedEntry {
2686                                    worktree_id,
2687                                    entry_id,
2688                                });
2689
2690                                // if only one entry was dragged and it was disambiguated, open the rename editor
2691                                if item_count == 1 && disambiguation_range.is_some() {
2692                                    project_panel.rename_impl(disambiguation_range, cx);
2693                                }
2694                            })
2695                            .ok();
2696                    }
2697                })
2698                .detach();
2699                Some(())
2700            });
2701        } else {
2702            for selection in selections.items() {
2703                self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
2704            }
2705        }
2706    }
2707
2708    fn index_for_entry(
2709        &self,
2710        entry_id: ProjectEntryId,
2711        worktree_id: WorktreeId,
2712    ) -> Option<(usize, usize, usize)> {
2713        let mut worktree_ix = 0;
2714        let mut total_ix = 0;
2715        for (current_worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2716            if worktree_id != *current_worktree_id {
2717                total_ix += visible_worktree_entries.len();
2718                worktree_ix += 1;
2719                continue;
2720            }
2721
2722            return visible_worktree_entries
2723                .iter()
2724                .enumerate()
2725                .find(|(_, entry)| entry.id == entry_id)
2726                .map(|(ix, _)| (worktree_ix, ix, total_ix + ix));
2727        }
2728        None
2729    }
2730
2731    fn entry_at_index(&self, index: usize) -> Option<(WorktreeId, GitEntryRef)> {
2732        let mut offset = 0;
2733        for (worktree_id, visible_worktree_entries, _) in &self.visible_entries {
2734            if visible_worktree_entries.len() > offset + index {
2735                return visible_worktree_entries
2736                    .get(index)
2737                    .map(|entry| (*worktree_id, entry.to_ref()));
2738            }
2739            offset += visible_worktree_entries.len();
2740        }
2741        None
2742    }
2743
2744    fn iter_visible_entries(
2745        &self,
2746        range: Range<usize>,
2747        cx: &mut ViewContext<ProjectPanel>,
2748        mut callback: impl FnMut(&Entry, &HashSet<Arc<Path>>, &mut ViewContext<ProjectPanel>),
2749    ) {
2750        let mut ix = 0;
2751        for (_, visible_worktree_entries, entries_paths) in &self.visible_entries {
2752            if ix >= range.end {
2753                return;
2754            }
2755
2756            if ix + visible_worktree_entries.len() <= range.start {
2757                ix += visible_worktree_entries.len();
2758                continue;
2759            }
2760
2761            let end_ix = range.end.min(ix + visible_worktree_entries.len());
2762            let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
2763            let entries = entries_paths.get_or_init(|| {
2764                visible_worktree_entries
2765                    .iter()
2766                    .map(|e| (e.path.clone()))
2767                    .collect()
2768            });
2769            for entry in visible_worktree_entries[entry_range].iter() {
2770                callback(&entry, entries, cx);
2771            }
2772            ix = end_ix;
2773        }
2774    }
2775
2776    fn for_each_visible_entry(
2777        &self,
2778        range: Range<usize>,
2779        cx: &mut ViewContext<ProjectPanel>,
2780        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
2781    ) {
2782        let mut ix = 0;
2783        for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
2784            if ix >= range.end {
2785                return;
2786            }
2787
2788            if ix + visible_worktree_entries.len() <= range.start {
2789                ix += visible_worktree_entries.len();
2790                continue;
2791            }
2792
2793            let end_ix = range.end.min(ix + visible_worktree_entries.len());
2794            let (git_status_setting, show_file_icons, show_folder_icons) = {
2795                let settings = ProjectPanelSettings::get_global(cx);
2796                (
2797                    settings.git_status,
2798                    settings.file_icons,
2799                    settings.folder_icons,
2800                )
2801            };
2802            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
2803                let snapshot = worktree.read(cx).snapshot();
2804                let root_name = OsStr::new(snapshot.root_name());
2805                let expanded_entry_ids = self
2806                    .expanded_dir_ids
2807                    .get(&snapshot.id())
2808                    .map(Vec::as_slice)
2809                    .unwrap_or(&[]);
2810
2811                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
2812                let entries = entries_paths.get_or_init(|| {
2813                    visible_worktree_entries
2814                        .iter()
2815                        .map(|e| (e.path.clone()))
2816                        .collect()
2817                });
2818                for entry in visible_worktree_entries[entry_range].iter() {
2819                    let status = git_status_setting.then_some(entry.git_status).flatten();
2820                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
2821                    let icon = match entry.kind {
2822                        EntryKind::File => {
2823                            if show_file_icons {
2824                                FileIcons::get_icon(&entry.path, cx)
2825                            } else {
2826                                None
2827                            }
2828                        }
2829                        _ => {
2830                            if show_folder_icons {
2831                                FileIcons::get_folder_icon(is_expanded, cx)
2832                            } else {
2833                                FileIcons::get_chevron_icon(is_expanded, cx)
2834                            }
2835                        }
2836                    };
2837
2838                    let (depth, difference) =
2839                        ProjectPanel::calculate_depth_and_difference(&entry, entries);
2840
2841                    let filename = match difference {
2842                        diff if diff > 1 => entry
2843                            .path
2844                            .iter()
2845                            .skip(entry.path.components().count() - diff)
2846                            .collect::<PathBuf>()
2847                            .to_str()
2848                            .unwrap_or_default()
2849                            .to_string(),
2850                        _ => entry
2851                            .path
2852                            .file_name()
2853                            .map(|name| name.to_string_lossy().into_owned())
2854                            .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
2855                    };
2856                    let selection = SelectedEntry {
2857                        worktree_id: snapshot.id(),
2858                        entry_id: entry.id,
2859                    };
2860
2861                    let is_marked = self.marked_entries.contains(&selection);
2862
2863                    let diagnostic_severity = self
2864                        .diagnostics
2865                        .get(&(*worktree_id, entry.path.to_path_buf()))
2866                        .cloned();
2867
2868                    let filename_text_color =
2869                        entry_git_aware_label_color(status, entry.is_ignored, is_marked);
2870
2871                    let mut details = EntryDetails {
2872                        filename,
2873                        icon,
2874                        path: entry.path.clone(),
2875                        depth,
2876                        kind: entry.kind,
2877                        is_ignored: entry.is_ignored,
2878                        is_expanded,
2879                        is_selected: self.selection == Some(selection),
2880                        is_marked,
2881                        is_editing: false,
2882                        is_processing: false,
2883                        is_cut: self
2884                            .clipboard
2885                            .as_ref()
2886                            .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
2887                        filename_text_color,
2888                        diagnostic_severity,
2889                        git_status: status,
2890                        is_private: entry.is_private,
2891                        worktree_id: *worktree_id,
2892                        canonical_path: entry.canonical_path.clone(),
2893                    };
2894
2895                    if let Some(edit_state) = &self.edit_state {
2896                        let is_edited_entry = if edit_state.is_new_entry() {
2897                            entry.id == NEW_ENTRY_ID
2898                        } else {
2899                            entry.id == edit_state.entry_id
2900                                || self
2901                                    .ancestors
2902                                    .get(&entry.id)
2903                                    .is_some_and(|auto_folded_dirs| {
2904                                        auto_folded_dirs
2905                                            .ancestors
2906                                            .iter()
2907                                            .any(|entry_id| *entry_id == edit_state.entry_id)
2908                                    })
2909                        };
2910
2911                        if is_edited_entry {
2912                            if let Some(processing_filename) = &edit_state.processing_filename {
2913                                details.is_processing = true;
2914                                if let Some(ancestors) = edit_state
2915                                    .leaf_entry_id
2916                                    .and_then(|entry| self.ancestors.get(&entry))
2917                                {
2918                                    let position = ancestors.ancestors.iter().position(|entry_id| *entry_id == edit_state.entry_id).expect("Edited sub-entry should be an ancestor of selected leaf entry") + 1;
2919                                    let all_components = ancestors.ancestors.len();
2920
2921                                    let prefix_components = all_components - position;
2922                                    let suffix_components = position.checked_sub(1);
2923                                    let mut previous_components =
2924                                        Path::new(&details.filename).components();
2925                                    let mut new_path = previous_components
2926                                        .by_ref()
2927                                        .take(prefix_components)
2928                                        .collect::<PathBuf>();
2929                                    if let Some(last_component) =
2930                                        Path::new(processing_filename).components().last()
2931                                    {
2932                                        new_path.push(last_component);
2933                                        previous_components.next();
2934                                    }
2935
2936                                    if let Some(_) = suffix_components {
2937                                        new_path.push(previous_components);
2938                                    }
2939                                    if let Some(str) = new_path.to_str() {
2940                                        details.filename.clear();
2941                                        details.filename.push_str(str);
2942                                    }
2943                                } else {
2944                                    details.filename.clear();
2945                                    details.filename.push_str(processing_filename);
2946                                }
2947                            } else {
2948                                if edit_state.is_new_entry() {
2949                                    details.filename.clear();
2950                                }
2951                                details.is_editing = true;
2952                            }
2953                        }
2954                    }
2955
2956                    callback(entry.id, details, cx);
2957                }
2958            }
2959            ix = end_ix;
2960        }
2961    }
2962
2963    fn find_entry_in_worktree(
2964        &self,
2965        worktree_id: WorktreeId,
2966        reverse_search: bool,
2967        only_visible_entries: bool,
2968        predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
2969        cx: &mut ViewContext<Self>,
2970    ) -> Option<GitEntry> {
2971        if only_visible_entries {
2972            let entries = self
2973                .visible_entries
2974                .iter()
2975                .find_map(|(tree_id, entries, _)| {
2976                    if worktree_id == *tree_id {
2977                        Some(entries)
2978                    } else {
2979                        None
2980                    }
2981                })?
2982                .clone();
2983
2984            return utils::ReversibleIterable::new(entries.iter(), reverse_search)
2985                .find(|ele| predicate(ele.to_ref(), worktree_id))
2986                .cloned();
2987        }
2988
2989        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
2990        worktree.update(cx, |tree, _| {
2991            utils::ReversibleIterable::new(
2992                tree.entries(true, 0usize).with_git_statuses(),
2993                reverse_search,
2994            )
2995            .find_single_ended(|ele| predicate(*ele, worktree_id))
2996            .map(|ele| ele.to_owned())
2997        })
2998    }
2999
3000    fn find_entry(
3001        &self,
3002        start: Option<&SelectedEntry>,
3003        reverse_search: bool,
3004        predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3005        cx: &mut ViewContext<Self>,
3006    ) -> Option<SelectedEntry> {
3007        let mut worktree_ids: Vec<_> = self
3008            .visible_entries
3009            .iter()
3010            .map(|(worktree_id, _, _)| *worktree_id)
3011            .collect();
3012
3013        let mut last_found: Option<SelectedEntry> = None;
3014
3015        if let Some(start) = start {
3016            let worktree = self
3017                .project
3018                .read(cx)
3019                .worktree_for_id(start.worktree_id, cx)?;
3020
3021            let search = worktree.update(cx, |tree, _| {
3022                let entry = tree.entry_for_id(start.entry_id)?;
3023                let root_entry = tree.root_entry()?;
3024                let tree_id = tree.id();
3025
3026                let mut first_iter = tree
3027                    .traverse_from_path(true, true, true, entry.path.as_ref())
3028                    .with_git_statuses();
3029
3030                if reverse_search {
3031                    first_iter.next();
3032                }
3033
3034                let first = first_iter
3035                    .enumerate()
3036                    .take_until(|(count, entry)| entry.entry == root_entry && *count != 0usize)
3037                    .map(|(_, entry)| entry)
3038                    .find(|ele| predicate(*ele, tree_id))
3039                    .map(|ele| ele.to_owned());
3040
3041                let second_iter = tree.entries(true, 0usize).with_git_statuses();
3042
3043                let second = if reverse_search {
3044                    second_iter
3045                        .take_until(|ele| ele.id == start.entry_id)
3046                        .filter(|ele| predicate(*ele, tree_id))
3047                        .last()
3048                        .map(|ele| ele.to_owned())
3049                } else {
3050                    second_iter
3051                        .take_while(|ele| ele.id != start.entry_id)
3052                        .filter(|ele| predicate(*ele, tree_id))
3053                        .last()
3054                        .map(|ele| ele.to_owned())
3055                };
3056
3057                if reverse_search {
3058                    Some((second, first))
3059                } else {
3060                    Some((first, second))
3061                }
3062            });
3063
3064            if let Some((first, second)) = search {
3065                let first = first.map(|entry| SelectedEntry {
3066                    worktree_id: start.worktree_id,
3067                    entry_id: entry.id,
3068                });
3069
3070                let second = second.map(|entry| SelectedEntry {
3071                    worktree_id: start.worktree_id,
3072                    entry_id: entry.id,
3073                });
3074
3075                if first.is_some() {
3076                    return first;
3077                }
3078                last_found = second;
3079
3080                let idx = worktree_ids
3081                    .iter()
3082                    .enumerate()
3083                    .find(|(_, ele)| **ele == start.worktree_id)
3084                    .map(|(idx, _)| idx);
3085
3086                if let Some(idx) = idx {
3087                    worktree_ids.rotate_left(idx + 1usize);
3088                    worktree_ids.pop();
3089                }
3090            }
3091        }
3092
3093        for tree_id in worktree_ids.into_iter() {
3094            if let Some(found) =
3095                self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
3096            {
3097                return Some(SelectedEntry {
3098                    worktree_id: tree_id,
3099                    entry_id: found.id,
3100                });
3101            }
3102        }
3103
3104        last_found
3105    }
3106
3107    fn find_visible_entry(
3108        &self,
3109        start: Option<&SelectedEntry>,
3110        reverse_search: bool,
3111        predicate: impl Fn(GitEntryRef, WorktreeId) -> bool,
3112        cx: &mut ViewContext<Self>,
3113    ) -> Option<SelectedEntry> {
3114        let mut worktree_ids: Vec<_> = self
3115            .visible_entries
3116            .iter()
3117            .map(|(worktree_id, _, _)| *worktree_id)
3118            .collect();
3119
3120        let mut last_found: Option<SelectedEntry> = None;
3121
3122        if let Some(start) = start {
3123            let entries = self
3124                .visible_entries
3125                .iter()
3126                .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id)
3127                .map(|(_, entries, _)| entries)?;
3128
3129            let mut start_idx = entries
3130                .iter()
3131                .enumerate()
3132                .find(|(_, ele)| ele.id == start.entry_id)
3133                .map(|(idx, _)| idx)?;
3134
3135            if reverse_search {
3136                start_idx = start_idx.saturating_add(1usize);
3137            }
3138
3139            let (left, right) = entries.split_at_checked(start_idx)?;
3140
3141            let (first_iter, second_iter) = if reverse_search {
3142                (
3143                    utils::ReversibleIterable::new(left.iter(), reverse_search),
3144                    utils::ReversibleIterable::new(right.iter(), reverse_search),
3145                )
3146            } else {
3147                (
3148                    utils::ReversibleIterable::new(right.iter(), reverse_search),
3149                    utils::ReversibleIterable::new(left.iter(), reverse_search),
3150                )
3151            };
3152
3153            let first_search = first_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3154            let second_search = second_iter.find(|ele| predicate(ele.to_ref(), start.worktree_id));
3155
3156            if first_search.is_some() {
3157                return first_search.map(|entry| SelectedEntry {
3158                    worktree_id: start.worktree_id,
3159                    entry_id: entry.id,
3160                });
3161            }
3162
3163            last_found = second_search.map(|entry| SelectedEntry {
3164                worktree_id: start.worktree_id,
3165                entry_id: entry.id,
3166            });
3167
3168            let idx = worktree_ids
3169                .iter()
3170                .enumerate()
3171                .find(|(_, ele)| **ele == start.worktree_id)
3172                .map(|(idx, _)| idx);
3173
3174            if let Some(idx) = idx {
3175                worktree_ids.rotate_left(idx + 1usize);
3176                worktree_ids.pop();
3177            }
3178        }
3179
3180        for tree_id in worktree_ids.into_iter() {
3181            if let Some(found) =
3182                self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
3183            {
3184                return Some(SelectedEntry {
3185                    worktree_id: tree_id,
3186                    entry_id: found.id,
3187                });
3188            }
3189        }
3190
3191        last_found
3192    }
3193
3194    fn calculate_depth_and_difference(
3195        entry: &Entry,
3196        visible_worktree_entries: &HashSet<Arc<Path>>,
3197    ) -> (usize, usize) {
3198        let (depth, difference) = entry
3199            .path
3200            .ancestors()
3201            .skip(1) // Skip the entry itself
3202            .find_map(|ancestor| {
3203                if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
3204                    let entry_path_components_count = entry.path.components().count();
3205                    let parent_path_components_count = parent_entry.components().count();
3206                    let difference = entry_path_components_count - parent_path_components_count;
3207                    let depth = parent_entry
3208                        .ancestors()
3209                        .skip(1)
3210                        .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
3211                        .count();
3212                    Some((depth + 1, difference))
3213                } else {
3214                    None
3215                }
3216            })
3217            .unwrap_or((0, 0));
3218
3219        (depth, difference)
3220    }
3221
3222    fn render_entry(
3223        &self,
3224        entry_id: ProjectEntryId,
3225        details: EntryDetails,
3226        cx: &mut ViewContext<Self>,
3227    ) -> Stateful<Div> {
3228        const GROUP_NAME: &str = "project_entry";
3229
3230        let kind = details.kind;
3231        let settings = ProjectPanelSettings::get_global(cx);
3232        let show_editor = details.is_editing && !details.is_processing;
3233
3234        let selection = SelectedEntry {
3235            worktree_id: details.worktree_id,
3236            entry_id,
3237        };
3238
3239        let is_marked = self.marked_entries.contains(&selection);
3240        let is_active = self
3241            .selection
3242            .map_or(false, |selection| selection.entry_id == entry_id);
3243
3244        let width = self.size(cx);
3245        let file_name = details.filename.clone();
3246
3247        let mut icon = details.icon.clone();
3248        if settings.file_icons && show_editor && details.kind.is_file() {
3249            let filename = self.filename_editor.read(cx).text(cx);
3250            if filename.len() > 2 {
3251                icon = FileIcons::get_icon(Path::new(&filename), cx);
3252            }
3253        }
3254
3255        let filename_text_color = details.filename_text_color;
3256        let diagnostic_severity = details.diagnostic_severity;
3257        let item_colors = get_item_color(cx);
3258
3259        let canonical_path = details
3260            .canonical_path
3261            .as_ref()
3262            .map(|f| f.to_string_lossy().to_string());
3263        let path = details.path.clone();
3264
3265        let depth = details.depth;
3266        let worktree_id = details.worktree_id;
3267        let selections = Arc::new(self.marked_entries.clone());
3268        let is_local = self.project.read(cx).is_local();
3269
3270        let dragged_selection = DraggedSelection {
3271            active_selection: selection,
3272            marked_selections: selections,
3273        };
3274
3275        let default_color = if is_marked {
3276            item_colors.marked_active
3277        } else {
3278            item_colors.default
3279        };
3280
3281        let bg_hover_color = if self.mouse_down || is_marked {
3282            item_colors.marked_active
3283        } else if !is_active {
3284            item_colors.hover
3285        } else {
3286            item_colors.default
3287        };
3288
3289        let border_color =
3290            if !self.mouse_down && is_active && self.focus_handle.contains_focused(cx) {
3291                item_colors.focused
3292            } else if self.mouse_down && is_marked || is_active {
3293                item_colors.marked_active
3294            } else {
3295                item_colors.default
3296            };
3297
3298        div()
3299            .id(entry_id.to_proto() as usize)
3300            .group(GROUP_NAME)
3301            .cursor_pointer()
3302            .rounded_none()
3303            .bg(default_color)
3304            .border_1()
3305            .border_r_2()
3306            .border_color(border_color)
3307            .hover(|style| style.bg(bg_hover_color))
3308            .when(is_local, |div| {
3309                div.on_drag_move::<ExternalPaths>(cx.listener(
3310                    move |this, event: &DragMoveEvent<ExternalPaths>, cx| {
3311                        if event.bounds.contains(&event.event.position) {
3312                            if this.last_external_paths_drag_over_entry == Some(entry_id) {
3313                                return;
3314                            }
3315                            this.last_external_paths_drag_over_entry = Some(entry_id);
3316                            this.marked_entries.clear();
3317
3318                            let Some((worktree, path, entry)) = maybe!({
3319                                let worktree = this
3320                                    .project
3321                                    .read(cx)
3322                                    .worktree_for_id(selection.worktree_id, cx)?;
3323                                let worktree = worktree.read(cx);
3324                                let abs_path = worktree.absolutize(&path).log_err()?;
3325                                let path = if abs_path.is_dir() {
3326                                    path.as_ref()
3327                                } else {
3328                                    path.parent()?
3329                                };
3330                                let entry = worktree.entry_for_path(path)?;
3331                                Some((worktree, path, entry))
3332                            }) else {
3333                                return;
3334                            };
3335
3336                            this.marked_entries.insert(SelectedEntry {
3337                                entry_id: entry.id,
3338                                worktree_id: worktree.id(),
3339                            });
3340
3341                            for entry in worktree.child_entries(path) {
3342                                this.marked_entries.insert(SelectedEntry {
3343                                    entry_id: entry.id,
3344                                    worktree_id: worktree.id(),
3345                                });
3346                            }
3347
3348                            cx.notify();
3349                        }
3350                    },
3351                ))
3352                .on_drop(cx.listener(
3353                    move |this, external_paths: &ExternalPaths, cx| {
3354                        this.hover_scroll_task.take();
3355                        this.last_external_paths_drag_over_entry = None;
3356                        this.marked_entries.clear();
3357                        this.drop_external_files(external_paths.paths(), entry_id, cx);
3358                        cx.stop_propagation();
3359                    },
3360                ))
3361            })
3362            .on_drag(dragged_selection, move |selection, click_offset, cx| {
3363                cx.new_view(|_| DraggedProjectEntryView {
3364                    details: details.clone(),
3365                    width,
3366                    click_offset,
3367                    selection: selection.active_selection,
3368                    selections: selection.marked_selections.clone(),
3369                })
3370            })
3371            .drag_over::<DraggedSelection>(move |style, _, _| style.bg(item_colors.drag_over))
3372            .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
3373                this.hover_scroll_task.take();
3374                this.drag_onto(selections, entry_id, kind.is_file(), cx);
3375            }))
3376            .on_mouse_down(
3377                MouseButton::Left,
3378                cx.listener(move |this, _, cx| {
3379                    this.mouse_down = true;
3380                    cx.propagate();
3381                }),
3382            )
3383            .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
3384                if event.down.button == MouseButton::Right || event.down.first_mouse || show_editor
3385                {
3386                    return;
3387                }
3388                if event.down.button == MouseButton::Left {
3389                    this.mouse_down = false;
3390                }
3391                cx.stop_propagation();
3392
3393                if let Some(selection) = this.selection.filter(|_| event.down.modifiers.shift) {
3394                    let current_selection = this.index_for_selection(selection);
3395                    let clicked_entry = SelectedEntry {
3396                        entry_id,
3397                        worktree_id,
3398                    };
3399                    let target_selection = this.index_for_selection(clicked_entry);
3400                    if let Some(((_, _, source_index), (_, _, target_index))) =
3401                        current_selection.zip(target_selection)
3402                    {
3403                        let range_start = source_index.min(target_index);
3404                        let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
3405                        let mut new_selections = BTreeSet::new();
3406                        this.for_each_visible_entry(
3407                            range_start..range_end,
3408                            cx,
3409                            |entry_id, details, _| {
3410                                new_selections.insert(SelectedEntry {
3411                                    entry_id,
3412                                    worktree_id: details.worktree_id,
3413                                });
3414                            },
3415                        );
3416
3417                        this.marked_entries = this
3418                            .marked_entries
3419                            .union(&new_selections)
3420                            .cloned()
3421                            .collect();
3422
3423                        this.selection = Some(clicked_entry);
3424                        this.marked_entries.insert(clicked_entry);
3425                    }
3426                } else if event.down.modifiers.secondary() {
3427                    if event.down.click_count > 1 {
3428                        this.split_entry(entry_id, cx);
3429                    } else {
3430                        this.selection = Some(selection);
3431                        if !this.marked_entries.insert(selection) {
3432                            this.marked_entries.remove(&selection);
3433                        }
3434                    }
3435                } else if kind.is_dir() {
3436                    this.marked_entries.clear();
3437                    this.toggle_expanded(entry_id, cx);
3438                } else {
3439                    let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
3440                    let click_count = event.up.click_count;
3441                    let focus_opened_item = !preview_tabs_enabled || click_count > 1;
3442                    let allow_preview = preview_tabs_enabled && click_count == 1;
3443                    this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
3444                }
3445            }))
3446            .child(
3447                ListItem::new(entry_id.to_proto() as usize)
3448                    .indent_level(depth)
3449                    .indent_step_size(px(settings.indent_size))
3450                    .spacing(match settings.entry_spacing {
3451                        project_panel_settings::EntrySpacing::Comfortable => ListItemSpacing::Dense,
3452                        project_panel_settings::EntrySpacing::Standard => {
3453                            ListItemSpacing::ExtraDense
3454                        }
3455                    })
3456                    .selectable(false)
3457                    .when_some(canonical_path, |this, path| {
3458                        this.end_slot::<AnyElement>(
3459                            div()
3460                                .id("symlink_icon")
3461                                .pr_3()
3462                                .tooltip(move |cx| {
3463                                    Tooltip::with_meta(path.to_string(), None, "Symbolic Link", cx)
3464                                })
3465                                .child(
3466                                    Icon::new(IconName::ArrowUpRight)
3467                                        .size(IconSize::Indicator)
3468                                        .color(filename_text_color),
3469                                )
3470                                .into_any_element(),
3471                        )
3472                    })
3473                    .child(if let Some(icon) = &icon {
3474                        // Check if there's a diagnostic severity and get the decoration color
3475                        if let Some((_, decoration_color)) =
3476                            entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
3477                        {
3478                            // Determine if the diagnostic is a warning
3479                            let is_warning = diagnostic_severity
3480                                .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
3481                                .unwrap_or(false);
3482                            div().child(
3483                                DecoratedIcon::new(
3484                                    Icon::from_path(icon.clone()).color(Color::Muted),
3485                                    Some(
3486                                        IconDecoration::new(
3487                                            if kind.is_file() {
3488                                                if is_warning {
3489                                                    IconDecorationKind::Triangle
3490                                                } else {
3491                                                    IconDecorationKind::X
3492                                                }
3493                                            } else {
3494                                                IconDecorationKind::Dot
3495                                            },
3496                                            default_color,
3497                                            cx,
3498                                        )
3499                                        .group_name(Some(GROUP_NAME.into()))
3500                                        .knockout_hover_color(bg_hover_color)
3501                                        .color(decoration_color.color(cx))
3502                                        .position(Point {
3503                                            x: px(-2.),
3504                                            y: px(-2.),
3505                                        }),
3506                                    ),
3507                                )
3508                                .into_any_element(),
3509                            )
3510                        } else {
3511                            h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
3512                        }
3513                    } else {
3514                        if let Some((icon_name, color)) =
3515                            entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
3516                        {
3517                            h_flex()
3518                                .size(IconSize::default().rems())
3519                                .child(Icon::new(icon_name).color(color).size(IconSize::Small))
3520                        } else {
3521                            h_flex()
3522                                .size(IconSize::default().rems())
3523                                .invisible()
3524                                .flex_none()
3525                        }
3526                    })
3527                    .child(
3528                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
3529                            h_flex().h_6().w_full().child(editor.clone())
3530                        } else {
3531                            h_flex().h_6().map(|mut this| {
3532                                if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
3533                                    let components = Path::new(&file_name)
3534                                        .components()
3535                                        .map(|comp| {
3536                                            let comp_str =
3537                                                comp.as_os_str().to_string_lossy().into_owned();
3538                                            comp_str
3539                                        })
3540                                        .collect::<Vec<_>>();
3541
3542                                    let components_len = components.len();
3543                                    let active_index = components_len
3544                                        - 1
3545                                        - folded_ancestors.current_ancestor_depth;
3546                                    const DELIMITER: SharedString =
3547                                        SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
3548                                    for (index, component) in components.into_iter().enumerate() {
3549                                        if index != 0 {
3550                                            this = this.child(
3551                                                Label::new(DELIMITER.clone())
3552                                                    .single_line()
3553                                                    .color(filename_text_color),
3554                                            );
3555                                        }
3556                                        let id = SharedString::from(format!(
3557                                            "project_panel_path_component_{}_{index}",
3558                                            entry_id.to_usize()
3559                                        ));
3560                                        let label = div()
3561                                            .id(id)
3562                                            .on_click(cx.listener(move |this, _, cx| {
3563                                                if index != active_index {
3564                                                    if let Some(folds) =
3565                                                        this.ancestors.get_mut(&entry_id)
3566                                                    {
3567                                                        folds.current_ancestor_depth =
3568                                                            components_len - 1 - index;
3569                                                        cx.notify();
3570                                                    }
3571                                                }
3572                                            }))
3573                                            .child(
3574                                                Label::new(component)
3575                                                    .single_line()
3576                                                    .color(filename_text_color)
3577                                                    .when(
3578                                                        index == active_index
3579                                                            && (is_active || is_marked),
3580                                                        |this| this.underline(true),
3581                                                    ),
3582                                            );
3583
3584                                        this = this.child(label);
3585                                    }
3586
3587                                    this
3588                                } else {
3589                                    this.child(
3590                                        Label::new(file_name)
3591                                            .single_line()
3592                                            .color(filename_text_color),
3593                                    )
3594                                }
3595                            })
3596                        }
3597                        .ml_1(),
3598                    )
3599                    .on_secondary_mouse_down(cx.listener(
3600                        move |this, event: &MouseDownEvent, cx| {
3601                            // Stop propagation to prevent the catch-all context menu for the project
3602                            // panel from being deployed.
3603                            cx.stop_propagation();
3604                            // Some context menu actions apply to all marked entries. If the user
3605                            // right-clicks on an entry that is not marked, they may not realize the
3606                            // action applies to multiple entries. To avoid inadvertent changes, all
3607                            // entries are unmarked.
3608                            if !this.marked_entries.contains(&selection) {
3609                                this.marked_entries.clear();
3610                            }
3611                            this.deploy_context_menu(event.position, entry_id, cx);
3612                        },
3613                    ))
3614                    .overflow_x(),
3615            )
3616    }
3617
3618    fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3619        if !Self::should_show_scrollbar(cx)
3620            || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
3621        {
3622            return None;
3623        }
3624        Some(
3625            div()
3626                .occlude()
3627                .id("project-panel-vertical-scroll")
3628                .on_mouse_move(cx.listener(|_, _, cx| {
3629                    cx.notify();
3630                    cx.stop_propagation()
3631                }))
3632                .on_hover(|_, cx| {
3633                    cx.stop_propagation();
3634                })
3635                .on_any_mouse_down(|_, cx| {
3636                    cx.stop_propagation();
3637                })
3638                .on_mouse_up(
3639                    MouseButton::Left,
3640                    cx.listener(|this, _, cx| {
3641                        if !this.vertical_scrollbar_state.is_dragging()
3642                            && !this.focus_handle.contains_focused(cx)
3643                        {
3644                            this.hide_scrollbar(cx);
3645                            cx.notify();
3646                        }
3647
3648                        cx.stop_propagation();
3649                    }),
3650                )
3651                .on_scroll_wheel(cx.listener(|_, _, cx| {
3652                    cx.notify();
3653                }))
3654                .h_full()
3655                .absolute()
3656                .right_1()
3657                .top_1()
3658                .bottom_1()
3659                .w(px(12.))
3660                .cursor_default()
3661                .children(Scrollbar::vertical(
3662                    // percentage as f32..end_offset as f32,
3663                    self.vertical_scrollbar_state.clone(),
3664                )),
3665        )
3666    }
3667
3668    fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3669        if !Self::should_show_scrollbar(cx)
3670            || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
3671        {
3672            return None;
3673        }
3674
3675        let scroll_handle = self.scroll_handle.0.borrow();
3676        let longest_item_width = scroll_handle
3677            .last_item_size
3678            .filter(|size| size.contents.width > size.item.width)?
3679            .contents
3680            .width
3681            .0 as f64;
3682        if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
3683            return None;
3684        }
3685
3686        Some(
3687            div()
3688                .occlude()
3689                .id("project-panel-horizontal-scroll")
3690                .on_mouse_move(cx.listener(|_, _, cx| {
3691                    cx.notify();
3692                    cx.stop_propagation()
3693                }))
3694                .on_hover(|_, cx| {
3695                    cx.stop_propagation();
3696                })
3697                .on_any_mouse_down(|_, cx| {
3698                    cx.stop_propagation();
3699                })
3700                .on_mouse_up(
3701                    MouseButton::Left,
3702                    cx.listener(|this, _, cx| {
3703                        if !this.horizontal_scrollbar_state.is_dragging()
3704                            && !this.focus_handle.contains_focused(cx)
3705                        {
3706                            this.hide_scrollbar(cx);
3707                            cx.notify();
3708                        }
3709
3710                        cx.stop_propagation();
3711                    }),
3712                )
3713                .on_scroll_wheel(cx.listener(|_, _, cx| {
3714                    cx.notify();
3715                }))
3716                .w_full()
3717                .absolute()
3718                .right_1()
3719                .left_1()
3720                .bottom_1()
3721                .h(px(12.))
3722                .cursor_default()
3723                .when(self.width.is_some(), |this| {
3724                    this.children(Scrollbar::horizontal(
3725                        self.horizontal_scrollbar_state.clone(),
3726                    ))
3727                }),
3728        )
3729    }
3730
3731    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
3732        let mut dispatch_context = KeyContext::new_with_defaults();
3733        dispatch_context.add("ProjectPanel");
3734        dispatch_context.add("menu");
3735
3736        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
3737            "editing"
3738        } else {
3739            "not_editing"
3740        };
3741
3742        dispatch_context.add(identifier);
3743        dispatch_context
3744    }
3745
3746    fn should_show_scrollbar(cx: &AppContext) -> bool {
3747        let show = ProjectPanelSettings::get_global(cx)
3748            .scrollbar
3749            .show
3750            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3751        match show {
3752            ShowScrollbar::Auto => true,
3753            ShowScrollbar::System => true,
3754            ShowScrollbar::Always => true,
3755            ShowScrollbar::Never => false,
3756        }
3757    }
3758
3759    fn should_autohide_scrollbar(cx: &AppContext) -> bool {
3760        let show = ProjectPanelSettings::get_global(cx)
3761            .scrollbar
3762            .show
3763            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3764        match show {
3765            ShowScrollbar::Auto => true,
3766            ShowScrollbar::System => cx
3767                .try_global::<ScrollbarAutoHide>()
3768                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
3769            ShowScrollbar::Always => false,
3770            ShowScrollbar::Never => true,
3771        }
3772    }
3773
3774    fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
3775        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
3776        if !Self::should_autohide_scrollbar(cx) {
3777            return;
3778        }
3779        self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
3780            cx.background_executor()
3781                .timer(SCROLLBAR_SHOW_INTERVAL)
3782                .await;
3783            panel
3784                .update(&mut cx, |panel, cx| {
3785                    panel.show_scrollbar = false;
3786                    cx.notify();
3787                })
3788                .log_err();
3789        }))
3790    }
3791
3792    fn reveal_entry(
3793        &mut self,
3794        project: Model<Project>,
3795        entry_id: ProjectEntryId,
3796        skip_ignored: bool,
3797        cx: &mut ViewContext<Self>,
3798    ) {
3799        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
3800            let worktree = worktree.read(cx);
3801            if skip_ignored
3802                && worktree
3803                    .entry_for_id(entry_id)
3804                    .map_or(true, |entry| entry.is_ignored)
3805            {
3806                return;
3807            }
3808
3809            let worktree_id = worktree.id();
3810            self.expand_entry(worktree_id, entry_id, cx);
3811            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
3812
3813            if self.marked_entries.len() == 1
3814                && self
3815                    .marked_entries
3816                    .first()
3817                    .filter(|entry| entry.entry_id == entry_id)
3818                    .is_none()
3819            {
3820                self.marked_entries.clear();
3821            }
3822            self.autoscroll(cx);
3823            cx.notify();
3824        }
3825    }
3826
3827    fn find_active_indent_guide(
3828        &self,
3829        indent_guides: &[IndentGuideLayout],
3830        cx: &AppContext,
3831    ) -> Option<usize> {
3832        let (worktree, entry) = self.selected_entry(cx)?;
3833
3834        // Find the parent entry of the indent guide, this will either be the
3835        // expanded folder we have selected, or the parent of the currently
3836        // selected file/collapsed directory
3837        let mut entry = entry;
3838        loop {
3839            let is_expanded_dir = entry.is_dir()
3840                && self
3841                    .expanded_dir_ids
3842                    .get(&worktree.id())
3843                    .map(|ids| ids.binary_search(&entry.id).is_ok())
3844                    .unwrap_or(false);
3845            if is_expanded_dir {
3846                break;
3847            }
3848            entry = worktree.entry_for_path(&entry.path.parent()?)?;
3849        }
3850
3851        let (active_indent_range, depth) = {
3852            let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
3853            let child_paths = &self.visible_entries[worktree_ix].1;
3854            let mut child_count = 0;
3855            let depth = entry.path.ancestors().count();
3856            while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
3857                if entry.path.ancestors().count() <= depth {
3858                    break;
3859                }
3860                child_count += 1;
3861            }
3862
3863            let start = ix + 1;
3864            let end = start + child_count;
3865
3866            let (_, entries, paths) = &self.visible_entries[worktree_ix];
3867            let visible_worktree_entries =
3868                paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
3869
3870            // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
3871            let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
3872            (start..end, depth)
3873        };
3874
3875        let candidates = indent_guides
3876            .iter()
3877            .enumerate()
3878            .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
3879
3880        for (i, indent) in candidates {
3881            // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
3882            if active_indent_range.start <= indent.offset.y + indent.length
3883                && indent.offset.y <= active_indent_range.end
3884            {
3885                return Some(i);
3886            }
3887        }
3888        None
3889    }
3890}
3891
3892fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
3893    const ICON_SIZE_FACTOR: usize = 2;
3894    let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
3895    if is_symlink {
3896        item_width += ICON_SIZE_FACTOR;
3897    }
3898    item_width
3899}
3900
3901impl Render for ProjectPanel {
3902    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3903        let has_worktree = !self.visible_entries.is_empty();
3904        let project = self.project.read(cx);
3905        let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
3906        let show_indent_guides =
3907            ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
3908        let is_local = project.is_local();
3909
3910        if has_worktree {
3911            let item_count = self
3912                .visible_entries
3913                .iter()
3914                .map(|(_, worktree_entries, _)| worktree_entries.len())
3915                .sum();
3916
3917            fn handle_drag_move_scroll<T: 'static>(
3918                this: &mut ProjectPanel,
3919                e: &DragMoveEvent<T>,
3920                cx: &mut ViewContext<ProjectPanel>,
3921            ) {
3922                if !e.bounds.contains(&e.event.position) {
3923                    return;
3924                }
3925                this.hover_scroll_task.take();
3926                let panel_height = e.bounds.size.height;
3927                if panel_height <= px(0.) {
3928                    return;
3929                }
3930
3931                let event_offset = e.event.position.y - e.bounds.origin.y;
3932                // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
3933                let hovered_region_offset = event_offset / panel_height;
3934
3935                // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
3936                // These pixels offsets were picked arbitrarily.
3937                let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
3938                    8.
3939                } else if hovered_region_offset <= 0.15 {
3940                    5.
3941                } else if hovered_region_offset >= 0.95 {
3942                    -8.
3943                } else if hovered_region_offset >= 0.85 {
3944                    -5.
3945                } else {
3946                    return;
3947                };
3948                let adjustment = point(px(0.), px(vertical_scroll_offset));
3949                this.hover_scroll_task = Some(cx.spawn(move |this, mut cx| async move {
3950                    loop {
3951                        let should_stop_scrolling = this
3952                            .update(&mut cx, |this, cx| {
3953                                this.hover_scroll_task.as_ref()?;
3954                                let handle = this.scroll_handle.0.borrow_mut();
3955                                let offset = handle.base_handle.offset();
3956
3957                                handle.base_handle.set_offset(offset + adjustment);
3958                                cx.notify();
3959                                Some(())
3960                            })
3961                            .ok()
3962                            .flatten()
3963                            .is_some();
3964                        if should_stop_scrolling {
3965                            return;
3966                        }
3967                        cx.background_executor()
3968                            .timer(Duration::from_millis(16))
3969                            .await;
3970                    }
3971                }));
3972            }
3973            h_flex()
3974                .id("project-panel")
3975                .group("project-panel")
3976                .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
3977                .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
3978                .size_full()
3979                .relative()
3980                .on_hover(cx.listener(|this, hovered, cx| {
3981                    if *hovered {
3982                        this.show_scrollbar = true;
3983                        this.hide_scrollbar_task.take();
3984                        cx.notify();
3985                    } else if !this.focus_handle.contains_focused(cx) {
3986                        this.hide_scrollbar(cx);
3987                    }
3988                }))
3989                .on_click(cx.listener(|this, _event, cx| {
3990                    cx.stop_propagation();
3991                    this.selection = None;
3992                    this.marked_entries.clear();
3993                }))
3994                .key_context(self.dispatch_context(cx))
3995                .on_action(cx.listener(Self::select_next))
3996                .on_action(cx.listener(Self::select_prev))
3997                .on_action(cx.listener(Self::select_first))
3998                .on_action(cx.listener(Self::select_last))
3999                .on_action(cx.listener(Self::select_parent))
4000                .on_action(cx.listener(Self::select_next_git_entry))
4001                .on_action(cx.listener(Self::select_prev_git_entry))
4002                .on_action(cx.listener(Self::select_next_diagnostic))
4003                .on_action(cx.listener(Self::select_prev_diagnostic))
4004                .on_action(cx.listener(Self::select_next_directory))
4005                .on_action(cx.listener(Self::select_prev_directory))
4006                .on_action(cx.listener(Self::expand_selected_entry))
4007                .on_action(cx.listener(Self::collapse_selected_entry))
4008                .on_action(cx.listener(Self::collapse_all_entries))
4009                .on_action(cx.listener(Self::open))
4010                .on_action(cx.listener(Self::open_permanent))
4011                .on_action(cx.listener(Self::confirm))
4012                .on_action(cx.listener(Self::cancel))
4013                .on_action(cx.listener(Self::copy_path))
4014                .on_action(cx.listener(Self::copy_relative_path))
4015                .on_action(cx.listener(Self::new_search_in_directory))
4016                .on_action(cx.listener(Self::unfold_directory))
4017                .on_action(cx.listener(Self::fold_directory))
4018                .on_action(cx.listener(Self::remove_from_project))
4019                .when(!project.is_read_only(cx), |el| {
4020                    el.on_action(cx.listener(Self::new_file))
4021                        .on_action(cx.listener(Self::new_directory))
4022                        .on_action(cx.listener(Self::rename))
4023                        .on_action(cx.listener(Self::delete))
4024                        .on_action(cx.listener(Self::trash))
4025                        .on_action(cx.listener(Self::cut))
4026                        .on_action(cx.listener(Self::copy))
4027                        .on_action(cx.listener(Self::paste))
4028                        .on_action(cx.listener(Self::duplicate))
4029                        .on_click(cx.listener(|this, event: &gpui::ClickEvent, cx| {
4030                            if event.up.click_count > 1 {
4031                                if let Some(entry_id) = this.last_worktree_root_id {
4032                                    let project = this.project.read(cx);
4033
4034                                    let worktree_id = if let Some(worktree) =
4035                                        project.worktree_for_entry(entry_id, cx)
4036                                    {
4037                                        worktree.read(cx).id()
4038                                    } else {
4039                                        return;
4040                                    };
4041
4042                                    this.selection = Some(SelectedEntry {
4043                                        worktree_id,
4044                                        entry_id,
4045                                    });
4046
4047                                    this.new_file(&NewFile, cx);
4048                                }
4049                            }
4050                        }))
4051                })
4052                .when(project.is_local(), |el| {
4053                    el.on_action(cx.listener(Self::reveal_in_finder))
4054                        .on_action(cx.listener(Self::open_system))
4055                        .on_action(cx.listener(Self::open_in_terminal))
4056                })
4057                .when(project.is_via_ssh(), |el| {
4058                    el.on_action(cx.listener(Self::open_in_terminal))
4059                })
4060                .on_mouse_down(
4061                    MouseButton::Right,
4062                    cx.listener(move |this, event: &MouseDownEvent, cx| {
4063                        // When deploying the context menu anywhere below the last project entry,
4064                        // act as if the user clicked the root of the last worktree.
4065                        if let Some(entry_id) = this.last_worktree_root_id {
4066                            this.deploy_context_menu(event.position, entry_id, cx);
4067                        }
4068                    }),
4069                )
4070                .track_focus(&self.focus_handle(cx))
4071                .child(
4072                    uniform_list(cx.view().clone(), "entries", item_count, {
4073                        |this, range, cx| {
4074                            let mut items = Vec::with_capacity(range.end - range.start);
4075                            this.for_each_visible_entry(range, cx, |id, details, cx| {
4076                                items.push(this.render_entry(id, details, cx));
4077                            });
4078                            items
4079                        }
4080                    })
4081                    .when(show_indent_guides, |list| {
4082                        list.with_decoration(
4083                            ui::indent_guides(
4084                                cx.view().clone(),
4085                                px(indent_size),
4086                                IndentGuideColors::panel(cx),
4087                                |this, range, cx| {
4088                                    let mut items =
4089                                        SmallVec::with_capacity(range.end - range.start);
4090                                    this.iter_visible_entries(range, cx, |entry, entries, _| {
4091                                        let (depth, _) =
4092                                            Self::calculate_depth_and_difference(entry, entries);
4093                                        items.push(depth);
4094                                    });
4095                                    items
4096                                },
4097                            )
4098                            .on_click(cx.listener(
4099                                |this, active_indent_guide: &IndentGuideLayout, cx| {
4100                                    if cx.modifiers().secondary() {
4101                                        let ix = active_indent_guide.offset.y;
4102                                        let Some((target_entry, worktree)) = maybe!({
4103                                            let (worktree_id, entry) = this.entry_at_index(ix)?;
4104                                            let worktree = this
4105                                                .project
4106                                                .read(cx)
4107                                                .worktree_for_id(worktree_id, cx)?;
4108                                            let target_entry = worktree
4109                                                .read(cx)
4110                                                .entry_for_path(&entry.path.parent()?)?;
4111                                            Some((target_entry, worktree))
4112                                        }) else {
4113                                            return;
4114                                        };
4115
4116                                        this.collapse_entry(target_entry.clone(), worktree, cx);
4117                                    }
4118                                },
4119                            ))
4120                            .with_render_fn(
4121                                cx.view().clone(),
4122                                move |this, params, cx| {
4123                                    const LEFT_OFFSET: f32 = 14.;
4124                                    const PADDING_Y: f32 = 4.;
4125                                    const HITBOX_OVERDRAW: f32 = 3.;
4126
4127                                    let active_indent_guide_index =
4128                                        this.find_active_indent_guide(&params.indent_guides, cx);
4129
4130                                    let indent_size = params.indent_size;
4131                                    let item_height = params.item_height;
4132
4133                                    params
4134                                        .indent_guides
4135                                        .into_iter()
4136                                        .enumerate()
4137                                        .map(|(idx, layout)| {
4138                                            let offset = if layout.continues_offscreen {
4139                                                px(0.)
4140                                            } else {
4141                                                px(PADDING_Y)
4142                                            };
4143                                            let bounds = Bounds::new(
4144                                                point(
4145                                                    px(layout.offset.x as f32) * indent_size
4146                                                        + px(LEFT_OFFSET),
4147                                                    px(layout.offset.y as f32) * item_height
4148                                                        + offset,
4149                                                ),
4150                                                size(
4151                                                    px(1.),
4152                                                    px(layout.length as f32) * item_height
4153                                                        - px(offset.0 * 2.),
4154                                                ),
4155                                            );
4156                                            ui::RenderedIndentGuide {
4157                                                bounds,
4158                                                layout,
4159                                                is_active: Some(idx) == active_indent_guide_index,
4160                                                hitbox: Some(Bounds::new(
4161                                                    point(
4162                                                        bounds.origin.x - px(HITBOX_OVERDRAW),
4163                                                        bounds.origin.y,
4164                                                    ),
4165                                                    size(
4166                                                        bounds.size.width
4167                                                            + px(2. * HITBOX_OVERDRAW),
4168                                                        bounds.size.height,
4169                                                    ),
4170                                                )),
4171                                            }
4172                                        })
4173                                        .collect()
4174                                },
4175                            ),
4176                        )
4177                    })
4178                    .size_full()
4179                    .with_sizing_behavior(ListSizingBehavior::Infer)
4180                    .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4181                    .with_width_from_item(self.max_width_item_index)
4182                    .track_scroll(self.scroll_handle.clone()),
4183                )
4184                .children(self.render_vertical_scrollbar(cx))
4185                .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4186                    this.pb_4().child(scrollbar)
4187                })
4188                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4189                    deferred(
4190                        anchored()
4191                            .position(*position)
4192                            .anchor(gpui::Corner::TopLeft)
4193                            .child(menu.clone()),
4194                    )
4195                    .with_priority(1)
4196                }))
4197        } else {
4198            v_flex()
4199                .id("empty-project_panel")
4200                .size_full()
4201                .p_4()
4202                .track_focus(&self.focus_handle(cx))
4203                .child(
4204                    Button::new("open_project", "Open a project")
4205                        .full_width()
4206                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
4207                        .on_click(cx.listener(|this, _, cx| {
4208                            this.workspace
4209                                .update(cx, |_, cx| cx.dispatch_action(Box::new(workspace::Open)))
4210                                .log_err();
4211                        })),
4212                )
4213                .when(is_local, |div| {
4214                    div.drag_over::<ExternalPaths>(|style, _, cx| {
4215                        style.bg(cx.theme().colors().drop_target_background)
4216                    })
4217                    .on_drop(cx.listener(
4218                        move |this, external_paths: &ExternalPaths, cx| {
4219                            this.last_external_paths_drag_over_entry = None;
4220                            this.marked_entries.clear();
4221                            this.hover_scroll_task.take();
4222                            if let Some(task) = this
4223                                .workspace
4224                                .update(cx, |workspace, cx| {
4225                                    workspace.open_workspace_for_paths(
4226                                        true,
4227                                        external_paths.paths().to_owned(),
4228                                        cx,
4229                                    )
4230                                })
4231                                .log_err()
4232                            {
4233                                task.detach_and_log_err(cx);
4234                            }
4235                            cx.stop_propagation();
4236                        },
4237                    ))
4238                })
4239        }
4240    }
4241}
4242
4243impl Render for DraggedProjectEntryView {
4244    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
4245        let settings = ProjectPanelSettings::get_global(cx);
4246        let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4247
4248        h_flex().font(ui_font).map(|this| {
4249            if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4250                this.flex_none()
4251                    .w(self.width)
4252                    .child(div().w(self.click_offset.x))
4253                    .child(
4254                        div()
4255                            .p_1()
4256                            .rounded_xl()
4257                            .bg(cx.theme().colors().background)
4258                            .child(Label::new(format!("{} entries", self.selections.len()))),
4259                    )
4260            } else {
4261                this.w(self.width).bg(cx.theme().colors().background).child(
4262                    ListItem::new(self.selection.entry_id.to_proto() as usize)
4263                        .indent_level(self.details.depth)
4264                        .indent_step_size(px(settings.indent_size))
4265                        .child(if let Some(icon) = &self.details.icon {
4266                            div().child(Icon::from_path(icon.clone()))
4267                        } else {
4268                            div()
4269                        })
4270                        .child(Label::new(self.details.filename.clone())),
4271                )
4272            }
4273        })
4274    }
4275}
4276
4277impl EventEmitter<Event> for ProjectPanel {}
4278
4279impl EventEmitter<PanelEvent> for ProjectPanel {}
4280
4281impl Panel for ProjectPanel {
4282    fn position(&self, cx: &WindowContext) -> DockPosition {
4283        match ProjectPanelSettings::get_global(cx).dock {
4284            ProjectPanelDockPosition::Left => DockPosition::Left,
4285            ProjectPanelDockPosition::Right => DockPosition::Right,
4286        }
4287    }
4288
4289    fn position_is_valid(&self, position: DockPosition) -> bool {
4290        matches!(position, DockPosition::Left | DockPosition::Right)
4291    }
4292
4293    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
4294        settings::update_settings_file::<ProjectPanelSettings>(
4295            self.fs.clone(),
4296            cx,
4297            move |settings, _| {
4298                let dock = match position {
4299                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4300                    DockPosition::Right => ProjectPanelDockPosition::Right,
4301                };
4302                settings.dock = Some(dock);
4303            },
4304        );
4305    }
4306
4307    fn size(&self, cx: &WindowContext) -> Pixels {
4308        self.width
4309            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4310    }
4311
4312    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
4313        self.width = size;
4314        self.serialize(cx);
4315        cx.notify();
4316    }
4317
4318    fn icon(&self, cx: &WindowContext) -> Option<IconName> {
4319        ProjectPanelSettings::get_global(cx)
4320            .button
4321            .then_some(IconName::FileTree)
4322    }
4323
4324    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
4325        Some("Project Panel")
4326    }
4327
4328    fn toggle_action(&self) -> Box<dyn Action> {
4329        Box::new(ToggleFocus)
4330    }
4331
4332    fn persistent_name() -> &'static str {
4333        "Project Panel"
4334    }
4335
4336    fn starts_open(&self, cx: &WindowContext) -> bool {
4337        let project = &self.project.read(cx);
4338        project.visible_worktrees(cx).any(|tree| {
4339            tree.read(cx)
4340                .root_entry()
4341                .map_or(false, |entry| entry.is_dir())
4342        })
4343    }
4344
4345    fn activation_priority(&self) -> u32 {
4346        0
4347    }
4348}
4349
4350impl FocusableView for ProjectPanel {
4351    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
4352        self.focus_handle.clone()
4353    }
4354}
4355
4356impl ClipboardEntry {
4357    fn is_cut(&self) -> bool {
4358        matches!(self, Self::Cut { .. })
4359    }
4360
4361    fn items(&self) -> &BTreeSet<SelectedEntry> {
4362        match self {
4363            ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4364        }
4365    }
4366}
4367
4368#[cfg(test)]
4369mod tests {
4370    use super::*;
4371    use collections::HashSet;
4372    use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
4373    use pretty_assertions::assert_eq;
4374    use project::{FakeFs, WorktreeSettings};
4375    use serde_json::json;
4376    use settings::SettingsStore;
4377    use std::path::{Path, PathBuf};
4378    use workspace::{
4379        item::{Item, ProjectItem},
4380        register_project_item, AppState,
4381    };
4382
4383    #[gpui::test]
4384    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
4385        init_test(cx);
4386
4387        let fs = FakeFs::new(cx.executor().clone());
4388        fs.insert_tree(
4389            "/root1",
4390            json!({
4391                ".dockerignore": "",
4392                ".git": {
4393                    "HEAD": "",
4394                },
4395                "a": {
4396                    "0": { "q": "", "r": "", "s": "" },
4397                    "1": { "t": "", "u": "" },
4398                    "2": { "v": "", "w": "", "x": "", "y": "" },
4399                },
4400                "b": {
4401                    "3": { "Q": "" },
4402                    "4": { "R": "", "S": "", "T": "", "U": "" },
4403                },
4404                "C": {
4405                    "5": {},
4406                    "6": { "V": "", "W": "" },
4407                    "7": { "X": "" },
4408                    "8": { "Y": {}, "Z": "" }
4409                }
4410            }),
4411        )
4412        .await;
4413        fs.insert_tree(
4414            "/root2",
4415            json!({
4416                "d": {
4417                    "9": ""
4418                },
4419                "e": {}
4420            }),
4421        )
4422        .await;
4423
4424        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4425        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4426        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4427        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4428        assert_eq!(
4429            visible_entries_as_strings(&panel, 0..50, cx),
4430            &[
4431                "v root1",
4432                "    > .git",
4433                "    > a",
4434                "    > b",
4435                "    > C",
4436                "      .dockerignore",
4437                "v root2",
4438                "    > d",
4439                "    > e",
4440            ]
4441        );
4442
4443        toggle_expand_dir(&panel, "root1/b", cx);
4444        assert_eq!(
4445            visible_entries_as_strings(&panel, 0..50, cx),
4446            &[
4447                "v root1",
4448                "    > .git",
4449                "    > a",
4450                "    v b  <== selected",
4451                "        > 3",
4452                "        > 4",
4453                "    > C",
4454                "      .dockerignore",
4455                "v root2",
4456                "    > d",
4457                "    > e",
4458            ]
4459        );
4460
4461        assert_eq!(
4462            visible_entries_as_strings(&panel, 6..9, cx),
4463            &[
4464                //
4465                "    > C",
4466                "      .dockerignore",
4467                "v root2",
4468            ]
4469        );
4470    }
4471
4472    #[gpui::test]
4473    async fn test_opening_file(cx: &mut gpui::TestAppContext) {
4474        init_test_with_editor(cx);
4475
4476        let fs = FakeFs::new(cx.executor().clone());
4477        fs.insert_tree(
4478            "/src",
4479            json!({
4480                "test": {
4481                    "first.rs": "// First Rust file",
4482                    "second.rs": "// Second Rust file",
4483                    "third.rs": "// Third Rust file",
4484                }
4485            }),
4486        )
4487        .await;
4488
4489        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4490        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4491        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4492        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4493
4494        toggle_expand_dir(&panel, "src/test", cx);
4495        select_path(&panel, "src/test/first.rs", cx);
4496        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4497        cx.executor().run_until_parked();
4498        assert_eq!(
4499            visible_entries_as_strings(&panel, 0..10, cx),
4500            &[
4501                "v src",
4502                "    v test",
4503                "          first.rs  <== selected  <== marked",
4504                "          second.rs",
4505                "          third.rs"
4506            ]
4507        );
4508        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4509
4510        select_path(&panel, "src/test/second.rs", cx);
4511        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4512        cx.executor().run_until_parked();
4513        assert_eq!(
4514            visible_entries_as_strings(&panel, 0..10, cx),
4515            &[
4516                "v src",
4517                "    v test",
4518                "          first.rs",
4519                "          second.rs  <== selected  <== marked",
4520                "          third.rs"
4521            ]
4522        );
4523        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4524    }
4525
4526    #[gpui::test]
4527    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
4528        init_test(cx);
4529        cx.update(|cx| {
4530            cx.update_global::<SettingsStore, _>(|store, cx| {
4531                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4532                    worktree_settings.file_scan_exclusions =
4533                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
4534                });
4535            });
4536        });
4537
4538        let fs = FakeFs::new(cx.background_executor.clone());
4539        fs.insert_tree(
4540            "/root1",
4541            json!({
4542                ".dockerignore": "",
4543                ".git": {
4544                    "HEAD": "",
4545                },
4546                "a": {
4547                    "0": { "q": "", "r": "", "s": "" },
4548                    "1": { "t": "", "u": "" },
4549                    "2": { "v": "", "w": "", "x": "", "y": "" },
4550                },
4551                "b": {
4552                    "3": { "Q": "" },
4553                    "4": { "R": "", "S": "", "T": "", "U": "" },
4554                },
4555                "C": {
4556                    "5": {},
4557                    "6": { "V": "", "W": "" },
4558                    "7": { "X": "" },
4559                    "8": { "Y": {}, "Z": "" }
4560                }
4561            }),
4562        )
4563        .await;
4564        fs.insert_tree(
4565            "/root2",
4566            json!({
4567                "d": {
4568                    "4": ""
4569                },
4570                "e": {}
4571            }),
4572        )
4573        .await;
4574
4575        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4576        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4577        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4578        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4579        assert_eq!(
4580            visible_entries_as_strings(&panel, 0..50, cx),
4581            &[
4582                "v root1",
4583                "    > a",
4584                "    > b",
4585                "    > C",
4586                "      .dockerignore",
4587                "v root2",
4588                "    > d",
4589                "    > e",
4590            ]
4591        );
4592
4593        toggle_expand_dir(&panel, "root1/b", cx);
4594        assert_eq!(
4595            visible_entries_as_strings(&panel, 0..50, cx),
4596            &[
4597                "v root1",
4598                "    > a",
4599                "    v b  <== selected",
4600                "        > 3",
4601                "    > C",
4602                "      .dockerignore",
4603                "v root2",
4604                "    > d",
4605                "    > e",
4606            ]
4607        );
4608
4609        toggle_expand_dir(&panel, "root2/d", cx);
4610        assert_eq!(
4611            visible_entries_as_strings(&panel, 0..50, cx),
4612            &[
4613                "v root1",
4614                "    > a",
4615                "    v b",
4616                "        > 3",
4617                "    > C",
4618                "      .dockerignore",
4619                "v root2",
4620                "    v d  <== selected",
4621                "    > e",
4622            ]
4623        );
4624
4625        toggle_expand_dir(&panel, "root2/e", cx);
4626        assert_eq!(
4627            visible_entries_as_strings(&panel, 0..50, cx),
4628            &[
4629                "v root1",
4630                "    > a",
4631                "    v b",
4632                "        > 3",
4633                "    > C",
4634                "      .dockerignore",
4635                "v root2",
4636                "    v d",
4637                "    v e  <== selected",
4638            ]
4639        );
4640    }
4641
4642    #[gpui::test]
4643    async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
4644        init_test(cx);
4645
4646        let fs = FakeFs::new(cx.executor().clone());
4647        fs.insert_tree(
4648            "/root1",
4649            json!({
4650                "dir_1": {
4651                    "nested_dir_1": {
4652                        "nested_dir_2": {
4653                            "nested_dir_3": {
4654                                "file_a.java": "// File contents",
4655                                "file_b.java": "// File contents",
4656                                "file_c.java": "// File contents",
4657                                "nested_dir_4": {
4658                                    "nested_dir_5": {
4659                                        "file_d.java": "// File contents",
4660                                    }
4661                                }
4662                            }
4663                        }
4664                    }
4665                }
4666            }),
4667        )
4668        .await;
4669        fs.insert_tree(
4670            "/root2",
4671            json!({
4672                "dir_2": {
4673                    "file_1.java": "// File contents",
4674                }
4675            }),
4676        )
4677        .await;
4678
4679        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4680        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4681        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4682        cx.update(|cx| {
4683            let settings = *ProjectPanelSettings::get_global(cx);
4684            ProjectPanelSettings::override_global(
4685                ProjectPanelSettings {
4686                    auto_fold_dirs: true,
4687                    ..settings
4688                },
4689                cx,
4690            );
4691        });
4692        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4693        assert_eq!(
4694            visible_entries_as_strings(&panel, 0..10, cx),
4695            &[
4696                "v root1",
4697                "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4698                "v root2",
4699                "    > dir_2",
4700            ]
4701        );
4702
4703        toggle_expand_dir(
4704            &panel,
4705            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4706            cx,
4707        );
4708        assert_eq!(
4709            visible_entries_as_strings(&panel, 0..10, cx),
4710            &[
4711                "v root1",
4712                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
4713                "        > nested_dir_4/nested_dir_5",
4714                "          file_a.java",
4715                "          file_b.java",
4716                "          file_c.java",
4717                "v root2",
4718                "    > dir_2",
4719            ]
4720        );
4721
4722        toggle_expand_dir(
4723            &panel,
4724            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
4725            cx,
4726        );
4727        assert_eq!(
4728            visible_entries_as_strings(&panel, 0..10, cx),
4729            &[
4730                "v root1",
4731                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4732                "        v nested_dir_4/nested_dir_5  <== selected",
4733                "              file_d.java",
4734                "          file_a.java",
4735                "          file_b.java",
4736                "          file_c.java",
4737                "v root2",
4738                "    > dir_2",
4739            ]
4740        );
4741        toggle_expand_dir(&panel, "root2/dir_2", cx);
4742        assert_eq!(
4743            visible_entries_as_strings(&panel, 0..10, cx),
4744            &[
4745                "v root1",
4746                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4747                "        v nested_dir_4/nested_dir_5",
4748                "              file_d.java",
4749                "          file_a.java",
4750                "          file_b.java",
4751                "          file_c.java",
4752                "v root2",
4753                "    v dir_2  <== selected",
4754                "          file_1.java",
4755            ]
4756        );
4757    }
4758
4759    #[gpui::test(iterations = 30)]
4760    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
4761        init_test(cx);
4762
4763        let fs = FakeFs::new(cx.executor().clone());
4764        fs.insert_tree(
4765            "/root1",
4766            json!({
4767                ".dockerignore": "",
4768                ".git": {
4769                    "HEAD": "",
4770                },
4771                "a": {
4772                    "0": { "q": "", "r": "", "s": "" },
4773                    "1": { "t": "", "u": "" },
4774                    "2": { "v": "", "w": "", "x": "", "y": "" },
4775                },
4776                "b": {
4777                    "3": { "Q": "" },
4778                    "4": { "R": "", "S": "", "T": "", "U": "" },
4779                },
4780                "C": {
4781                    "5": {},
4782                    "6": { "V": "", "W": "" },
4783                    "7": { "X": "" },
4784                    "8": { "Y": {}, "Z": "" }
4785                }
4786            }),
4787        )
4788        .await;
4789        fs.insert_tree(
4790            "/root2",
4791            json!({
4792                "d": {
4793                    "9": ""
4794                },
4795                "e": {}
4796            }),
4797        )
4798        .await;
4799
4800        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4801        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4802        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4803        let panel = workspace
4804            .update(cx, |workspace, cx| {
4805                let panel = ProjectPanel::new(workspace, cx);
4806                workspace.add_panel(panel.clone(), cx);
4807                panel
4808            })
4809            .unwrap();
4810
4811        select_path(&panel, "root1", cx);
4812        assert_eq!(
4813            visible_entries_as_strings(&panel, 0..10, cx),
4814            &[
4815                "v root1  <== selected",
4816                "    > .git",
4817                "    > a",
4818                "    > b",
4819                "    > C",
4820                "      .dockerignore",
4821                "v root2",
4822                "    > d",
4823                "    > e",
4824            ]
4825        );
4826
4827        // Add a file with the root folder selected. The filename editor is placed
4828        // before the first file in the root folder.
4829        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4830        panel.update(cx, |panel, cx| {
4831            assert!(panel.filename_editor.read(cx).is_focused(cx));
4832        });
4833        assert_eq!(
4834            visible_entries_as_strings(&panel, 0..10, cx),
4835            &[
4836                "v root1",
4837                "    > .git",
4838                "    > a",
4839                "    > b",
4840                "    > C",
4841                "      [EDITOR: '']  <== selected",
4842                "      .dockerignore",
4843                "v root2",
4844                "    > d",
4845                "    > e",
4846            ]
4847        );
4848
4849        let confirm = panel.update(cx, |panel, cx| {
4850            panel
4851                .filename_editor
4852                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
4853            panel.confirm_edit(cx).unwrap()
4854        });
4855        assert_eq!(
4856            visible_entries_as_strings(&panel, 0..10, cx),
4857            &[
4858                "v root1",
4859                "    > .git",
4860                "    > a",
4861                "    > b",
4862                "    > C",
4863                "      [PROCESSING: 'the-new-filename']  <== selected",
4864                "      .dockerignore",
4865                "v root2",
4866                "    > d",
4867                "    > e",
4868            ]
4869        );
4870
4871        confirm.await.unwrap();
4872        assert_eq!(
4873            visible_entries_as_strings(&panel, 0..10, cx),
4874            &[
4875                "v root1",
4876                "    > .git",
4877                "    > a",
4878                "    > b",
4879                "    > C",
4880                "      .dockerignore",
4881                "      the-new-filename  <== selected  <== marked",
4882                "v root2",
4883                "    > d",
4884                "    > e",
4885            ]
4886        );
4887
4888        select_path(&panel, "root1/b", cx);
4889        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4890        assert_eq!(
4891            visible_entries_as_strings(&panel, 0..10, cx),
4892            &[
4893                "v root1",
4894                "    > .git",
4895                "    > a",
4896                "    v b",
4897                "        > 3",
4898                "        > 4",
4899                "          [EDITOR: '']  <== selected",
4900                "    > C",
4901                "      .dockerignore",
4902                "      the-new-filename",
4903            ]
4904        );
4905
4906        panel
4907            .update(cx, |panel, cx| {
4908                panel
4909                    .filename_editor
4910                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
4911                panel.confirm_edit(cx).unwrap()
4912            })
4913            .await
4914            .unwrap();
4915        assert_eq!(
4916            visible_entries_as_strings(&panel, 0..10, cx),
4917            &[
4918                "v root1",
4919                "    > .git",
4920                "    > a",
4921                "    v b",
4922                "        > 3",
4923                "        > 4",
4924                "          another-filename.txt  <== selected  <== marked",
4925                "    > C",
4926                "      .dockerignore",
4927                "      the-new-filename",
4928            ]
4929        );
4930
4931        select_path(&panel, "root1/b/another-filename.txt", cx);
4932        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4933        assert_eq!(
4934            visible_entries_as_strings(&panel, 0..10, cx),
4935            &[
4936                "v root1",
4937                "    > .git",
4938                "    > a",
4939                "    v b",
4940                "        > 3",
4941                "        > 4",
4942                "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
4943                "    > C",
4944                "      .dockerignore",
4945                "      the-new-filename",
4946            ]
4947        );
4948
4949        let confirm = panel.update(cx, |panel, cx| {
4950            panel.filename_editor.update(cx, |editor, cx| {
4951                let file_name_selections = editor.selections.all::<usize>(cx);
4952                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4953                let file_name_selection = &file_name_selections[0];
4954                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4955                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
4956
4957                editor.set_text("a-different-filename.tar.gz", cx)
4958            });
4959            panel.confirm_edit(cx).unwrap()
4960        });
4961        assert_eq!(
4962            visible_entries_as_strings(&panel, 0..10, cx),
4963            &[
4964                "v root1",
4965                "    > .git",
4966                "    > a",
4967                "    v b",
4968                "        > 3",
4969                "        > 4",
4970                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
4971                "    > C",
4972                "      .dockerignore",
4973                "      the-new-filename",
4974            ]
4975        );
4976
4977        confirm.await.unwrap();
4978        assert_eq!(
4979            visible_entries_as_strings(&panel, 0..10, cx),
4980            &[
4981                "v root1",
4982                "    > .git",
4983                "    > a",
4984                "    v b",
4985                "        > 3",
4986                "        > 4",
4987                "          a-different-filename.tar.gz  <== selected",
4988                "    > C",
4989                "      .dockerignore",
4990                "      the-new-filename",
4991            ]
4992        );
4993
4994        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4995        assert_eq!(
4996            visible_entries_as_strings(&panel, 0..10, cx),
4997            &[
4998                "v root1",
4999                "    > .git",
5000                "    > a",
5001                "    v b",
5002                "        > 3",
5003                "        > 4",
5004                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
5005                "    > C",
5006                "      .dockerignore",
5007                "      the-new-filename",
5008            ]
5009        );
5010
5011        panel.update(cx, |panel, cx| {
5012            panel.filename_editor.update(cx, |editor, cx| {
5013                let file_name_selections = editor.selections.all::<usize>(cx);
5014                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5015                let file_name_selection = &file_name_selections[0];
5016                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5017                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..");
5018
5019            });
5020            panel.cancel(&menu::Cancel, cx)
5021        });
5022
5023        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5024        assert_eq!(
5025            visible_entries_as_strings(&panel, 0..10, cx),
5026            &[
5027                "v root1",
5028                "    > .git",
5029                "    > a",
5030                "    v b",
5031                "        > 3",
5032                "        > 4",
5033                "        > [EDITOR: '']  <== selected",
5034                "          a-different-filename.tar.gz",
5035                "    > C",
5036                "      .dockerignore",
5037            ]
5038        );
5039
5040        let confirm = panel.update(cx, |panel, cx| {
5041            panel
5042                .filename_editor
5043                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
5044            panel.confirm_edit(cx).unwrap()
5045        });
5046        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
5047        assert_eq!(
5048            visible_entries_as_strings(&panel, 0..10, cx),
5049            &[
5050                "v root1",
5051                "    > .git",
5052                "    > a",
5053                "    v b",
5054                "        > 3",
5055                "        > 4",
5056                "        > [PROCESSING: 'new-dir']",
5057                "          a-different-filename.tar.gz  <== selected",
5058                "    > C",
5059                "      .dockerignore",
5060            ]
5061        );
5062
5063        confirm.await.unwrap();
5064        assert_eq!(
5065            visible_entries_as_strings(&panel, 0..10, cx),
5066            &[
5067                "v root1",
5068                "    > .git",
5069                "    > a",
5070                "    v b",
5071                "        > 3",
5072                "        > 4",
5073                "        > new-dir",
5074                "          a-different-filename.tar.gz  <== selected",
5075                "    > C",
5076                "      .dockerignore",
5077            ]
5078        );
5079
5080        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
5081        assert_eq!(
5082            visible_entries_as_strings(&panel, 0..10, cx),
5083            &[
5084                "v root1",
5085                "    > .git",
5086                "    > a",
5087                "    v b",
5088                "        > 3",
5089                "        > 4",
5090                "        > new-dir",
5091                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
5092                "    > C",
5093                "      .dockerignore",
5094            ]
5095        );
5096
5097        // Dismiss the rename editor when it loses focus.
5098        workspace.update(cx, |_, cx| cx.blur()).unwrap();
5099        assert_eq!(
5100            visible_entries_as_strings(&panel, 0..10, cx),
5101            &[
5102                "v root1",
5103                "    > .git",
5104                "    > a",
5105                "    v b",
5106                "        > 3",
5107                "        > 4",
5108                "        > new-dir",
5109                "          a-different-filename.tar.gz  <== selected",
5110                "    > C",
5111                "      .dockerignore",
5112            ]
5113        );
5114    }
5115
5116    #[gpui::test(iterations = 10)]
5117    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
5118        init_test(cx);
5119
5120        let fs = FakeFs::new(cx.executor().clone());
5121        fs.insert_tree(
5122            "/root1",
5123            json!({
5124                ".dockerignore": "",
5125                ".git": {
5126                    "HEAD": "",
5127                },
5128                "a": {
5129                    "0": { "q": "", "r": "", "s": "" },
5130                    "1": { "t": "", "u": "" },
5131                    "2": { "v": "", "w": "", "x": "", "y": "" },
5132                },
5133                "b": {
5134                    "3": { "Q": "" },
5135                    "4": { "R": "", "S": "", "T": "", "U": "" },
5136                },
5137                "C": {
5138                    "5": {},
5139                    "6": { "V": "", "W": "" },
5140                    "7": { "X": "" },
5141                    "8": { "Y": {}, "Z": "" }
5142                }
5143            }),
5144        )
5145        .await;
5146        fs.insert_tree(
5147            "/root2",
5148            json!({
5149                "d": {
5150                    "9": ""
5151                },
5152                "e": {}
5153            }),
5154        )
5155        .await;
5156
5157        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5158        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5159        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5160        let panel = workspace
5161            .update(cx, |workspace, cx| {
5162                let panel = ProjectPanel::new(workspace, cx);
5163                workspace.add_panel(panel.clone(), cx);
5164                panel
5165            })
5166            .unwrap();
5167
5168        select_path(&panel, "root1", cx);
5169        assert_eq!(
5170            visible_entries_as_strings(&panel, 0..10, cx),
5171            &[
5172                "v root1  <== selected",
5173                "    > .git",
5174                "    > a",
5175                "    > b",
5176                "    > C",
5177                "      .dockerignore",
5178                "v root2",
5179                "    > d",
5180                "    > e",
5181            ]
5182        );
5183
5184        // Add a file with the root folder selected. The filename editor is placed
5185        // before the first file in the root folder.
5186        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5187        panel.update(cx, |panel, cx| {
5188            assert!(panel.filename_editor.read(cx).is_focused(cx));
5189        });
5190        assert_eq!(
5191            visible_entries_as_strings(&panel, 0..10, cx),
5192            &[
5193                "v root1",
5194                "    > .git",
5195                "    > a",
5196                "    > b",
5197                "    > C",
5198                "      [EDITOR: '']  <== selected",
5199                "      .dockerignore",
5200                "v root2",
5201                "    > d",
5202                "    > e",
5203            ]
5204        );
5205
5206        let confirm = panel.update(cx, |panel, cx| {
5207            panel.filename_editor.update(cx, |editor, cx| {
5208                editor.set_text("/bdir1/dir2/the-new-filename", cx)
5209            });
5210            panel.confirm_edit(cx).unwrap()
5211        });
5212
5213        assert_eq!(
5214            visible_entries_as_strings(&panel, 0..10, cx),
5215            &[
5216                "v root1",
5217                "    > .git",
5218                "    > a",
5219                "    > b",
5220                "    > C",
5221                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
5222                "      .dockerignore",
5223                "v root2",
5224                "    > d",
5225                "    > e",
5226            ]
5227        );
5228
5229        confirm.await.unwrap();
5230        assert_eq!(
5231            visible_entries_as_strings(&panel, 0..13, cx),
5232            &[
5233                "v root1",
5234                "    > .git",
5235                "    > a",
5236                "    > b",
5237                "    v bdir1",
5238                "        v dir2",
5239                "              the-new-filename  <== selected  <== marked",
5240                "    > C",
5241                "      .dockerignore",
5242                "v root2",
5243                "    > d",
5244                "    > e",
5245            ]
5246        );
5247    }
5248
5249    #[gpui::test]
5250    async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
5251        init_test(cx);
5252
5253        let fs = FakeFs::new(cx.executor().clone());
5254        fs.insert_tree(
5255            "/root1",
5256            json!({
5257                ".dockerignore": "",
5258                ".git": {
5259                    "HEAD": "",
5260                },
5261            }),
5262        )
5263        .await;
5264
5265        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5266        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5267        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5268        let panel = workspace
5269            .update(cx, |workspace, cx| {
5270                let panel = ProjectPanel::new(workspace, cx);
5271                workspace.add_panel(panel.clone(), cx);
5272                panel
5273            })
5274            .unwrap();
5275
5276        select_path(&panel, "root1", cx);
5277        assert_eq!(
5278            visible_entries_as_strings(&panel, 0..10, cx),
5279            &["v root1  <== selected", "    > .git", "      .dockerignore",]
5280        );
5281
5282        // Add a file with the root folder selected. The filename editor is placed
5283        // before the first file in the root folder.
5284        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5285        panel.update(cx, |panel, cx| {
5286            assert!(panel.filename_editor.read(cx).is_focused(cx));
5287        });
5288        assert_eq!(
5289            visible_entries_as_strings(&panel, 0..10, cx),
5290            &[
5291                "v root1",
5292                "    > .git",
5293                "      [EDITOR: '']  <== selected",
5294                "      .dockerignore",
5295            ]
5296        );
5297
5298        let confirm = panel.update(cx, |panel, cx| {
5299            panel
5300                .filename_editor
5301                .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
5302            panel.confirm_edit(cx).unwrap()
5303        });
5304
5305        assert_eq!(
5306            visible_entries_as_strings(&panel, 0..10, cx),
5307            &[
5308                "v root1",
5309                "    > .git",
5310                "      [PROCESSING: '/new_dir/']  <== selected",
5311                "      .dockerignore",
5312            ]
5313        );
5314
5315        confirm.await.unwrap();
5316        assert_eq!(
5317            visible_entries_as_strings(&panel, 0..13, cx),
5318            &[
5319                "v root1",
5320                "    > .git",
5321                "    v new_dir  <== selected",
5322                "      .dockerignore",
5323            ]
5324        );
5325    }
5326
5327    #[gpui::test]
5328    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
5329        init_test(cx);
5330
5331        let fs = FakeFs::new(cx.executor().clone());
5332        fs.insert_tree(
5333            "/root1",
5334            json!({
5335                "one.two.txt": "",
5336                "one.txt": ""
5337            }),
5338        )
5339        .await;
5340
5341        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5342        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5343        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5344        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5345
5346        panel.update(cx, |panel, cx| {
5347            panel.select_next(&Default::default(), cx);
5348            panel.select_next(&Default::default(), cx);
5349        });
5350
5351        assert_eq!(
5352            visible_entries_as_strings(&panel, 0..50, cx),
5353            &[
5354                //
5355                "v root1",
5356                "      one.txt  <== selected",
5357                "      one.two.txt",
5358            ]
5359        );
5360
5361        // Regression test - file name is created correctly when
5362        // the copied file's name contains multiple dots.
5363        panel.update(cx, |panel, cx| {
5364            panel.copy(&Default::default(), cx);
5365            panel.paste(&Default::default(), cx);
5366        });
5367        cx.executor().run_until_parked();
5368
5369        assert_eq!(
5370            visible_entries_as_strings(&panel, 0..50, cx),
5371            &[
5372                //
5373                "v root1",
5374                "      one.txt",
5375                "      [EDITOR: 'one copy.txt']  <== selected",
5376                "      one.two.txt",
5377            ]
5378        );
5379
5380        panel.update(cx, |panel, cx| {
5381            panel.filename_editor.update(cx, |editor, cx| {
5382                let file_name_selections = editor.selections.all::<usize>(cx);
5383                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5384                let file_name_selection = &file_name_selections[0];
5385                assert_eq!(file_name_selection.start, "one".len(), "Should select the file name disambiguation after the original file name");
5386                assert_eq!(file_name_selection.end, "one copy".len(), "Should select the file name disambiguation until the extension");
5387            });
5388            assert!(panel.confirm_edit(cx).is_none());
5389        });
5390
5391        panel.update(cx, |panel, cx| {
5392            panel.paste(&Default::default(), cx);
5393        });
5394        cx.executor().run_until_parked();
5395
5396        assert_eq!(
5397            visible_entries_as_strings(&panel, 0..50, cx),
5398            &[
5399                //
5400                "v root1",
5401                "      one.txt",
5402                "      one copy.txt",
5403                "      [EDITOR: 'one copy 1.txt']  <== selected",
5404                "      one.two.txt",
5405            ]
5406        );
5407
5408        panel.update(cx, |panel, cx| assert!(panel.confirm_edit(cx).is_none()));
5409    }
5410
5411    #[gpui::test]
5412    async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5413        init_test(cx);
5414
5415        let fs = FakeFs::new(cx.executor().clone());
5416        fs.insert_tree(
5417            "/root1",
5418            json!({
5419                "one.txt": "",
5420                "two.txt": "",
5421                "three.txt": "",
5422                "a": {
5423                    "0": { "q": "", "r": "", "s": "" },
5424                    "1": { "t": "", "u": "" },
5425                    "2": { "v": "", "w": "", "x": "", "y": "" },
5426                },
5427            }),
5428        )
5429        .await;
5430
5431        fs.insert_tree(
5432            "/root2",
5433            json!({
5434                "one.txt": "",
5435                "two.txt": "",
5436                "four.txt": "",
5437                "b": {
5438                    "3": { "Q": "" },
5439                    "4": { "R": "", "S": "", "T": "", "U": "" },
5440                },
5441            }),
5442        )
5443        .await;
5444
5445        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5446        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5447        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5448        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5449
5450        select_path(&panel, "root1/three.txt", cx);
5451        panel.update(cx, |panel, cx| {
5452            panel.cut(&Default::default(), cx);
5453        });
5454
5455        select_path(&panel, "root2/one.txt", cx);
5456        panel.update(cx, |panel, cx| {
5457            panel.select_next(&Default::default(), cx);
5458            panel.paste(&Default::default(), cx);
5459        });
5460        cx.executor().run_until_parked();
5461        assert_eq!(
5462            visible_entries_as_strings(&panel, 0..50, cx),
5463            &[
5464                //
5465                "v root1",
5466                "    > a",
5467                "      one.txt",
5468                "      two.txt",
5469                "v root2",
5470                "    > b",
5471                "      four.txt",
5472                "      one.txt",
5473                "      three.txt  <== selected",
5474                "      two.txt",
5475            ]
5476        );
5477
5478        select_path(&panel, "root1/a", cx);
5479        panel.update(cx, |panel, cx| {
5480            panel.cut(&Default::default(), cx);
5481        });
5482        select_path(&panel, "root2/two.txt", cx);
5483        panel.update(cx, |panel, cx| {
5484            panel.select_next(&Default::default(), cx);
5485            panel.paste(&Default::default(), cx);
5486        });
5487
5488        cx.executor().run_until_parked();
5489        assert_eq!(
5490            visible_entries_as_strings(&panel, 0..50, cx),
5491            &[
5492                //
5493                "v root1",
5494                "      one.txt",
5495                "      two.txt",
5496                "v root2",
5497                "    > a  <== selected",
5498                "    > b",
5499                "      four.txt",
5500                "      one.txt",
5501                "      three.txt",
5502                "      two.txt",
5503            ]
5504        );
5505    }
5506
5507    #[gpui::test]
5508    async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5509        init_test(cx);
5510
5511        let fs = FakeFs::new(cx.executor().clone());
5512        fs.insert_tree(
5513            "/root1",
5514            json!({
5515                "one.txt": "",
5516                "two.txt": "",
5517                "three.txt": "",
5518                "a": {
5519                    "0": { "q": "", "r": "", "s": "" },
5520                    "1": { "t": "", "u": "" },
5521                    "2": { "v": "", "w": "", "x": "", "y": "" },
5522                },
5523            }),
5524        )
5525        .await;
5526
5527        fs.insert_tree(
5528            "/root2",
5529            json!({
5530                "one.txt": "",
5531                "two.txt": "",
5532                "four.txt": "",
5533                "b": {
5534                    "3": { "Q": "" },
5535                    "4": { "R": "", "S": "", "T": "", "U": "" },
5536                },
5537            }),
5538        )
5539        .await;
5540
5541        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5542        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5543        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5544        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5545
5546        select_path(&panel, "root1/three.txt", cx);
5547        panel.update(cx, |panel, cx| {
5548            panel.copy(&Default::default(), cx);
5549        });
5550
5551        select_path(&panel, "root2/one.txt", cx);
5552        panel.update(cx, |panel, cx| {
5553            panel.select_next(&Default::default(), cx);
5554            panel.paste(&Default::default(), cx);
5555        });
5556        cx.executor().run_until_parked();
5557        assert_eq!(
5558            visible_entries_as_strings(&panel, 0..50, cx),
5559            &[
5560                //
5561                "v root1",
5562                "    > a",
5563                "      one.txt",
5564                "      three.txt",
5565                "      two.txt",
5566                "v root2",
5567                "    > b",
5568                "      four.txt",
5569                "      one.txt",
5570                "      three.txt  <== selected",
5571                "      two.txt",
5572            ]
5573        );
5574
5575        select_path(&panel, "root1/three.txt", cx);
5576        panel.update(cx, |panel, cx| {
5577            panel.copy(&Default::default(), cx);
5578        });
5579        select_path(&panel, "root2/two.txt", cx);
5580        panel.update(cx, |panel, cx| {
5581            panel.select_next(&Default::default(), cx);
5582            panel.paste(&Default::default(), cx);
5583        });
5584
5585        cx.executor().run_until_parked();
5586        assert_eq!(
5587            visible_entries_as_strings(&panel, 0..50, cx),
5588            &[
5589                //
5590                "v root1",
5591                "    > a",
5592                "      one.txt",
5593                "      three.txt",
5594                "      two.txt",
5595                "v root2",
5596                "    > b",
5597                "      four.txt",
5598                "      one.txt",
5599                "      three.txt",
5600                "      [EDITOR: 'three copy.txt']  <== selected",
5601                "      two.txt",
5602            ]
5603        );
5604
5605        panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel {}, cx));
5606        cx.executor().run_until_parked();
5607
5608        select_path(&panel, "root1/a", cx);
5609        panel.update(cx, |panel, cx| {
5610            panel.copy(&Default::default(), cx);
5611        });
5612        select_path(&panel, "root2/two.txt", cx);
5613        panel.update(cx, |panel, cx| {
5614            panel.select_next(&Default::default(), cx);
5615            panel.paste(&Default::default(), cx);
5616        });
5617
5618        cx.executor().run_until_parked();
5619        assert_eq!(
5620            visible_entries_as_strings(&panel, 0..50, cx),
5621            &[
5622                //
5623                "v root1",
5624                "    > a",
5625                "      one.txt",
5626                "      three.txt",
5627                "      two.txt",
5628                "v root2",
5629                "    > a  <== selected",
5630                "    > b",
5631                "      four.txt",
5632                "      one.txt",
5633                "      three.txt",
5634                "      three copy.txt",
5635                "      two.txt",
5636            ]
5637        );
5638    }
5639
5640    #[gpui::test]
5641    async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
5642        init_test(cx);
5643
5644        let fs = FakeFs::new(cx.executor().clone());
5645        fs.insert_tree(
5646            "/root",
5647            json!({
5648                "a": {
5649                    "one.txt": "",
5650                    "two.txt": "",
5651                    "inner_dir": {
5652                        "three.txt": "",
5653                        "four.txt": "",
5654                    }
5655                },
5656                "b": {}
5657            }),
5658        )
5659        .await;
5660
5661        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5662        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5663        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5664        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5665
5666        select_path(&panel, "root/a", cx);
5667        panel.update(cx, |panel, cx| {
5668            panel.copy(&Default::default(), cx);
5669            panel.select_next(&Default::default(), cx);
5670            panel.paste(&Default::default(), cx);
5671        });
5672        cx.executor().run_until_parked();
5673
5674        let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
5675        assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
5676
5677        let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
5678        assert_ne!(
5679            pasted_dir_file, None,
5680            "Pasted directory file should have an entry"
5681        );
5682
5683        let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
5684        assert_ne!(
5685            pasted_dir_inner_dir, None,
5686            "Directories inside pasted directory should have an entry"
5687        );
5688
5689        toggle_expand_dir(&panel, "root/b/a", cx);
5690        toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
5691
5692        assert_eq!(
5693            visible_entries_as_strings(&panel, 0..50, cx),
5694            &[
5695                //
5696                "v root",
5697                "    > a",
5698                "    v b",
5699                "        v a",
5700                "            v inner_dir  <== selected",
5701                "                  four.txt",
5702                "                  three.txt",
5703                "              one.txt",
5704                "              two.txt",
5705            ]
5706        );
5707
5708        select_path(&panel, "root", cx);
5709        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5710        cx.executor().run_until_parked();
5711        assert_eq!(
5712            visible_entries_as_strings(&panel, 0..50, cx),
5713            &[
5714                //
5715                "v root",
5716                "    > a",
5717                "    > [EDITOR: 'a copy']  <== selected",
5718                "    v b",
5719                "        v a",
5720                "            v inner_dir",
5721                "                  four.txt",
5722                "                  three.txt",
5723                "              one.txt",
5724                "              two.txt"
5725            ]
5726        );
5727
5728        let confirm = panel.update(cx, |panel, cx| {
5729            panel
5730                .filename_editor
5731                .update(cx, |editor, cx| editor.set_text("c", cx));
5732            panel.confirm_edit(cx).unwrap()
5733        });
5734        assert_eq!(
5735            visible_entries_as_strings(&panel, 0..50, cx),
5736            &[
5737                //
5738                "v root",
5739                "    > a",
5740                "    > [PROCESSING: 'c']  <== selected",
5741                "    v b",
5742                "        v a",
5743                "            v inner_dir",
5744                "                  four.txt",
5745                "                  three.txt",
5746                "              one.txt",
5747                "              two.txt"
5748            ]
5749        );
5750
5751        confirm.await.unwrap();
5752
5753        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5754        cx.executor().run_until_parked();
5755        assert_eq!(
5756            visible_entries_as_strings(&panel, 0..50, cx),
5757            &[
5758                //
5759                "v root",
5760                "    > a",
5761                "    v b",
5762                "        v a",
5763                "            v inner_dir",
5764                "                  four.txt",
5765                "                  three.txt",
5766                "              one.txt",
5767                "              two.txt",
5768                "    v c",
5769                "        > a  <== selected",
5770                "        > inner_dir",
5771                "          one.txt",
5772                "          two.txt",
5773            ]
5774        );
5775    }
5776
5777    #[gpui::test]
5778    async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
5779        init_test(cx);
5780
5781        let fs = FakeFs::new(cx.executor().clone());
5782        fs.insert_tree(
5783            "/test",
5784            json!({
5785                "dir1": {
5786                    "a.txt": "",
5787                    "b.txt": "",
5788                },
5789                "dir2": {},
5790                "c.txt": "",
5791                "d.txt": "",
5792            }),
5793        )
5794        .await;
5795
5796        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5797        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5798        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5799        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5800
5801        toggle_expand_dir(&panel, "test/dir1", cx);
5802
5803        cx.simulate_modifiers_change(gpui::Modifiers {
5804            control: true,
5805            ..Default::default()
5806        });
5807
5808        select_path_with_mark(&panel, "test/dir1", cx);
5809        select_path_with_mark(&panel, "test/c.txt", cx);
5810
5811        assert_eq!(
5812            visible_entries_as_strings(&panel, 0..15, cx),
5813            &[
5814                "v test",
5815                "    v dir1  <== marked",
5816                "          a.txt",
5817                "          b.txt",
5818                "    > dir2",
5819                "      c.txt  <== selected  <== marked",
5820                "      d.txt",
5821            ],
5822            "Initial state before copying dir1 and c.txt"
5823        );
5824
5825        panel.update(cx, |panel, cx| {
5826            panel.copy(&Default::default(), cx);
5827        });
5828        select_path(&panel, "test/dir2", cx);
5829        panel.update(cx, |panel, cx| {
5830            panel.paste(&Default::default(), cx);
5831        });
5832        cx.executor().run_until_parked();
5833
5834        toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5835
5836        assert_eq!(
5837            visible_entries_as_strings(&panel, 0..15, cx),
5838            &[
5839                "v test",
5840                "    v dir1  <== marked",
5841                "          a.txt",
5842                "          b.txt",
5843                "    v dir2",
5844                "        v dir1  <== selected",
5845                "              a.txt",
5846                "              b.txt",
5847                "          c.txt",
5848                "      c.txt  <== marked",
5849                "      d.txt",
5850            ],
5851            "Should copy dir1 as well as c.txt into dir2"
5852        );
5853
5854        // Disambiguating multiple files should not open the rename editor.
5855        select_path(&panel, "test/dir2", cx);
5856        panel.update(cx, |panel, cx| {
5857            panel.paste(&Default::default(), cx);
5858        });
5859        cx.executor().run_until_parked();
5860
5861        assert_eq!(
5862            visible_entries_as_strings(&panel, 0..15, cx),
5863            &[
5864                "v test",
5865                "    v dir1  <== marked",
5866                "          a.txt",
5867                "          b.txt",
5868                "    v dir2",
5869                "        v dir1",
5870                "              a.txt",
5871                "              b.txt",
5872                "        > dir1 copy  <== selected",
5873                "          c.txt",
5874                "          c copy.txt",
5875                "      c.txt  <== marked",
5876                "      d.txt",
5877            ],
5878            "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
5879        );
5880    }
5881
5882    #[gpui::test]
5883    async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
5884        init_test(cx);
5885
5886        let fs = FakeFs::new(cx.executor().clone());
5887        fs.insert_tree(
5888            "/test",
5889            json!({
5890                "dir1": {
5891                    "a.txt": "",
5892                    "b.txt": "",
5893                },
5894                "dir2": {},
5895                "c.txt": "",
5896                "d.txt": "",
5897            }),
5898        )
5899        .await;
5900
5901        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5902        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5903        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5904        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5905
5906        toggle_expand_dir(&panel, "test/dir1", cx);
5907
5908        cx.simulate_modifiers_change(gpui::Modifiers {
5909            control: true,
5910            ..Default::default()
5911        });
5912
5913        select_path_with_mark(&panel, "test/dir1/a.txt", cx);
5914        select_path_with_mark(&panel, "test/dir1", cx);
5915        select_path_with_mark(&panel, "test/c.txt", cx);
5916
5917        assert_eq!(
5918            visible_entries_as_strings(&panel, 0..15, cx),
5919            &[
5920                "v test",
5921                "    v dir1  <== marked",
5922                "          a.txt  <== marked",
5923                "          b.txt",
5924                "    > dir2",
5925                "      c.txt  <== selected  <== marked",
5926                "      d.txt",
5927            ],
5928            "Initial state before copying a.txt, dir1 and c.txt"
5929        );
5930
5931        panel.update(cx, |panel, cx| {
5932            panel.copy(&Default::default(), cx);
5933        });
5934        select_path(&panel, "test/dir2", cx);
5935        panel.update(cx, |panel, cx| {
5936            panel.paste(&Default::default(), cx);
5937        });
5938        cx.executor().run_until_parked();
5939
5940        toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5941
5942        assert_eq!(
5943            visible_entries_as_strings(&panel, 0..20, cx),
5944            &[
5945                "v test",
5946                "    v dir1  <== marked",
5947                "          a.txt  <== marked",
5948                "          b.txt",
5949                "    v dir2",
5950                "        v dir1  <== selected",
5951                "              a.txt",
5952                "              b.txt",
5953                "          c.txt",
5954                "      c.txt  <== marked",
5955                "      d.txt",
5956            ],
5957            "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
5958        );
5959    }
5960
5961    #[gpui::test]
5962    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
5963        init_test_with_editor(cx);
5964
5965        let fs = FakeFs::new(cx.executor().clone());
5966        fs.insert_tree(
5967            "/src",
5968            json!({
5969                "test": {
5970                    "first.rs": "// First Rust file",
5971                    "second.rs": "// Second Rust file",
5972                    "third.rs": "// Third Rust file",
5973                }
5974            }),
5975        )
5976        .await;
5977
5978        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5979        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5980        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5981        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5982
5983        toggle_expand_dir(&panel, "src/test", cx);
5984        select_path(&panel, "src/test/first.rs", cx);
5985        panel.update(cx, |panel, cx| panel.open(&Open, cx));
5986        cx.executor().run_until_parked();
5987        assert_eq!(
5988            visible_entries_as_strings(&panel, 0..10, cx),
5989            &[
5990                "v src",
5991                "    v test",
5992                "          first.rs  <== selected  <== marked",
5993                "          second.rs",
5994                "          third.rs"
5995            ]
5996        );
5997        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
5998
5999        submit_deletion(&panel, cx);
6000        assert_eq!(
6001            visible_entries_as_strings(&panel, 0..10, cx),
6002            &[
6003                "v src",
6004                "    v test",
6005                "          second.rs  <== selected",
6006                "          third.rs"
6007            ],
6008            "Project panel should have no deleted file, no other file is selected in it"
6009        );
6010        ensure_no_open_items_and_panes(&workspace, cx);
6011
6012        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6013        cx.executor().run_until_parked();
6014        assert_eq!(
6015            visible_entries_as_strings(&panel, 0..10, cx),
6016            &[
6017                "v src",
6018                "    v test",
6019                "          second.rs  <== selected  <== marked",
6020                "          third.rs"
6021            ]
6022        );
6023        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
6024
6025        workspace
6026            .update(cx, |workspace, cx| {
6027                let active_items = workspace
6028                    .panes()
6029                    .iter()
6030                    .filter_map(|pane| pane.read(cx).active_item())
6031                    .collect::<Vec<_>>();
6032                assert_eq!(active_items.len(), 1);
6033                let open_editor = active_items
6034                    .into_iter()
6035                    .next()
6036                    .unwrap()
6037                    .downcast::<Editor>()
6038                    .expect("Open item should be an editor");
6039                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
6040            })
6041            .unwrap();
6042        submit_deletion_skipping_prompt(&panel, cx);
6043        assert_eq!(
6044            visible_entries_as_strings(&panel, 0..10, cx),
6045            &["v src", "    v test", "          third.rs  <== selected"],
6046            "Project panel should have no deleted file, with one last file remaining"
6047        );
6048        ensure_no_open_items_and_panes(&workspace, cx);
6049    }
6050
6051    #[gpui::test]
6052    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
6053        init_test_with_editor(cx);
6054
6055        let fs = FakeFs::new(cx.executor().clone());
6056        fs.insert_tree(
6057            "/src",
6058            json!({
6059                "test": {
6060                    "first.rs": "// First Rust file",
6061                    "second.rs": "// Second Rust file",
6062                    "third.rs": "// Third Rust file",
6063                }
6064            }),
6065        )
6066        .await;
6067
6068        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6069        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6070        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6071        let panel = workspace
6072            .update(cx, |workspace, cx| {
6073                let panel = ProjectPanel::new(workspace, cx);
6074                workspace.add_panel(panel.clone(), cx);
6075                panel
6076            })
6077            .unwrap();
6078
6079        select_path(&panel, "src/", cx);
6080        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6081        cx.executor().run_until_parked();
6082        assert_eq!(
6083            visible_entries_as_strings(&panel, 0..10, cx),
6084            &[
6085                //
6086                "v src  <== selected",
6087                "    > test"
6088            ]
6089        );
6090        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
6091        panel.update(cx, |panel, cx| {
6092            assert!(panel.filename_editor.read(cx).is_focused(cx));
6093        });
6094        assert_eq!(
6095            visible_entries_as_strings(&panel, 0..10, cx),
6096            &[
6097                //
6098                "v src",
6099                "    > [EDITOR: '']  <== selected",
6100                "    > test"
6101            ]
6102        );
6103        panel.update(cx, |panel, cx| {
6104            panel
6105                .filename_editor
6106                .update(cx, |editor, cx| editor.set_text("test", cx));
6107            assert!(
6108                panel.confirm_edit(cx).is_none(),
6109                "Should not allow to confirm on conflicting new directory name"
6110            )
6111        });
6112        assert_eq!(
6113            visible_entries_as_strings(&panel, 0..10, cx),
6114            &[
6115                //
6116                "v src",
6117                "    > test"
6118            ],
6119            "File list should be unchanged after failed folder create confirmation"
6120        );
6121
6122        select_path(&panel, "src/test/", cx);
6123        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6124        cx.executor().run_until_parked();
6125        assert_eq!(
6126            visible_entries_as_strings(&panel, 0..10, cx),
6127            &[
6128                //
6129                "v src",
6130                "    > test  <== selected"
6131            ]
6132        );
6133        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
6134        panel.update(cx, |panel, cx| {
6135            assert!(panel.filename_editor.read(cx).is_focused(cx));
6136        });
6137        assert_eq!(
6138            visible_entries_as_strings(&panel, 0..10, cx),
6139            &[
6140                "v src",
6141                "    v test",
6142                "          [EDITOR: '']  <== selected",
6143                "          first.rs",
6144                "          second.rs",
6145                "          third.rs"
6146            ]
6147        );
6148        panel.update(cx, |panel, cx| {
6149            panel
6150                .filename_editor
6151                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
6152            assert!(
6153                panel.confirm_edit(cx).is_none(),
6154                "Should not allow to confirm on conflicting new file name"
6155            )
6156        });
6157        assert_eq!(
6158            visible_entries_as_strings(&panel, 0..10, cx),
6159            &[
6160                "v src",
6161                "    v test",
6162                "          first.rs",
6163                "          second.rs",
6164                "          third.rs"
6165            ],
6166            "File list should be unchanged after failed file create confirmation"
6167        );
6168
6169        select_path(&panel, "src/test/first.rs", cx);
6170        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6171        cx.executor().run_until_parked();
6172        assert_eq!(
6173            visible_entries_as_strings(&panel, 0..10, cx),
6174            &[
6175                "v src",
6176                "    v test",
6177                "          first.rs  <== selected",
6178                "          second.rs",
6179                "          third.rs"
6180            ],
6181        );
6182        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6183        panel.update(cx, |panel, cx| {
6184            assert!(panel.filename_editor.read(cx).is_focused(cx));
6185        });
6186        assert_eq!(
6187            visible_entries_as_strings(&panel, 0..10, cx),
6188            &[
6189                "v src",
6190                "    v test",
6191                "          [EDITOR: 'first.rs']  <== selected",
6192                "          second.rs",
6193                "          third.rs"
6194            ]
6195        );
6196        panel.update(cx, |panel, cx| {
6197            panel
6198                .filename_editor
6199                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
6200            assert!(
6201                panel.confirm_edit(cx).is_none(),
6202                "Should not allow to confirm on conflicting file rename"
6203            )
6204        });
6205        assert_eq!(
6206            visible_entries_as_strings(&panel, 0..10, cx),
6207            &[
6208                "v src",
6209                "    v test",
6210                "          first.rs  <== selected",
6211                "          second.rs",
6212                "          third.rs"
6213            ],
6214            "File list should be unchanged after failed rename confirmation"
6215        );
6216    }
6217
6218    #[gpui::test]
6219    async fn test_select_directory(cx: &mut gpui::TestAppContext) {
6220        init_test_with_editor(cx);
6221
6222        let fs = FakeFs::new(cx.executor().clone());
6223        fs.insert_tree(
6224            "/project_root",
6225            json!({
6226                "dir_1": {
6227                    "nested_dir": {
6228                        "file_a.py": "# File contents",
6229                    }
6230                },
6231                "file_1.py": "# File contents",
6232                "dir_2": {
6233
6234                },
6235                "dir_3": {
6236
6237                },
6238                "file_2.py": "# File contents",
6239                "dir_4": {
6240
6241                },
6242            }),
6243        )
6244        .await;
6245
6246        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6247        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6248        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6249        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6250
6251        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6252        cx.executor().run_until_parked();
6253        select_path(&panel, "project_root/dir_1", cx);
6254        cx.executor().run_until_parked();
6255        assert_eq!(
6256            visible_entries_as_strings(&panel, 0..10, cx),
6257            &[
6258                "v project_root",
6259                "    > dir_1  <== selected",
6260                "    > dir_2",
6261                "    > dir_3",
6262                "    > dir_4",
6263                "      file_1.py",
6264                "      file_2.py",
6265            ]
6266        );
6267        panel.update(cx, |panel, cx| {
6268            panel.select_prev_directory(&SelectPrevDirectory, cx)
6269        });
6270
6271        assert_eq!(
6272            visible_entries_as_strings(&panel, 0..10, cx),
6273            &[
6274                "v project_root  <== selected",
6275                "    > dir_1",
6276                "    > dir_2",
6277                "    > dir_3",
6278                "    > dir_4",
6279                "      file_1.py",
6280                "      file_2.py",
6281            ]
6282        );
6283
6284        panel.update(cx, |panel, cx| {
6285            panel.select_prev_directory(&SelectPrevDirectory, cx)
6286        });
6287
6288        assert_eq!(
6289            visible_entries_as_strings(&panel, 0..10, cx),
6290            &[
6291                "v project_root",
6292                "    > dir_1",
6293                "    > dir_2",
6294                "    > dir_3",
6295                "    > dir_4  <== selected",
6296                "      file_1.py",
6297                "      file_2.py",
6298            ]
6299        );
6300
6301        panel.update(cx, |panel, cx| {
6302            panel.select_next_directory(&SelectNextDirectory, cx)
6303        });
6304
6305        assert_eq!(
6306            visible_entries_as_strings(&panel, 0..10, cx),
6307            &[
6308                "v project_root  <== selected",
6309                "    > dir_1",
6310                "    > dir_2",
6311                "    > dir_3",
6312                "    > dir_4",
6313                "      file_1.py",
6314                "      file_2.py",
6315            ]
6316        );
6317    }
6318
6319    #[gpui::test]
6320    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
6321        init_test_with_editor(cx);
6322
6323        let fs = FakeFs::new(cx.executor().clone());
6324        fs.insert_tree(
6325            "/project_root",
6326            json!({
6327                "dir_1": {
6328                    "nested_dir": {
6329                        "file_a.py": "# File contents",
6330                    }
6331                },
6332                "file_1.py": "# File contents",
6333            }),
6334        )
6335        .await;
6336
6337        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6338        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6339        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6340        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6341
6342        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6343        cx.executor().run_until_parked();
6344        select_path(&panel, "project_root/dir_1", cx);
6345        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6346        select_path(&panel, "project_root/dir_1/nested_dir", cx);
6347        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6348        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6349        cx.executor().run_until_parked();
6350        assert_eq!(
6351            visible_entries_as_strings(&panel, 0..10, cx),
6352            &[
6353                "v project_root",
6354                "    v dir_1",
6355                "        > nested_dir  <== selected",
6356                "      file_1.py",
6357            ]
6358        );
6359    }
6360
6361    #[gpui::test]
6362    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
6363        init_test_with_editor(cx);
6364
6365        let fs = FakeFs::new(cx.executor().clone());
6366        fs.insert_tree(
6367            "/project_root",
6368            json!({
6369                "dir_1": {
6370                    "nested_dir": {
6371                        "file_a.py": "# File contents",
6372                        "file_b.py": "# File contents",
6373                        "file_c.py": "# File contents",
6374                    },
6375                    "file_1.py": "# File contents",
6376                    "file_2.py": "# File contents",
6377                    "file_3.py": "# File contents",
6378                },
6379                "dir_2": {
6380                    "file_1.py": "# File contents",
6381                    "file_2.py": "# File contents",
6382                    "file_3.py": "# File contents",
6383                }
6384            }),
6385        )
6386        .await;
6387
6388        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6389        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6390        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6391        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6392
6393        panel.update(cx, |panel, cx| {
6394            panel.collapse_all_entries(&CollapseAllEntries, cx)
6395        });
6396        cx.executor().run_until_parked();
6397        assert_eq!(
6398            visible_entries_as_strings(&panel, 0..10, cx),
6399            &["v project_root", "    > dir_1", "    > dir_2",]
6400        );
6401
6402        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
6403        toggle_expand_dir(&panel, "project_root/dir_1", cx);
6404        cx.executor().run_until_parked();
6405        assert_eq!(
6406            visible_entries_as_strings(&panel, 0..10, cx),
6407            &[
6408                "v project_root",
6409                "    v dir_1  <== selected",
6410                "        > nested_dir",
6411                "          file_1.py",
6412                "          file_2.py",
6413                "          file_3.py",
6414                "    > dir_2",
6415            ]
6416        );
6417    }
6418
6419    #[gpui::test]
6420    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
6421        init_test(cx);
6422
6423        let fs = FakeFs::new(cx.executor().clone());
6424        fs.as_fake().insert_tree("/root", json!({})).await;
6425        let project = Project::test(fs, ["/root".as_ref()], cx).await;
6426        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6427        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6428        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6429
6430        // Make a new buffer with no backing file
6431        workspace
6432            .update(cx, |workspace, cx| {
6433                Editor::new_file(workspace, &Default::default(), cx)
6434            })
6435            .unwrap();
6436
6437        cx.executor().run_until_parked();
6438
6439        // "Save as" the buffer, creating a new backing file for it
6440        let save_task = workspace
6441            .update(cx, |workspace, cx| {
6442                workspace.save_active_item(workspace::SaveIntent::Save, cx)
6443            })
6444            .unwrap();
6445
6446        cx.executor().run_until_parked();
6447        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
6448        save_task.await.unwrap();
6449
6450        // Rename the file
6451        select_path(&panel, "root/new", cx);
6452        assert_eq!(
6453            visible_entries_as_strings(&panel, 0..10, cx),
6454            &["v root", "      new  <== selected"]
6455        );
6456        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6457        panel.update(cx, |panel, cx| {
6458            panel
6459                .filename_editor
6460                .update(cx, |editor, cx| editor.set_text("newer", cx));
6461        });
6462        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6463
6464        cx.executor().run_until_parked();
6465        assert_eq!(
6466            visible_entries_as_strings(&panel, 0..10, cx),
6467            &["v root", "      newer  <== selected"]
6468        );
6469
6470        workspace
6471            .update(cx, |workspace, cx| {
6472                workspace.save_active_item(workspace::SaveIntent::Save, cx)
6473            })
6474            .unwrap()
6475            .await
6476            .unwrap();
6477
6478        cx.executor().run_until_parked();
6479        // assert that saving the file doesn't restore "new"
6480        assert_eq!(
6481            visible_entries_as_strings(&panel, 0..10, cx),
6482            &["v root", "      newer  <== selected"]
6483        );
6484    }
6485
6486    #[gpui::test]
6487    async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
6488        init_test_with_editor(cx);
6489        let fs = FakeFs::new(cx.executor().clone());
6490        fs.insert_tree(
6491            "/project_root",
6492            json!({
6493                "dir_1": {
6494                    "nested_dir": {
6495                        "file_a.py": "# File contents",
6496                    }
6497                },
6498                "file_1.py": "# File contents",
6499            }),
6500        )
6501        .await;
6502
6503        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6504        let worktree_id =
6505            cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
6506        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6507        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6508        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6509        cx.update(|cx| {
6510            panel.update(cx, |this, cx| {
6511                this.select_next(&Default::default(), cx);
6512                this.expand_selected_entry(&Default::default(), cx);
6513                this.expand_selected_entry(&Default::default(), cx);
6514                this.select_next(&Default::default(), cx);
6515                this.expand_selected_entry(&Default::default(), cx);
6516                this.select_next(&Default::default(), cx);
6517            })
6518        });
6519        assert_eq!(
6520            visible_entries_as_strings(&panel, 0..10, cx),
6521            &[
6522                "v project_root",
6523                "    v dir_1",
6524                "        v nested_dir",
6525                "              file_a.py  <== selected",
6526                "      file_1.py",
6527            ]
6528        );
6529        let modifiers_with_shift = gpui::Modifiers {
6530            shift: true,
6531            ..Default::default()
6532        };
6533        cx.simulate_modifiers_change(modifiers_with_shift);
6534        cx.update(|cx| {
6535            panel.update(cx, |this, cx| {
6536                this.select_next(&Default::default(), cx);
6537            })
6538        });
6539        assert_eq!(
6540            visible_entries_as_strings(&panel, 0..10, cx),
6541            &[
6542                "v project_root",
6543                "    v dir_1",
6544                "        v nested_dir",
6545                "              file_a.py",
6546                "      file_1.py  <== selected  <== marked",
6547            ]
6548        );
6549        cx.update(|cx| {
6550            panel.update(cx, |this, cx| {
6551                this.select_prev(&Default::default(), cx);
6552            })
6553        });
6554        assert_eq!(
6555            visible_entries_as_strings(&panel, 0..10, cx),
6556            &[
6557                "v project_root",
6558                "    v dir_1",
6559                "        v nested_dir",
6560                "              file_a.py  <== selected  <== marked",
6561                "      file_1.py  <== marked",
6562            ]
6563        );
6564        cx.update(|cx| {
6565            panel.update(cx, |this, cx| {
6566                let drag = DraggedSelection {
6567                    active_selection: this.selection.unwrap(),
6568                    marked_selections: Arc::new(this.marked_entries.clone()),
6569                };
6570                let target_entry = this
6571                    .project
6572                    .read(cx)
6573                    .entry_for_path(&(worktree_id, "").into(), cx)
6574                    .unwrap();
6575                this.drag_onto(&drag, target_entry.id, false, cx);
6576            });
6577        });
6578        cx.run_until_parked();
6579        assert_eq!(
6580            visible_entries_as_strings(&panel, 0..10, cx),
6581            &[
6582                "v project_root",
6583                "    v dir_1",
6584                "        v nested_dir",
6585                "      file_1.py  <== marked",
6586                "      file_a.py  <== selected  <== marked",
6587            ]
6588        );
6589        // ESC clears out all marks
6590        cx.update(|cx| {
6591            panel.update(cx, |this, cx| {
6592                this.cancel(&menu::Cancel, cx);
6593            })
6594        });
6595        assert_eq!(
6596            visible_entries_as_strings(&panel, 0..10, cx),
6597            &[
6598                "v project_root",
6599                "    v dir_1",
6600                "        v nested_dir",
6601                "      file_1.py",
6602                "      file_a.py  <== selected",
6603            ]
6604        );
6605        // ESC clears out all marks
6606        cx.update(|cx| {
6607            panel.update(cx, |this, cx| {
6608                this.select_prev(&SelectPrev, cx);
6609                this.select_next(&SelectNext, cx);
6610            })
6611        });
6612        assert_eq!(
6613            visible_entries_as_strings(&panel, 0..10, cx),
6614            &[
6615                "v project_root",
6616                "    v dir_1",
6617                "        v nested_dir",
6618                "      file_1.py  <== marked",
6619                "      file_a.py  <== selected  <== marked",
6620            ]
6621        );
6622        cx.simulate_modifiers_change(Default::default());
6623        cx.update(|cx| {
6624            panel.update(cx, |this, cx| {
6625                this.cut(&Cut, cx);
6626                this.select_prev(&SelectPrev, cx);
6627                this.select_prev(&SelectPrev, cx);
6628
6629                this.paste(&Paste, cx);
6630                // this.expand_selected_entry(&ExpandSelectedEntry, cx);
6631            })
6632        });
6633        cx.run_until_parked();
6634        assert_eq!(
6635            visible_entries_as_strings(&panel, 0..10, cx),
6636            &[
6637                "v project_root",
6638                "    v dir_1",
6639                "        v nested_dir",
6640                "              file_1.py  <== marked",
6641                "              file_a.py  <== selected  <== marked",
6642            ]
6643        );
6644        cx.simulate_modifiers_change(modifiers_with_shift);
6645        cx.update(|cx| {
6646            panel.update(cx, |this, cx| {
6647                this.expand_selected_entry(&Default::default(), cx);
6648                this.select_next(&SelectNext, cx);
6649                this.select_next(&SelectNext, cx);
6650            })
6651        });
6652        submit_deletion(&panel, cx);
6653        assert_eq!(
6654            visible_entries_as_strings(&panel, 0..10, cx),
6655            &[
6656                "v project_root",
6657                "    v dir_1",
6658                "        v nested_dir  <== selected",
6659            ]
6660        );
6661    }
6662    #[gpui::test]
6663    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
6664        init_test_with_editor(cx);
6665        cx.update(|cx| {
6666            cx.update_global::<SettingsStore, _>(|store, cx| {
6667                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6668                    worktree_settings.file_scan_exclusions = Some(Vec::new());
6669                });
6670                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6671                    project_panel_settings.auto_reveal_entries = Some(false)
6672                });
6673            })
6674        });
6675
6676        let fs = FakeFs::new(cx.background_executor.clone());
6677        fs.insert_tree(
6678            "/project_root",
6679            json!({
6680                ".git": {},
6681                ".gitignore": "**/gitignored_dir",
6682                "dir_1": {
6683                    "file_1.py": "# File 1_1 contents",
6684                    "file_2.py": "# File 1_2 contents",
6685                    "file_3.py": "# File 1_3 contents",
6686                    "gitignored_dir": {
6687                        "file_a.py": "# File contents",
6688                        "file_b.py": "# File contents",
6689                        "file_c.py": "# File contents",
6690                    },
6691                },
6692                "dir_2": {
6693                    "file_1.py": "# File 2_1 contents",
6694                    "file_2.py": "# File 2_2 contents",
6695                    "file_3.py": "# File 2_3 contents",
6696                }
6697            }),
6698        )
6699        .await;
6700
6701        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6702        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6703        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6704        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6705
6706        assert_eq!(
6707            visible_entries_as_strings(&panel, 0..20, cx),
6708            &[
6709                "v project_root",
6710                "    > .git",
6711                "    > dir_1",
6712                "    > dir_2",
6713                "      .gitignore",
6714            ]
6715        );
6716
6717        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6718            .expect("dir 1 file is not ignored and should have an entry");
6719        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6720            .expect("dir 2 file is not ignored and should have an entry");
6721        let gitignored_dir_file =
6722            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6723        assert_eq!(
6724            gitignored_dir_file, None,
6725            "File in the gitignored dir should not have an entry before its dir is toggled"
6726        );
6727
6728        toggle_expand_dir(&panel, "project_root/dir_1", cx);
6729        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6730        cx.executor().run_until_parked();
6731        assert_eq!(
6732            visible_entries_as_strings(&panel, 0..20, cx),
6733            &[
6734                "v project_root",
6735                "    > .git",
6736                "    v dir_1",
6737                "        v gitignored_dir  <== selected",
6738                "              file_a.py",
6739                "              file_b.py",
6740                "              file_c.py",
6741                "          file_1.py",
6742                "          file_2.py",
6743                "          file_3.py",
6744                "    > dir_2",
6745                "      .gitignore",
6746            ],
6747            "Should show gitignored dir file list in the project panel"
6748        );
6749        let gitignored_dir_file =
6750            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6751                .expect("after gitignored dir got opened, a file entry should be present");
6752
6753        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6754        toggle_expand_dir(&panel, "project_root/dir_1", cx);
6755        assert_eq!(
6756            visible_entries_as_strings(&panel, 0..20, cx),
6757            &[
6758                "v project_root",
6759                "    > .git",
6760                "    > dir_1  <== selected",
6761                "    > dir_2",
6762                "      .gitignore",
6763            ],
6764            "Should hide all dir contents again and prepare for the auto reveal test"
6765        );
6766
6767        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6768            panel.update(cx, |panel, cx| {
6769                panel.project.update(cx, |_, cx| {
6770                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6771                })
6772            });
6773            cx.run_until_parked();
6774            assert_eq!(
6775                visible_entries_as_strings(&panel, 0..20, cx),
6776                &[
6777                    "v project_root",
6778                    "    > .git",
6779                    "    > dir_1  <== selected",
6780                    "    > dir_2",
6781                    "      .gitignore",
6782                ],
6783                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6784            );
6785        }
6786
6787        cx.update(|cx| {
6788            cx.update_global::<SettingsStore, _>(|store, cx| {
6789                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6790                    project_panel_settings.auto_reveal_entries = Some(true)
6791                });
6792            })
6793        });
6794
6795        panel.update(cx, |panel, cx| {
6796            panel.project.update(cx, |_, cx| {
6797                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
6798            })
6799        });
6800        cx.run_until_parked();
6801        assert_eq!(
6802            visible_entries_as_strings(&panel, 0..20, cx),
6803            &[
6804                "v project_root",
6805                "    > .git",
6806                "    v dir_1",
6807                "        > gitignored_dir",
6808                "          file_1.py  <== selected",
6809                "          file_2.py",
6810                "          file_3.py",
6811                "    > dir_2",
6812                "      .gitignore",
6813            ],
6814            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
6815        );
6816
6817        panel.update(cx, |panel, cx| {
6818            panel.project.update(cx, |_, cx| {
6819                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
6820            })
6821        });
6822        cx.run_until_parked();
6823        assert_eq!(
6824            visible_entries_as_strings(&panel, 0..20, cx),
6825            &[
6826                "v project_root",
6827                "    > .git",
6828                "    v dir_1",
6829                "        > gitignored_dir",
6830                "          file_1.py",
6831                "          file_2.py",
6832                "          file_3.py",
6833                "    v dir_2",
6834                "          file_1.py  <== selected",
6835                "          file_2.py",
6836                "          file_3.py",
6837                "      .gitignore",
6838            ],
6839            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
6840        );
6841
6842        panel.update(cx, |panel, cx| {
6843            panel.project.update(cx, |_, cx| {
6844                cx.emit(project::Event::ActiveEntryChanged(Some(
6845                    gitignored_dir_file,
6846                )))
6847            })
6848        });
6849        cx.run_until_parked();
6850        assert_eq!(
6851            visible_entries_as_strings(&panel, 0..20, cx),
6852            &[
6853                "v project_root",
6854                "    > .git",
6855                "    v dir_1",
6856                "        > gitignored_dir",
6857                "          file_1.py",
6858                "          file_2.py",
6859                "          file_3.py",
6860                "    v dir_2",
6861                "          file_1.py  <== selected",
6862                "          file_2.py",
6863                "          file_3.py",
6864                "      .gitignore",
6865            ],
6866            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
6867        );
6868
6869        panel.update(cx, |panel, cx| {
6870            panel.project.update(cx, |_, cx| {
6871                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6872            })
6873        });
6874        cx.run_until_parked();
6875        assert_eq!(
6876            visible_entries_as_strings(&panel, 0..20, cx),
6877            &[
6878                "v project_root",
6879                "    > .git",
6880                "    v dir_1",
6881                "        v gitignored_dir",
6882                "              file_a.py  <== selected",
6883                "              file_b.py",
6884                "              file_c.py",
6885                "          file_1.py",
6886                "          file_2.py",
6887                "          file_3.py",
6888                "    v dir_2",
6889                "          file_1.py",
6890                "          file_2.py",
6891                "          file_3.py",
6892                "      .gitignore",
6893            ],
6894            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
6895        );
6896    }
6897
6898    #[gpui::test]
6899    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
6900        init_test_with_editor(cx);
6901        cx.update(|cx| {
6902            cx.update_global::<SettingsStore, _>(|store, cx| {
6903                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6904                    worktree_settings.file_scan_exclusions = Some(Vec::new());
6905                });
6906                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6907                    project_panel_settings.auto_reveal_entries = Some(false)
6908                });
6909            })
6910        });
6911
6912        let fs = FakeFs::new(cx.background_executor.clone());
6913        fs.insert_tree(
6914            "/project_root",
6915            json!({
6916                ".git": {},
6917                ".gitignore": "**/gitignored_dir",
6918                "dir_1": {
6919                    "file_1.py": "# File 1_1 contents",
6920                    "file_2.py": "# File 1_2 contents",
6921                    "file_3.py": "# File 1_3 contents",
6922                    "gitignored_dir": {
6923                        "file_a.py": "# File contents",
6924                        "file_b.py": "# File contents",
6925                        "file_c.py": "# File contents",
6926                    },
6927                },
6928                "dir_2": {
6929                    "file_1.py": "# File 2_1 contents",
6930                    "file_2.py": "# File 2_2 contents",
6931                    "file_3.py": "# File 2_3 contents",
6932                }
6933            }),
6934        )
6935        .await;
6936
6937        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6938        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6939        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6940        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6941
6942        assert_eq!(
6943            visible_entries_as_strings(&panel, 0..20, cx),
6944            &[
6945                "v project_root",
6946                "    > .git",
6947                "    > dir_1",
6948                "    > dir_2",
6949                "      .gitignore",
6950            ]
6951        );
6952
6953        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6954            .expect("dir 1 file is not ignored and should have an entry");
6955        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6956            .expect("dir 2 file is not ignored and should have an entry");
6957        let gitignored_dir_file =
6958            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6959        assert_eq!(
6960            gitignored_dir_file, None,
6961            "File in the gitignored dir should not have an entry before its dir is toggled"
6962        );
6963
6964        toggle_expand_dir(&panel, "project_root/dir_1", cx);
6965        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6966        cx.run_until_parked();
6967        assert_eq!(
6968            visible_entries_as_strings(&panel, 0..20, cx),
6969            &[
6970                "v project_root",
6971                "    > .git",
6972                "    v dir_1",
6973                "        v gitignored_dir  <== selected",
6974                "              file_a.py",
6975                "              file_b.py",
6976                "              file_c.py",
6977                "          file_1.py",
6978                "          file_2.py",
6979                "          file_3.py",
6980                "    > dir_2",
6981                "      .gitignore",
6982            ],
6983            "Should show gitignored dir file list in the project panel"
6984        );
6985        let gitignored_dir_file =
6986            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6987                .expect("after gitignored dir got opened, a file entry should be present");
6988
6989        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6990        toggle_expand_dir(&panel, "project_root/dir_1", cx);
6991        assert_eq!(
6992            visible_entries_as_strings(&panel, 0..20, cx),
6993            &[
6994                "v project_root",
6995                "    > .git",
6996                "    > dir_1  <== selected",
6997                "    > dir_2",
6998                "      .gitignore",
6999            ],
7000            "Should hide all dir contents again and prepare for the explicit reveal test"
7001        );
7002
7003        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7004            panel.update(cx, |panel, cx| {
7005                panel.project.update(cx, |_, cx| {
7006                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7007                })
7008            });
7009            cx.run_until_parked();
7010            assert_eq!(
7011                visible_entries_as_strings(&panel, 0..20, cx),
7012                &[
7013                    "v project_root",
7014                    "    > .git",
7015                    "    > dir_1  <== selected",
7016                    "    > dir_2",
7017                    "      .gitignore",
7018                ],
7019                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7020            );
7021        }
7022
7023        panel.update(cx, |panel, cx| {
7024            panel.project.update(cx, |_, cx| {
7025                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
7026            })
7027        });
7028        cx.run_until_parked();
7029        assert_eq!(
7030            visible_entries_as_strings(&panel, 0..20, cx),
7031            &[
7032                "v project_root",
7033                "    > .git",
7034                "    v dir_1",
7035                "        > gitignored_dir",
7036                "          file_1.py  <== selected",
7037                "          file_2.py",
7038                "          file_3.py",
7039                "    > dir_2",
7040                "      .gitignore",
7041            ],
7042            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
7043        );
7044
7045        panel.update(cx, |panel, cx| {
7046            panel.project.update(cx, |_, cx| {
7047                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
7048            })
7049        });
7050        cx.run_until_parked();
7051        assert_eq!(
7052            visible_entries_as_strings(&panel, 0..20, cx),
7053            &[
7054                "v project_root",
7055                "    > .git",
7056                "    v dir_1",
7057                "        > gitignored_dir",
7058                "          file_1.py",
7059                "          file_2.py",
7060                "          file_3.py",
7061                "    v dir_2",
7062                "          file_1.py  <== selected",
7063                "          file_2.py",
7064                "          file_3.py",
7065                "      .gitignore",
7066            ],
7067            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
7068        );
7069
7070        panel.update(cx, |panel, cx| {
7071            panel.project.update(cx, |_, cx| {
7072                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7073            })
7074        });
7075        cx.run_until_parked();
7076        assert_eq!(
7077            visible_entries_as_strings(&panel, 0..20, cx),
7078            &[
7079                "v project_root",
7080                "    > .git",
7081                "    v dir_1",
7082                "        v gitignored_dir",
7083                "              file_a.py  <== selected",
7084                "              file_b.py",
7085                "              file_c.py",
7086                "          file_1.py",
7087                "          file_2.py",
7088                "          file_3.py",
7089                "    v dir_2",
7090                "          file_1.py",
7091                "          file_2.py",
7092                "          file_3.py",
7093                "      .gitignore",
7094            ],
7095            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
7096        );
7097    }
7098
7099    #[gpui::test]
7100    async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
7101        init_test(cx);
7102        cx.update(|cx| {
7103            cx.update_global::<SettingsStore, _>(|store, cx| {
7104                store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
7105                    project_settings.file_scan_exclusions =
7106                        Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
7107                });
7108            });
7109        });
7110
7111        cx.update(|cx| {
7112            register_project_item::<TestProjectItemView>(cx);
7113        });
7114
7115        let fs = FakeFs::new(cx.executor().clone());
7116        fs.insert_tree(
7117            "/root1",
7118            json!({
7119                ".dockerignore": "",
7120                ".git": {
7121                    "HEAD": "",
7122                },
7123            }),
7124        )
7125        .await;
7126
7127        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7128        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7129        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7130        let panel = workspace
7131            .update(cx, |workspace, cx| {
7132                let panel = ProjectPanel::new(workspace, cx);
7133                workspace.add_panel(panel.clone(), cx);
7134                panel
7135            })
7136            .unwrap();
7137
7138        select_path(&panel, "root1", cx);
7139        assert_eq!(
7140            visible_entries_as_strings(&panel, 0..10, cx),
7141            &["v root1  <== selected", "      .dockerignore",]
7142        );
7143        workspace
7144            .update(cx, |workspace, cx| {
7145                assert!(
7146                    workspace.active_item(cx).is_none(),
7147                    "Should have no active items in the beginning"
7148                );
7149            })
7150            .unwrap();
7151
7152        let excluded_file_path = ".git/COMMIT_EDITMSG";
7153        let excluded_dir_path = "excluded_dir";
7154
7155        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
7156        panel.update(cx, |panel, cx| {
7157            assert!(panel.filename_editor.read(cx).is_focused(cx));
7158        });
7159        panel
7160            .update(cx, |panel, cx| {
7161                panel
7162                    .filename_editor
7163                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
7164                panel.confirm_edit(cx).unwrap()
7165            })
7166            .await
7167            .unwrap();
7168
7169        assert_eq!(
7170            visible_entries_as_strings(&panel, 0..13, cx),
7171            &["v root1", "      .dockerignore"],
7172            "Excluded dir should not be shown after opening a file in it"
7173        );
7174        panel.update(cx, |panel, cx| {
7175            assert!(
7176                !panel.filename_editor.read(cx).is_focused(cx),
7177                "Should have closed the file name editor"
7178            );
7179        });
7180        workspace
7181            .update(cx, |workspace, cx| {
7182                let active_entry_path = workspace
7183                    .active_item(cx)
7184                    .expect("should have opened and activated the excluded item")
7185                    .act_as::<TestProjectItemView>(cx)
7186                    .expect(
7187                        "should have opened the corresponding project item for the excluded item",
7188                    )
7189                    .read(cx)
7190                    .path
7191                    .clone();
7192                assert_eq!(
7193                    active_entry_path.path.as_ref(),
7194                    Path::new(excluded_file_path),
7195                    "Should open the excluded file"
7196                );
7197
7198                assert!(
7199                    workspace.notification_ids().is_empty(),
7200                    "Should have no notifications after opening an excluded file"
7201                );
7202            })
7203            .unwrap();
7204        assert!(
7205            fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
7206            "Should have created the excluded file"
7207        );
7208
7209        select_path(&panel, "root1", cx);
7210        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7211        panel.update(cx, |panel, cx| {
7212            assert!(panel.filename_editor.read(cx).is_focused(cx));
7213        });
7214        panel
7215            .update(cx, |panel, cx| {
7216                panel
7217                    .filename_editor
7218                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
7219                panel.confirm_edit(cx).unwrap()
7220            })
7221            .await
7222            .unwrap();
7223
7224        assert_eq!(
7225            visible_entries_as_strings(&panel, 0..13, cx),
7226            &["v root1", "      .dockerignore"],
7227            "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
7228        );
7229        panel.update(cx, |panel, cx| {
7230            assert!(
7231                !panel.filename_editor.read(cx).is_focused(cx),
7232                "Should have closed the file name editor"
7233            );
7234        });
7235        workspace
7236            .update(cx, |workspace, cx| {
7237                let notifications = workspace.notification_ids();
7238                assert_eq!(
7239                    notifications.len(),
7240                    1,
7241                    "Should receive one notification with the error message"
7242                );
7243                workspace.dismiss_notification(notifications.first().unwrap(), cx);
7244                assert!(workspace.notification_ids().is_empty());
7245            })
7246            .unwrap();
7247
7248        select_path(&panel, "root1", cx);
7249        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7250        panel.update(cx, |panel, cx| {
7251            assert!(panel.filename_editor.read(cx).is_focused(cx));
7252        });
7253        panel
7254            .update(cx, |panel, cx| {
7255                panel
7256                    .filename_editor
7257                    .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
7258                panel.confirm_edit(cx).unwrap()
7259            })
7260            .await
7261            .unwrap();
7262
7263        assert_eq!(
7264            visible_entries_as_strings(&panel, 0..13, cx),
7265            &["v root1", "      .dockerignore"],
7266            "Should not change the project panel after trying to create an excluded directory"
7267        );
7268        panel.update(cx, |panel, cx| {
7269            assert!(
7270                !panel.filename_editor.read(cx).is_focused(cx),
7271                "Should have closed the file name editor"
7272            );
7273        });
7274        workspace
7275            .update(cx, |workspace, cx| {
7276                let notifications = workspace.notification_ids();
7277                assert_eq!(
7278                    notifications.len(),
7279                    1,
7280                    "Should receive one notification explaining that no directory is actually shown"
7281                );
7282                workspace.dismiss_notification(notifications.first().unwrap(), cx);
7283                assert!(workspace.notification_ids().is_empty());
7284            })
7285            .unwrap();
7286        assert!(
7287            fs.is_dir(Path::new("/root1/excluded_dir")).await,
7288            "Should have created the excluded directory"
7289        );
7290    }
7291
7292    #[gpui::test]
7293    async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
7294        init_test_with_editor(cx);
7295
7296        let fs = FakeFs::new(cx.executor().clone());
7297        fs.insert_tree(
7298            "/src",
7299            json!({
7300                "test": {
7301                    "first.rs": "// First Rust file",
7302                    "second.rs": "// Second Rust file",
7303                    "third.rs": "// Third Rust file",
7304                }
7305            }),
7306        )
7307        .await;
7308
7309        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
7310        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7311        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7312        let panel = workspace
7313            .update(cx, |workspace, cx| {
7314                let panel = ProjectPanel::new(workspace, cx);
7315                workspace.add_panel(panel.clone(), cx);
7316                panel
7317            })
7318            .unwrap();
7319
7320        select_path(&panel, "src/", cx);
7321        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
7322        cx.executor().run_until_parked();
7323        assert_eq!(
7324            visible_entries_as_strings(&panel, 0..10, cx),
7325            &[
7326                //
7327                "v src  <== selected",
7328                "    > test"
7329            ]
7330        );
7331        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7332        panel.update(cx, |panel, cx| {
7333            assert!(panel.filename_editor.read(cx).is_focused(cx));
7334        });
7335        assert_eq!(
7336            visible_entries_as_strings(&panel, 0..10, cx),
7337            &[
7338                //
7339                "v src",
7340                "    > [EDITOR: '']  <== selected",
7341                "    > test"
7342            ]
7343        );
7344
7345        panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel, cx));
7346        assert_eq!(
7347            visible_entries_as_strings(&panel, 0..10, cx),
7348            &[
7349                //
7350                "v src  <== selected",
7351                "    > test"
7352            ]
7353        );
7354    }
7355
7356    #[gpui::test]
7357    async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
7358        init_test_with_editor(cx);
7359
7360        let fs = FakeFs::new(cx.executor().clone());
7361        fs.insert_tree(
7362            "/root",
7363            json!({
7364                "dir1": {
7365                    "subdir1": {},
7366                    "file1.txt": "",
7367                    "file2.txt": "",
7368                },
7369                "dir2": {
7370                    "subdir2": {},
7371                    "file3.txt": "",
7372                    "file4.txt": "",
7373                },
7374                "file5.txt": "",
7375                "file6.txt": "",
7376            }),
7377        )
7378        .await;
7379
7380        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7381        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7382        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7383        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7384
7385        toggle_expand_dir(&panel, "root/dir1", cx);
7386        toggle_expand_dir(&panel, "root/dir2", cx);
7387
7388        // Test Case 1: Delete middle file in directory
7389        select_path(&panel, "root/dir1/file1.txt", cx);
7390        assert_eq!(
7391            visible_entries_as_strings(&panel, 0..15, cx),
7392            &[
7393                "v root",
7394                "    v dir1",
7395                "        > subdir1",
7396                "          file1.txt  <== selected",
7397                "          file2.txt",
7398                "    v dir2",
7399                "        > subdir2",
7400                "          file3.txt",
7401                "          file4.txt",
7402                "      file5.txt",
7403                "      file6.txt",
7404            ],
7405            "Initial state before deleting middle file"
7406        );
7407
7408        submit_deletion(&panel, cx);
7409        assert_eq!(
7410            visible_entries_as_strings(&panel, 0..15, cx),
7411            &[
7412                "v root",
7413                "    v dir1",
7414                "        > subdir1",
7415                "          file2.txt  <== selected",
7416                "    v dir2",
7417                "        > subdir2",
7418                "          file3.txt",
7419                "          file4.txt",
7420                "      file5.txt",
7421                "      file6.txt",
7422            ],
7423            "Should select next file after deleting middle file"
7424        );
7425
7426        // Test Case 2: Delete last file in directory
7427        submit_deletion(&panel, cx);
7428        assert_eq!(
7429            visible_entries_as_strings(&panel, 0..15, cx),
7430            &[
7431                "v root",
7432                "    v dir1",
7433                "        > subdir1  <== selected",
7434                "    v dir2",
7435                "        > subdir2",
7436                "          file3.txt",
7437                "          file4.txt",
7438                "      file5.txt",
7439                "      file6.txt",
7440            ],
7441            "Should select next directory when last file is deleted"
7442        );
7443
7444        // Test Case 3: Delete root level file
7445        select_path(&panel, "root/file6.txt", cx);
7446        assert_eq!(
7447            visible_entries_as_strings(&panel, 0..15, cx),
7448            &[
7449                "v root",
7450                "    v dir1",
7451                "        > subdir1",
7452                "    v dir2",
7453                "        > subdir2",
7454                "          file3.txt",
7455                "          file4.txt",
7456                "      file5.txt",
7457                "      file6.txt  <== selected",
7458            ],
7459            "Initial state before deleting root level file"
7460        );
7461
7462        submit_deletion(&panel, cx);
7463        assert_eq!(
7464            visible_entries_as_strings(&panel, 0..15, cx),
7465            &[
7466                "v root",
7467                "    v dir1",
7468                "        > subdir1",
7469                "    v dir2",
7470                "        > subdir2",
7471                "          file3.txt",
7472                "          file4.txt",
7473                "      file5.txt  <== selected",
7474            ],
7475            "Should select prev entry at root level"
7476        );
7477    }
7478
7479    #[gpui::test]
7480    async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
7481        init_test_with_editor(cx);
7482
7483        let fs = FakeFs::new(cx.executor().clone());
7484        fs.insert_tree(
7485            "/root",
7486            json!({
7487                "dir1": {
7488                    "subdir1": {
7489                        "a.txt": "",
7490                        "b.txt": ""
7491                    },
7492                    "file1.txt": "",
7493                },
7494                "dir2": {
7495                    "subdir2": {
7496                        "c.txt": "",
7497                        "d.txt": ""
7498                    },
7499                    "file2.txt": "",
7500                },
7501                "file3.txt": "",
7502            }),
7503        )
7504        .await;
7505
7506        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7507        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7508        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7509        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7510
7511        toggle_expand_dir(&panel, "root/dir1", cx);
7512        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7513        toggle_expand_dir(&panel, "root/dir2", cx);
7514        toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7515
7516        // Test Case 1: Select and delete nested directory with parent
7517        cx.simulate_modifiers_change(gpui::Modifiers {
7518            control: true,
7519            ..Default::default()
7520        });
7521        select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7522        select_path_with_mark(&panel, "root/dir1", cx);
7523
7524        assert_eq!(
7525            visible_entries_as_strings(&panel, 0..15, cx),
7526            &[
7527                "v root",
7528                "    v dir1  <== selected  <== marked",
7529                "        v subdir1  <== marked",
7530                "              a.txt",
7531                "              b.txt",
7532                "          file1.txt",
7533                "    v dir2",
7534                "        v subdir2",
7535                "              c.txt",
7536                "              d.txt",
7537                "          file2.txt",
7538                "      file3.txt",
7539            ],
7540            "Initial state before deleting nested directory with parent"
7541        );
7542
7543        submit_deletion(&panel, cx);
7544        assert_eq!(
7545            visible_entries_as_strings(&panel, 0..15, cx),
7546            &[
7547                "v root",
7548                "    v dir2  <== selected",
7549                "        v subdir2",
7550                "              c.txt",
7551                "              d.txt",
7552                "          file2.txt",
7553                "      file3.txt",
7554            ],
7555            "Should select next directory after deleting directory with parent"
7556        );
7557
7558        // Test Case 2: Select mixed files and directories across levels
7559        select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
7560        select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
7561        select_path_with_mark(&panel, "root/file3.txt", cx);
7562
7563        assert_eq!(
7564            visible_entries_as_strings(&panel, 0..15, cx),
7565            &[
7566                "v root",
7567                "    v dir2",
7568                "        v subdir2",
7569                "              c.txt  <== marked",
7570                "              d.txt",
7571                "          file2.txt  <== marked",
7572                "      file3.txt  <== selected  <== marked",
7573            ],
7574            "Initial state before deleting"
7575        );
7576
7577        submit_deletion(&panel, cx);
7578        assert_eq!(
7579            visible_entries_as_strings(&panel, 0..15, cx),
7580            &[
7581                "v root",
7582                "    v dir2  <== selected",
7583                "        v subdir2",
7584                "              d.txt",
7585            ],
7586            "Should select sibling directory"
7587        );
7588    }
7589
7590    #[gpui::test]
7591    async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
7592        init_test_with_editor(cx);
7593
7594        let fs = FakeFs::new(cx.executor().clone());
7595        fs.insert_tree(
7596            "/root",
7597            json!({
7598                "dir1": {
7599                    "subdir1": {
7600                        "a.txt": "",
7601                        "b.txt": ""
7602                    },
7603                    "file1.txt": "",
7604                },
7605                "dir2": {
7606                    "subdir2": {
7607                        "c.txt": "",
7608                        "d.txt": ""
7609                    },
7610                    "file2.txt": "",
7611                },
7612                "file3.txt": "",
7613                "file4.txt": "",
7614            }),
7615        )
7616        .await;
7617
7618        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7619        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7620        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7621        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7622
7623        toggle_expand_dir(&panel, "root/dir1", cx);
7624        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7625        toggle_expand_dir(&panel, "root/dir2", cx);
7626        toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7627
7628        // Test Case 1: Select all root files and directories
7629        cx.simulate_modifiers_change(gpui::Modifiers {
7630            control: true,
7631            ..Default::default()
7632        });
7633        select_path_with_mark(&panel, "root/dir1", cx);
7634        select_path_with_mark(&panel, "root/dir2", cx);
7635        select_path_with_mark(&panel, "root/file3.txt", cx);
7636        select_path_with_mark(&panel, "root/file4.txt", cx);
7637        assert_eq!(
7638            visible_entries_as_strings(&panel, 0..20, cx),
7639            &[
7640                "v root",
7641                "    v dir1  <== marked",
7642                "        v subdir1",
7643                "              a.txt",
7644                "              b.txt",
7645                "          file1.txt",
7646                "    v dir2  <== marked",
7647                "        v subdir2",
7648                "              c.txt",
7649                "              d.txt",
7650                "          file2.txt",
7651                "      file3.txt  <== marked",
7652                "      file4.txt  <== selected  <== marked",
7653            ],
7654            "State before deleting all contents"
7655        );
7656
7657        submit_deletion(&panel, cx);
7658        assert_eq!(
7659            visible_entries_as_strings(&panel, 0..20, cx),
7660            &["v root  <== selected"],
7661            "Only empty root directory should remain after deleting all contents"
7662        );
7663    }
7664
7665    #[gpui::test]
7666    async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
7667        init_test_with_editor(cx);
7668
7669        let fs = FakeFs::new(cx.executor().clone());
7670        fs.insert_tree(
7671            "/root",
7672            json!({
7673                "dir1": {
7674                    "subdir1": {
7675                        "file_a.txt": "content a",
7676                        "file_b.txt": "content b",
7677                    },
7678                    "subdir2": {
7679                        "file_c.txt": "content c",
7680                    },
7681                    "file1.txt": "content 1",
7682                },
7683                "dir2": {
7684                    "file2.txt": "content 2",
7685                },
7686            }),
7687        )
7688        .await;
7689
7690        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7691        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7692        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7693        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7694
7695        toggle_expand_dir(&panel, "root/dir1", cx);
7696        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7697        toggle_expand_dir(&panel, "root/dir2", cx);
7698        cx.simulate_modifiers_change(gpui::Modifiers {
7699            control: true,
7700            ..Default::default()
7701        });
7702
7703        // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
7704        select_path_with_mark(&panel, "root/dir1", cx);
7705        select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7706        select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
7707
7708        assert_eq!(
7709            visible_entries_as_strings(&panel, 0..20, cx),
7710            &[
7711                "v root",
7712                "    v dir1  <== marked",
7713                "        v subdir1  <== marked",
7714                "              file_a.txt  <== selected  <== marked",
7715                "              file_b.txt",
7716                "        > subdir2",
7717                "          file1.txt",
7718                "    v dir2",
7719                "          file2.txt",
7720            ],
7721            "State with parent dir, subdir, and file selected"
7722        );
7723        submit_deletion(&panel, cx);
7724        assert_eq!(
7725            visible_entries_as_strings(&panel, 0..20, cx),
7726            &["v root", "    v dir2  <== selected", "          file2.txt",],
7727            "Only dir2 should remain after deletion"
7728        );
7729    }
7730
7731    #[gpui::test]
7732    async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
7733        init_test_with_editor(cx);
7734
7735        let fs = FakeFs::new(cx.executor().clone());
7736        // First worktree
7737        fs.insert_tree(
7738            "/root1",
7739            json!({
7740                "dir1": {
7741                    "file1.txt": "content 1",
7742                    "file2.txt": "content 2",
7743                },
7744                "dir2": {
7745                    "file3.txt": "content 3",
7746                },
7747            }),
7748        )
7749        .await;
7750
7751        // Second worktree
7752        fs.insert_tree(
7753            "/root2",
7754            json!({
7755                "dir3": {
7756                    "file4.txt": "content 4",
7757                    "file5.txt": "content 5",
7758                },
7759                "file6.txt": "content 6",
7760            }),
7761        )
7762        .await;
7763
7764        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7765        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7766        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7767        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7768
7769        // Expand all directories for testing
7770        toggle_expand_dir(&panel, "root1/dir1", cx);
7771        toggle_expand_dir(&panel, "root1/dir2", cx);
7772        toggle_expand_dir(&panel, "root2/dir3", cx);
7773
7774        // Test Case 1: Delete files across different worktrees
7775        cx.simulate_modifiers_change(gpui::Modifiers {
7776            control: true,
7777            ..Default::default()
7778        });
7779        select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
7780        select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
7781
7782        assert_eq!(
7783            visible_entries_as_strings(&panel, 0..20, cx),
7784            &[
7785                "v root1",
7786                "    v dir1",
7787                "          file1.txt  <== marked",
7788                "          file2.txt",
7789                "    v dir2",
7790                "          file3.txt",
7791                "v root2",
7792                "    v dir3",
7793                "          file4.txt  <== selected  <== marked",
7794                "          file5.txt",
7795                "      file6.txt",
7796            ],
7797            "Initial state with files selected from different worktrees"
7798        );
7799
7800        submit_deletion(&panel, cx);
7801        assert_eq!(
7802            visible_entries_as_strings(&panel, 0..20, cx),
7803            &[
7804                "v root1",
7805                "    v dir1",
7806                "          file2.txt",
7807                "    v dir2",
7808                "          file3.txt",
7809                "v root2",
7810                "    v dir3",
7811                "          file5.txt  <== selected",
7812                "      file6.txt",
7813            ],
7814            "Should select next file in the last worktree after deletion"
7815        );
7816
7817        // Test Case 2: Delete directories from different worktrees
7818        select_path_with_mark(&panel, "root1/dir1", cx);
7819        select_path_with_mark(&panel, "root2/dir3", cx);
7820
7821        assert_eq!(
7822            visible_entries_as_strings(&panel, 0..20, cx),
7823            &[
7824                "v root1",
7825                "    v dir1  <== marked",
7826                "          file2.txt",
7827                "    v dir2",
7828                "          file3.txt",
7829                "v root2",
7830                "    v dir3  <== selected  <== marked",
7831                "          file5.txt",
7832                "      file6.txt",
7833            ],
7834            "State with directories marked from different worktrees"
7835        );
7836
7837        submit_deletion(&panel, cx);
7838        assert_eq!(
7839            visible_entries_as_strings(&panel, 0..20, cx),
7840            &[
7841                "v root1",
7842                "    v dir2",
7843                "          file3.txt",
7844                "v root2",
7845                "      file6.txt  <== selected",
7846            ],
7847            "Should select remaining file in last worktree after directory deletion"
7848        );
7849
7850        // Test Case 4: Delete all remaining files except roots
7851        select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
7852        select_path_with_mark(&panel, "root2/file6.txt", cx);
7853
7854        assert_eq!(
7855            visible_entries_as_strings(&panel, 0..20, cx),
7856            &[
7857                "v root1",
7858                "    v dir2",
7859                "          file3.txt  <== marked",
7860                "v root2",
7861                "      file6.txt  <== selected  <== marked",
7862            ],
7863            "State with all remaining files marked"
7864        );
7865
7866        submit_deletion(&panel, cx);
7867        assert_eq!(
7868            visible_entries_as_strings(&panel, 0..20, cx),
7869            &["v root1", "    v dir2", "v root2  <== selected"],
7870            "Second parent root should be selected after deleting"
7871        );
7872    }
7873
7874    #[gpui::test]
7875    async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
7876        init_test_with_editor(cx);
7877
7878        let fs = FakeFs::new(cx.executor().clone());
7879        fs.insert_tree(
7880            "/root",
7881            json!({
7882                "dir1": {
7883                    "file1.txt": "",
7884                    "file2.txt": "",
7885                    "file3.txt": "",
7886                },
7887                "dir2": {
7888                    "file4.txt": "",
7889                    "file5.txt": "",
7890                },
7891            }),
7892        )
7893        .await;
7894
7895        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7896        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7897        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7898        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7899
7900        toggle_expand_dir(&panel, "root/dir1", cx);
7901        toggle_expand_dir(&panel, "root/dir2", cx);
7902
7903        cx.simulate_modifiers_change(gpui::Modifiers {
7904            control: true,
7905            ..Default::default()
7906        });
7907
7908        select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
7909        select_path(&panel, "root/dir1/file1.txt", cx);
7910
7911        assert_eq!(
7912            visible_entries_as_strings(&panel, 0..15, cx),
7913            &[
7914                "v root",
7915                "    v dir1",
7916                "          file1.txt  <== selected",
7917                "          file2.txt  <== marked",
7918                "          file3.txt",
7919                "    v dir2",
7920                "          file4.txt",
7921                "          file5.txt",
7922            ],
7923            "Initial state with one marked entry and different selection"
7924        );
7925
7926        // Delete should operate on the selected entry (file1.txt)
7927        submit_deletion(&panel, cx);
7928        assert_eq!(
7929            visible_entries_as_strings(&panel, 0..15, cx),
7930            &[
7931                "v root",
7932                "    v dir1",
7933                "          file2.txt  <== selected  <== marked",
7934                "          file3.txt",
7935                "    v dir2",
7936                "          file4.txt",
7937                "          file5.txt",
7938            ],
7939            "Should delete selected file, not marked file"
7940        );
7941
7942        select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
7943        select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
7944        select_path(&panel, "root/dir2/file5.txt", cx);
7945
7946        assert_eq!(
7947            visible_entries_as_strings(&panel, 0..15, cx),
7948            &[
7949                "v root",
7950                "    v dir1",
7951                "          file2.txt  <== marked",
7952                "          file3.txt  <== marked",
7953                "    v dir2",
7954                "          file4.txt  <== marked",
7955                "          file5.txt  <== selected",
7956            ],
7957            "Initial state with multiple marked entries and different selection"
7958        );
7959
7960        // Delete should operate on all marked entries, ignoring the selection
7961        submit_deletion(&panel, cx);
7962        assert_eq!(
7963            visible_entries_as_strings(&panel, 0..15, cx),
7964            &[
7965                "v root",
7966                "    v dir1",
7967                "    v dir2",
7968                "          file5.txt  <== selected",
7969            ],
7970            "Should delete all marked files, leaving only the selected file"
7971        );
7972    }
7973
7974    #[gpui::test]
7975    async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
7976        init_test_with_editor(cx);
7977
7978        let fs = FakeFs::new(cx.executor().clone());
7979        fs.insert_tree(
7980            "/root_b",
7981            json!({
7982                "dir1": {
7983                    "file1.txt": "content 1",
7984                    "file2.txt": "content 2",
7985                },
7986            }),
7987        )
7988        .await;
7989
7990        fs.insert_tree(
7991            "/root_c",
7992            json!({
7993                "dir2": {},
7994            }),
7995        )
7996        .await;
7997
7998        let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
7999        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
8000        let cx = &mut VisualTestContext::from_window(*workspace, cx);
8001        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8002
8003        toggle_expand_dir(&panel, "root_b/dir1", cx);
8004        toggle_expand_dir(&panel, "root_c/dir2", cx);
8005
8006        cx.simulate_modifiers_change(gpui::Modifiers {
8007            control: true,
8008            ..Default::default()
8009        });
8010        select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
8011        select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
8012
8013        assert_eq!(
8014            visible_entries_as_strings(&panel, 0..20, cx),
8015            &[
8016                "v root_b",
8017                "    v dir1",
8018                "          file1.txt  <== marked",
8019                "          file2.txt  <== selected  <== marked",
8020                "v root_c",
8021                "    v dir2",
8022            ],
8023            "Initial state with files marked in root_b"
8024        );
8025
8026        submit_deletion(&panel, cx);
8027        assert_eq!(
8028            visible_entries_as_strings(&panel, 0..20, cx),
8029            &[
8030                "v root_b",
8031                "    v dir1  <== selected",
8032                "v root_c",
8033                "    v dir2",
8034            ],
8035            "After deletion in root_b as it's last deletion, selection should be in root_b"
8036        );
8037
8038        select_path_with_mark(&panel, "root_c/dir2", cx);
8039
8040        submit_deletion(&panel, cx);
8041        assert_eq!(
8042            visible_entries_as_strings(&panel, 0..20, cx),
8043            &["v root_b", "    v dir1", "v root_c  <== selected",],
8044            "After deleting from root_c, it should remain in root_c"
8045        );
8046    }
8047
8048    fn toggle_expand_dir(
8049        panel: &View<ProjectPanel>,
8050        path: impl AsRef<Path>,
8051        cx: &mut VisualTestContext,
8052    ) {
8053        let path = path.as_ref();
8054        panel.update(cx, |panel, cx| {
8055            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8056                let worktree = worktree.read(cx);
8057                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8058                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8059                    panel.toggle_expanded(entry_id, cx);
8060                    return;
8061                }
8062            }
8063            panic!("no worktree for path {:?}", path);
8064        });
8065    }
8066
8067    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
8068        let path = path.as_ref();
8069        panel.update(cx, |panel, cx| {
8070            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8071                let worktree = worktree.read(cx);
8072                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8073                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8074                    panel.selection = Some(crate::SelectedEntry {
8075                        worktree_id: worktree.id(),
8076                        entry_id,
8077                    });
8078                    return;
8079                }
8080            }
8081            panic!("no worktree for path {:?}", path);
8082        });
8083    }
8084
8085    fn select_path_with_mark(
8086        panel: &View<ProjectPanel>,
8087        path: impl AsRef<Path>,
8088        cx: &mut VisualTestContext,
8089    ) {
8090        let path = path.as_ref();
8091        panel.update(cx, |panel, cx| {
8092            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8093                let worktree = worktree.read(cx);
8094                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8095                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8096                    let entry = crate::SelectedEntry {
8097                        worktree_id: worktree.id(),
8098                        entry_id,
8099                    };
8100                    if !panel.marked_entries.contains(&entry) {
8101                        panel.marked_entries.insert(entry);
8102                    }
8103                    panel.selection = Some(entry);
8104                    return;
8105                }
8106            }
8107            panic!("no worktree for path {:?}", path);
8108        });
8109    }
8110
8111    fn find_project_entry(
8112        panel: &View<ProjectPanel>,
8113        path: impl AsRef<Path>,
8114        cx: &mut VisualTestContext,
8115    ) -> Option<ProjectEntryId> {
8116        let path = path.as_ref();
8117        panel.update(cx, |panel, cx| {
8118            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8119                let worktree = worktree.read(cx);
8120                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8121                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
8122                }
8123            }
8124            panic!("no worktree for path {path:?}");
8125        })
8126    }
8127
8128    fn visible_entries_as_strings(
8129        panel: &View<ProjectPanel>,
8130        range: Range<usize>,
8131        cx: &mut VisualTestContext,
8132    ) -> Vec<String> {
8133        let mut result = Vec::new();
8134        let mut project_entries = HashSet::default();
8135        let mut has_editor = false;
8136
8137        panel.update(cx, |panel, cx| {
8138            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
8139                if details.is_editing {
8140                    assert!(!has_editor, "duplicate editor entry");
8141                    has_editor = true;
8142                } else {
8143                    assert!(
8144                        project_entries.insert(project_entry),
8145                        "duplicate project entry {:?} {:?}",
8146                        project_entry,
8147                        details
8148                    );
8149                }
8150
8151                let indent = "    ".repeat(details.depth);
8152                let icon = if details.kind.is_dir() {
8153                    if details.is_expanded {
8154                        "v "
8155                    } else {
8156                        "> "
8157                    }
8158                } else {
8159                    "  "
8160                };
8161                let name = if details.is_editing {
8162                    format!("[EDITOR: '{}']", details.filename)
8163                } else if details.is_processing {
8164                    format!("[PROCESSING: '{}']", details.filename)
8165                } else {
8166                    details.filename.clone()
8167                };
8168                let selected = if details.is_selected {
8169                    "  <== selected"
8170                } else {
8171                    ""
8172                };
8173                let marked = if details.is_marked {
8174                    "  <== marked"
8175                } else {
8176                    ""
8177                };
8178
8179                result.push(format!("{indent}{icon}{name}{selected}{marked}"));
8180            });
8181        });
8182
8183        result
8184    }
8185
8186    fn init_test(cx: &mut TestAppContext) {
8187        cx.update(|cx| {
8188            let settings_store = SettingsStore::test(cx);
8189            cx.set_global(settings_store);
8190            init_settings(cx);
8191            theme::init(theme::LoadThemes::JustBase, cx);
8192            language::init(cx);
8193            editor::init_settings(cx);
8194            crate::init((), cx);
8195            workspace::init_settings(cx);
8196            client::init_settings(cx);
8197            Project::init_settings(cx);
8198
8199            cx.update_global::<SettingsStore, _>(|store, cx| {
8200                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
8201                    project_panel_settings.auto_fold_dirs = Some(false);
8202                });
8203                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
8204                    worktree_settings.file_scan_exclusions = Some(Vec::new());
8205                });
8206            });
8207        });
8208    }
8209
8210    fn init_test_with_editor(cx: &mut TestAppContext) {
8211        cx.update(|cx| {
8212            let app_state = AppState::test(cx);
8213            theme::init(theme::LoadThemes::JustBase, cx);
8214            init_settings(cx);
8215            language::init(cx);
8216            editor::init(cx);
8217            crate::init((), cx);
8218            workspace::init(app_state.clone(), cx);
8219            Project::init_settings(cx);
8220
8221            cx.update_global::<SettingsStore, _>(|store, cx| {
8222                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
8223                    project_panel_settings.auto_fold_dirs = Some(false);
8224                });
8225                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
8226                    worktree_settings.file_scan_exclusions = Some(Vec::new());
8227                });
8228            });
8229        });
8230    }
8231
8232    fn ensure_single_file_is_opened(
8233        window: &WindowHandle<Workspace>,
8234        expected_path: &str,
8235        cx: &mut TestAppContext,
8236    ) {
8237        window
8238            .update(cx, |workspace, cx| {
8239                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
8240                assert_eq!(worktrees.len(), 1);
8241                let worktree_id = worktrees[0].read(cx).id();
8242
8243                let open_project_paths = workspace
8244                    .panes()
8245                    .iter()
8246                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8247                    .collect::<Vec<_>>();
8248                assert_eq!(
8249                    open_project_paths,
8250                    vec![ProjectPath {
8251                        worktree_id,
8252                        path: Arc::from(Path::new(expected_path))
8253                    }],
8254                    "Should have opened file, selected in project panel"
8255                );
8256            })
8257            .unwrap();
8258    }
8259
8260    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
8261        assert!(
8262            !cx.has_pending_prompt(),
8263            "Should have no prompts before the deletion"
8264        );
8265        panel.update(cx, |panel, cx| {
8266            panel.delete(&Delete { skip_prompt: false }, cx)
8267        });
8268        assert!(
8269            cx.has_pending_prompt(),
8270            "Should have a prompt after the deletion"
8271        );
8272        cx.simulate_prompt_answer(0);
8273        assert!(
8274            !cx.has_pending_prompt(),
8275            "Should have no prompts after prompt was replied to"
8276        );
8277        cx.executor().run_until_parked();
8278    }
8279
8280    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
8281        assert!(
8282            !cx.has_pending_prompt(),
8283            "Should have no prompts before the deletion"
8284        );
8285        panel.update(cx, |panel, cx| {
8286            panel.delete(&Delete { skip_prompt: true }, cx)
8287        });
8288        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
8289        cx.executor().run_until_parked();
8290    }
8291
8292    fn ensure_no_open_items_and_panes(
8293        workspace: &WindowHandle<Workspace>,
8294        cx: &mut VisualTestContext,
8295    ) {
8296        assert!(
8297            !cx.has_pending_prompt(),
8298            "Should have no prompts after deletion operation closes the file"
8299        );
8300        workspace
8301            .read_with(cx, |workspace, cx| {
8302                let open_project_paths = workspace
8303                    .panes()
8304                    .iter()
8305                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8306                    .collect::<Vec<_>>();
8307                assert!(
8308                    open_project_paths.is_empty(),
8309                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
8310                );
8311            })
8312            .unwrap();
8313    }
8314
8315    struct TestProjectItemView {
8316        focus_handle: FocusHandle,
8317        path: ProjectPath,
8318    }
8319
8320    struct TestProjectItem {
8321        path: ProjectPath,
8322    }
8323
8324    impl project::ProjectItem for TestProjectItem {
8325        fn try_open(
8326            _project: &Model<Project>,
8327            path: &ProjectPath,
8328            cx: &mut AppContext,
8329        ) -> Option<Task<gpui::Result<Model<Self>>>> {
8330            let path = path.clone();
8331            Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
8332        }
8333
8334        fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
8335            None
8336        }
8337
8338        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
8339            Some(self.path.clone())
8340        }
8341
8342        fn is_dirty(&self) -> bool {
8343            false
8344        }
8345    }
8346
8347    impl ProjectItem for TestProjectItemView {
8348        type Item = TestProjectItem;
8349
8350        fn for_project_item(
8351            _: Model<Project>,
8352            project_item: Model<Self::Item>,
8353            cx: &mut ViewContext<Self>,
8354        ) -> Self
8355        where
8356            Self: Sized,
8357        {
8358            Self {
8359                path: project_item.update(cx, |project_item, _| project_item.path.clone()),
8360                focus_handle: cx.focus_handle(),
8361            }
8362        }
8363    }
8364
8365    impl Item for TestProjectItemView {
8366        type Event = ();
8367    }
8368
8369    impl EventEmitter<()> for TestProjectItemView {}
8370
8371    impl FocusableView for TestProjectItemView {
8372        fn focus_handle(&self, _: &AppContext) -> FocusHandle {
8373            self.focus_handle.clone()
8374        }
8375    }
8376
8377    impl Render for TestProjectItemView {
8378        fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
8379            Empty
8380        }
8381    }
8382}