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