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, Scrollbar, ScrollbarState,
  58    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                    .selectable(false)
3451                    .when_some(canonical_path, |this, path| {
3452                        this.end_slot::<AnyElement>(
3453                            div()
3454                                .id("symlink_icon")
3455                                .pr_3()
3456                                .tooltip(move |cx| {
3457                                    Tooltip::with_meta(path.to_string(), None, "Symbolic Link", cx)
3458                                })
3459                                .child(
3460                                    Icon::new(IconName::ArrowUpRight)
3461                                        .size(IconSize::Indicator)
3462                                        .color(filename_text_color),
3463                                )
3464                                .into_any_element(),
3465                        )
3466                    })
3467                    .child(if let Some(icon) = &icon {
3468                        // Check if there's a diagnostic severity and get the decoration color
3469                        if let Some((_, decoration_color)) =
3470                            entry_diagnostic_aware_icon_decoration_and_color(diagnostic_severity)
3471                        {
3472                            // Determine if the diagnostic is a warning
3473                            let is_warning = diagnostic_severity
3474                                .map(|severity| matches!(severity, DiagnosticSeverity::WARNING))
3475                                .unwrap_or(false);
3476                            div().child(
3477                                DecoratedIcon::new(
3478                                    Icon::from_path(icon.clone()).color(Color::Muted),
3479                                    Some(
3480                                        IconDecoration::new(
3481                                            if kind.is_file() {
3482                                                if is_warning {
3483                                                    IconDecorationKind::Triangle
3484                                                } else {
3485                                                    IconDecorationKind::X
3486                                                }
3487                                            } else {
3488                                                IconDecorationKind::Dot
3489                                            },
3490                                            default_color,
3491                                            cx,
3492                                        )
3493                                        .group_name(Some(GROUP_NAME.into()))
3494                                        .knockout_hover_color(bg_hover_color)
3495                                        .color(decoration_color.color(cx))
3496                                        .position(Point {
3497                                            x: px(-2.),
3498                                            y: px(-2.),
3499                                        }),
3500                                    ),
3501                                )
3502                                .into_any_element(),
3503                            )
3504                        } else {
3505                            h_flex().child(Icon::from_path(icon.to_string()).color(Color::Muted))
3506                        }
3507                    } else {
3508                        if let Some((icon_name, color)) =
3509                            entry_diagnostic_aware_icon_name_and_color(diagnostic_severity)
3510                        {
3511                            h_flex()
3512                                .size(IconSize::default().rems())
3513                                .child(Icon::new(icon_name).color(color).size(IconSize::Small))
3514                        } else {
3515                            h_flex()
3516                                .size(IconSize::default().rems())
3517                                .invisible()
3518                                .flex_none()
3519                        }
3520                    })
3521                    .child(
3522                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
3523                            h_flex().h_6().w_full().child(editor.clone())
3524                        } else {
3525                            h_flex().h_6().map(|mut this| {
3526                                if let Some(folded_ancestors) = self.ancestors.get(&entry_id) {
3527                                    let components = Path::new(&file_name)
3528                                        .components()
3529                                        .map(|comp| {
3530                                            let comp_str =
3531                                                comp.as_os_str().to_string_lossy().into_owned();
3532                                            comp_str
3533                                        })
3534                                        .collect::<Vec<_>>();
3535
3536                                    let components_len = components.len();
3537                                    let active_index = components_len
3538                                        - 1
3539                                        - folded_ancestors.current_ancestor_depth;
3540                                    const DELIMITER: SharedString =
3541                                        SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
3542                                    for (index, component) in components.into_iter().enumerate() {
3543                                        if index != 0 {
3544                                            this = this.child(
3545                                                Label::new(DELIMITER.clone())
3546                                                    .single_line()
3547                                                    .color(filename_text_color),
3548                                            );
3549                                        }
3550                                        let id = SharedString::from(format!(
3551                                            "project_panel_path_component_{}_{index}",
3552                                            entry_id.to_usize()
3553                                        ));
3554                                        let label = div()
3555                                            .id(id)
3556                                            .on_click(cx.listener(move |this, _, cx| {
3557                                                if index != active_index {
3558                                                    if let Some(folds) =
3559                                                        this.ancestors.get_mut(&entry_id)
3560                                                    {
3561                                                        folds.current_ancestor_depth =
3562                                                            components_len - 1 - index;
3563                                                        cx.notify();
3564                                                    }
3565                                                }
3566                                            }))
3567                                            .child(
3568                                                Label::new(component)
3569                                                    .single_line()
3570                                                    .color(filename_text_color)
3571                                                    .when(
3572                                                        index == active_index
3573                                                            && (is_active || is_marked),
3574                                                        |this| this.underline(true),
3575                                                    ),
3576                                            );
3577
3578                                        this = this.child(label);
3579                                    }
3580
3581                                    this
3582                                } else {
3583                                    this.child(
3584                                        Label::new(file_name)
3585                                            .single_line()
3586                                            .color(filename_text_color),
3587                                    )
3588                                }
3589                            })
3590                        }
3591                        .ml_1(),
3592                    )
3593                    .on_secondary_mouse_down(cx.listener(
3594                        move |this, event: &MouseDownEvent, cx| {
3595                            // Stop propagation to prevent the catch-all context menu for the project
3596                            // panel from being deployed.
3597                            cx.stop_propagation();
3598                            // Some context menu actions apply to all marked entries. If the user
3599                            // right-clicks on an entry that is not marked, they may not realize the
3600                            // action applies to multiple entries. To avoid inadvertent changes, all
3601                            // entries are unmarked.
3602                            if !this.marked_entries.contains(&selection) {
3603                                this.marked_entries.clear();
3604                            }
3605                            this.deploy_context_menu(event.position, entry_id, cx);
3606                        },
3607                    ))
3608                    .overflow_x(),
3609            )
3610    }
3611
3612    fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3613        if !Self::should_show_scrollbar(cx)
3614            || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
3615        {
3616            return None;
3617        }
3618        Some(
3619            div()
3620                .occlude()
3621                .id("project-panel-vertical-scroll")
3622                .on_mouse_move(cx.listener(|_, _, cx| {
3623                    cx.notify();
3624                    cx.stop_propagation()
3625                }))
3626                .on_hover(|_, cx| {
3627                    cx.stop_propagation();
3628                })
3629                .on_any_mouse_down(|_, cx| {
3630                    cx.stop_propagation();
3631                })
3632                .on_mouse_up(
3633                    MouseButton::Left,
3634                    cx.listener(|this, _, cx| {
3635                        if !this.vertical_scrollbar_state.is_dragging()
3636                            && !this.focus_handle.contains_focused(cx)
3637                        {
3638                            this.hide_scrollbar(cx);
3639                            cx.notify();
3640                        }
3641
3642                        cx.stop_propagation();
3643                    }),
3644                )
3645                .on_scroll_wheel(cx.listener(|_, _, cx| {
3646                    cx.notify();
3647                }))
3648                .h_full()
3649                .absolute()
3650                .right_1()
3651                .top_1()
3652                .bottom_1()
3653                .w(px(12.))
3654                .cursor_default()
3655                .children(Scrollbar::vertical(
3656                    // percentage as f32..end_offset as f32,
3657                    self.vertical_scrollbar_state.clone(),
3658                )),
3659        )
3660    }
3661
3662    fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
3663        if !Self::should_show_scrollbar(cx)
3664            || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
3665        {
3666            return None;
3667        }
3668
3669        let scroll_handle = self.scroll_handle.0.borrow();
3670        let longest_item_width = scroll_handle
3671            .last_item_size
3672            .filter(|size| size.contents.width > size.item.width)?
3673            .contents
3674            .width
3675            .0 as f64;
3676        if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 {
3677            return None;
3678        }
3679
3680        Some(
3681            div()
3682                .occlude()
3683                .id("project-panel-horizontal-scroll")
3684                .on_mouse_move(cx.listener(|_, _, cx| {
3685                    cx.notify();
3686                    cx.stop_propagation()
3687                }))
3688                .on_hover(|_, cx| {
3689                    cx.stop_propagation();
3690                })
3691                .on_any_mouse_down(|_, cx| {
3692                    cx.stop_propagation();
3693                })
3694                .on_mouse_up(
3695                    MouseButton::Left,
3696                    cx.listener(|this, _, cx| {
3697                        if !this.horizontal_scrollbar_state.is_dragging()
3698                            && !this.focus_handle.contains_focused(cx)
3699                        {
3700                            this.hide_scrollbar(cx);
3701                            cx.notify();
3702                        }
3703
3704                        cx.stop_propagation();
3705                    }),
3706                )
3707                .on_scroll_wheel(cx.listener(|_, _, cx| {
3708                    cx.notify();
3709                }))
3710                .w_full()
3711                .absolute()
3712                .right_1()
3713                .left_1()
3714                .bottom_1()
3715                .h(px(12.))
3716                .cursor_default()
3717                .when(self.width.is_some(), |this| {
3718                    this.children(Scrollbar::horizontal(
3719                        self.horizontal_scrollbar_state.clone(),
3720                    ))
3721                }),
3722        )
3723    }
3724
3725    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
3726        let mut dispatch_context = KeyContext::new_with_defaults();
3727        dispatch_context.add("ProjectPanel");
3728        dispatch_context.add("menu");
3729
3730        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
3731            "editing"
3732        } else {
3733            "not_editing"
3734        };
3735
3736        dispatch_context.add(identifier);
3737        dispatch_context
3738    }
3739
3740    fn should_show_scrollbar(cx: &AppContext) -> bool {
3741        let show = ProjectPanelSettings::get_global(cx)
3742            .scrollbar
3743            .show
3744            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3745        match show {
3746            ShowScrollbar::Auto => true,
3747            ShowScrollbar::System => true,
3748            ShowScrollbar::Always => true,
3749            ShowScrollbar::Never => false,
3750        }
3751    }
3752
3753    fn should_autohide_scrollbar(cx: &AppContext) -> bool {
3754        let show = ProjectPanelSettings::get_global(cx)
3755            .scrollbar
3756            .show
3757            .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
3758        match show {
3759            ShowScrollbar::Auto => true,
3760            ShowScrollbar::System => cx
3761                .try_global::<ScrollbarAutoHide>()
3762                .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
3763            ShowScrollbar::Always => false,
3764            ShowScrollbar::Never => true,
3765        }
3766    }
3767
3768    fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
3769        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
3770        if !Self::should_autohide_scrollbar(cx) {
3771            return;
3772        }
3773        self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
3774            cx.background_executor()
3775                .timer(SCROLLBAR_SHOW_INTERVAL)
3776                .await;
3777            panel
3778                .update(&mut cx, |panel, cx| {
3779                    panel.show_scrollbar = false;
3780                    cx.notify();
3781                })
3782                .log_err();
3783        }))
3784    }
3785
3786    fn reveal_entry(
3787        &mut self,
3788        project: Model<Project>,
3789        entry_id: ProjectEntryId,
3790        skip_ignored: bool,
3791        cx: &mut ViewContext<Self>,
3792    ) {
3793        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
3794            let worktree = worktree.read(cx);
3795            if skip_ignored
3796                && worktree
3797                    .entry_for_id(entry_id)
3798                    .map_or(true, |entry| entry.is_ignored)
3799            {
3800                return;
3801            }
3802
3803            let worktree_id = worktree.id();
3804            self.expand_entry(worktree_id, entry_id, cx);
3805            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
3806
3807            if self.marked_entries.len() == 1
3808                && self
3809                    .marked_entries
3810                    .first()
3811                    .filter(|entry| entry.entry_id == entry_id)
3812                    .is_none()
3813            {
3814                self.marked_entries.clear();
3815            }
3816            self.autoscroll(cx);
3817            cx.notify();
3818        }
3819    }
3820
3821    fn find_active_indent_guide(
3822        &self,
3823        indent_guides: &[IndentGuideLayout],
3824        cx: &AppContext,
3825    ) -> Option<usize> {
3826        let (worktree, entry) = self.selected_entry(cx)?;
3827
3828        // Find the parent entry of the indent guide, this will either be the
3829        // expanded folder we have selected, or the parent of the currently
3830        // selected file/collapsed directory
3831        let mut entry = entry;
3832        loop {
3833            let is_expanded_dir = entry.is_dir()
3834                && self
3835                    .expanded_dir_ids
3836                    .get(&worktree.id())
3837                    .map(|ids| ids.binary_search(&entry.id).is_ok())
3838                    .unwrap_or(false);
3839            if is_expanded_dir {
3840                break;
3841            }
3842            entry = worktree.entry_for_path(&entry.path.parent()?)?;
3843        }
3844
3845        let (active_indent_range, depth) = {
3846            let (worktree_ix, child_offset, ix) = self.index_for_entry(entry.id, worktree.id())?;
3847            let child_paths = &self.visible_entries[worktree_ix].1;
3848            let mut child_count = 0;
3849            let depth = entry.path.ancestors().count();
3850            while let Some(entry) = child_paths.get(child_offset + child_count + 1) {
3851                if entry.path.ancestors().count() <= depth {
3852                    break;
3853                }
3854                child_count += 1;
3855            }
3856
3857            let start = ix + 1;
3858            let end = start + child_count;
3859
3860            let (_, entries, paths) = &self.visible_entries[worktree_ix];
3861            let visible_worktree_entries =
3862                paths.get_or_init(|| entries.iter().map(|e| (e.path.clone())).collect());
3863
3864            // Calculate the actual depth of the entry, taking into account that directories can be auto-folded.
3865            let (depth, _) = Self::calculate_depth_and_difference(entry, visible_worktree_entries);
3866            (start..end, depth)
3867        };
3868
3869        let candidates = indent_guides
3870            .iter()
3871            .enumerate()
3872            .filter(|(_, indent_guide)| indent_guide.offset.x == depth);
3873
3874        for (i, indent) in candidates {
3875            // Find matches that are either an exact match, partially on screen, or inside the enclosing indent
3876            if active_indent_range.start <= indent.offset.y + indent.length
3877                && indent.offset.y <= active_indent_range.end
3878            {
3879                return Some(i);
3880            }
3881        }
3882        None
3883    }
3884}
3885
3886fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
3887    const ICON_SIZE_FACTOR: usize = 2;
3888    let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
3889    if is_symlink {
3890        item_width += ICON_SIZE_FACTOR;
3891    }
3892    item_width
3893}
3894
3895impl Render for ProjectPanel {
3896    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
3897        let has_worktree = !self.visible_entries.is_empty();
3898        let project = self.project.read(cx);
3899        let indent_size = ProjectPanelSettings::get_global(cx).indent_size;
3900        let show_indent_guides =
3901            ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always;
3902        let is_local = project.is_local();
3903
3904        if has_worktree {
3905            let item_count = self
3906                .visible_entries
3907                .iter()
3908                .map(|(_, worktree_entries, _)| worktree_entries.len())
3909                .sum();
3910
3911            fn handle_drag_move_scroll<T: 'static>(
3912                this: &mut ProjectPanel,
3913                e: &DragMoveEvent<T>,
3914                cx: &mut ViewContext<ProjectPanel>,
3915            ) {
3916                if !e.bounds.contains(&e.event.position) {
3917                    return;
3918                }
3919                this.hover_scroll_task.take();
3920                let panel_height = e.bounds.size.height;
3921                if panel_height <= px(0.) {
3922                    return;
3923                }
3924
3925                let event_offset = e.event.position.y - e.bounds.origin.y;
3926                // How far along in the project panel is our cursor? (0. is the top of a list, 1. is the bottom)
3927                let hovered_region_offset = event_offset / panel_height;
3928
3929                // We want the scrolling to be a bit faster when the cursor is closer to the edge of a list.
3930                // These pixels offsets were picked arbitrarily.
3931                let vertical_scroll_offset = if hovered_region_offset <= 0.05 {
3932                    8.
3933                } else if hovered_region_offset <= 0.15 {
3934                    5.
3935                } else if hovered_region_offset >= 0.95 {
3936                    -8.
3937                } else if hovered_region_offset >= 0.85 {
3938                    -5.
3939                } else {
3940                    return;
3941                };
3942                let adjustment = point(px(0.), px(vertical_scroll_offset));
3943                this.hover_scroll_task = Some(cx.spawn(move |this, mut cx| async move {
3944                    loop {
3945                        let should_stop_scrolling = this
3946                            .update(&mut cx, |this, cx| {
3947                                this.hover_scroll_task.as_ref()?;
3948                                let handle = this.scroll_handle.0.borrow_mut();
3949                                let offset = handle.base_handle.offset();
3950
3951                                handle.base_handle.set_offset(offset + adjustment);
3952                                cx.notify();
3953                                Some(())
3954                            })
3955                            .ok()
3956                            .flatten()
3957                            .is_some();
3958                        if should_stop_scrolling {
3959                            return;
3960                        }
3961                        cx.background_executor()
3962                            .timer(Duration::from_millis(16))
3963                            .await;
3964                    }
3965                }));
3966            }
3967            h_flex()
3968                .id("project-panel")
3969                .group("project-panel")
3970                .on_drag_move(cx.listener(handle_drag_move_scroll::<ExternalPaths>))
3971                .on_drag_move(cx.listener(handle_drag_move_scroll::<DraggedSelection>))
3972                .size_full()
3973                .relative()
3974                .on_hover(cx.listener(|this, hovered, cx| {
3975                    if *hovered {
3976                        this.show_scrollbar = true;
3977                        this.hide_scrollbar_task.take();
3978                        cx.notify();
3979                    } else if !this.focus_handle.contains_focused(cx) {
3980                        this.hide_scrollbar(cx);
3981                    }
3982                }))
3983                .on_click(cx.listener(|this, _event, cx| {
3984                    cx.stop_propagation();
3985                    this.selection = None;
3986                    this.marked_entries.clear();
3987                }))
3988                .key_context(self.dispatch_context(cx))
3989                .on_action(cx.listener(Self::select_next))
3990                .on_action(cx.listener(Self::select_prev))
3991                .on_action(cx.listener(Self::select_first))
3992                .on_action(cx.listener(Self::select_last))
3993                .on_action(cx.listener(Self::select_parent))
3994                .on_action(cx.listener(Self::select_next_git_entry))
3995                .on_action(cx.listener(Self::select_prev_git_entry))
3996                .on_action(cx.listener(Self::select_next_diagnostic))
3997                .on_action(cx.listener(Self::select_prev_diagnostic))
3998                .on_action(cx.listener(Self::select_next_directory))
3999                .on_action(cx.listener(Self::select_prev_directory))
4000                .on_action(cx.listener(Self::expand_selected_entry))
4001                .on_action(cx.listener(Self::collapse_selected_entry))
4002                .on_action(cx.listener(Self::collapse_all_entries))
4003                .on_action(cx.listener(Self::open))
4004                .on_action(cx.listener(Self::open_permanent))
4005                .on_action(cx.listener(Self::confirm))
4006                .on_action(cx.listener(Self::cancel))
4007                .on_action(cx.listener(Self::copy_path))
4008                .on_action(cx.listener(Self::copy_relative_path))
4009                .on_action(cx.listener(Self::new_search_in_directory))
4010                .on_action(cx.listener(Self::unfold_directory))
4011                .on_action(cx.listener(Self::fold_directory))
4012                .on_action(cx.listener(Self::remove_from_project))
4013                .when(!project.is_read_only(cx), |el| {
4014                    el.on_action(cx.listener(Self::new_file))
4015                        .on_action(cx.listener(Self::new_directory))
4016                        .on_action(cx.listener(Self::rename))
4017                        .on_action(cx.listener(Self::delete))
4018                        .on_action(cx.listener(Self::trash))
4019                        .on_action(cx.listener(Self::cut))
4020                        .on_action(cx.listener(Self::copy))
4021                        .on_action(cx.listener(Self::paste))
4022                        .on_action(cx.listener(Self::duplicate))
4023                        .on_click(cx.listener(|this, event: &gpui::ClickEvent, cx| {
4024                            if event.up.click_count > 1 {
4025                                if let Some(entry_id) = this.last_worktree_root_id {
4026                                    let project = this.project.read(cx);
4027
4028                                    let worktree_id = if let Some(worktree) =
4029                                        project.worktree_for_entry(entry_id, cx)
4030                                    {
4031                                        worktree.read(cx).id()
4032                                    } else {
4033                                        return;
4034                                    };
4035
4036                                    this.selection = Some(SelectedEntry {
4037                                        worktree_id,
4038                                        entry_id,
4039                                    });
4040
4041                                    this.new_file(&NewFile, cx);
4042                                }
4043                            }
4044                        }))
4045                })
4046                .when(project.is_local(), |el| {
4047                    el.on_action(cx.listener(Self::reveal_in_finder))
4048                        .on_action(cx.listener(Self::open_system))
4049                        .on_action(cx.listener(Self::open_in_terminal))
4050                })
4051                .when(project.is_via_ssh(), |el| {
4052                    el.on_action(cx.listener(Self::open_in_terminal))
4053                })
4054                .on_mouse_down(
4055                    MouseButton::Right,
4056                    cx.listener(move |this, event: &MouseDownEvent, cx| {
4057                        // When deploying the context menu anywhere below the last project entry,
4058                        // act as if the user clicked the root of the last worktree.
4059                        if let Some(entry_id) = this.last_worktree_root_id {
4060                            this.deploy_context_menu(event.position, entry_id, cx);
4061                        }
4062                    }),
4063                )
4064                .track_focus(&self.focus_handle(cx))
4065                .child(
4066                    uniform_list(cx.view().clone(), "entries", item_count, {
4067                        |this, range, cx| {
4068                            let mut items = Vec::with_capacity(range.end - range.start);
4069                            this.for_each_visible_entry(range, cx, |id, details, cx| {
4070                                items.push(this.render_entry(id, details, cx));
4071                            });
4072                            items
4073                        }
4074                    })
4075                    .when(show_indent_guides, |list| {
4076                        list.with_decoration(
4077                            ui::indent_guides(
4078                                cx.view().clone(),
4079                                px(indent_size),
4080                                IndentGuideColors::panel(cx),
4081                                |this, range, cx| {
4082                                    let mut items =
4083                                        SmallVec::with_capacity(range.end - range.start);
4084                                    this.iter_visible_entries(range, cx, |entry, entries, _| {
4085                                        let (depth, _) =
4086                                            Self::calculate_depth_and_difference(entry, entries);
4087                                        items.push(depth);
4088                                    });
4089                                    items
4090                                },
4091                            )
4092                            .on_click(cx.listener(
4093                                |this, active_indent_guide: &IndentGuideLayout, cx| {
4094                                    if cx.modifiers().secondary() {
4095                                        let ix = active_indent_guide.offset.y;
4096                                        let Some((target_entry, worktree)) = maybe!({
4097                                            let (worktree_id, entry) = this.entry_at_index(ix)?;
4098                                            let worktree = this
4099                                                .project
4100                                                .read(cx)
4101                                                .worktree_for_id(worktree_id, cx)?;
4102                                            let target_entry = worktree
4103                                                .read(cx)
4104                                                .entry_for_path(&entry.path.parent()?)?;
4105                                            Some((target_entry, worktree))
4106                                        }) else {
4107                                            return;
4108                                        };
4109
4110                                        this.collapse_entry(target_entry.clone(), worktree, cx);
4111                                    }
4112                                },
4113                            ))
4114                            .with_render_fn(
4115                                cx.view().clone(),
4116                                move |this, params, cx| {
4117                                    const LEFT_OFFSET: f32 = 14.;
4118                                    const PADDING_Y: f32 = 4.;
4119                                    const HITBOX_OVERDRAW: f32 = 3.;
4120
4121                                    let active_indent_guide_index =
4122                                        this.find_active_indent_guide(&params.indent_guides, cx);
4123
4124                                    let indent_size = params.indent_size;
4125                                    let item_height = params.item_height;
4126
4127                                    params
4128                                        .indent_guides
4129                                        .into_iter()
4130                                        .enumerate()
4131                                        .map(|(idx, layout)| {
4132                                            let offset = if layout.continues_offscreen {
4133                                                px(0.)
4134                                            } else {
4135                                                px(PADDING_Y)
4136                                            };
4137                                            let bounds = Bounds::new(
4138                                                point(
4139                                                    px(layout.offset.x as f32) * indent_size
4140                                                        + px(LEFT_OFFSET),
4141                                                    px(layout.offset.y as f32) * item_height
4142                                                        + offset,
4143                                                ),
4144                                                size(
4145                                                    px(1.),
4146                                                    px(layout.length as f32) * item_height
4147                                                        - px(offset.0 * 2.),
4148                                                ),
4149                                            );
4150                                            ui::RenderedIndentGuide {
4151                                                bounds,
4152                                                layout,
4153                                                is_active: Some(idx) == active_indent_guide_index,
4154                                                hitbox: Some(Bounds::new(
4155                                                    point(
4156                                                        bounds.origin.x - px(HITBOX_OVERDRAW),
4157                                                        bounds.origin.y,
4158                                                    ),
4159                                                    size(
4160                                                        bounds.size.width
4161                                                            + px(2. * HITBOX_OVERDRAW),
4162                                                        bounds.size.height,
4163                                                    ),
4164                                                )),
4165                                            }
4166                                        })
4167                                        .collect()
4168                                },
4169                            ),
4170                        )
4171                    })
4172                    .size_full()
4173                    .with_sizing_behavior(ListSizingBehavior::Infer)
4174                    .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
4175                    .with_width_from_item(self.max_width_item_index)
4176                    .track_scroll(self.scroll_handle.clone()),
4177                )
4178                .children(self.render_vertical_scrollbar(cx))
4179                .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
4180                    this.pb_4().child(scrollbar)
4181                })
4182                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4183                    deferred(
4184                        anchored()
4185                            .position(*position)
4186                            .anchor(gpui::Corner::TopLeft)
4187                            .child(menu.clone()),
4188                    )
4189                    .with_priority(1)
4190                }))
4191        } else {
4192            v_flex()
4193                .id("empty-project_panel")
4194                .size_full()
4195                .p_4()
4196                .track_focus(&self.focus_handle(cx))
4197                .child(
4198                    Button::new("open_project", "Open a project")
4199                        .full_width()
4200                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
4201                        .on_click(cx.listener(|this, _, cx| {
4202                            this.workspace
4203                                .update(cx, |_, cx| cx.dispatch_action(Box::new(workspace::Open)))
4204                                .log_err();
4205                        })),
4206                )
4207                .when(is_local, |div| {
4208                    div.drag_over::<ExternalPaths>(|style, _, cx| {
4209                        style.bg(cx.theme().colors().drop_target_background)
4210                    })
4211                    .on_drop(cx.listener(
4212                        move |this, external_paths: &ExternalPaths, cx| {
4213                            this.last_external_paths_drag_over_entry = None;
4214                            this.marked_entries.clear();
4215                            this.hover_scroll_task.take();
4216                            if let Some(task) = this
4217                                .workspace
4218                                .update(cx, |workspace, cx| {
4219                                    workspace.open_workspace_for_paths(
4220                                        true,
4221                                        external_paths.paths().to_owned(),
4222                                        cx,
4223                                    )
4224                                })
4225                                .log_err()
4226                            {
4227                                task.detach_and_log_err(cx);
4228                            }
4229                            cx.stop_propagation();
4230                        },
4231                    ))
4232                })
4233        }
4234    }
4235}
4236
4237impl Render for DraggedProjectEntryView {
4238    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
4239        let settings = ProjectPanelSettings::get_global(cx);
4240        let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
4241
4242        h_flex().font(ui_font).map(|this| {
4243            if self.selections.len() > 1 && self.selections.contains(&self.selection) {
4244                this.flex_none()
4245                    .w(self.width)
4246                    .child(div().w(self.click_offset.x))
4247                    .child(
4248                        div()
4249                            .p_1()
4250                            .rounded_xl()
4251                            .bg(cx.theme().colors().background)
4252                            .child(Label::new(format!("{} entries", self.selections.len()))),
4253                    )
4254            } else {
4255                this.w(self.width).bg(cx.theme().colors().background).child(
4256                    ListItem::new(self.selection.entry_id.to_proto() as usize)
4257                        .indent_level(self.details.depth)
4258                        .indent_step_size(px(settings.indent_size))
4259                        .child(if let Some(icon) = &self.details.icon {
4260                            div().child(Icon::from_path(icon.clone()))
4261                        } else {
4262                            div()
4263                        })
4264                        .child(Label::new(self.details.filename.clone())),
4265                )
4266            }
4267        })
4268    }
4269}
4270
4271impl EventEmitter<Event> for ProjectPanel {}
4272
4273impl EventEmitter<PanelEvent> for ProjectPanel {}
4274
4275impl Panel for ProjectPanel {
4276    fn position(&self, cx: &WindowContext) -> DockPosition {
4277        match ProjectPanelSettings::get_global(cx).dock {
4278            ProjectPanelDockPosition::Left => DockPosition::Left,
4279            ProjectPanelDockPosition::Right => DockPosition::Right,
4280        }
4281    }
4282
4283    fn position_is_valid(&self, position: DockPosition) -> bool {
4284        matches!(position, DockPosition::Left | DockPosition::Right)
4285    }
4286
4287    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
4288        settings::update_settings_file::<ProjectPanelSettings>(
4289            self.fs.clone(),
4290            cx,
4291            move |settings, _| {
4292                let dock = match position {
4293                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
4294                    DockPosition::Right => ProjectPanelDockPosition::Right,
4295                };
4296                settings.dock = Some(dock);
4297            },
4298        );
4299    }
4300
4301    fn size(&self, cx: &WindowContext) -> Pixels {
4302        self.width
4303            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
4304    }
4305
4306    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
4307        self.width = size;
4308        self.serialize(cx);
4309        cx.notify();
4310    }
4311
4312    fn icon(&self, cx: &WindowContext) -> Option<IconName> {
4313        ProjectPanelSettings::get_global(cx)
4314            .button
4315            .then_some(IconName::FileTree)
4316    }
4317
4318    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
4319        Some("Project Panel")
4320    }
4321
4322    fn toggle_action(&self) -> Box<dyn Action> {
4323        Box::new(ToggleFocus)
4324    }
4325
4326    fn persistent_name() -> &'static str {
4327        "Project Panel"
4328    }
4329
4330    fn starts_open(&self, cx: &WindowContext) -> bool {
4331        let project = &self.project.read(cx);
4332        project.visible_worktrees(cx).any(|tree| {
4333            tree.read(cx)
4334                .root_entry()
4335                .map_or(false, |entry| entry.is_dir())
4336        })
4337    }
4338
4339    fn activation_priority(&self) -> u32 {
4340        0
4341    }
4342}
4343
4344impl FocusableView for ProjectPanel {
4345    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
4346        self.focus_handle.clone()
4347    }
4348}
4349
4350impl ClipboardEntry {
4351    fn is_cut(&self) -> bool {
4352        matches!(self, Self::Cut { .. })
4353    }
4354
4355    fn items(&self) -> &BTreeSet<SelectedEntry> {
4356        match self {
4357            ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
4358        }
4359    }
4360}
4361
4362#[cfg(test)]
4363mod tests {
4364    use super::*;
4365    use collections::HashSet;
4366    use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
4367    use pretty_assertions::assert_eq;
4368    use project::{FakeFs, WorktreeSettings};
4369    use serde_json::json;
4370    use settings::SettingsStore;
4371    use std::path::{Path, PathBuf};
4372    use workspace::{
4373        item::{Item, ProjectItem},
4374        register_project_item, AppState,
4375    };
4376
4377    #[gpui::test]
4378    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
4379        init_test(cx);
4380
4381        let fs = FakeFs::new(cx.executor().clone());
4382        fs.insert_tree(
4383            "/root1",
4384            json!({
4385                ".dockerignore": "",
4386                ".git": {
4387                    "HEAD": "",
4388                },
4389                "a": {
4390                    "0": { "q": "", "r": "", "s": "" },
4391                    "1": { "t": "", "u": "" },
4392                    "2": { "v": "", "w": "", "x": "", "y": "" },
4393                },
4394                "b": {
4395                    "3": { "Q": "" },
4396                    "4": { "R": "", "S": "", "T": "", "U": "" },
4397                },
4398                "C": {
4399                    "5": {},
4400                    "6": { "V": "", "W": "" },
4401                    "7": { "X": "" },
4402                    "8": { "Y": {}, "Z": "" }
4403                }
4404            }),
4405        )
4406        .await;
4407        fs.insert_tree(
4408            "/root2",
4409            json!({
4410                "d": {
4411                    "9": ""
4412                },
4413                "e": {}
4414            }),
4415        )
4416        .await;
4417
4418        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4419        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4420        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4421        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4422        assert_eq!(
4423            visible_entries_as_strings(&panel, 0..50, cx),
4424            &[
4425                "v root1",
4426                "    > .git",
4427                "    > a",
4428                "    > b",
4429                "    > C",
4430                "      .dockerignore",
4431                "v root2",
4432                "    > d",
4433                "    > e",
4434            ]
4435        );
4436
4437        toggle_expand_dir(&panel, "root1/b", cx);
4438        assert_eq!(
4439            visible_entries_as_strings(&panel, 0..50, cx),
4440            &[
4441                "v root1",
4442                "    > .git",
4443                "    > a",
4444                "    v b  <== selected",
4445                "        > 3",
4446                "        > 4",
4447                "    > C",
4448                "      .dockerignore",
4449                "v root2",
4450                "    > d",
4451                "    > e",
4452            ]
4453        );
4454
4455        assert_eq!(
4456            visible_entries_as_strings(&panel, 6..9, cx),
4457            &[
4458                //
4459                "    > C",
4460                "      .dockerignore",
4461                "v root2",
4462            ]
4463        );
4464    }
4465
4466    #[gpui::test]
4467    async fn test_opening_file(cx: &mut gpui::TestAppContext) {
4468        init_test_with_editor(cx);
4469
4470        let fs = FakeFs::new(cx.executor().clone());
4471        fs.insert_tree(
4472            "/src",
4473            json!({
4474                "test": {
4475                    "first.rs": "// First Rust file",
4476                    "second.rs": "// Second Rust file",
4477                    "third.rs": "// Third Rust file",
4478                }
4479            }),
4480        )
4481        .await;
4482
4483        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4484        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4485        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4486        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4487
4488        toggle_expand_dir(&panel, "src/test", cx);
4489        select_path(&panel, "src/test/first.rs", cx);
4490        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4491        cx.executor().run_until_parked();
4492        assert_eq!(
4493            visible_entries_as_strings(&panel, 0..10, cx),
4494            &[
4495                "v src",
4496                "    v test",
4497                "          first.rs  <== selected  <== marked",
4498                "          second.rs",
4499                "          third.rs"
4500            ]
4501        );
4502        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4503
4504        select_path(&panel, "src/test/second.rs", cx);
4505        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4506        cx.executor().run_until_parked();
4507        assert_eq!(
4508            visible_entries_as_strings(&panel, 0..10, cx),
4509            &[
4510                "v src",
4511                "    v test",
4512                "          first.rs",
4513                "          second.rs  <== selected  <== marked",
4514                "          third.rs"
4515            ]
4516        );
4517        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4518    }
4519
4520    #[gpui::test]
4521    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
4522        init_test(cx);
4523        cx.update(|cx| {
4524            cx.update_global::<SettingsStore, _>(|store, cx| {
4525                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4526                    worktree_settings.file_scan_exclusions =
4527                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
4528                });
4529            });
4530        });
4531
4532        let fs = FakeFs::new(cx.background_executor.clone());
4533        fs.insert_tree(
4534            "/root1",
4535            json!({
4536                ".dockerignore": "",
4537                ".git": {
4538                    "HEAD": "",
4539                },
4540                "a": {
4541                    "0": { "q": "", "r": "", "s": "" },
4542                    "1": { "t": "", "u": "" },
4543                    "2": { "v": "", "w": "", "x": "", "y": "" },
4544                },
4545                "b": {
4546                    "3": { "Q": "" },
4547                    "4": { "R": "", "S": "", "T": "", "U": "" },
4548                },
4549                "C": {
4550                    "5": {},
4551                    "6": { "V": "", "W": "" },
4552                    "7": { "X": "" },
4553                    "8": { "Y": {}, "Z": "" }
4554                }
4555            }),
4556        )
4557        .await;
4558        fs.insert_tree(
4559            "/root2",
4560            json!({
4561                "d": {
4562                    "4": ""
4563                },
4564                "e": {}
4565            }),
4566        )
4567        .await;
4568
4569        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4570        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4571        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4572        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4573        assert_eq!(
4574            visible_entries_as_strings(&panel, 0..50, cx),
4575            &[
4576                "v root1",
4577                "    > a",
4578                "    > b",
4579                "    > C",
4580                "      .dockerignore",
4581                "v root2",
4582                "    > d",
4583                "    > e",
4584            ]
4585        );
4586
4587        toggle_expand_dir(&panel, "root1/b", cx);
4588        assert_eq!(
4589            visible_entries_as_strings(&panel, 0..50, cx),
4590            &[
4591                "v root1",
4592                "    > a",
4593                "    v b  <== selected",
4594                "        > 3",
4595                "    > C",
4596                "      .dockerignore",
4597                "v root2",
4598                "    > d",
4599                "    > e",
4600            ]
4601        );
4602
4603        toggle_expand_dir(&panel, "root2/d", cx);
4604        assert_eq!(
4605            visible_entries_as_strings(&panel, 0..50, cx),
4606            &[
4607                "v root1",
4608                "    > a",
4609                "    v b",
4610                "        > 3",
4611                "    > C",
4612                "      .dockerignore",
4613                "v root2",
4614                "    v d  <== selected",
4615                "    > e",
4616            ]
4617        );
4618
4619        toggle_expand_dir(&panel, "root2/e", cx);
4620        assert_eq!(
4621            visible_entries_as_strings(&panel, 0..50, cx),
4622            &[
4623                "v root1",
4624                "    > a",
4625                "    v b",
4626                "        > 3",
4627                "    > C",
4628                "      .dockerignore",
4629                "v root2",
4630                "    v d",
4631                "    v e  <== selected",
4632            ]
4633        );
4634    }
4635
4636    #[gpui::test]
4637    async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
4638        init_test(cx);
4639
4640        let fs = FakeFs::new(cx.executor().clone());
4641        fs.insert_tree(
4642            "/root1",
4643            json!({
4644                "dir_1": {
4645                    "nested_dir_1": {
4646                        "nested_dir_2": {
4647                            "nested_dir_3": {
4648                                "file_a.java": "// File contents",
4649                                "file_b.java": "// File contents",
4650                                "file_c.java": "// File contents",
4651                                "nested_dir_4": {
4652                                    "nested_dir_5": {
4653                                        "file_d.java": "// File contents",
4654                                    }
4655                                }
4656                            }
4657                        }
4658                    }
4659                }
4660            }),
4661        )
4662        .await;
4663        fs.insert_tree(
4664            "/root2",
4665            json!({
4666                "dir_2": {
4667                    "file_1.java": "// File contents",
4668                }
4669            }),
4670        )
4671        .await;
4672
4673        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4674        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4675        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4676        cx.update(|cx| {
4677            let settings = *ProjectPanelSettings::get_global(cx);
4678            ProjectPanelSettings::override_global(
4679                ProjectPanelSettings {
4680                    auto_fold_dirs: true,
4681                    ..settings
4682                },
4683                cx,
4684            );
4685        });
4686        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4687        assert_eq!(
4688            visible_entries_as_strings(&panel, 0..10, cx),
4689            &[
4690                "v root1",
4691                "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4692                "v root2",
4693                "    > dir_2",
4694            ]
4695        );
4696
4697        toggle_expand_dir(
4698            &panel,
4699            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4700            cx,
4701        );
4702        assert_eq!(
4703            visible_entries_as_strings(&panel, 0..10, cx),
4704            &[
4705                "v root1",
4706                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
4707                "        > nested_dir_4/nested_dir_5",
4708                "          file_a.java",
4709                "          file_b.java",
4710                "          file_c.java",
4711                "v root2",
4712                "    > dir_2",
4713            ]
4714        );
4715
4716        toggle_expand_dir(
4717            &panel,
4718            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
4719            cx,
4720        );
4721        assert_eq!(
4722            visible_entries_as_strings(&panel, 0..10, cx),
4723            &[
4724                "v root1",
4725                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4726                "        v nested_dir_4/nested_dir_5  <== selected",
4727                "              file_d.java",
4728                "          file_a.java",
4729                "          file_b.java",
4730                "          file_c.java",
4731                "v root2",
4732                "    > dir_2",
4733            ]
4734        );
4735        toggle_expand_dir(&panel, "root2/dir_2", cx);
4736        assert_eq!(
4737            visible_entries_as_strings(&panel, 0..10, cx),
4738            &[
4739                "v root1",
4740                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
4741                "        v nested_dir_4/nested_dir_5",
4742                "              file_d.java",
4743                "          file_a.java",
4744                "          file_b.java",
4745                "          file_c.java",
4746                "v root2",
4747                "    v dir_2  <== selected",
4748                "          file_1.java",
4749            ]
4750        );
4751    }
4752
4753    #[gpui::test(iterations = 30)]
4754    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
4755        init_test(cx);
4756
4757        let fs = FakeFs::new(cx.executor().clone());
4758        fs.insert_tree(
4759            "/root1",
4760            json!({
4761                ".dockerignore": "",
4762                ".git": {
4763                    "HEAD": "",
4764                },
4765                "a": {
4766                    "0": { "q": "", "r": "", "s": "" },
4767                    "1": { "t": "", "u": "" },
4768                    "2": { "v": "", "w": "", "x": "", "y": "" },
4769                },
4770                "b": {
4771                    "3": { "Q": "" },
4772                    "4": { "R": "", "S": "", "T": "", "U": "" },
4773                },
4774                "C": {
4775                    "5": {},
4776                    "6": { "V": "", "W": "" },
4777                    "7": { "X": "" },
4778                    "8": { "Y": {}, "Z": "" }
4779                }
4780            }),
4781        )
4782        .await;
4783        fs.insert_tree(
4784            "/root2",
4785            json!({
4786                "d": {
4787                    "9": ""
4788                },
4789                "e": {}
4790            }),
4791        )
4792        .await;
4793
4794        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4795        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4796        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4797        let panel = workspace
4798            .update(cx, |workspace, cx| {
4799                let panel = ProjectPanel::new(workspace, cx);
4800                workspace.add_panel(panel.clone(), cx);
4801                panel
4802            })
4803            .unwrap();
4804
4805        select_path(&panel, "root1", cx);
4806        assert_eq!(
4807            visible_entries_as_strings(&panel, 0..10, cx),
4808            &[
4809                "v root1  <== selected",
4810                "    > .git",
4811                "    > a",
4812                "    > b",
4813                "    > C",
4814                "      .dockerignore",
4815                "v root2",
4816                "    > d",
4817                "    > e",
4818            ]
4819        );
4820
4821        // Add a file with the root folder selected. The filename editor is placed
4822        // before the first file in the root folder.
4823        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4824        panel.update(cx, |panel, cx| {
4825            assert!(panel.filename_editor.read(cx).is_focused(cx));
4826        });
4827        assert_eq!(
4828            visible_entries_as_strings(&panel, 0..10, cx),
4829            &[
4830                "v root1",
4831                "    > .git",
4832                "    > a",
4833                "    > b",
4834                "    > C",
4835                "      [EDITOR: '']  <== selected",
4836                "      .dockerignore",
4837                "v root2",
4838                "    > d",
4839                "    > e",
4840            ]
4841        );
4842
4843        let confirm = panel.update(cx, |panel, cx| {
4844            panel
4845                .filename_editor
4846                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
4847            panel.confirm_edit(cx).unwrap()
4848        });
4849        assert_eq!(
4850            visible_entries_as_strings(&panel, 0..10, cx),
4851            &[
4852                "v root1",
4853                "    > .git",
4854                "    > a",
4855                "    > b",
4856                "    > C",
4857                "      [PROCESSING: 'the-new-filename']  <== selected",
4858                "      .dockerignore",
4859                "v root2",
4860                "    > d",
4861                "    > e",
4862            ]
4863        );
4864
4865        confirm.await.unwrap();
4866        assert_eq!(
4867            visible_entries_as_strings(&panel, 0..10, cx),
4868            &[
4869                "v root1",
4870                "    > .git",
4871                "    > a",
4872                "    > b",
4873                "    > C",
4874                "      .dockerignore",
4875                "      the-new-filename  <== selected  <== marked",
4876                "v root2",
4877                "    > d",
4878                "    > e",
4879            ]
4880        );
4881
4882        select_path(&panel, "root1/b", cx);
4883        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4884        assert_eq!(
4885            visible_entries_as_strings(&panel, 0..10, cx),
4886            &[
4887                "v root1",
4888                "    > .git",
4889                "    > a",
4890                "    v b",
4891                "        > 3",
4892                "        > 4",
4893                "          [EDITOR: '']  <== selected",
4894                "    > C",
4895                "      .dockerignore",
4896                "      the-new-filename",
4897            ]
4898        );
4899
4900        panel
4901            .update(cx, |panel, cx| {
4902                panel
4903                    .filename_editor
4904                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
4905                panel.confirm_edit(cx).unwrap()
4906            })
4907            .await
4908            .unwrap();
4909        assert_eq!(
4910            visible_entries_as_strings(&panel, 0..10, cx),
4911            &[
4912                "v root1",
4913                "    > .git",
4914                "    > a",
4915                "    v b",
4916                "        > 3",
4917                "        > 4",
4918                "          another-filename.txt  <== selected  <== marked",
4919                "    > C",
4920                "      .dockerignore",
4921                "      the-new-filename",
4922            ]
4923        );
4924
4925        select_path(&panel, "root1/b/another-filename.txt", cx);
4926        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4927        assert_eq!(
4928            visible_entries_as_strings(&panel, 0..10, cx),
4929            &[
4930                "v root1",
4931                "    > .git",
4932                "    > a",
4933                "    v b",
4934                "        > 3",
4935                "        > 4",
4936                "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
4937                "    > C",
4938                "      .dockerignore",
4939                "      the-new-filename",
4940            ]
4941        );
4942
4943        let confirm = panel.update(cx, |panel, cx| {
4944            panel.filename_editor.update(cx, |editor, cx| {
4945                let file_name_selections = editor.selections.all::<usize>(cx);
4946                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
4947                let file_name_selection = &file_name_selections[0];
4948                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
4949                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
4950
4951                editor.set_text("a-different-filename.tar.gz", cx)
4952            });
4953            panel.confirm_edit(cx).unwrap()
4954        });
4955        assert_eq!(
4956            visible_entries_as_strings(&panel, 0..10, cx),
4957            &[
4958                "v root1",
4959                "    > .git",
4960                "    > a",
4961                "    v b",
4962                "        > 3",
4963                "        > 4",
4964                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
4965                "    > C",
4966                "      .dockerignore",
4967                "      the-new-filename",
4968            ]
4969        );
4970
4971        confirm.await.unwrap();
4972        assert_eq!(
4973            visible_entries_as_strings(&panel, 0..10, cx),
4974            &[
4975                "v root1",
4976                "    > .git",
4977                "    > a",
4978                "    v b",
4979                "        > 3",
4980                "        > 4",
4981                "          a-different-filename.tar.gz  <== selected",
4982                "    > C",
4983                "      .dockerignore",
4984                "      the-new-filename",
4985            ]
4986        );
4987
4988        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4989        assert_eq!(
4990            visible_entries_as_strings(&panel, 0..10, cx),
4991            &[
4992                "v root1",
4993                "    > .git",
4994                "    > a",
4995                "    v b",
4996                "        > 3",
4997                "        > 4",
4998                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
4999                "    > C",
5000                "      .dockerignore",
5001                "      the-new-filename",
5002            ]
5003        );
5004
5005        panel.update(cx, |panel, cx| {
5006            panel.filename_editor.update(cx, |editor, cx| {
5007                let file_name_selections = editor.selections.all::<usize>(cx);
5008                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5009                let file_name_selection = &file_name_selections[0];
5010                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
5011                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..");
5012
5013            });
5014            panel.cancel(&menu::Cancel, cx)
5015        });
5016
5017        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5018        assert_eq!(
5019            visible_entries_as_strings(&panel, 0..10, cx),
5020            &[
5021                "v root1",
5022                "    > .git",
5023                "    > a",
5024                "    v b",
5025                "        > 3",
5026                "        > 4",
5027                "        > [EDITOR: '']  <== selected",
5028                "          a-different-filename.tar.gz",
5029                "    > C",
5030                "      .dockerignore",
5031            ]
5032        );
5033
5034        let confirm = panel.update(cx, |panel, cx| {
5035            panel
5036                .filename_editor
5037                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
5038            panel.confirm_edit(cx).unwrap()
5039        });
5040        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
5041        assert_eq!(
5042            visible_entries_as_strings(&panel, 0..10, cx),
5043            &[
5044                "v root1",
5045                "    > .git",
5046                "    > a",
5047                "    v b",
5048                "        > 3",
5049                "        > 4",
5050                "        > [PROCESSING: 'new-dir']",
5051                "          a-different-filename.tar.gz  <== selected",
5052                "    > C",
5053                "      .dockerignore",
5054            ]
5055        );
5056
5057        confirm.await.unwrap();
5058        assert_eq!(
5059            visible_entries_as_strings(&panel, 0..10, cx),
5060            &[
5061                "v root1",
5062                "    > .git",
5063                "    > a",
5064                "    v b",
5065                "        > 3",
5066                "        > 4",
5067                "        > new-dir",
5068                "          a-different-filename.tar.gz  <== selected",
5069                "    > C",
5070                "      .dockerignore",
5071            ]
5072        );
5073
5074        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
5075        assert_eq!(
5076            visible_entries_as_strings(&panel, 0..10, cx),
5077            &[
5078                "v root1",
5079                "    > .git",
5080                "    > a",
5081                "    v b",
5082                "        > 3",
5083                "        > 4",
5084                "        > new-dir",
5085                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
5086                "    > C",
5087                "      .dockerignore",
5088            ]
5089        );
5090
5091        // Dismiss the rename editor when it loses focus.
5092        workspace.update(cx, |_, cx| cx.blur()).unwrap();
5093        assert_eq!(
5094            visible_entries_as_strings(&panel, 0..10, cx),
5095            &[
5096                "v root1",
5097                "    > .git",
5098                "    > a",
5099                "    v b",
5100                "        > 3",
5101                "        > 4",
5102                "        > new-dir",
5103                "          a-different-filename.tar.gz  <== selected",
5104                "    > C",
5105                "      .dockerignore",
5106            ]
5107        );
5108    }
5109
5110    #[gpui::test(iterations = 10)]
5111    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
5112        init_test(cx);
5113
5114        let fs = FakeFs::new(cx.executor().clone());
5115        fs.insert_tree(
5116            "/root1",
5117            json!({
5118                ".dockerignore": "",
5119                ".git": {
5120                    "HEAD": "",
5121                },
5122                "a": {
5123                    "0": { "q": "", "r": "", "s": "" },
5124                    "1": { "t": "", "u": "" },
5125                    "2": { "v": "", "w": "", "x": "", "y": "" },
5126                },
5127                "b": {
5128                    "3": { "Q": "" },
5129                    "4": { "R": "", "S": "", "T": "", "U": "" },
5130                },
5131                "C": {
5132                    "5": {},
5133                    "6": { "V": "", "W": "" },
5134                    "7": { "X": "" },
5135                    "8": { "Y": {}, "Z": "" }
5136                }
5137            }),
5138        )
5139        .await;
5140        fs.insert_tree(
5141            "/root2",
5142            json!({
5143                "d": {
5144                    "9": ""
5145                },
5146                "e": {}
5147            }),
5148        )
5149        .await;
5150
5151        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5152        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5153        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5154        let panel = workspace
5155            .update(cx, |workspace, cx| {
5156                let panel = ProjectPanel::new(workspace, cx);
5157                workspace.add_panel(panel.clone(), cx);
5158                panel
5159            })
5160            .unwrap();
5161
5162        select_path(&panel, "root1", cx);
5163        assert_eq!(
5164            visible_entries_as_strings(&panel, 0..10, cx),
5165            &[
5166                "v root1  <== selected",
5167                "    > .git",
5168                "    > a",
5169                "    > b",
5170                "    > C",
5171                "      .dockerignore",
5172                "v root2",
5173                "    > d",
5174                "    > e",
5175            ]
5176        );
5177
5178        // Add a file with the root folder selected. The filename editor is placed
5179        // before the first file in the root folder.
5180        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5181        panel.update(cx, |panel, cx| {
5182            assert!(panel.filename_editor.read(cx).is_focused(cx));
5183        });
5184        assert_eq!(
5185            visible_entries_as_strings(&panel, 0..10, cx),
5186            &[
5187                "v root1",
5188                "    > .git",
5189                "    > a",
5190                "    > b",
5191                "    > C",
5192                "      [EDITOR: '']  <== selected",
5193                "      .dockerignore",
5194                "v root2",
5195                "    > d",
5196                "    > e",
5197            ]
5198        );
5199
5200        let confirm = panel.update(cx, |panel, cx| {
5201            panel.filename_editor.update(cx, |editor, cx| {
5202                editor.set_text("/bdir1/dir2/the-new-filename", cx)
5203            });
5204            panel.confirm_edit(cx).unwrap()
5205        });
5206
5207        assert_eq!(
5208            visible_entries_as_strings(&panel, 0..10, cx),
5209            &[
5210                "v root1",
5211                "    > .git",
5212                "    > a",
5213                "    > b",
5214                "    > C",
5215                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
5216                "      .dockerignore",
5217                "v root2",
5218                "    > d",
5219                "    > e",
5220            ]
5221        );
5222
5223        confirm.await.unwrap();
5224        assert_eq!(
5225            visible_entries_as_strings(&panel, 0..13, cx),
5226            &[
5227                "v root1",
5228                "    > .git",
5229                "    > a",
5230                "    > b",
5231                "    v bdir1",
5232                "        v dir2",
5233                "              the-new-filename  <== selected  <== marked",
5234                "    > C",
5235                "      .dockerignore",
5236                "v root2",
5237                "    > d",
5238                "    > e",
5239            ]
5240        );
5241    }
5242
5243    #[gpui::test]
5244    async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
5245        init_test(cx);
5246
5247        let fs = FakeFs::new(cx.executor().clone());
5248        fs.insert_tree(
5249            "/root1",
5250            json!({
5251                ".dockerignore": "",
5252                ".git": {
5253                    "HEAD": "",
5254                },
5255            }),
5256        )
5257        .await;
5258
5259        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5260        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5261        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5262        let panel = workspace
5263            .update(cx, |workspace, cx| {
5264                let panel = ProjectPanel::new(workspace, cx);
5265                workspace.add_panel(panel.clone(), cx);
5266                panel
5267            })
5268            .unwrap();
5269
5270        select_path(&panel, "root1", cx);
5271        assert_eq!(
5272            visible_entries_as_strings(&panel, 0..10, cx),
5273            &["v root1  <== selected", "    > .git", "      .dockerignore",]
5274        );
5275
5276        // Add a file with the root folder selected. The filename editor is placed
5277        // before the first file in the root folder.
5278        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5279        panel.update(cx, |panel, cx| {
5280            assert!(panel.filename_editor.read(cx).is_focused(cx));
5281        });
5282        assert_eq!(
5283            visible_entries_as_strings(&panel, 0..10, cx),
5284            &[
5285                "v root1",
5286                "    > .git",
5287                "      [EDITOR: '']  <== selected",
5288                "      .dockerignore",
5289            ]
5290        );
5291
5292        let confirm = panel.update(cx, |panel, cx| {
5293            panel
5294                .filename_editor
5295                .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
5296            panel.confirm_edit(cx).unwrap()
5297        });
5298
5299        assert_eq!(
5300            visible_entries_as_strings(&panel, 0..10, cx),
5301            &[
5302                "v root1",
5303                "    > .git",
5304                "      [PROCESSING: '/new_dir/']  <== selected",
5305                "      .dockerignore",
5306            ]
5307        );
5308
5309        confirm.await.unwrap();
5310        assert_eq!(
5311            visible_entries_as_strings(&panel, 0..13, cx),
5312            &[
5313                "v root1",
5314                "    > .git",
5315                "    v new_dir  <== selected",
5316                "      .dockerignore",
5317            ]
5318        );
5319    }
5320
5321    #[gpui::test]
5322    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
5323        init_test(cx);
5324
5325        let fs = FakeFs::new(cx.executor().clone());
5326        fs.insert_tree(
5327            "/root1",
5328            json!({
5329                "one.two.txt": "",
5330                "one.txt": ""
5331            }),
5332        )
5333        .await;
5334
5335        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5336        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5337        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5338        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5339
5340        panel.update(cx, |panel, cx| {
5341            panel.select_next(&Default::default(), cx);
5342            panel.select_next(&Default::default(), cx);
5343        });
5344
5345        assert_eq!(
5346            visible_entries_as_strings(&panel, 0..50, cx),
5347            &[
5348                //
5349                "v root1",
5350                "      one.txt  <== selected",
5351                "      one.two.txt",
5352            ]
5353        );
5354
5355        // Regression test - file name is created correctly when
5356        // the copied file's name contains multiple dots.
5357        panel.update(cx, |panel, cx| {
5358            panel.copy(&Default::default(), cx);
5359            panel.paste(&Default::default(), cx);
5360        });
5361        cx.executor().run_until_parked();
5362
5363        assert_eq!(
5364            visible_entries_as_strings(&panel, 0..50, cx),
5365            &[
5366                //
5367                "v root1",
5368                "      one.txt",
5369                "      [EDITOR: 'one copy.txt']  <== selected",
5370                "      one.two.txt",
5371            ]
5372        );
5373
5374        panel.update(cx, |panel, cx| {
5375            panel.filename_editor.update(cx, |editor, cx| {
5376                let file_name_selections = editor.selections.all::<usize>(cx);
5377                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
5378                let file_name_selection = &file_name_selections[0];
5379                assert_eq!(file_name_selection.start, "one".len(), "Should select the file name disambiguation after the original file name");
5380                assert_eq!(file_name_selection.end, "one copy".len(), "Should select the file name disambiguation until the extension");
5381            });
5382            assert!(panel.confirm_edit(cx).is_none());
5383        });
5384
5385        panel.update(cx, |panel, cx| {
5386            panel.paste(&Default::default(), cx);
5387        });
5388        cx.executor().run_until_parked();
5389
5390        assert_eq!(
5391            visible_entries_as_strings(&panel, 0..50, cx),
5392            &[
5393                //
5394                "v root1",
5395                "      one.txt",
5396                "      one copy.txt",
5397                "      [EDITOR: 'one copy 1.txt']  <== selected",
5398                "      one.two.txt",
5399            ]
5400        );
5401
5402        panel.update(cx, |panel, cx| assert!(panel.confirm_edit(cx).is_none()));
5403    }
5404
5405    #[gpui::test]
5406    async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5407        init_test(cx);
5408
5409        let fs = FakeFs::new(cx.executor().clone());
5410        fs.insert_tree(
5411            "/root1",
5412            json!({
5413                "one.txt": "",
5414                "two.txt": "",
5415                "three.txt": "",
5416                "a": {
5417                    "0": { "q": "", "r": "", "s": "" },
5418                    "1": { "t": "", "u": "" },
5419                    "2": { "v": "", "w": "", "x": "", "y": "" },
5420                },
5421            }),
5422        )
5423        .await;
5424
5425        fs.insert_tree(
5426            "/root2",
5427            json!({
5428                "one.txt": "",
5429                "two.txt": "",
5430                "four.txt": "",
5431                "b": {
5432                    "3": { "Q": "" },
5433                    "4": { "R": "", "S": "", "T": "", "U": "" },
5434                },
5435            }),
5436        )
5437        .await;
5438
5439        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5440        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5441        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5442        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5443
5444        select_path(&panel, "root1/three.txt", cx);
5445        panel.update(cx, |panel, cx| {
5446            panel.cut(&Default::default(), cx);
5447        });
5448
5449        select_path(&panel, "root2/one.txt", cx);
5450        panel.update(cx, |panel, cx| {
5451            panel.select_next(&Default::default(), cx);
5452            panel.paste(&Default::default(), cx);
5453        });
5454        cx.executor().run_until_parked();
5455        assert_eq!(
5456            visible_entries_as_strings(&panel, 0..50, cx),
5457            &[
5458                //
5459                "v root1",
5460                "    > a",
5461                "      one.txt",
5462                "      two.txt",
5463                "v root2",
5464                "    > b",
5465                "      four.txt",
5466                "      one.txt",
5467                "      three.txt  <== selected",
5468                "      two.txt",
5469            ]
5470        );
5471
5472        select_path(&panel, "root1/a", cx);
5473        panel.update(cx, |panel, cx| {
5474            panel.cut(&Default::default(), cx);
5475        });
5476        select_path(&panel, "root2/two.txt", cx);
5477        panel.update(cx, |panel, cx| {
5478            panel.select_next(&Default::default(), cx);
5479            panel.paste(&Default::default(), cx);
5480        });
5481
5482        cx.executor().run_until_parked();
5483        assert_eq!(
5484            visible_entries_as_strings(&panel, 0..50, cx),
5485            &[
5486                //
5487                "v root1",
5488                "      one.txt",
5489                "      two.txt",
5490                "v root2",
5491                "    > a  <== selected",
5492                "    > b",
5493                "      four.txt",
5494                "      one.txt",
5495                "      three.txt",
5496                "      two.txt",
5497            ]
5498        );
5499    }
5500
5501    #[gpui::test]
5502    async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
5503        init_test(cx);
5504
5505        let fs = FakeFs::new(cx.executor().clone());
5506        fs.insert_tree(
5507            "/root1",
5508            json!({
5509                "one.txt": "",
5510                "two.txt": "",
5511                "three.txt": "",
5512                "a": {
5513                    "0": { "q": "", "r": "", "s": "" },
5514                    "1": { "t": "", "u": "" },
5515                    "2": { "v": "", "w": "", "x": "", "y": "" },
5516                },
5517            }),
5518        )
5519        .await;
5520
5521        fs.insert_tree(
5522            "/root2",
5523            json!({
5524                "one.txt": "",
5525                "two.txt": "",
5526                "four.txt": "",
5527                "b": {
5528                    "3": { "Q": "" },
5529                    "4": { "R": "", "S": "", "T": "", "U": "" },
5530                },
5531            }),
5532        )
5533        .await;
5534
5535        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
5536        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5537        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5538        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5539
5540        select_path(&panel, "root1/three.txt", cx);
5541        panel.update(cx, |panel, cx| {
5542            panel.copy(&Default::default(), cx);
5543        });
5544
5545        select_path(&panel, "root2/one.txt", cx);
5546        panel.update(cx, |panel, cx| {
5547            panel.select_next(&Default::default(), cx);
5548            panel.paste(&Default::default(), cx);
5549        });
5550        cx.executor().run_until_parked();
5551        assert_eq!(
5552            visible_entries_as_strings(&panel, 0..50, cx),
5553            &[
5554                //
5555                "v root1",
5556                "    > a",
5557                "      one.txt",
5558                "      three.txt",
5559                "      two.txt",
5560                "v root2",
5561                "    > b",
5562                "      four.txt",
5563                "      one.txt",
5564                "      three.txt  <== selected",
5565                "      two.txt",
5566            ]
5567        );
5568
5569        select_path(&panel, "root1/three.txt", cx);
5570        panel.update(cx, |panel, cx| {
5571            panel.copy(&Default::default(), cx);
5572        });
5573        select_path(&panel, "root2/two.txt", cx);
5574        panel.update(cx, |panel, cx| {
5575            panel.select_next(&Default::default(), cx);
5576            panel.paste(&Default::default(), cx);
5577        });
5578
5579        cx.executor().run_until_parked();
5580        assert_eq!(
5581            visible_entries_as_strings(&panel, 0..50, cx),
5582            &[
5583                //
5584                "v root1",
5585                "    > a",
5586                "      one.txt",
5587                "      three.txt",
5588                "      two.txt",
5589                "v root2",
5590                "    > b",
5591                "      four.txt",
5592                "      one.txt",
5593                "      three.txt",
5594                "      [EDITOR: 'three copy.txt']  <== selected",
5595                "      two.txt",
5596            ]
5597        );
5598
5599        panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel {}, cx));
5600        cx.executor().run_until_parked();
5601
5602        select_path(&panel, "root1/a", cx);
5603        panel.update(cx, |panel, cx| {
5604            panel.copy(&Default::default(), cx);
5605        });
5606        select_path(&panel, "root2/two.txt", cx);
5607        panel.update(cx, |panel, cx| {
5608            panel.select_next(&Default::default(), cx);
5609            panel.paste(&Default::default(), cx);
5610        });
5611
5612        cx.executor().run_until_parked();
5613        assert_eq!(
5614            visible_entries_as_strings(&panel, 0..50, cx),
5615            &[
5616                //
5617                "v root1",
5618                "    > a",
5619                "      one.txt",
5620                "      three.txt",
5621                "      two.txt",
5622                "v root2",
5623                "    > a  <== selected",
5624                "    > b",
5625                "      four.txt",
5626                "      one.txt",
5627                "      three.txt",
5628                "      three copy.txt",
5629                "      two.txt",
5630            ]
5631        );
5632    }
5633
5634    #[gpui::test]
5635    async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
5636        init_test(cx);
5637
5638        let fs = FakeFs::new(cx.executor().clone());
5639        fs.insert_tree(
5640            "/root",
5641            json!({
5642                "a": {
5643                    "one.txt": "",
5644                    "two.txt": "",
5645                    "inner_dir": {
5646                        "three.txt": "",
5647                        "four.txt": "",
5648                    }
5649                },
5650                "b": {}
5651            }),
5652        )
5653        .await;
5654
5655        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
5656        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5657        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5658        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5659
5660        select_path(&panel, "root/a", cx);
5661        panel.update(cx, |panel, cx| {
5662            panel.copy(&Default::default(), cx);
5663            panel.select_next(&Default::default(), cx);
5664            panel.paste(&Default::default(), cx);
5665        });
5666        cx.executor().run_until_parked();
5667
5668        let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
5669        assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
5670
5671        let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
5672        assert_ne!(
5673            pasted_dir_file, None,
5674            "Pasted directory file should have an entry"
5675        );
5676
5677        let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
5678        assert_ne!(
5679            pasted_dir_inner_dir, None,
5680            "Directories inside pasted directory should have an entry"
5681        );
5682
5683        toggle_expand_dir(&panel, "root/b/a", cx);
5684        toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
5685
5686        assert_eq!(
5687            visible_entries_as_strings(&panel, 0..50, cx),
5688            &[
5689                //
5690                "v root",
5691                "    > a",
5692                "    v b",
5693                "        v a",
5694                "            v inner_dir  <== selected",
5695                "                  four.txt",
5696                "                  three.txt",
5697                "              one.txt",
5698                "              two.txt",
5699            ]
5700        );
5701
5702        select_path(&panel, "root", cx);
5703        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5704        cx.executor().run_until_parked();
5705        assert_eq!(
5706            visible_entries_as_strings(&panel, 0..50, cx),
5707            &[
5708                //
5709                "v root",
5710                "    > a",
5711                "    > [EDITOR: 'a copy']  <== selected",
5712                "    v b",
5713                "        v a",
5714                "            v inner_dir",
5715                "                  four.txt",
5716                "                  three.txt",
5717                "              one.txt",
5718                "              two.txt"
5719            ]
5720        );
5721
5722        let confirm = panel.update(cx, |panel, cx| {
5723            panel
5724                .filename_editor
5725                .update(cx, |editor, cx| editor.set_text("c", cx));
5726            panel.confirm_edit(cx).unwrap()
5727        });
5728        assert_eq!(
5729            visible_entries_as_strings(&panel, 0..50, cx),
5730            &[
5731                //
5732                "v root",
5733                "    > a",
5734                "    > [PROCESSING: 'c']  <== selected",
5735                "    v b",
5736                "        v a",
5737                "            v inner_dir",
5738                "                  four.txt",
5739                "                  three.txt",
5740                "              one.txt",
5741                "              two.txt"
5742            ]
5743        );
5744
5745        confirm.await.unwrap();
5746
5747        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
5748        cx.executor().run_until_parked();
5749        assert_eq!(
5750            visible_entries_as_strings(&panel, 0..50, cx),
5751            &[
5752                //
5753                "v root",
5754                "    > a",
5755                "    v b",
5756                "        v a",
5757                "            v inner_dir",
5758                "                  four.txt",
5759                "                  three.txt",
5760                "              one.txt",
5761                "              two.txt",
5762                "    v c",
5763                "        > a  <== selected",
5764                "        > inner_dir",
5765                "          one.txt",
5766                "          two.txt",
5767            ]
5768        );
5769    }
5770
5771    #[gpui::test]
5772    async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) {
5773        init_test(cx);
5774
5775        let fs = FakeFs::new(cx.executor().clone());
5776        fs.insert_tree(
5777            "/test",
5778            json!({
5779                "dir1": {
5780                    "a.txt": "",
5781                    "b.txt": "",
5782                },
5783                "dir2": {},
5784                "c.txt": "",
5785                "d.txt": "",
5786            }),
5787        )
5788        .await;
5789
5790        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5791        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5792        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5793        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5794
5795        toggle_expand_dir(&panel, "test/dir1", cx);
5796
5797        cx.simulate_modifiers_change(gpui::Modifiers {
5798            control: true,
5799            ..Default::default()
5800        });
5801
5802        select_path_with_mark(&panel, "test/dir1", cx);
5803        select_path_with_mark(&panel, "test/c.txt", cx);
5804
5805        assert_eq!(
5806            visible_entries_as_strings(&panel, 0..15, cx),
5807            &[
5808                "v test",
5809                "    v dir1  <== marked",
5810                "          a.txt",
5811                "          b.txt",
5812                "    > dir2",
5813                "      c.txt  <== selected  <== marked",
5814                "      d.txt",
5815            ],
5816            "Initial state before copying dir1 and c.txt"
5817        );
5818
5819        panel.update(cx, |panel, cx| {
5820            panel.copy(&Default::default(), cx);
5821        });
5822        select_path(&panel, "test/dir2", cx);
5823        panel.update(cx, |panel, cx| {
5824            panel.paste(&Default::default(), cx);
5825        });
5826        cx.executor().run_until_parked();
5827
5828        toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5829
5830        assert_eq!(
5831            visible_entries_as_strings(&panel, 0..15, cx),
5832            &[
5833                "v test",
5834                "    v dir1  <== marked",
5835                "          a.txt",
5836                "          b.txt",
5837                "    v dir2",
5838                "        v dir1  <== selected",
5839                "              a.txt",
5840                "              b.txt",
5841                "          c.txt",
5842                "      c.txt  <== marked",
5843                "      d.txt",
5844            ],
5845            "Should copy dir1 as well as c.txt into dir2"
5846        );
5847
5848        // Disambiguating multiple files should not open the rename editor.
5849        select_path(&panel, "test/dir2", cx);
5850        panel.update(cx, |panel, cx| {
5851            panel.paste(&Default::default(), cx);
5852        });
5853        cx.executor().run_until_parked();
5854
5855        assert_eq!(
5856            visible_entries_as_strings(&panel, 0..15, cx),
5857            &[
5858                "v test",
5859                "    v dir1  <== marked",
5860                "          a.txt",
5861                "          b.txt",
5862                "    v dir2",
5863                "        v dir1",
5864                "              a.txt",
5865                "              b.txt",
5866                "        > dir1 copy  <== selected",
5867                "          c.txt",
5868                "          c copy.txt",
5869                "      c.txt  <== marked",
5870                "      d.txt",
5871            ],
5872            "Should copy dir1 as well as c.txt into dir2 and disambiguate them without opening the rename editor"
5873        );
5874    }
5875
5876    #[gpui::test]
5877    async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) {
5878        init_test(cx);
5879
5880        let fs = FakeFs::new(cx.executor().clone());
5881        fs.insert_tree(
5882            "/test",
5883            json!({
5884                "dir1": {
5885                    "a.txt": "",
5886                    "b.txt": "",
5887                },
5888                "dir2": {},
5889                "c.txt": "",
5890                "d.txt": "",
5891            }),
5892        )
5893        .await;
5894
5895        let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
5896        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5897        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5898        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5899
5900        toggle_expand_dir(&panel, "test/dir1", cx);
5901
5902        cx.simulate_modifiers_change(gpui::Modifiers {
5903            control: true,
5904            ..Default::default()
5905        });
5906
5907        select_path_with_mark(&panel, "test/dir1/a.txt", cx);
5908        select_path_with_mark(&panel, "test/dir1", cx);
5909        select_path_with_mark(&panel, "test/c.txt", cx);
5910
5911        assert_eq!(
5912            visible_entries_as_strings(&panel, 0..15, cx),
5913            &[
5914                "v test",
5915                "    v dir1  <== marked",
5916                "          a.txt  <== marked",
5917                "          b.txt",
5918                "    > dir2",
5919                "      c.txt  <== selected  <== marked",
5920                "      d.txt",
5921            ],
5922            "Initial state before copying a.txt, dir1 and c.txt"
5923        );
5924
5925        panel.update(cx, |panel, cx| {
5926            panel.copy(&Default::default(), cx);
5927        });
5928        select_path(&panel, "test/dir2", cx);
5929        panel.update(cx, |panel, cx| {
5930            panel.paste(&Default::default(), cx);
5931        });
5932        cx.executor().run_until_parked();
5933
5934        toggle_expand_dir(&panel, "test/dir2/dir1", cx);
5935
5936        assert_eq!(
5937            visible_entries_as_strings(&panel, 0..20, cx),
5938            &[
5939                "v test",
5940                "    v dir1  <== marked",
5941                "          a.txt  <== marked",
5942                "          b.txt",
5943                "    v dir2",
5944                "        v dir1  <== selected",
5945                "              a.txt",
5946                "              b.txt",
5947                "          c.txt",
5948                "      c.txt  <== marked",
5949                "      d.txt",
5950            ],
5951            "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1."
5952        );
5953    }
5954
5955    #[gpui::test]
5956    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
5957        init_test_with_editor(cx);
5958
5959        let fs = FakeFs::new(cx.executor().clone());
5960        fs.insert_tree(
5961            "/src",
5962            json!({
5963                "test": {
5964                    "first.rs": "// First Rust file",
5965                    "second.rs": "// Second Rust file",
5966                    "third.rs": "// Third Rust file",
5967                }
5968            }),
5969        )
5970        .await;
5971
5972        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
5973        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5974        let cx = &mut VisualTestContext::from_window(*workspace, cx);
5975        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5976
5977        toggle_expand_dir(&panel, "src/test", cx);
5978        select_path(&panel, "src/test/first.rs", cx);
5979        panel.update(cx, |panel, cx| panel.open(&Open, cx));
5980        cx.executor().run_until_parked();
5981        assert_eq!(
5982            visible_entries_as_strings(&panel, 0..10, cx),
5983            &[
5984                "v src",
5985                "    v test",
5986                "          first.rs  <== selected  <== marked",
5987                "          second.rs",
5988                "          third.rs"
5989            ]
5990        );
5991        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
5992
5993        submit_deletion(&panel, cx);
5994        assert_eq!(
5995            visible_entries_as_strings(&panel, 0..10, cx),
5996            &[
5997                "v src",
5998                "    v test",
5999                "          second.rs  <== selected",
6000                "          third.rs"
6001            ],
6002            "Project panel should have no deleted file, no other file is selected in it"
6003        );
6004        ensure_no_open_items_and_panes(&workspace, cx);
6005
6006        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6007        cx.executor().run_until_parked();
6008        assert_eq!(
6009            visible_entries_as_strings(&panel, 0..10, cx),
6010            &[
6011                "v src",
6012                "    v test",
6013                "          second.rs  <== selected  <== marked",
6014                "          third.rs"
6015            ]
6016        );
6017        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
6018
6019        workspace
6020            .update(cx, |workspace, cx| {
6021                let active_items = workspace
6022                    .panes()
6023                    .iter()
6024                    .filter_map(|pane| pane.read(cx).active_item())
6025                    .collect::<Vec<_>>();
6026                assert_eq!(active_items.len(), 1);
6027                let open_editor = active_items
6028                    .into_iter()
6029                    .next()
6030                    .unwrap()
6031                    .downcast::<Editor>()
6032                    .expect("Open item should be an editor");
6033                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
6034            })
6035            .unwrap();
6036        submit_deletion_skipping_prompt(&panel, cx);
6037        assert_eq!(
6038            visible_entries_as_strings(&panel, 0..10, cx),
6039            &["v src", "    v test", "          third.rs  <== selected"],
6040            "Project panel should have no deleted file, with one last file remaining"
6041        );
6042        ensure_no_open_items_and_panes(&workspace, cx);
6043    }
6044
6045    #[gpui::test]
6046    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
6047        init_test_with_editor(cx);
6048
6049        let fs = FakeFs::new(cx.executor().clone());
6050        fs.insert_tree(
6051            "/src",
6052            json!({
6053                "test": {
6054                    "first.rs": "// First Rust file",
6055                    "second.rs": "// Second Rust file",
6056                    "third.rs": "// Third Rust file",
6057                }
6058            }),
6059        )
6060        .await;
6061
6062        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
6063        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6064        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6065        let panel = workspace
6066            .update(cx, |workspace, cx| {
6067                let panel = ProjectPanel::new(workspace, cx);
6068                workspace.add_panel(panel.clone(), cx);
6069                panel
6070            })
6071            .unwrap();
6072
6073        select_path(&panel, "src/", cx);
6074        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6075        cx.executor().run_until_parked();
6076        assert_eq!(
6077            visible_entries_as_strings(&panel, 0..10, cx),
6078            &[
6079                //
6080                "v src  <== selected",
6081                "    > test"
6082            ]
6083        );
6084        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
6085        panel.update(cx, |panel, cx| {
6086            assert!(panel.filename_editor.read(cx).is_focused(cx));
6087        });
6088        assert_eq!(
6089            visible_entries_as_strings(&panel, 0..10, cx),
6090            &[
6091                //
6092                "v src",
6093                "    > [EDITOR: '']  <== selected",
6094                "    > test"
6095            ]
6096        );
6097        panel.update(cx, |panel, cx| {
6098            panel
6099                .filename_editor
6100                .update(cx, |editor, cx| editor.set_text("test", cx));
6101            assert!(
6102                panel.confirm_edit(cx).is_none(),
6103                "Should not allow to confirm on conflicting new directory name"
6104            )
6105        });
6106        assert_eq!(
6107            visible_entries_as_strings(&panel, 0..10, cx),
6108            &[
6109                //
6110                "v src",
6111                "    > test"
6112            ],
6113            "File list should be unchanged after failed folder create confirmation"
6114        );
6115
6116        select_path(&panel, "src/test/", cx);
6117        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6118        cx.executor().run_until_parked();
6119        assert_eq!(
6120            visible_entries_as_strings(&panel, 0..10, cx),
6121            &[
6122                //
6123                "v src",
6124                "    > test  <== selected"
6125            ]
6126        );
6127        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
6128        panel.update(cx, |panel, cx| {
6129            assert!(panel.filename_editor.read(cx).is_focused(cx));
6130        });
6131        assert_eq!(
6132            visible_entries_as_strings(&panel, 0..10, cx),
6133            &[
6134                "v src",
6135                "    v test",
6136                "          [EDITOR: '']  <== selected",
6137                "          first.rs",
6138                "          second.rs",
6139                "          third.rs"
6140            ]
6141        );
6142        panel.update(cx, |panel, cx| {
6143            panel
6144                .filename_editor
6145                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
6146            assert!(
6147                panel.confirm_edit(cx).is_none(),
6148                "Should not allow to confirm on conflicting new file name"
6149            )
6150        });
6151        assert_eq!(
6152            visible_entries_as_strings(&panel, 0..10, cx),
6153            &[
6154                "v src",
6155                "    v test",
6156                "          first.rs",
6157                "          second.rs",
6158                "          third.rs"
6159            ],
6160            "File list should be unchanged after failed file create confirmation"
6161        );
6162
6163        select_path(&panel, "src/test/first.rs", cx);
6164        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6165        cx.executor().run_until_parked();
6166        assert_eq!(
6167            visible_entries_as_strings(&panel, 0..10, cx),
6168            &[
6169                "v src",
6170                "    v test",
6171                "          first.rs  <== selected",
6172                "          second.rs",
6173                "          third.rs"
6174            ],
6175        );
6176        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6177        panel.update(cx, |panel, cx| {
6178            assert!(panel.filename_editor.read(cx).is_focused(cx));
6179        });
6180        assert_eq!(
6181            visible_entries_as_strings(&panel, 0..10, cx),
6182            &[
6183                "v src",
6184                "    v test",
6185                "          [EDITOR: 'first.rs']  <== selected",
6186                "          second.rs",
6187                "          third.rs"
6188            ]
6189        );
6190        panel.update(cx, |panel, cx| {
6191            panel
6192                .filename_editor
6193                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
6194            assert!(
6195                panel.confirm_edit(cx).is_none(),
6196                "Should not allow to confirm on conflicting file rename"
6197            )
6198        });
6199        assert_eq!(
6200            visible_entries_as_strings(&panel, 0..10, cx),
6201            &[
6202                "v src",
6203                "    v test",
6204                "          first.rs  <== selected",
6205                "          second.rs",
6206                "          third.rs"
6207            ],
6208            "File list should be unchanged after failed rename confirmation"
6209        );
6210    }
6211
6212    #[gpui::test]
6213    async fn test_select_directory(cx: &mut gpui::TestAppContext) {
6214        init_test_with_editor(cx);
6215
6216        let fs = FakeFs::new(cx.executor().clone());
6217        fs.insert_tree(
6218            "/project_root",
6219            json!({
6220                "dir_1": {
6221                    "nested_dir": {
6222                        "file_a.py": "# File contents",
6223                    }
6224                },
6225                "file_1.py": "# File contents",
6226                "dir_2": {
6227
6228                },
6229                "dir_3": {
6230
6231                },
6232                "file_2.py": "# File contents",
6233                "dir_4": {
6234
6235                },
6236            }),
6237        )
6238        .await;
6239
6240        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6241        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6242        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6243        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6244
6245        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6246        cx.executor().run_until_parked();
6247        select_path(&panel, "project_root/dir_1", cx);
6248        cx.executor().run_until_parked();
6249        assert_eq!(
6250            visible_entries_as_strings(&panel, 0..10, cx),
6251            &[
6252                "v project_root",
6253                "    > dir_1  <== selected",
6254                "    > dir_2",
6255                "    > dir_3",
6256                "    > dir_4",
6257                "      file_1.py",
6258                "      file_2.py",
6259            ]
6260        );
6261        panel.update(cx, |panel, cx| {
6262            panel.select_prev_directory(&SelectPrevDirectory, cx)
6263        });
6264
6265        assert_eq!(
6266            visible_entries_as_strings(&panel, 0..10, cx),
6267            &[
6268                "v project_root  <== selected",
6269                "    > dir_1",
6270                "    > dir_2",
6271                "    > dir_3",
6272                "    > dir_4",
6273                "      file_1.py",
6274                "      file_2.py",
6275            ]
6276        );
6277
6278        panel.update(cx, |panel, cx| {
6279            panel.select_prev_directory(&SelectPrevDirectory, cx)
6280        });
6281
6282        assert_eq!(
6283            visible_entries_as_strings(&panel, 0..10, cx),
6284            &[
6285                "v project_root",
6286                "    > dir_1",
6287                "    > dir_2",
6288                "    > dir_3",
6289                "    > dir_4  <== selected",
6290                "      file_1.py",
6291                "      file_2.py",
6292            ]
6293        );
6294
6295        panel.update(cx, |panel, cx| {
6296            panel.select_next_directory(&SelectNextDirectory, cx)
6297        });
6298
6299        assert_eq!(
6300            visible_entries_as_strings(&panel, 0..10, cx),
6301            &[
6302                "v project_root  <== selected",
6303                "    > dir_1",
6304                "    > dir_2",
6305                "    > dir_3",
6306                "    > dir_4",
6307                "      file_1.py",
6308                "      file_2.py",
6309            ]
6310        );
6311    }
6312
6313    #[gpui::test]
6314    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
6315        init_test_with_editor(cx);
6316
6317        let fs = FakeFs::new(cx.executor().clone());
6318        fs.insert_tree(
6319            "/project_root",
6320            json!({
6321                "dir_1": {
6322                    "nested_dir": {
6323                        "file_a.py": "# File contents",
6324                    }
6325                },
6326                "file_1.py": "# File contents",
6327            }),
6328        )
6329        .await;
6330
6331        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6332        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6333        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6334        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6335
6336        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6337        cx.executor().run_until_parked();
6338        select_path(&panel, "project_root/dir_1", cx);
6339        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6340        select_path(&panel, "project_root/dir_1/nested_dir", cx);
6341        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6342        panel.update(cx, |panel, cx| panel.open(&Open, cx));
6343        cx.executor().run_until_parked();
6344        assert_eq!(
6345            visible_entries_as_strings(&panel, 0..10, cx),
6346            &[
6347                "v project_root",
6348                "    v dir_1",
6349                "        > nested_dir  <== selected",
6350                "      file_1.py",
6351            ]
6352        );
6353    }
6354
6355    #[gpui::test]
6356    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
6357        init_test_with_editor(cx);
6358
6359        let fs = FakeFs::new(cx.executor().clone());
6360        fs.insert_tree(
6361            "/project_root",
6362            json!({
6363                "dir_1": {
6364                    "nested_dir": {
6365                        "file_a.py": "# File contents",
6366                        "file_b.py": "# File contents",
6367                        "file_c.py": "# File contents",
6368                    },
6369                    "file_1.py": "# File contents",
6370                    "file_2.py": "# File contents",
6371                    "file_3.py": "# File contents",
6372                },
6373                "dir_2": {
6374                    "file_1.py": "# File contents",
6375                    "file_2.py": "# File contents",
6376                    "file_3.py": "# File contents",
6377                }
6378            }),
6379        )
6380        .await;
6381
6382        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6383        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6384        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6385        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6386
6387        panel.update(cx, |panel, cx| {
6388            panel.collapse_all_entries(&CollapseAllEntries, cx)
6389        });
6390        cx.executor().run_until_parked();
6391        assert_eq!(
6392            visible_entries_as_strings(&panel, 0..10, cx),
6393            &["v project_root", "    > dir_1", "    > dir_2",]
6394        );
6395
6396        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
6397        toggle_expand_dir(&panel, "project_root/dir_1", cx);
6398        cx.executor().run_until_parked();
6399        assert_eq!(
6400            visible_entries_as_strings(&panel, 0..10, cx),
6401            &[
6402                "v project_root",
6403                "    v dir_1  <== selected",
6404                "        > nested_dir",
6405                "          file_1.py",
6406                "          file_2.py",
6407                "          file_3.py",
6408                "    > dir_2",
6409            ]
6410        );
6411    }
6412
6413    #[gpui::test]
6414    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
6415        init_test(cx);
6416
6417        let fs = FakeFs::new(cx.executor().clone());
6418        fs.as_fake().insert_tree("/root", json!({})).await;
6419        let project = Project::test(fs, ["/root".as_ref()], cx).await;
6420        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6421        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6422        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6423
6424        // Make a new buffer with no backing file
6425        workspace
6426            .update(cx, |workspace, cx| {
6427                Editor::new_file(workspace, &Default::default(), cx)
6428            })
6429            .unwrap();
6430
6431        cx.executor().run_until_parked();
6432
6433        // "Save as" the buffer, creating a new backing file for it
6434        let save_task = workspace
6435            .update(cx, |workspace, cx| {
6436                workspace.save_active_item(workspace::SaveIntent::Save, cx)
6437            })
6438            .unwrap();
6439
6440        cx.executor().run_until_parked();
6441        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
6442        save_task.await.unwrap();
6443
6444        // Rename the file
6445        select_path(&panel, "root/new", cx);
6446        assert_eq!(
6447            visible_entries_as_strings(&panel, 0..10, cx),
6448            &["v root", "      new  <== selected"]
6449        );
6450        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
6451        panel.update(cx, |panel, cx| {
6452            panel
6453                .filename_editor
6454                .update(cx, |editor, cx| editor.set_text("newer", cx));
6455        });
6456        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
6457
6458        cx.executor().run_until_parked();
6459        assert_eq!(
6460            visible_entries_as_strings(&panel, 0..10, cx),
6461            &["v root", "      newer  <== selected"]
6462        );
6463
6464        workspace
6465            .update(cx, |workspace, cx| {
6466                workspace.save_active_item(workspace::SaveIntent::Save, cx)
6467            })
6468            .unwrap()
6469            .await
6470            .unwrap();
6471
6472        cx.executor().run_until_parked();
6473        // assert that saving the file doesn't restore "new"
6474        assert_eq!(
6475            visible_entries_as_strings(&panel, 0..10, cx),
6476            &["v root", "      newer  <== selected"]
6477        );
6478    }
6479
6480    #[gpui::test]
6481    async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
6482        init_test_with_editor(cx);
6483        let fs = FakeFs::new(cx.executor().clone());
6484        fs.insert_tree(
6485            "/project_root",
6486            json!({
6487                "dir_1": {
6488                    "nested_dir": {
6489                        "file_a.py": "# File contents",
6490                    }
6491                },
6492                "file_1.py": "# File contents",
6493            }),
6494        )
6495        .await;
6496
6497        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6498        let worktree_id =
6499            cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
6500        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6501        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6502        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6503        cx.update(|cx| {
6504            panel.update(cx, |this, cx| {
6505                this.select_next(&Default::default(), cx);
6506                this.expand_selected_entry(&Default::default(), cx);
6507                this.expand_selected_entry(&Default::default(), cx);
6508                this.select_next(&Default::default(), cx);
6509                this.expand_selected_entry(&Default::default(), cx);
6510                this.select_next(&Default::default(), cx);
6511            })
6512        });
6513        assert_eq!(
6514            visible_entries_as_strings(&panel, 0..10, cx),
6515            &[
6516                "v project_root",
6517                "    v dir_1",
6518                "        v nested_dir",
6519                "              file_a.py  <== selected",
6520                "      file_1.py",
6521            ]
6522        );
6523        let modifiers_with_shift = gpui::Modifiers {
6524            shift: true,
6525            ..Default::default()
6526        };
6527        cx.simulate_modifiers_change(modifiers_with_shift);
6528        cx.update(|cx| {
6529            panel.update(cx, |this, cx| {
6530                this.select_next(&Default::default(), cx);
6531            })
6532        });
6533        assert_eq!(
6534            visible_entries_as_strings(&panel, 0..10, cx),
6535            &[
6536                "v project_root",
6537                "    v dir_1",
6538                "        v nested_dir",
6539                "              file_a.py",
6540                "      file_1.py  <== selected  <== marked",
6541            ]
6542        );
6543        cx.update(|cx| {
6544            panel.update(cx, |this, cx| {
6545                this.select_prev(&Default::default(), cx);
6546            })
6547        });
6548        assert_eq!(
6549            visible_entries_as_strings(&panel, 0..10, cx),
6550            &[
6551                "v project_root",
6552                "    v dir_1",
6553                "        v nested_dir",
6554                "              file_a.py  <== selected  <== marked",
6555                "      file_1.py  <== marked",
6556            ]
6557        );
6558        cx.update(|cx| {
6559            panel.update(cx, |this, cx| {
6560                let drag = DraggedSelection {
6561                    active_selection: this.selection.unwrap(),
6562                    marked_selections: Arc::new(this.marked_entries.clone()),
6563                };
6564                let target_entry = this
6565                    .project
6566                    .read(cx)
6567                    .entry_for_path(&(worktree_id, "").into(), cx)
6568                    .unwrap();
6569                this.drag_onto(&drag, target_entry.id, false, cx);
6570            });
6571        });
6572        cx.run_until_parked();
6573        assert_eq!(
6574            visible_entries_as_strings(&panel, 0..10, cx),
6575            &[
6576                "v project_root",
6577                "    v dir_1",
6578                "        v nested_dir",
6579                "      file_1.py  <== marked",
6580                "      file_a.py  <== selected  <== marked",
6581            ]
6582        );
6583        // ESC clears out all marks
6584        cx.update(|cx| {
6585            panel.update(cx, |this, cx| {
6586                this.cancel(&menu::Cancel, cx);
6587            })
6588        });
6589        assert_eq!(
6590            visible_entries_as_strings(&panel, 0..10, cx),
6591            &[
6592                "v project_root",
6593                "    v dir_1",
6594                "        v nested_dir",
6595                "      file_1.py",
6596                "      file_a.py  <== selected",
6597            ]
6598        );
6599        // ESC clears out all marks
6600        cx.update(|cx| {
6601            panel.update(cx, |this, cx| {
6602                this.select_prev(&SelectPrev, cx);
6603                this.select_next(&SelectNext, cx);
6604            })
6605        });
6606        assert_eq!(
6607            visible_entries_as_strings(&panel, 0..10, cx),
6608            &[
6609                "v project_root",
6610                "    v dir_1",
6611                "        v nested_dir",
6612                "      file_1.py  <== marked",
6613                "      file_a.py  <== selected  <== marked",
6614            ]
6615        );
6616        cx.simulate_modifiers_change(Default::default());
6617        cx.update(|cx| {
6618            panel.update(cx, |this, cx| {
6619                this.cut(&Cut, cx);
6620                this.select_prev(&SelectPrev, cx);
6621                this.select_prev(&SelectPrev, cx);
6622
6623                this.paste(&Paste, cx);
6624                // this.expand_selected_entry(&ExpandSelectedEntry, cx);
6625            })
6626        });
6627        cx.run_until_parked();
6628        assert_eq!(
6629            visible_entries_as_strings(&panel, 0..10, cx),
6630            &[
6631                "v project_root",
6632                "    v dir_1",
6633                "        v nested_dir",
6634                "              file_1.py  <== marked",
6635                "              file_a.py  <== selected  <== marked",
6636            ]
6637        );
6638        cx.simulate_modifiers_change(modifiers_with_shift);
6639        cx.update(|cx| {
6640            panel.update(cx, |this, cx| {
6641                this.expand_selected_entry(&Default::default(), cx);
6642                this.select_next(&SelectNext, cx);
6643                this.select_next(&SelectNext, cx);
6644            })
6645        });
6646        submit_deletion(&panel, cx);
6647        assert_eq!(
6648            visible_entries_as_strings(&panel, 0..10, cx),
6649            &[
6650                "v project_root",
6651                "    v dir_1",
6652                "        v nested_dir  <== selected",
6653            ]
6654        );
6655    }
6656    #[gpui::test]
6657    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
6658        init_test_with_editor(cx);
6659        cx.update(|cx| {
6660            cx.update_global::<SettingsStore, _>(|store, cx| {
6661                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6662                    worktree_settings.file_scan_exclusions = Some(Vec::new());
6663                });
6664                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6665                    project_panel_settings.auto_reveal_entries = Some(false)
6666                });
6667            })
6668        });
6669
6670        let fs = FakeFs::new(cx.background_executor.clone());
6671        fs.insert_tree(
6672            "/project_root",
6673            json!({
6674                ".git": {},
6675                ".gitignore": "**/gitignored_dir",
6676                "dir_1": {
6677                    "file_1.py": "# File 1_1 contents",
6678                    "file_2.py": "# File 1_2 contents",
6679                    "file_3.py": "# File 1_3 contents",
6680                    "gitignored_dir": {
6681                        "file_a.py": "# File contents",
6682                        "file_b.py": "# File contents",
6683                        "file_c.py": "# File contents",
6684                    },
6685                },
6686                "dir_2": {
6687                    "file_1.py": "# File 2_1 contents",
6688                    "file_2.py": "# File 2_2 contents",
6689                    "file_3.py": "# File 2_3 contents",
6690                }
6691            }),
6692        )
6693        .await;
6694
6695        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6696        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6697        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6698        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6699
6700        assert_eq!(
6701            visible_entries_as_strings(&panel, 0..20, cx),
6702            &[
6703                "v project_root",
6704                "    > .git",
6705                "    > dir_1",
6706                "    > dir_2",
6707                "      .gitignore",
6708            ]
6709        );
6710
6711        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6712            .expect("dir 1 file is not ignored and should have an entry");
6713        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6714            .expect("dir 2 file is not ignored and should have an entry");
6715        let gitignored_dir_file =
6716            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6717        assert_eq!(
6718            gitignored_dir_file, None,
6719            "File in the gitignored dir should not have an entry before its dir is toggled"
6720        );
6721
6722        toggle_expand_dir(&panel, "project_root/dir_1", cx);
6723        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6724        cx.executor().run_until_parked();
6725        assert_eq!(
6726            visible_entries_as_strings(&panel, 0..20, cx),
6727            &[
6728                "v project_root",
6729                "    > .git",
6730                "    v dir_1",
6731                "        v gitignored_dir  <== selected",
6732                "              file_a.py",
6733                "              file_b.py",
6734                "              file_c.py",
6735                "          file_1.py",
6736                "          file_2.py",
6737                "          file_3.py",
6738                "    > dir_2",
6739                "      .gitignore",
6740            ],
6741            "Should show gitignored dir file list in the project panel"
6742        );
6743        let gitignored_dir_file =
6744            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6745                .expect("after gitignored dir got opened, a file entry should be present");
6746
6747        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6748        toggle_expand_dir(&panel, "project_root/dir_1", cx);
6749        assert_eq!(
6750            visible_entries_as_strings(&panel, 0..20, cx),
6751            &[
6752                "v project_root",
6753                "    > .git",
6754                "    > dir_1  <== selected",
6755                "    > dir_2",
6756                "      .gitignore",
6757            ],
6758            "Should hide all dir contents again and prepare for the auto reveal test"
6759        );
6760
6761        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6762            panel.update(cx, |panel, cx| {
6763                panel.project.update(cx, |_, cx| {
6764                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
6765                })
6766            });
6767            cx.run_until_parked();
6768            assert_eq!(
6769                visible_entries_as_strings(&panel, 0..20, cx),
6770                &[
6771                    "v project_root",
6772                    "    > .git",
6773                    "    > dir_1  <== selected",
6774                    "    > dir_2",
6775                    "      .gitignore",
6776                ],
6777                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
6778            );
6779        }
6780
6781        cx.update(|cx| {
6782            cx.update_global::<SettingsStore, _>(|store, cx| {
6783                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6784                    project_panel_settings.auto_reveal_entries = Some(true)
6785                });
6786            })
6787        });
6788
6789        panel.update(cx, |panel, cx| {
6790            panel.project.update(cx, |_, cx| {
6791                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
6792            })
6793        });
6794        cx.run_until_parked();
6795        assert_eq!(
6796            visible_entries_as_strings(&panel, 0..20, cx),
6797            &[
6798                "v project_root",
6799                "    > .git",
6800                "    v dir_1",
6801                "        > gitignored_dir",
6802                "          file_1.py  <== selected",
6803                "          file_2.py",
6804                "          file_3.py",
6805                "    > dir_2",
6806                "      .gitignore",
6807            ],
6808            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
6809        );
6810
6811        panel.update(cx, |panel, cx| {
6812            panel.project.update(cx, |_, cx| {
6813                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
6814            })
6815        });
6816        cx.run_until_parked();
6817        assert_eq!(
6818            visible_entries_as_strings(&panel, 0..20, cx),
6819            &[
6820                "v project_root",
6821                "    > .git",
6822                "    v dir_1",
6823                "        > gitignored_dir",
6824                "          file_1.py",
6825                "          file_2.py",
6826                "          file_3.py",
6827                "    v dir_2",
6828                "          file_1.py  <== selected",
6829                "          file_2.py",
6830                "          file_3.py",
6831                "      .gitignore",
6832            ],
6833            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
6834        );
6835
6836        panel.update(cx, |panel, cx| {
6837            panel.project.update(cx, |_, cx| {
6838                cx.emit(project::Event::ActiveEntryChanged(Some(
6839                    gitignored_dir_file,
6840                )))
6841            })
6842        });
6843        cx.run_until_parked();
6844        assert_eq!(
6845            visible_entries_as_strings(&panel, 0..20, cx),
6846            &[
6847                "v project_root",
6848                "    > .git",
6849                "    v dir_1",
6850                "        > gitignored_dir",
6851                "          file_1.py",
6852                "          file_2.py",
6853                "          file_3.py",
6854                "    v dir_2",
6855                "          file_1.py  <== selected",
6856                "          file_2.py",
6857                "          file_3.py",
6858                "      .gitignore",
6859            ],
6860            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
6861        );
6862
6863        panel.update(cx, |panel, cx| {
6864            panel.project.update(cx, |_, cx| {
6865                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
6866            })
6867        });
6868        cx.run_until_parked();
6869        assert_eq!(
6870            visible_entries_as_strings(&panel, 0..20, cx),
6871            &[
6872                "v project_root",
6873                "    > .git",
6874                "    v dir_1",
6875                "        v gitignored_dir",
6876                "              file_a.py  <== selected",
6877                "              file_b.py",
6878                "              file_c.py",
6879                "          file_1.py",
6880                "          file_2.py",
6881                "          file_3.py",
6882                "    v dir_2",
6883                "          file_1.py",
6884                "          file_2.py",
6885                "          file_3.py",
6886                "      .gitignore",
6887            ],
6888            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
6889        );
6890    }
6891
6892    #[gpui::test]
6893    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
6894        init_test_with_editor(cx);
6895        cx.update(|cx| {
6896            cx.update_global::<SettingsStore, _>(|store, cx| {
6897                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
6898                    worktree_settings.file_scan_exclusions = Some(Vec::new());
6899                });
6900                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
6901                    project_panel_settings.auto_reveal_entries = Some(false)
6902                });
6903            })
6904        });
6905
6906        let fs = FakeFs::new(cx.background_executor.clone());
6907        fs.insert_tree(
6908            "/project_root",
6909            json!({
6910                ".git": {},
6911                ".gitignore": "**/gitignored_dir",
6912                "dir_1": {
6913                    "file_1.py": "# File 1_1 contents",
6914                    "file_2.py": "# File 1_2 contents",
6915                    "file_3.py": "# File 1_3 contents",
6916                    "gitignored_dir": {
6917                        "file_a.py": "# File contents",
6918                        "file_b.py": "# File contents",
6919                        "file_c.py": "# File contents",
6920                    },
6921                },
6922                "dir_2": {
6923                    "file_1.py": "# File 2_1 contents",
6924                    "file_2.py": "# File 2_2 contents",
6925                    "file_3.py": "# File 2_3 contents",
6926                }
6927            }),
6928        )
6929        .await;
6930
6931        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
6932        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
6933        let cx = &mut VisualTestContext::from_window(*workspace, cx);
6934        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
6935
6936        assert_eq!(
6937            visible_entries_as_strings(&panel, 0..20, cx),
6938            &[
6939                "v project_root",
6940                "    > .git",
6941                "    > dir_1",
6942                "    > dir_2",
6943                "      .gitignore",
6944            ]
6945        );
6946
6947        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
6948            .expect("dir 1 file is not ignored and should have an entry");
6949        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
6950            .expect("dir 2 file is not ignored and should have an entry");
6951        let gitignored_dir_file =
6952            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
6953        assert_eq!(
6954            gitignored_dir_file, None,
6955            "File in the gitignored dir should not have an entry before its dir is toggled"
6956        );
6957
6958        toggle_expand_dir(&panel, "project_root/dir_1", cx);
6959        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6960        cx.run_until_parked();
6961        assert_eq!(
6962            visible_entries_as_strings(&panel, 0..20, cx),
6963            &[
6964                "v project_root",
6965                "    > .git",
6966                "    v dir_1",
6967                "        v gitignored_dir  <== selected",
6968                "              file_a.py",
6969                "              file_b.py",
6970                "              file_c.py",
6971                "          file_1.py",
6972                "          file_2.py",
6973                "          file_3.py",
6974                "    > dir_2",
6975                "      .gitignore",
6976            ],
6977            "Should show gitignored dir file list in the project panel"
6978        );
6979        let gitignored_dir_file =
6980            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
6981                .expect("after gitignored dir got opened, a file entry should be present");
6982
6983        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
6984        toggle_expand_dir(&panel, "project_root/dir_1", cx);
6985        assert_eq!(
6986            visible_entries_as_strings(&panel, 0..20, cx),
6987            &[
6988                "v project_root",
6989                "    > .git",
6990                "    > dir_1  <== selected",
6991                "    > dir_2",
6992                "      .gitignore",
6993            ],
6994            "Should hide all dir contents again and prepare for the explicit reveal test"
6995        );
6996
6997        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
6998            panel.update(cx, |panel, cx| {
6999                panel.project.update(cx, |_, cx| {
7000                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7001                })
7002            });
7003            cx.run_until_parked();
7004            assert_eq!(
7005                visible_entries_as_strings(&panel, 0..20, cx),
7006                &[
7007                    "v project_root",
7008                    "    > .git",
7009                    "    > dir_1  <== selected",
7010                    "    > dir_2",
7011                    "      .gitignore",
7012                ],
7013                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7014            );
7015        }
7016
7017        panel.update(cx, |panel, cx| {
7018            panel.project.update(cx, |_, cx| {
7019                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
7020            })
7021        });
7022        cx.run_until_parked();
7023        assert_eq!(
7024            visible_entries_as_strings(&panel, 0..20, cx),
7025            &[
7026                "v project_root",
7027                "    > .git",
7028                "    v dir_1",
7029                "        > gitignored_dir",
7030                "          file_1.py  <== selected",
7031                "          file_2.py",
7032                "          file_3.py",
7033                "    > dir_2",
7034                "      .gitignore",
7035            ],
7036            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
7037        );
7038
7039        panel.update(cx, |panel, cx| {
7040            panel.project.update(cx, |_, cx| {
7041                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
7042            })
7043        });
7044        cx.run_until_parked();
7045        assert_eq!(
7046            visible_entries_as_strings(&panel, 0..20, cx),
7047            &[
7048                "v project_root",
7049                "    > .git",
7050                "    v dir_1",
7051                "        > gitignored_dir",
7052                "          file_1.py",
7053                "          file_2.py",
7054                "          file_3.py",
7055                "    v dir_2",
7056                "          file_1.py  <== selected",
7057                "          file_2.py",
7058                "          file_3.py",
7059                "      .gitignore",
7060            ],
7061            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
7062        );
7063
7064        panel.update(cx, |panel, cx| {
7065            panel.project.update(cx, |_, cx| {
7066                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7067            })
7068        });
7069        cx.run_until_parked();
7070        assert_eq!(
7071            visible_entries_as_strings(&panel, 0..20, cx),
7072            &[
7073                "v project_root",
7074                "    > .git",
7075                "    v dir_1",
7076                "        v gitignored_dir",
7077                "              file_a.py  <== selected",
7078                "              file_b.py",
7079                "              file_c.py",
7080                "          file_1.py",
7081                "          file_2.py",
7082                "          file_3.py",
7083                "    v dir_2",
7084                "          file_1.py",
7085                "          file_2.py",
7086                "          file_3.py",
7087                "      .gitignore",
7088            ],
7089            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
7090        );
7091    }
7092
7093    #[gpui::test]
7094    async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
7095        init_test(cx);
7096        cx.update(|cx| {
7097            cx.update_global::<SettingsStore, _>(|store, cx| {
7098                store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
7099                    project_settings.file_scan_exclusions =
7100                        Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
7101                });
7102            });
7103        });
7104
7105        cx.update(|cx| {
7106            register_project_item::<TestProjectItemView>(cx);
7107        });
7108
7109        let fs = FakeFs::new(cx.executor().clone());
7110        fs.insert_tree(
7111            "/root1",
7112            json!({
7113                ".dockerignore": "",
7114                ".git": {
7115                    "HEAD": "",
7116                },
7117            }),
7118        )
7119        .await;
7120
7121        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7122        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7123        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7124        let panel = workspace
7125            .update(cx, |workspace, cx| {
7126                let panel = ProjectPanel::new(workspace, cx);
7127                workspace.add_panel(panel.clone(), cx);
7128                panel
7129            })
7130            .unwrap();
7131
7132        select_path(&panel, "root1", cx);
7133        assert_eq!(
7134            visible_entries_as_strings(&panel, 0..10, cx),
7135            &["v root1  <== selected", "      .dockerignore",]
7136        );
7137        workspace
7138            .update(cx, |workspace, cx| {
7139                assert!(
7140                    workspace.active_item(cx).is_none(),
7141                    "Should have no active items in the beginning"
7142                );
7143            })
7144            .unwrap();
7145
7146        let excluded_file_path = ".git/COMMIT_EDITMSG";
7147        let excluded_dir_path = "excluded_dir";
7148
7149        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
7150        panel.update(cx, |panel, cx| {
7151            assert!(panel.filename_editor.read(cx).is_focused(cx));
7152        });
7153        panel
7154            .update(cx, |panel, cx| {
7155                panel
7156                    .filename_editor
7157                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
7158                panel.confirm_edit(cx).unwrap()
7159            })
7160            .await
7161            .unwrap();
7162
7163        assert_eq!(
7164            visible_entries_as_strings(&panel, 0..13, cx),
7165            &["v root1", "      .dockerignore"],
7166            "Excluded dir should not be shown after opening a file in it"
7167        );
7168        panel.update(cx, |panel, cx| {
7169            assert!(
7170                !panel.filename_editor.read(cx).is_focused(cx),
7171                "Should have closed the file name editor"
7172            );
7173        });
7174        workspace
7175            .update(cx, |workspace, cx| {
7176                let active_entry_path = workspace
7177                    .active_item(cx)
7178                    .expect("should have opened and activated the excluded item")
7179                    .act_as::<TestProjectItemView>(cx)
7180                    .expect(
7181                        "should have opened the corresponding project item for the excluded item",
7182                    )
7183                    .read(cx)
7184                    .path
7185                    .clone();
7186                assert_eq!(
7187                    active_entry_path.path.as_ref(),
7188                    Path::new(excluded_file_path),
7189                    "Should open the excluded file"
7190                );
7191
7192                assert!(
7193                    workspace.notification_ids().is_empty(),
7194                    "Should have no notifications after opening an excluded file"
7195                );
7196            })
7197            .unwrap();
7198        assert!(
7199            fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
7200            "Should have created the excluded file"
7201        );
7202
7203        select_path(&panel, "root1", cx);
7204        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7205        panel.update(cx, |panel, cx| {
7206            assert!(panel.filename_editor.read(cx).is_focused(cx));
7207        });
7208        panel
7209            .update(cx, |panel, cx| {
7210                panel
7211                    .filename_editor
7212                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
7213                panel.confirm_edit(cx).unwrap()
7214            })
7215            .await
7216            .unwrap();
7217
7218        assert_eq!(
7219            visible_entries_as_strings(&panel, 0..13, cx),
7220            &["v root1", "      .dockerignore"],
7221            "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
7222        );
7223        panel.update(cx, |panel, cx| {
7224            assert!(
7225                !panel.filename_editor.read(cx).is_focused(cx),
7226                "Should have closed the file name editor"
7227            );
7228        });
7229        workspace
7230            .update(cx, |workspace, cx| {
7231                let notifications = workspace.notification_ids();
7232                assert_eq!(
7233                    notifications.len(),
7234                    1,
7235                    "Should receive one notification with the error message"
7236                );
7237                workspace.dismiss_notification(notifications.first().unwrap(), cx);
7238                assert!(workspace.notification_ids().is_empty());
7239            })
7240            .unwrap();
7241
7242        select_path(&panel, "root1", cx);
7243        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7244        panel.update(cx, |panel, cx| {
7245            assert!(panel.filename_editor.read(cx).is_focused(cx));
7246        });
7247        panel
7248            .update(cx, |panel, cx| {
7249                panel
7250                    .filename_editor
7251                    .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
7252                panel.confirm_edit(cx).unwrap()
7253            })
7254            .await
7255            .unwrap();
7256
7257        assert_eq!(
7258            visible_entries_as_strings(&panel, 0..13, cx),
7259            &["v root1", "      .dockerignore"],
7260            "Should not change the project panel after trying to create an excluded directory"
7261        );
7262        panel.update(cx, |panel, cx| {
7263            assert!(
7264                !panel.filename_editor.read(cx).is_focused(cx),
7265                "Should have closed the file name editor"
7266            );
7267        });
7268        workspace
7269            .update(cx, |workspace, cx| {
7270                let notifications = workspace.notification_ids();
7271                assert_eq!(
7272                    notifications.len(),
7273                    1,
7274                    "Should receive one notification explaining that no directory is actually shown"
7275                );
7276                workspace.dismiss_notification(notifications.first().unwrap(), cx);
7277                assert!(workspace.notification_ids().is_empty());
7278            })
7279            .unwrap();
7280        assert!(
7281            fs.is_dir(Path::new("/root1/excluded_dir")).await,
7282            "Should have created the excluded directory"
7283        );
7284    }
7285
7286    #[gpui::test]
7287    async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
7288        init_test_with_editor(cx);
7289
7290        let fs = FakeFs::new(cx.executor().clone());
7291        fs.insert_tree(
7292            "/src",
7293            json!({
7294                "test": {
7295                    "first.rs": "// First Rust file",
7296                    "second.rs": "// Second Rust file",
7297                    "third.rs": "// Third Rust file",
7298                }
7299            }),
7300        )
7301        .await;
7302
7303        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
7304        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7305        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7306        let panel = workspace
7307            .update(cx, |workspace, cx| {
7308                let panel = ProjectPanel::new(workspace, cx);
7309                workspace.add_panel(panel.clone(), cx);
7310                panel
7311            })
7312            .unwrap();
7313
7314        select_path(&panel, "src/", cx);
7315        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
7316        cx.executor().run_until_parked();
7317        assert_eq!(
7318            visible_entries_as_strings(&panel, 0..10, cx),
7319            &[
7320                //
7321                "v src  <== selected",
7322                "    > test"
7323            ]
7324        );
7325        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
7326        panel.update(cx, |panel, cx| {
7327            assert!(panel.filename_editor.read(cx).is_focused(cx));
7328        });
7329        assert_eq!(
7330            visible_entries_as_strings(&panel, 0..10, cx),
7331            &[
7332                //
7333                "v src",
7334                "    > [EDITOR: '']  <== selected",
7335                "    > test"
7336            ]
7337        );
7338
7339        panel.update(cx, |panel, cx| panel.cancel(&menu::Cancel, cx));
7340        assert_eq!(
7341            visible_entries_as_strings(&panel, 0..10, cx),
7342            &[
7343                //
7344                "v src  <== selected",
7345                "    > test"
7346            ]
7347        );
7348    }
7349
7350    #[gpui::test]
7351    async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
7352        init_test_with_editor(cx);
7353
7354        let fs = FakeFs::new(cx.executor().clone());
7355        fs.insert_tree(
7356            "/root",
7357            json!({
7358                "dir1": {
7359                    "subdir1": {},
7360                    "file1.txt": "",
7361                    "file2.txt": "",
7362                },
7363                "dir2": {
7364                    "subdir2": {},
7365                    "file3.txt": "",
7366                    "file4.txt": "",
7367                },
7368                "file5.txt": "",
7369                "file6.txt": "",
7370            }),
7371        )
7372        .await;
7373
7374        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7375        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7376        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7377        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7378
7379        toggle_expand_dir(&panel, "root/dir1", cx);
7380        toggle_expand_dir(&panel, "root/dir2", cx);
7381
7382        // Test Case 1: Delete middle file in directory
7383        select_path(&panel, "root/dir1/file1.txt", cx);
7384        assert_eq!(
7385            visible_entries_as_strings(&panel, 0..15, cx),
7386            &[
7387                "v root",
7388                "    v dir1",
7389                "        > subdir1",
7390                "          file1.txt  <== selected",
7391                "          file2.txt",
7392                "    v dir2",
7393                "        > subdir2",
7394                "          file3.txt",
7395                "          file4.txt",
7396                "      file5.txt",
7397                "      file6.txt",
7398            ],
7399            "Initial state before deleting middle file"
7400        );
7401
7402        submit_deletion(&panel, cx);
7403        assert_eq!(
7404            visible_entries_as_strings(&panel, 0..15, cx),
7405            &[
7406                "v root",
7407                "    v dir1",
7408                "        > subdir1",
7409                "          file2.txt  <== selected",
7410                "    v dir2",
7411                "        > subdir2",
7412                "          file3.txt",
7413                "          file4.txt",
7414                "      file5.txt",
7415                "      file6.txt",
7416            ],
7417            "Should select next file after deleting middle file"
7418        );
7419
7420        // Test Case 2: Delete last file in directory
7421        submit_deletion(&panel, cx);
7422        assert_eq!(
7423            visible_entries_as_strings(&panel, 0..15, cx),
7424            &[
7425                "v root",
7426                "    v dir1",
7427                "        > subdir1  <== selected",
7428                "    v dir2",
7429                "        > subdir2",
7430                "          file3.txt",
7431                "          file4.txt",
7432                "      file5.txt",
7433                "      file6.txt",
7434            ],
7435            "Should select next directory when last file is deleted"
7436        );
7437
7438        // Test Case 3: Delete root level file
7439        select_path(&panel, "root/file6.txt", cx);
7440        assert_eq!(
7441            visible_entries_as_strings(&panel, 0..15, cx),
7442            &[
7443                "v root",
7444                "    v dir1",
7445                "        > subdir1",
7446                "    v dir2",
7447                "        > subdir2",
7448                "          file3.txt",
7449                "          file4.txt",
7450                "      file5.txt",
7451                "      file6.txt  <== selected",
7452            ],
7453            "Initial state before deleting root level file"
7454        );
7455
7456        submit_deletion(&panel, cx);
7457        assert_eq!(
7458            visible_entries_as_strings(&panel, 0..15, cx),
7459            &[
7460                "v root",
7461                "    v dir1",
7462                "        > subdir1",
7463                "    v dir2",
7464                "        > subdir2",
7465                "          file3.txt",
7466                "          file4.txt",
7467                "      file5.txt  <== selected",
7468            ],
7469            "Should select prev entry at root level"
7470        );
7471    }
7472
7473    #[gpui::test]
7474    async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
7475        init_test_with_editor(cx);
7476
7477        let fs = FakeFs::new(cx.executor().clone());
7478        fs.insert_tree(
7479            "/root",
7480            json!({
7481                "dir1": {
7482                    "subdir1": {
7483                        "a.txt": "",
7484                        "b.txt": ""
7485                    },
7486                    "file1.txt": "",
7487                },
7488                "dir2": {
7489                    "subdir2": {
7490                        "c.txt": "",
7491                        "d.txt": ""
7492                    },
7493                    "file2.txt": "",
7494                },
7495                "file3.txt": "",
7496            }),
7497        )
7498        .await;
7499
7500        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7501        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7502        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7503        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7504
7505        toggle_expand_dir(&panel, "root/dir1", cx);
7506        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7507        toggle_expand_dir(&panel, "root/dir2", cx);
7508        toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7509
7510        // Test Case 1: Select and delete nested directory with parent
7511        cx.simulate_modifiers_change(gpui::Modifiers {
7512            control: true,
7513            ..Default::default()
7514        });
7515        select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7516        select_path_with_mark(&panel, "root/dir1", cx);
7517
7518        assert_eq!(
7519            visible_entries_as_strings(&panel, 0..15, cx),
7520            &[
7521                "v root",
7522                "    v dir1  <== selected  <== marked",
7523                "        v subdir1  <== marked",
7524                "              a.txt",
7525                "              b.txt",
7526                "          file1.txt",
7527                "    v dir2",
7528                "        v subdir2",
7529                "              c.txt",
7530                "              d.txt",
7531                "          file2.txt",
7532                "      file3.txt",
7533            ],
7534            "Initial state before deleting nested directory with parent"
7535        );
7536
7537        submit_deletion(&panel, cx);
7538        assert_eq!(
7539            visible_entries_as_strings(&panel, 0..15, cx),
7540            &[
7541                "v root",
7542                "    v dir2  <== selected",
7543                "        v subdir2",
7544                "              c.txt",
7545                "              d.txt",
7546                "          file2.txt",
7547                "      file3.txt",
7548            ],
7549            "Should select next directory after deleting directory with parent"
7550        );
7551
7552        // Test Case 2: Select mixed files and directories across levels
7553        select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
7554        select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
7555        select_path_with_mark(&panel, "root/file3.txt", cx);
7556
7557        assert_eq!(
7558            visible_entries_as_strings(&panel, 0..15, cx),
7559            &[
7560                "v root",
7561                "    v dir2",
7562                "        v subdir2",
7563                "              c.txt  <== marked",
7564                "              d.txt",
7565                "          file2.txt  <== marked",
7566                "      file3.txt  <== selected  <== marked",
7567            ],
7568            "Initial state before deleting"
7569        );
7570
7571        submit_deletion(&panel, cx);
7572        assert_eq!(
7573            visible_entries_as_strings(&panel, 0..15, cx),
7574            &[
7575                "v root",
7576                "    v dir2  <== selected",
7577                "        v subdir2",
7578                "              d.txt",
7579            ],
7580            "Should select sibling directory"
7581        );
7582    }
7583
7584    #[gpui::test]
7585    async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
7586        init_test_with_editor(cx);
7587
7588        let fs = FakeFs::new(cx.executor().clone());
7589        fs.insert_tree(
7590            "/root",
7591            json!({
7592                "dir1": {
7593                    "subdir1": {
7594                        "a.txt": "",
7595                        "b.txt": ""
7596                    },
7597                    "file1.txt": "",
7598                },
7599                "dir2": {
7600                    "subdir2": {
7601                        "c.txt": "",
7602                        "d.txt": ""
7603                    },
7604                    "file2.txt": "",
7605                },
7606                "file3.txt": "",
7607                "file4.txt": "",
7608            }),
7609        )
7610        .await;
7611
7612        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7613        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7614        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7615        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7616
7617        toggle_expand_dir(&panel, "root/dir1", cx);
7618        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7619        toggle_expand_dir(&panel, "root/dir2", cx);
7620        toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
7621
7622        // Test Case 1: Select all root files and directories
7623        cx.simulate_modifiers_change(gpui::Modifiers {
7624            control: true,
7625            ..Default::default()
7626        });
7627        select_path_with_mark(&panel, "root/dir1", cx);
7628        select_path_with_mark(&panel, "root/dir2", cx);
7629        select_path_with_mark(&panel, "root/file3.txt", cx);
7630        select_path_with_mark(&panel, "root/file4.txt", cx);
7631        assert_eq!(
7632            visible_entries_as_strings(&panel, 0..20, cx),
7633            &[
7634                "v root",
7635                "    v dir1  <== marked",
7636                "        v subdir1",
7637                "              a.txt",
7638                "              b.txt",
7639                "          file1.txt",
7640                "    v dir2  <== marked",
7641                "        v subdir2",
7642                "              c.txt",
7643                "              d.txt",
7644                "          file2.txt",
7645                "      file3.txt  <== marked",
7646                "      file4.txt  <== selected  <== marked",
7647            ],
7648            "State before deleting all contents"
7649        );
7650
7651        submit_deletion(&panel, cx);
7652        assert_eq!(
7653            visible_entries_as_strings(&panel, 0..20, cx),
7654            &["v root  <== selected"],
7655            "Only empty root directory should remain after deleting all contents"
7656        );
7657    }
7658
7659    #[gpui::test]
7660    async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
7661        init_test_with_editor(cx);
7662
7663        let fs = FakeFs::new(cx.executor().clone());
7664        fs.insert_tree(
7665            "/root",
7666            json!({
7667                "dir1": {
7668                    "subdir1": {
7669                        "file_a.txt": "content a",
7670                        "file_b.txt": "content b",
7671                    },
7672                    "subdir2": {
7673                        "file_c.txt": "content c",
7674                    },
7675                    "file1.txt": "content 1",
7676                },
7677                "dir2": {
7678                    "file2.txt": "content 2",
7679                },
7680            }),
7681        )
7682        .await;
7683
7684        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7685        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7686        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7687        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7688
7689        toggle_expand_dir(&panel, "root/dir1", cx);
7690        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
7691        toggle_expand_dir(&panel, "root/dir2", cx);
7692        cx.simulate_modifiers_change(gpui::Modifiers {
7693            control: true,
7694            ..Default::default()
7695        });
7696
7697        // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
7698        select_path_with_mark(&panel, "root/dir1", cx);
7699        select_path_with_mark(&panel, "root/dir1/subdir1", cx);
7700        select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
7701
7702        assert_eq!(
7703            visible_entries_as_strings(&panel, 0..20, cx),
7704            &[
7705                "v root",
7706                "    v dir1  <== marked",
7707                "        v subdir1  <== marked",
7708                "              file_a.txt  <== selected  <== marked",
7709                "              file_b.txt",
7710                "        > subdir2",
7711                "          file1.txt",
7712                "    v dir2",
7713                "          file2.txt",
7714            ],
7715            "State with parent dir, subdir, and file selected"
7716        );
7717        submit_deletion(&panel, cx);
7718        assert_eq!(
7719            visible_entries_as_strings(&panel, 0..20, cx),
7720            &["v root", "    v dir2  <== selected", "          file2.txt",],
7721            "Only dir2 should remain after deletion"
7722        );
7723    }
7724
7725    #[gpui::test]
7726    async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
7727        init_test_with_editor(cx);
7728
7729        let fs = FakeFs::new(cx.executor().clone());
7730        // First worktree
7731        fs.insert_tree(
7732            "/root1",
7733            json!({
7734                "dir1": {
7735                    "file1.txt": "content 1",
7736                    "file2.txt": "content 2",
7737                },
7738                "dir2": {
7739                    "file3.txt": "content 3",
7740                },
7741            }),
7742        )
7743        .await;
7744
7745        // Second worktree
7746        fs.insert_tree(
7747            "/root2",
7748            json!({
7749                "dir3": {
7750                    "file4.txt": "content 4",
7751                    "file5.txt": "content 5",
7752                },
7753                "file6.txt": "content 6",
7754            }),
7755        )
7756        .await;
7757
7758        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
7759        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7760        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7761        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7762
7763        // Expand all directories for testing
7764        toggle_expand_dir(&panel, "root1/dir1", cx);
7765        toggle_expand_dir(&panel, "root1/dir2", cx);
7766        toggle_expand_dir(&panel, "root2/dir3", cx);
7767
7768        // Test Case 1: Delete files across different worktrees
7769        cx.simulate_modifiers_change(gpui::Modifiers {
7770            control: true,
7771            ..Default::default()
7772        });
7773        select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
7774        select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
7775
7776        assert_eq!(
7777            visible_entries_as_strings(&panel, 0..20, cx),
7778            &[
7779                "v root1",
7780                "    v dir1",
7781                "          file1.txt  <== marked",
7782                "          file2.txt",
7783                "    v dir2",
7784                "          file3.txt",
7785                "v root2",
7786                "    v dir3",
7787                "          file4.txt  <== selected  <== marked",
7788                "          file5.txt",
7789                "      file6.txt",
7790            ],
7791            "Initial state with files selected from different worktrees"
7792        );
7793
7794        submit_deletion(&panel, cx);
7795        assert_eq!(
7796            visible_entries_as_strings(&panel, 0..20, cx),
7797            &[
7798                "v root1",
7799                "    v dir1",
7800                "          file2.txt",
7801                "    v dir2",
7802                "          file3.txt",
7803                "v root2",
7804                "    v dir3",
7805                "          file5.txt  <== selected",
7806                "      file6.txt",
7807            ],
7808            "Should select next file in the last worktree after deletion"
7809        );
7810
7811        // Test Case 2: Delete directories from different worktrees
7812        select_path_with_mark(&panel, "root1/dir1", cx);
7813        select_path_with_mark(&panel, "root2/dir3", cx);
7814
7815        assert_eq!(
7816            visible_entries_as_strings(&panel, 0..20, cx),
7817            &[
7818                "v root1",
7819                "    v dir1  <== marked",
7820                "          file2.txt",
7821                "    v dir2",
7822                "          file3.txt",
7823                "v root2",
7824                "    v dir3  <== selected  <== marked",
7825                "          file5.txt",
7826                "      file6.txt",
7827            ],
7828            "State with directories marked from different worktrees"
7829        );
7830
7831        submit_deletion(&panel, cx);
7832        assert_eq!(
7833            visible_entries_as_strings(&panel, 0..20, cx),
7834            &[
7835                "v root1",
7836                "    v dir2",
7837                "          file3.txt",
7838                "v root2",
7839                "      file6.txt  <== selected",
7840            ],
7841            "Should select remaining file in last worktree after directory deletion"
7842        );
7843
7844        // Test Case 4: Delete all remaining files except roots
7845        select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
7846        select_path_with_mark(&panel, "root2/file6.txt", cx);
7847
7848        assert_eq!(
7849            visible_entries_as_strings(&panel, 0..20, cx),
7850            &[
7851                "v root1",
7852                "    v dir2",
7853                "          file3.txt  <== marked",
7854                "v root2",
7855                "      file6.txt  <== selected  <== marked",
7856            ],
7857            "State with all remaining files marked"
7858        );
7859
7860        submit_deletion(&panel, cx);
7861        assert_eq!(
7862            visible_entries_as_strings(&panel, 0..20, cx),
7863            &["v root1", "    v dir2", "v root2  <== selected"],
7864            "Second parent root should be selected after deleting"
7865        );
7866    }
7867
7868    #[gpui::test]
7869    async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
7870        init_test_with_editor(cx);
7871
7872        let fs = FakeFs::new(cx.executor().clone());
7873        fs.insert_tree(
7874            "/root",
7875            json!({
7876                "dir1": {
7877                    "file1.txt": "",
7878                    "file2.txt": "",
7879                    "file3.txt": "",
7880                },
7881                "dir2": {
7882                    "file4.txt": "",
7883                    "file5.txt": "",
7884                },
7885            }),
7886        )
7887        .await;
7888
7889        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
7890        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7891        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7892        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7893
7894        toggle_expand_dir(&panel, "root/dir1", cx);
7895        toggle_expand_dir(&panel, "root/dir2", cx);
7896
7897        cx.simulate_modifiers_change(gpui::Modifiers {
7898            control: true,
7899            ..Default::default()
7900        });
7901
7902        select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
7903        select_path(&panel, "root/dir1/file1.txt", cx);
7904
7905        assert_eq!(
7906            visible_entries_as_strings(&panel, 0..15, cx),
7907            &[
7908                "v root",
7909                "    v dir1",
7910                "          file1.txt  <== selected",
7911                "          file2.txt  <== marked",
7912                "          file3.txt",
7913                "    v dir2",
7914                "          file4.txt",
7915                "          file5.txt",
7916            ],
7917            "Initial state with one marked entry and different selection"
7918        );
7919
7920        // Delete should operate on the selected entry (file1.txt)
7921        submit_deletion(&panel, cx);
7922        assert_eq!(
7923            visible_entries_as_strings(&panel, 0..15, cx),
7924            &[
7925                "v root",
7926                "    v dir1",
7927                "          file2.txt  <== selected  <== marked",
7928                "          file3.txt",
7929                "    v dir2",
7930                "          file4.txt",
7931                "          file5.txt",
7932            ],
7933            "Should delete selected file, not marked file"
7934        );
7935
7936        select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
7937        select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
7938        select_path(&panel, "root/dir2/file5.txt", cx);
7939
7940        assert_eq!(
7941            visible_entries_as_strings(&panel, 0..15, cx),
7942            &[
7943                "v root",
7944                "    v dir1",
7945                "          file2.txt  <== marked",
7946                "          file3.txt  <== marked",
7947                "    v dir2",
7948                "          file4.txt  <== marked",
7949                "          file5.txt  <== selected",
7950            ],
7951            "Initial state with multiple marked entries and different selection"
7952        );
7953
7954        // Delete should operate on all marked entries, ignoring the selection
7955        submit_deletion(&panel, cx);
7956        assert_eq!(
7957            visible_entries_as_strings(&panel, 0..15, cx),
7958            &[
7959                "v root",
7960                "    v dir1",
7961                "    v dir2",
7962                "          file5.txt  <== selected",
7963            ],
7964            "Should delete all marked files, leaving only the selected file"
7965        );
7966    }
7967
7968    #[gpui::test]
7969    async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
7970        init_test_with_editor(cx);
7971
7972        let fs = FakeFs::new(cx.executor().clone());
7973        fs.insert_tree(
7974            "/root_b",
7975            json!({
7976                "dir1": {
7977                    "file1.txt": "content 1",
7978                    "file2.txt": "content 2",
7979                },
7980            }),
7981        )
7982        .await;
7983
7984        fs.insert_tree(
7985            "/root_c",
7986            json!({
7987                "dir2": {},
7988            }),
7989        )
7990        .await;
7991
7992        let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
7993        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
7994        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7995        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7996
7997        toggle_expand_dir(&panel, "root_b/dir1", cx);
7998        toggle_expand_dir(&panel, "root_c/dir2", cx);
7999
8000        cx.simulate_modifiers_change(gpui::Modifiers {
8001            control: true,
8002            ..Default::default()
8003        });
8004        select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
8005        select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
8006
8007        assert_eq!(
8008            visible_entries_as_strings(&panel, 0..20, cx),
8009            &[
8010                "v root_b",
8011                "    v dir1",
8012                "          file1.txt  <== marked",
8013                "          file2.txt  <== selected  <== marked",
8014                "v root_c",
8015                "    v dir2",
8016            ],
8017            "Initial state with files marked in root_b"
8018        );
8019
8020        submit_deletion(&panel, cx);
8021        assert_eq!(
8022            visible_entries_as_strings(&panel, 0..20, cx),
8023            &[
8024                "v root_b",
8025                "    v dir1  <== selected",
8026                "v root_c",
8027                "    v dir2",
8028            ],
8029            "After deletion in root_b as it's last deletion, selection should be in root_b"
8030        );
8031
8032        select_path_with_mark(&panel, "root_c/dir2", cx);
8033
8034        submit_deletion(&panel, cx);
8035        assert_eq!(
8036            visible_entries_as_strings(&panel, 0..20, cx),
8037            &["v root_b", "    v dir1", "v root_c  <== selected",],
8038            "After deleting from root_c, it should remain in root_c"
8039        );
8040    }
8041
8042    fn toggle_expand_dir(
8043        panel: &View<ProjectPanel>,
8044        path: impl AsRef<Path>,
8045        cx: &mut VisualTestContext,
8046    ) {
8047        let path = path.as_ref();
8048        panel.update(cx, |panel, cx| {
8049            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8050                let worktree = worktree.read(cx);
8051                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8052                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8053                    panel.toggle_expanded(entry_id, cx);
8054                    return;
8055                }
8056            }
8057            panic!("no worktree for path {:?}", path);
8058        });
8059    }
8060
8061    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
8062        let path = path.as_ref();
8063        panel.update(cx, |panel, cx| {
8064            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8065                let worktree = worktree.read(cx);
8066                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8067                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8068                    panel.selection = Some(crate::SelectedEntry {
8069                        worktree_id: worktree.id(),
8070                        entry_id,
8071                    });
8072                    return;
8073                }
8074            }
8075            panic!("no worktree for path {:?}", path);
8076        });
8077    }
8078
8079    fn select_path_with_mark(
8080        panel: &View<ProjectPanel>,
8081        path: impl AsRef<Path>,
8082        cx: &mut VisualTestContext,
8083    ) {
8084        let path = path.as_ref();
8085        panel.update(cx, |panel, cx| {
8086            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8087                let worktree = worktree.read(cx);
8088                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8089                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
8090                    let entry = crate::SelectedEntry {
8091                        worktree_id: worktree.id(),
8092                        entry_id,
8093                    };
8094                    if !panel.marked_entries.contains(&entry) {
8095                        panel.marked_entries.insert(entry);
8096                    }
8097                    panel.selection = Some(entry);
8098                    return;
8099                }
8100            }
8101            panic!("no worktree for path {:?}", path);
8102        });
8103    }
8104
8105    fn find_project_entry(
8106        panel: &View<ProjectPanel>,
8107        path: impl AsRef<Path>,
8108        cx: &mut VisualTestContext,
8109    ) -> Option<ProjectEntryId> {
8110        let path = path.as_ref();
8111        panel.update(cx, |panel, cx| {
8112            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
8113                let worktree = worktree.read(cx);
8114                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
8115                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
8116                }
8117            }
8118            panic!("no worktree for path {path:?}");
8119        })
8120    }
8121
8122    fn visible_entries_as_strings(
8123        panel: &View<ProjectPanel>,
8124        range: Range<usize>,
8125        cx: &mut VisualTestContext,
8126    ) -> Vec<String> {
8127        let mut result = Vec::new();
8128        let mut project_entries = HashSet::default();
8129        let mut has_editor = false;
8130
8131        panel.update(cx, |panel, cx| {
8132            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
8133                if details.is_editing {
8134                    assert!(!has_editor, "duplicate editor entry");
8135                    has_editor = true;
8136                } else {
8137                    assert!(
8138                        project_entries.insert(project_entry),
8139                        "duplicate project entry {:?} {:?}",
8140                        project_entry,
8141                        details
8142                    );
8143                }
8144
8145                let indent = "    ".repeat(details.depth);
8146                let icon = if details.kind.is_dir() {
8147                    if details.is_expanded {
8148                        "v "
8149                    } else {
8150                        "> "
8151                    }
8152                } else {
8153                    "  "
8154                };
8155                let name = if details.is_editing {
8156                    format!("[EDITOR: '{}']", details.filename)
8157                } else if details.is_processing {
8158                    format!("[PROCESSING: '{}']", details.filename)
8159                } else {
8160                    details.filename.clone()
8161                };
8162                let selected = if details.is_selected {
8163                    "  <== selected"
8164                } else {
8165                    ""
8166                };
8167                let marked = if details.is_marked {
8168                    "  <== marked"
8169                } else {
8170                    ""
8171                };
8172
8173                result.push(format!("{indent}{icon}{name}{selected}{marked}"));
8174            });
8175        });
8176
8177        result
8178    }
8179
8180    fn init_test(cx: &mut TestAppContext) {
8181        cx.update(|cx| {
8182            let settings_store = SettingsStore::test(cx);
8183            cx.set_global(settings_store);
8184            init_settings(cx);
8185            theme::init(theme::LoadThemes::JustBase, cx);
8186            language::init(cx);
8187            editor::init_settings(cx);
8188            crate::init((), cx);
8189            workspace::init_settings(cx);
8190            client::init_settings(cx);
8191            Project::init_settings(cx);
8192
8193            cx.update_global::<SettingsStore, _>(|store, cx| {
8194                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
8195                    project_panel_settings.auto_fold_dirs = Some(false);
8196                });
8197                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
8198                    worktree_settings.file_scan_exclusions = Some(Vec::new());
8199                });
8200            });
8201        });
8202    }
8203
8204    fn init_test_with_editor(cx: &mut TestAppContext) {
8205        cx.update(|cx| {
8206            let app_state = AppState::test(cx);
8207            theme::init(theme::LoadThemes::JustBase, cx);
8208            init_settings(cx);
8209            language::init(cx);
8210            editor::init(cx);
8211            crate::init((), cx);
8212            workspace::init(app_state.clone(), cx);
8213            Project::init_settings(cx);
8214
8215            cx.update_global::<SettingsStore, _>(|store, cx| {
8216                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
8217                    project_panel_settings.auto_fold_dirs = Some(false);
8218                });
8219                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
8220                    worktree_settings.file_scan_exclusions = Some(Vec::new());
8221                });
8222            });
8223        });
8224    }
8225
8226    fn ensure_single_file_is_opened(
8227        window: &WindowHandle<Workspace>,
8228        expected_path: &str,
8229        cx: &mut TestAppContext,
8230    ) {
8231        window
8232            .update(cx, |workspace, cx| {
8233                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
8234                assert_eq!(worktrees.len(), 1);
8235                let worktree_id = worktrees[0].read(cx).id();
8236
8237                let open_project_paths = workspace
8238                    .panes()
8239                    .iter()
8240                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8241                    .collect::<Vec<_>>();
8242                assert_eq!(
8243                    open_project_paths,
8244                    vec![ProjectPath {
8245                        worktree_id,
8246                        path: Arc::from(Path::new(expected_path))
8247                    }],
8248                    "Should have opened file, selected in project panel"
8249                );
8250            })
8251            .unwrap();
8252    }
8253
8254    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
8255        assert!(
8256            !cx.has_pending_prompt(),
8257            "Should have no prompts before the deletion"
8258        );
8259        panel.update(cx, |panel, cx| {
8260            panel.delete(&Delete { skip_prompt: false }, cx)
8261        });
8262        assert!(
8263            cx.has_pending_prompt(),
8264            "Should have a prompt after the deletion"
8265        );
8266        cx.simulate_prompt_answer(0);
8267        assert!(
8268            !cx.has_pending_prompt(),
8269            "Should have no prompts after prompt was replied to"
8270        );
8271        cx.executor().run_until_parked();
8272    }
8273
8274    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
8275        assert!(
8276            !cx.has_pending_prompt(),
8277            "Should have no prompts before the deletion"
8278        );
8279        panel.update(cx, |panel, cx| {
8280            panel.delete(&Delete { skip_prompt: true }, cx)
8281        });
8282        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
8283        cx.executor().run_until_parked();
8284    }
8285
8286    fn ensure_no_open_items_and_panes(
8287        workspace: &WindowHandle<Workspace>,
8288        cx: &mut VisualTestContext,
8289    ) {
8290        assert!(
8291            !cx.has_pending_prompt(),
8292            "Should have no prompts after deletion operation closes the file"
8293        );
8294        workspace
8295            .read_with(cx, |workspace, cx| {
8296                let open_project_paths = workspace
8297                    .panes()
8298                    .iter()
8299                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
8300                    .collect::<Vec<_>>();
8301                assert!(
8302                    open_project_paths.is_empty(),
8303                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
8304                );
8305            })
8306            .unwrap();
8307    }
8308
8309    struct TestProjectItemView {
8310        focus_handle: FocusHandle,
8311        path: ProjectPath,
8312    }
8313
8314    struct TestProjectItem {
8315        path: ProjectPath,
8316    }
8317
8318    impl project::ProjectItem for TestProjectItem {
8319        fn try_open(
8320            _project: &Model<Project>,
8321            path: &ProjectPath,
8322            cx: &mut AppContext,
8323        ) -> Option<Task<gpui::Result<Model<Self>>>> {
8324            let path = path.clone();
8325            Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
8326        }
8327
8328        fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
8329            None
8330        }
8331
8332        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
8333            Some(self.path.clone())
8334        }
8335
8336        fn is_dirty(&self) -> bool {
8337            false
8338        }
8339    }
8340
8341    impl ProjectItem for TestProjectItemView {
8342        type Item = TestProjectItem;
8343
8344        fn for_project_item(
8345            _: Model<Project>,
8346            project_item: Model<Self::Item>,
8347            cx: &mut ViewContext<Self>,
8348        ) -> Self
8349        where
8350            Self: Sized,
8351        {
8352            Self {
8353                path: project_item.update(cx, |project_item, _| project_item.path.clone()),
8354                focus_handle: cx.focus_handle(),
8355            }
8356        }
8357    }
8358
8359    impl Item for TestProjectItemView {
8360        type Event = ();
8361    }
8362
8363    impl EventEmitter<()> for TestProjectItemView {}
8364
8365    impl FocusableView for TestProjectItemView {
8366        fn focus_handle(&self, _: &AppContext) -> FocusHandle {
8367            self.focus_handle.clone()
8368        }
8369    }
8370
8371    impl Render for TestProjectItemView {
8372        fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
8373            Empty
8374        }
8375    }
8376}