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