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