project_panel.rs

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