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