project_panel.rs

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