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