project_panel.rs

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