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