project_panel.rs

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