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