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