project_panel.rs

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