project_panel.rs

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