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        if let Some((worktree_id, visible_worktree_entries, _)) = self.visible_entries.last() {
1946            let worktree = self.project.read(cx).worktree_for_id(*worktree_id, cx);
1947            if let (Some(worktree), Some(entry)) = (worktree, visible_worktree_entries.last()) {
1948                let worktree = worktree.read(cx);
1949                if let Some(entry) = worktree.entry_for_id(entry.id) {
1950                    let selection = SelectedEntry {
1951                        worktree_id: *worktree_id,
1952                        entry_id: entry.id,
1953                    };
1954                    self.selection = Some(selection);
1955                    self.autoscroll(cx);
1956                    cx.notify();
1957                }
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    #[gpui::test]
7128    async fn test_select_first_last(cx: &mut gpui::TestAppContext) {
7129        init_test_with_editor(cx);
7130
7131        let fs = FakeFs::new(cx.executor().clone());
7132        fs.insert_tree(
7133            "/project_root",
7134            json!({
7135                "dir_1": {
7136                    "nested_dir": {
7137                        "file_a.py": "# File contents",
7138                    }
7139                },
7140                "file_1.py": "# File contents",
7141                "file_2.py": "# File contents",
7142                "zdir_2": {
7143                    "nested_dir2": {
7144                        "file_b.py": "# File contents",
7145                    }
7146                },
7147            }),
7148        )
7149        .await;
7150
7151        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7152        let workspace =
7153            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7154        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7155        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7156
7157        assert_eq!(
7158            visible_entries_as_strings(&panel, 0..10, cx),
7159            &[
7160                "v project_root",
7161                "    > dir_1",
7162                "    > zdir_2",
7163                "      file_1.py",
7164                "      file_2.py",
7165            ]
7166        );
7167        panel.update_in(cx, |panel, window, cx| {
7168            panel.select_first(&SelectFirst, window, cx)
7169        });
7170
7171        assert_eq!(
7172            visible_entries_as_strings(&panel, 0..10, cx),
7173            &[
7174                "v project_root  <== selected",
7175                "    > dir_1",
7176                "    > zdir_2",
7177                "      file_1.py",
7178                "      file_2.py",
7179            ]
7180        );
7181
7182        panel.update_in(cx, |panel, window, cx| {
7183            panel.select_last(&SelectLast, window, cx)
7184        });
7185
7186        assert_eq!(
7187            visible_entries_as_strings(&panel, 0..10, cx),
7188            &[
7189                "v project_root",
7190                "    > dir_1",
7191                "    > zdir_2",
7192                "      file_1.py",
7193                "      file_2.py  <== selected",
7194            ]
7195        );
7196    }
7197
7198    #[gpui::test]
7199    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
7200        init_test_with_editor(cx);
7201
7202        let fs = FakeFs::new(cx.executor().clone());
7203        fs.insert_tree(
7204            "/project_root",
7205            json!({
7206                "dir_1": {
7207                    "nested_dir": {
7208                        "file_a.py": "# File contents",
7209                    }
7210                },
7211                "file_1.py": "# File contents",
7212            }),
7213        )
7214        .await;
7215
7216        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7217        let workspace =
7218            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7219        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7220        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7221
7222        panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7223        cx.executor().run_until_parked();
7224        select_path(&panel, "project_root/dir_1", cx);
7225        panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7226        select_path(&panel, "project_root/dir_1/nested_dir", cx);
7227        panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7228        panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx));
7229        cx.executor().run_until_parked();
7230        assert_eq!(
7231            visible_entries_as_strings(&panel, 0..10, cx),
7232            &[
7233                "v project_root",
7234                "    v dir_1",
7235                "        > nested_dir  <== selected",
7236                "      file_1.py",
7237            ]
7238        );
7239    }
7240
7241    #[gpui::test]
7242    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
7243        init_test_with_editor(cx);
7244
7245        let fs = FakeFs::new(cx.executor().clone());
7246        fs.insert_tree(
7247            "/project_root",
7248            json!({
7249                "dir_1": {
7250                    "nested_dir": {
7251                        "file_a.py": "# File contents",
7252                        "file_b.py": "# File contents",
7253                        "file_c.py": "# File contents",
7254                    },
7255                    "file_1.py": "# File contents",
7256                    "file_2.py": "# File contents",
7257                    "file_3.py": "# File contents",
7258                },
7259                "dir_2": {
7260                    "file_1.py": "# File contents",
7261                    "file_2.py": "# File contents",
7262                    "file_3.py": "# File contents",
7263                }
7264            }),
7265        )
7266        .await;
7267
7268        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7269        let workspace =
7270            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7271        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7272        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7273
7274        panel.update_in(cx, |panel, window, cx| {
7275            panel.collapse_all_entries(&CollapseAllEntries, window, cx)
7276        });
7277        cx.executor().run_until_parked();
7278        assert_eq!(
7279            visible_entries_as_strings(&panel, 0..10, cx),
7280            &["v project_root", "    > dir_1", "    > dir_2",]
7281        );
7282
7283        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
7284        toggle_expand_dir(&panel, "project_root/dir_1", cx);
7285        cx.executor().run_until_parked();
7286        assert_eq!(
7287            visible_entries_as_strings(&panel, 0..10, cx),
7288            &[
7289                "v project_root",
7290                "    v dir_1  <== selected",
7291                "        > nested_dir",
7292                "          file_1.py",
7293                "          file_2.py",
7294                "          file_3.py",
7295                "    > dir_2",
7296            ]
7297        );
7298    }
7299
7300    #[gpui::test]
7301    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
7302        init_test(cx);
7303
7304        let fs = FakeFs::new(cx.executor().clone());
7305        fs.as_fake().insert_tree(path!("/root"), json!({})).await;
7306        let project = Project::test(fs, [path!("/root").as_ref()], cx).await;
7307        let workspace =
7308            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7309        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7310        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7311
7312        // Make a new buffer with no backing file
7313        workspace
7314            .update(cx, |workspace, window, cx| {
7315                Editor::new_file(workspace, &Default::default(), window, cx)
7316            })
7317            .unwrap();
7318
7319        cx.executor().run_until_parked();
7320
7321        // "Save as" the buffer, creating a new backing file for it
7322        let save_task = workspace
7323            .update(cx, |workspace, window, cx| {
7324                workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
7325            })
7326            .unwrap();
7327
7328        cx.executor().run_until_parked();
7329        cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new"))));
7330        save_task.await.unwrap();
7331
7332        // Rename the file
7333        select_path(&panel, "root/new", cx);
7334        assert_eq!(
7335            visible_entries_as_strings(&panel, 0..10, cx),
7336            &["v root", "      new  <== selected"]
7337        );
7338        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
7339        panel.update_in(cx, |panel, window, cx| {
7340            panel
7341                .filename_editor
7342                .update(cx, |editor, cx| editor.set_text("newer", window, cx));
7343        });
7344        panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
7345
7346        cx.executor().run_until_parked();
7347        assert_eq!(
7348            visible_entries_as_strings(&panel, 0..10, cx),
7349            &["v root", "      newer  <== selected"]
7350        );
7351
7352        workspace
7353            .update(cx, |workspace, window, cx| {
7354                workspace.save_active_item(workspace::SaveIntent::Save, window, cx)
7355            })
7356            .unwrap()
7357            .await
7358            .unwrap();
7359
7360        cx.executor().run_until_parked();
7361        // assert that saving the file doesn't restore "new"
7362        assert_eq!(
7363            visible_entries_as_strings(&panel, 0..10, cx),
7364            &["v root", "      newer  <== selected"]
7365        );
7366    }
7367
7368    #[gpui::test]
7369    #[cfg_attr(target_os = "windows", ignore)]
7370    async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) {
7371        init_test_with_editor(cx);
7372
7373        let fs = FakeFs::new(cx.executor().clone());
7374        fs.insert_tree(
7375            "/root1",
7376            json!({
7377                "dir1": {
7378                    "file1.txt": "content 1",
7379                },
7380            }),
7381        )
7382        .await;
7383
7384        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
7385        let workspace =
7386            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7387        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7388        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7389
7390        toggle_expand_dir(&panel, "root1/dir1", cx);
7391
7392        assert_eq!(
7393            visible_entries_as_strings(&panel, 0..20, cx),
7394            &["v root1", "    v dir1  <== selected", "          file1.txt",],
7395            "Initial state with worktrees"
7396        );
7397
7398        select_path(&panel, "root1", cx);
7399        assert_eq!(
7400            visible_entries_as_strings(&panel, 0..20, cx),
7401            &["v root1  <== selected", "    v dir1", "          file1.txt",],
7402        );
7403
7404        // Rename root1 to new_root1
7405        panel.update_in(cx, |panel, window, cx| panel.rename(&Rename, window, cx));
7406
7407        assert_eq!(
7408            visible_entries_as_strings(&panel, 0..20, cx),
7409            &[
7410                "v [EDITOR: 'root1']  <== selected",
7411                "    v dir1",
7412                "          file1.txt",
7413            ],
7414        );
7415
7416        let confirm = panel.update_in(cx, |panel, window, cx| {
7417            panel
7418                .filename_editor
7419                .update(cx, |editor, cx| editor.set_text("new_root1", window, cx));
7420            panel.confirm_edit(window, cx).unwrap()
7421        });
7422        confirm.await.unwrap();
7423        assert_eq!(
7424            visible_entries_as_strings(&panel, 0..20, cx),
7425            &[
7426                "v new_root1  <== selected",
7427                "    v dir1",
7428                "          file1.txt",
7429            ],
7430            "Should update worktree name"
7431        );
7432
7433        // Ensure internal paths have been updated
7434        select_path(&panel, "new_root1/dir1/file1.txt", cx);
7435        assert_eq!(
7436            visible_entries_as_strings(&panel, 0..20, cx),
7437            &[
7438                "v new_root1",
7439                "    v dir1",
7440                "          file1.txt  <== selected",
7441            ],
7442            "Files in renamed worktree are selectable"
7443        );
7444    }
7445
7446    #[gpui::test]
7447    async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
7448        init_test_with_editor(cx);
7449        let fs = FakeFs::new(cx.executor().clone());
7450        fs.insert_tree(
7451            "/project_root",
7452            json!({
7453                "dir_1": {
7454                    "nested_dir": {
7455                        "file_a.py": "# File contents",
7456                    }
7457                },
7458                "file_1.py": "# File contents",
7459            }),
7460        )
7461        .await;
7462
7463        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7464        let worktree_id =
7465            cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
7466        let workspace =
7467            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7468        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7469        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7470        cx.update(|window, cx| {
7471            panel.update(cx, |this, cx| {
7472                this.select_next(&Default::default(), window, cx);
7473                this.expand_selected_entry(&Default::default(), window, cx);
7474                this.expand_selected_entry(&Default::default(), window, cx);
7475                this.select_next(&Default::default(), window, cx);
7476                this.expand_selected_entry(&Default::default(), window, cx);
7477                this.select_next(&Default::default(), window, cx);
7478            })
7479        });
7480        assert_eq!(
7481            visible_entries_as_strings(&panel, 0..10, cx),
7482            &[
7483                "v project_root",
7484                "    v dir_1",
7485                "        v nested_dir",
7486                "              file_a.py  <== selected",
7487                "      file_1.py",
7488            ]
7489        );
7490        let modifiers_with_shift = gpui::Modifiers {
7491            shift: true,
7492            ..Default::default()
7493        };
7494        cx.simulate_modifiers_change(modifiers_with_shift);
7495        cx.update(|window, cx| {
7496            panel.update(cx, |this, cx| {
7497                this.select_next(&Default::default(), window, cx);
7498            })
7499        });
7500        assert_eq!(
7501            visible_entries_as_strings(&panel, 0..10, cx),
7502            &[
7503                "v project_root",
7504                "    v dir_1",
7505                "        v nested_dir",
7506                "              file_a.py",
7507                "      file_1.py  <== selected  <== marked",
7508            ]
7509        );
7510        cx.update(|window, cx| {
7511            panel.update(cx, |this, cx| {
7512                this.select_prev(&Default::default(), window, cx);
7513            })
7514        });
7515        assert_eq!(
7516            visible_entries_as_strings(&panel, 0..10, cx),
7517            &[
7518                "v project_root",
7519                "    v dir_1",
7520                "        v nested_dir",
7521                "              file_a.py  <== selected  <== marked",
7522                "      file_1.py  <== marked",
7523            ]
7524        );
7525        cx.update(|window, cx| {
7526            panel.update(cx, |this, cx| {
7527                let drag = DraggedSelection {
7528                    active_selection: this.selection.unwrap(),
7529                    marked_selections: Arc::new(this.marked_entries.clone()),
7530                };
7531                let target_entry = this
7532                    .project
7533                    .read(cx)
7534                    .entry_for_path(&(worktree_id, "").into(), cx)
7535                    .unwrap();
7536                this.drag_onto(&drag, target_entry.id, false, window, cx);
7537            });
7538        });
7539        cx.run_until_parked();
7540        assert_eq!(
7541            visible_entries_as_strings(&panel, 0..10, cx),
7542            &[
7543                "v project_root",
7544                "    v dir_1",
7545                "        v nested_dir",
7546                "      file_1.py  <== marked",
7547                "      file_a.py  <== selected  <== marked",
7548            ]
7549        );
7550        // ESC clears out all marks
7551        cx.update(|window, cx| {
7552            panel.update(cx, |this, cx| {
7553                this.cancel(&menu::Cancel, window, cx);
7554            })
7555        });
7556        assert_eq!(
7557            visible_entries_as_strings(&panel, 0..10, cx),
7558            &[
7559                "v project_root",
7560                "    v dir_1",
7561                "        v nested_dir",
7562                "      file_1.py",
7563                "      file_a.py  <== selected",
7564            ]
7565        );
7566        // ESC clears out all marks
7567        cx.update(|window, cx| {
7568            panel.update(cx, |this, cx| {
7569                this.select_prev(&SelectPrev, window, cx);
7570                this.select_next(&SelectNext, window, cx);
7571            })
7572        });
7573        assert_eq!(
7574            visible_entries_as_strings(&panel, 0..10, cx),
7575            &[
7576                "v project_root",
7577                "    v dir_1",
7578                "        v nested_dir",
7579                "      file_1.py  <== marked",
7580                "      file_a.py  <== selected  <== marked",
7581            ]
7582        );
7583        cx.simulate_modifiers_change(Default::default());
7584        cx.update(|window, cx| {
7585            panel.update(cx, |this, cx| {
7586                this.cut(&Cut, window, cx);
7587                this.select_prev(&SelectPrev, window, cx);
7588                this.select_prev(&SelectPrev, window, cx);
7589
7590                this.paste(&Paste, window, cx);
7591                // this.expand_selected_entry(&ExpandSelectedEntry, cx);
7592            })
7593        });
7594        cx.run_until_parked();
7595        assert_eq!(
7596            visible_entries_as_strings(&panel, 0..10, cx),
7597            &[
7598                "v project_root",
7599                "    v dir_1",
7600                "        v nested_dir",
7601                "              file_1.py  <== marked",
7602                "              file_a.py  <== selected  <== marked",
7603            ]
7604        );
7605        cx.simulate_modifiers_change(modifiers_with_shift);
7606        cx.update(|window, cx| {
7607            panel.update(cx, |this, cx| {
7608                this.expand_selected_entry(&Default::default(), window, cx);
7609                this.select_next(&SelectNext, window, cx);
7610                this.select_next(&SelectNext, window, cx);
7611            })
7612        });
7613        submit_deletion(&panel, cx);
7614        assert_eq!(
7615            visible_entries_as_strings(&panel, 0..10, cx),
7616            &[
7617                "v project_root",
7618                "    v dir_1",
7619                "        v nested_dir  <== selected",
7620            ]
7621        );
7622    }
7623    #[gpui::test]
7624    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
7625        init_test_with_editor(cx);
7626        cx.update(|cx| {
7627            cx.update_global::<SettingsStore, _>(|store, cx| {
7628                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7629                    worktree_settings.file_scan_exclusions = Some(Vec::new());
7630                });
7631                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7632                    project_panel_settings.auto_reveal_entries = Some(false)
7633                });
7634            })
7635        });
7636
7637        let fs = FakeFs::new(cx.background_executor.clone());
7638        fs.insert_tree(
7639            "/project_root",
7640            json!({
7641                ".git": {},
7642                ".gitignore": "**/gitignored_dir",
7643                "dir_1": {
7644                    "file_1.py": "# File 1_1 contents",
7645                    "file_2.py": "# File 1_2 contents",
7646                    "file_3.py": "# File 1_3 contents",
7647                    "gitignored_dir": {
7648                        "file_a.py": "# File contents",
7649                        "file_b.py": "# File contents",
7650                        "file_c.py": "# File contents",
7651                    },
7652                },
7653                "dir_2": {
7654                    "file_1.py": "# File 2_1 contents",
7655                    "file_2.py": "# File 2_2 contents",
7656                    "file_3.py": "# File 2_3 contents",
7657                }
7658            }),
7659        )
7660        .await;
7661
7662        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7663        let workspace =
7664            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7665        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7666        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7667
7668        assert_eq!(
7669            visible_entries_as_strings(&panel, 0..20, cx),
7670            &[
7671                "v project_root",
7672                "    > .git",
7673                "    > dir_1",
7674                "    > dir_2",
7675                "      .gitignore",
7676            ]
7677        );
7678
7679        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
7680            .expect("dir 1 file is not ignored and should have an entry");
7681        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
7682            .expect("dir 2 file is not ignored and should have an entry");
7683        let gitignored_dir_file =
7684            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7685        assert_eq!(
7686            gitignored_dir_file, None,
7687            "File in the gitignored dir should not have an entry before its dir is toggled"
7688        );
7689
7690        toggle_expand_dir(&panel, "project_root/dir_1", cx);
7691        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7692        cx.executor().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                "        v gitignored_dir  <== selected",
7700                "              file_a.py",
7701                "              file_b.py",
7702                "              file_c.py",
7703                "          file_1.py",
7704                "          file_2.py",
7705                "          file_3.py",
7706                "    > dir_2",
7707                "      .gitignore",
7708            ],
7709            "Should show gitignored dir file list in the project panel"
7710        );
7711        let gitignored_dir_file =
7712            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
7713                .expect("after gitignored dir got opened, a file entry should be present");
7714
7715        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7716        toggle_expand_dir(&panel, "project_root/dir_1", cx);
7717        assert_eq!(
7718            visible_entries_as_strings(&panel, 0..20, cx),
7719            &[
7720                "v project_root",
7721                "    > .git",
7722                "    > dir_1  <== selected",
7723                "    > dir_2",
7724                "      .gitignore",
7725            ],
7726            "Should hide all dir contents again and prepare for the auto reveal test"
7727        );
7728
7729        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7730            panel.update(cx, |panel, cx| {
7731                panel.project.update(cx, |_, cx| {
7732                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7733                })
7734            });
7735            cx.run_until_parked();
7736            assert_eq!(
7737                visible_entries_as_strings(&panel, 0..20, cx),
7738                &[
7739                    "v project_root",
7740                    "    > .git",
7741                    "    > dir_1  <== selected",
7742                    "    > dir_2",
7743                    "      .gitignore",
7744                ],
7745                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7746            );
7747        }
7748
7749        cx.update(|_, cx| {
7750            cx.update_global::<SettingsStore, _>(|store, cx| {
7751                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7752                    project_panel_settings.auto_reveal_entries = Some(true)
7753                });
7754            })
7755        });
7756
7757        panel.update(cx, |panel, cx| {
7758            panel.project.update(cx, |_, cx| {
7759                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
7760            })
7761        });
7762        cx.run_until_parked();
7763        assert_eq!(
7764            visible_entries_as_strings(&panel, 0..20, cx),
7765            &[
7766                "v project_root",
7767                "    > .git",
7768                "    v dir_1",
7769                "        > gitignored_dir",
7770                "          file_1.py  <== selected",
7771                "          file_2.py",
7772                "          file_3.py",
7773                "    > dir_2",
7774                "      .gitignore",
7775            ],
7776            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
7777        );
7778
7779        panel.update(cx, |panel, cx| {
7780            panel.project.update(cx, |_, cx| {
7781                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
7782            })
7783        });
7784        cx.run_until_parked();
7785        assert_eq!(
7786            visible_entries_as_strings(&panel, 0..20, cx),
7787            &[
7788                "v project_root",
7789                "    > .git",
7790                "    v dir_1",
7791                "        > gitignored_dir",
7792                "          file_1.py",
7793                "          file_2.py",
7794                "          file_3.py",
7795                "    v dir_2",
7796                "          file_1.py  <== selected",
7797                "          file_2.py",
7798                "          file_3.py",
7799                "      .gitignore",
7800            ],
7801            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
7802        );
7803
7804        panel.update(cx, |panel, cx| {
7805            panel.project.update(cx, |_, cx| {
7806                cx.emit(project::Event::ActiveEntryChanged(Some(
7807                    gitignored_dir_file,
7808                )))
7809            })
7810        });
7811        cx.run_until_parked();
7812        assert_eq!(
7813            visible_entries_as_strings(&panel, 0..20, cx),
7814            &[
7815                "v project_root",
7816                "    > .git",
7817                "    v dir_1",
7818                "        > gitignored_dir",
7819                "          file_1.py",
7820                "          file_2.py",
7821                "          file_3.py",
7822                "    v dir_2",
7823                "          file_1.py  <== selected",
7824                "          file_2.py",
7825                "          file_3.py",
7826                "      .gitignore",
7827            ],
7828            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
7829        );
7830
7831        panel.update(cx, |panel, cx| {
7832            panel.project.update(cx, |_, cx| {
7833                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
7834            })
7835        });
7836        cx.run_until_parked();
7837        assert_eq!(
7838            visible_entries_as_strings(&panel, 0..20, cx),
7839            &[
7840                "v project_root",
7841                "    > .git",
7842                "    v dir_1",
7843                "        v gitignored_dir",
7844                "              file_a.py  <== selected",
7845                "              file_b.py",
7846                "              file_c.py",
7847                "          file_1.py",
7848                "          file_2.py",
7849                "          file_3.py",
7850                "    v dir_2",
7851                "          file_1.py",
7852                "          file_2.py",
7853                "          file_3.py",
7854                "      .gitignore",
7855            ],
7856            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
7857        );
7858    }
7859
7860    #[gpui::test]
7861    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
7862        init_test_with_editor(cx);
7863        cx.update(|cx| {
7864            cx.update_global::<SettingsStore, _>(|store, cx| {
7865                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
7866                    worktree_settings.file_scan_exclusions = Some(Vec::new());
7867                });
7868                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
7869                    project_panel_settings.auto_reveal_entries = Some(false)
7870                });
7871            })
7872        });
7873
7874        let fs = FakeFs::new(cx.background_executor.clone());
7875        fs.insert_tree(
7876            "/project_root",
7877            json!({
7878                ".git": {},
7879                ".gitignore": "**/gitignored_dir",
7880                "dir_1": {
7881                    "file_1.py": "# File 1_1 contents",
7882                    "file_2.py": "# File 1_2 contents",
7883                    "file_3.py": "# File 1_3 contents",
7884                    "gitignored_dir": {
7885                        "file_a.py": "# File contents",
7886                        "file_b.py": "# File contents",
7887                        "file_c.py": "# File contents",
7888                    },
7889                },
7890                "dir_2": {
7891                    "file_1.py": "# File 2_1 contents",
7892                    "file_2.py": "# File 2_2 contents",
7893                    "file_3.py": "# File 2_3 contents",
7894                }
7895            }),
7896        )
7897        .await;
7898
7899        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
7900        let workspace =
7901            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7902        let cx = &mut VisualTestContext::from_window(*workspace, cx);
7903        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
7904
7905        assert_eq!(
7906            visible_entries_as_strings(&panel, 0..20, cx),
7907            &[
7908                "v project_root",
7909                "    > .git",
7910                "    > dir_1",
7911                "    > dir_2",
7912                "      .gitignore",
7913            ]
7914        );
7915
7916        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
7917            .expect("dir 1 file is not ignored and should have an entry");
7918        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
7919            .expect("dir 2 file is not ignored and should have an entry");
7920        let gitignored_dir_file =
7921            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
7922        assert_eq!(
7923            gitignored_dir_file, None,
7924            "File in the gitignored dir should not have an entry before its dir is toggled"
7925        );
7926
7927        toggle_expand_dir(&panel, "project_root/dir_1", cx);
7928        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7929        cx.run_until_parked();
7930        assert_eq!(
7931            visible_entries_as_strings(&panel, 0..20, cx),
7932            &[
7933                "v project_root",
7934                "    > .git",
7935                "    v dir_1",
7936                "        v gitignored_dir  <== selected",
7937                "              file_a.py",
7938                "              file_b.py",
7939                "              file_c.py",
7940                "          file_1.py",
7941                "          file_2.py",
7942                "          file_3.py",
7943                "    > dir_2",
7944                "      .gitignore",
7945            ],
7946            "Should show gitignored dir file list in the project panel"
7947        );
7948        let gitignored_dir_file =
7949            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
7950                .expect("after gitignored dir got opened, a file entry should be present");
7951
7952        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
7953        toggle_expand_dir(&panel, "project_root/dir_1", cx);
7954        assert_eq!(
7955            visible_entries_as_strings(&panel, 0..20, cx),
7956            &[
7957                "v project_root",
7958                "    > .git",
7959                "    > dir_1  <== selected",
7960                "    > dir_2",
7961                "      .gitignore",
7962            ],
7963            "Should hide all dir contents again and prepare for the explicit reveal test"
7964        );
7965
7966        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
7967            panel.update(cx, |panel, cx| {
7968                panel.project.update(cx, |_, cx| {
7969                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
7970                })
7971            });
7972            cx.run_until_parked();
7973            assert_eq!(
7974                visible_entries_as_strings(&panel, 0..20, cx),
7975                &[
7976                    "v project_root",
7977                    "    > .git",
7978                    "    > dir_1  <== selected",
7979                    "    > dir_2",
7980                    "      .gitignore",
7981                ],
7982                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
7983            );
7984        }
7985
7986        panel.update(cx, |panel, cx| {
7987            panel.project.update(cx, |_, cx| {
7988                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
7989            })
7990        });
7991        cx.run_until_parked();
7992        assert_eq!(
7993            visible_entries_as_strings(&panel, 0..20, cx),
7994            &[
7995                "v project_root",
7996                "    > .git",
7997                "    v dir_1",
7998                "        > gitignored_dir",
7999                "          file_1.py  <== selected",
8000                "          file_2.py",
8001                "          file_3.py",
8002                "    > dir_2",
8003                "      .gitignore",
8004            ],
8005            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
8006        );
8007
8008        panel.update(cx, |panel, cx| {
8009            panel.project.update(cx, |_, cx| {
8010                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
8011            })
8012        });
8013        cx.run_until_parked();
8014        assert_eq!(
8015            visible_entries_as_strings(&panel, 0..20, cx),
8016            &[
8017                "v project_root",
8018                "    > .git",
8019                "    v dir_1",
8020                "        > gitignored_dir",
8021                "          file_1.py",
8022                "          file_2.py",
8023                "          file_3.py",
8024                "    v dir_2",
8025                "          file_1.py  <== selected",
8026                "          file_2.py",
8027                "          file_3.py",
8028                "      .gitignore",
8029            ],
8030            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
8031        );
8032
8033        panel.update(cx, |panel, cx| {
8034            panel.project.update(cx, |_, cx| {
8035                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
8036            })
8037        });
8038        cx.run_until_parked();
8039        assert_eq!(
8040            visible_entries_as_strings(&panel, 0..20, cx),
8041            &[
8042                "v project_root",
8043                "    > .git",
8044                "    v dir_1",
8045                "        v gitignored_dir",
8046                "              file_a.py  <== selected",
8047                "              file_b.py",
8048                "              file_c.py",
8049                "          file_1.py",
8050                "          file_2.py",
8051                "          file_3.py",
8052                "    v dir_2",
8053                "          file_1.py",
8054                "          file_2.py",
8055                "          file_3.py",
8056                "      .gitignore",
8057            ],
8058            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
8059        );
8060    }
8061
8062    #[gpui::test]
8063    async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
8064        init_test(cx);
8065        cx.update(|cx| {
8066            cx.update_global::<SettingsStore, _>(|store, cx| {
8067                store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
8068                    project_settings.file_scan_exclusions =
8069                        Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
8070                });
8071            });
8072        });
8073
8074        cx.update(|cx| {
8075            register_project_item::<TestProjectItemView>(cx);
8076        });
8077
8078        let fs = FakeFs::new(cx.executor().clone());
8079        fs.insert_tree(
8080            "/root1",
8081            json!({
8082                ".dockerignore": "",
8083                ".git": {
8084                    "HEAD": "",
8085                },
8086            }),
8087        )
8088        .await;
8089
8090        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
8091        let workspace =
8092            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8093        let cx = &mut VisualTestContext::from_window(*workspace, cx);
8094        let panel = workspace
8095            .update(cx, |workspace, window, cx| {
8096                let panel = ProjectPanel::new(workspace, window, cx);
8097                workspace.add_panel(panel.clone(), window, cx);
8098                panel
8099            })
8100            .unwrap();
8101
8102        select_path(&panel, "root1", cx);
8103        assert_eq!(
8104            visible_entries_as_strings(&panel, 0..10, cx),
8105            &["v root1  <== selected", "      .dockerignore",]
8106        );
8107        workspace
8108            .update(cx, |workspace, _, cx| {
8109                assert!(
8110                    workspace.active_item(cx).is_none(),
8111                    "Should have no active items in the beginning"
8112                );
8113            })
8114            .unwrap();
8115
8116        let excluded_file_path = ".git/COMMIT_EDITMSG";
8117        let excluded_dir_path = "excluded_dir";
8118
8119        panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx));
8120        panel.update_in(cx, |panel, window, cx| {
8121            assert!(panel.filename_editor.read(cx).is_focused(window));
8122        });
8123        panel
8124            .update_in(cx, |panel, window, cx| {
8125                panel.filename_editor.update(cx, |editor, cx| {
8126                    editor.set_text(excluded_file_path, window, cx)
8127                });
8128                panel.confirm_edit(window, cx).unwrap()
8129            })
8130            .await
8131            .unwrap();
8132
8133        assert_eq!(
8134            visible_entries_as_strings(&panel, 0..13, cx),
8135            &["v root1", "      .dockerignore"],
8136            "Excluded dir should not be shown after opening a file in it"
8137        );
8138        panel.update_in(cx, |panel, window, cx| {
8139            assert!(
8140                !panel.filename_editor.read(cx).is_focused(window),
8141                "Should have closed the file name editor"
8142            );
8143        });
8144        workspace
8145            .update(cx, |workspace, _, cx| {
8146                let active_entry_path = workspace
8147                    .active_item(cx)
8148                    .expect("should have opened and activated the excluded item")
8149                    .act_as::<TestProjectItemView>(cx)
8150                    .expect(
8151                        "should have opened the corresponding project item for the excluded item",
8152                    )
8153                    .read(cx)
8154                    .path
8155                    .clone();
8156                assert_eq!(
8157                    active_entry_path.path.as_ref(),
8158                    Path::new(excluded_file_path),
8159                    "Should open the excluded file"
8160                );
8161
8162                assert!(
8163                    workspace.notification_ids().is_empty(),
8164                    "Should have no notifications after opening an excluded file"
8165                );
8166            })
8167            .unwrap();
8168        assert!(
8169            fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
8170            "Should have created the excluded file"
8171        );
8172
8173        select_path(&panel, "root1", cx);
8174        panel.update_in(cx, |panel, window, cx| {
8175            panel.new_directory(&NewDirectory, window, cx)
8176        });
8177        panel.update_in(cx, |panel, window, cx| {
8178            assert!(panel.filename_editor.read(cx).is_focused(window));
8179        });
8180        panel
8181            .update_in(cx, |panel, window, cx| {
8182                panel.filename_editor.update(cx, |editor, cx| {
8183                    editor.set_text(excluded_file_path, window, cx)
8184                });
8185                panel.confirm_edit(window, cx).unwrap()
8186            })
8187            .await
8188            .unwrap();
8189
8190        assert_eq!(
8191            visible_entries_as_strings(&panel, 0..13, cx),
8192            &["v root1", "      .dockerignore"],
8193            "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
8194        );
8195        panel.update_in(cx, |panel, window, cx| {
8196            assert!(
8197                !panel.filename_editor.read(cx).is_focused(window),
8198                "Should have closed the file name editor"
8199            );
8200        });
8201        workspace
8202            .update(cx, |workspace, _, cx| {
8203                let notifications = workspace.notification_ids();
8204                assert_eq!(
8205                    notifications.len(),
8206                    1,
8207                    "Should receive one notification with the error message"
8208                );
8209                workspace.dismiss_notification(notifications.first().unwrap(), cx);
8210                assert!(workspace.notification_ids().is_empty());
8211            })
8212            .unwrap();
8213
8214        select_path(&panel, "root1", cx);
8215        panel.update_in(cx, |panel, window, cx| {
8216            panel.new_directory(&NewDirectory, window, cx)
8217        });
8218        panel.update_in(cx, |panel, window, cx| {
8219            assert!(panel.filename_editor.read(cx).is_focused(window));
8220        });
8221        panel
8222            .update_in(cx, |panel, window, cx| {
8223                panel.filename_editor.update(cx, |editor, cx| {
8224                    editor.set_text(excluded_dir_path, window, cx)
8225                });
8226                panel.confirm_edit(window, cx).unwrap()
8227            })
8228            .await
8229            .unwrap();
8230
8231        assert_eq!(
8232            visible_entries_as_strings(&panel, 0..13, cx),
8233            &["v root1", "      .dockerignore"],
8234            "Should not change the project panel after trying to create an excluded directory"
8235        );
8236        panel.update_in(cx, |panel, window, cx| {
8237            assert!(
8238                !panel.filename_editor.read(cx).is_focused(window),
8239                "Should have closed the file name editor"
8240            );
8241        });
8242        workspace
8243            .update(cx, |workspace, _, cx| {
8244                let notifications = workspace.notification_ids();
8245                assert_eq!(
8246                    notifications.len(),
8247                    1,
8248                    "Should receive one notification explaining that no directory is actually shown"
8249                );
8250                workspace.dismiss_notification(notifications.first().unwrap(), cx);
8251                assert!(workspace.notification_ids().is_empty());
8252            })
8253            .unwrap();
8254        assert!(
8255            fs.is_dir(Path::new("/root1/excluded_dir")).await,
8256            "Should have created the excluded directory"
8257        );
8258    }
8259
8260    #[gpui::test]
8261    async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppContext) {
8262        init_test_with_editor(cx);
8263
8264        let fs = FakeFs::new(cx.executor().clone());
8265        fs.insert_tree(
8266            "/src",
8267            json!({
8268                "test": {
8269                    "first.rs": "// First Rust file",
8270                    "second.rs": "// Second Rust file",
8271                    "third.rs": "// Third Rust file",
8272                }
8273            }),
8274        )
8275        .await;
8276
8277        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
8278        let workspace =
8279            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8280        let cx = &mut VisualTestContext::from_window(*workspace, cx);
8281        let panel = workspace
8282            .update(cx, |workspace, window, cx| {
8283                let panel = ProjectPanel::new(workspace, window, cx);
8284                workspace.add_panel(panel.clone(), window, cx);
8285                panel
8286            })
8287            .unwrap();
8288
8289        select_path(&panel, "src/", cx);
8290        panel.update_in(cx, |panel, window, cx| panel.confirm(&Confirm, window, cx));
8291        cx.executor().run_until_parked();
8292        assert_eq!(
8293            visible_entries_as_strings(&panel, 0..10, cx),
8294            &[
8295                //
8296                "v src  <== selected",
8297                "    > test"
8298            ]
8299        );
8300        panel.update_in(cx, |panel, window, cx| {
8301            panel.new_directory(&NewDirectory, window, cx)
8302        });
8303        panel.update_in(cx, |panel, window, cx| {
8304            assert!(panel.filename_editor.read(cx).is_focused(window));
8305        });
8306        assert_eq!(
8307            visible_entries_as_strings(&panel, 0..10, cx),
8308            &[
8309                //
8310                "v src",
8311                "    > [EDITOR: '']  <== selected",
8312                "    > test"
8313            ]
8314        );
8315
8316        panel.update_in(cx, |panel, window, cx| {
8317            panel.cancel(&menu::Cancel, window, cx)
8318        });
8319        assert_eq!(
8320            visible_entries_as_strings(&panel, 0..10, cx),
8321            &[
8322                //
8323                "v src  <== selected",
8324                "    > test"
8325            ]
8326        );
8327    }
8328
8329    #[gpui::test]
8330    async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) {
8331        init_test_with_editor(cx);
8332
8333        let fs = FakeFs::new(cx.executor().clone());
8334        fs.insert_tree(
8335            "/root",
8336            json!({
8337                "dir1": {
8338                    "subdir1": {},
8339                    "file1.txt": "",
8340                    "file2.txt": "",
8341                },
8342                "dir2": {
8343                    "subdir2": {},
8344                    "file3.txt": "",
8345                    "file4.txt": "",
8346                },
8347                "file5.txt": "",
8348                "file6.txt": "",
8349            }),
8350        )
8351        .await;
8352
8353        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8354        let workspace =
8355            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8356        let cx = &mut VisualTestContext::from_window(*workspace, cx);
8357        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8358
8359        toggle_expand_dir(&panel, "root/dir1", cx);
8360        toggle_expand_dir(&panel, "root/dir2", cx);
8361
8362        // Test Case 1: Delete middle file in directory
8363        select_path(&panel, "root/dir1/file1.txt", cx);
8364        assert_eq!(
8365            visible_entries_as_strings(&panel, 0..15, cx),
8366            &[
8367                "v root",
8368                "    v dir1",
8369                "        > subdir1",
8370                "          file1.txt  <== selected",
8371                "          file2.txt",
8372                "    v dir2",
8373                "        > subdir2",
8374                "          file3.txt",
8375                "          file4.txt",
8376                "      file5.txt",
8377                "      file6.txt",
8378            ],
8379            "Initial state before deleting middle file"
8380        );
8381
8382        submit_deletion(&panel, cx);
8383        assert_eq!(
8384            visible_entries_as_strings(&panel, 0..15, cx),
8385            &[
8386                "v root",
8387                "    v dir1",
8388                "        > subdir1",
8389                "          file2.txt  <== selected",
8390                "    v dir2",
8391                "        > subdir2",
8392                "          file3.txt",
8393                "          file4.txt",
8394                "      file5.txt",
8395                "      file6.txt",
8396            ],
8397            "Should select next file after deleting middle file"
8398        );
8399
8400        // Test Case 2: Delete last file in directory
8401        submit_deletion(&panel, cx);
8402        assert_eq!(
8403            visible_entries_as_strings(&panel, 0..15, cx),
8404            &[
8405                "v root",
8406                "    v dir1",
8407                "        > subdir1  <== selected",
8408                "    v dir2",
8409                "        > subdir2",
8410                "          file3.txt",
8411                "          file4.txt",
8412                "      file5.txt",
8413                "      file6.txt",
8414            ],
8415            "Should select next directory when last file is deleted"
8416        );
8417
8418        // Test Case 3: Delete root level file
8419        select_path(&panel, "root/file6.txt", cx);
8420        assert_eq!(
8421            visible_entries_as_strings(&panel, 0..15, cx),
8422            &[
8423                "v root",
8424                "    v dir1",
8425                "        > subdir1",
8426                "    v dir2",
8427                "        > subdir2",
8428                "          file3.txt",
8429                "          file4.txt",
8430                "      file5.txt",
8431                "      file6.txt  <== selected",
8432            ],
8433            "Initial state before deleting root level file"
8434        );
8435
8436        submit_deletion(&panel, cx);
8437        assert_eq!(
8438            visible_entries_as_strings(&panel, 0..15, cx),
8439            &[
8440                "v root",
8441                "    v dir1",
8442                "        > subdir1",
8443                "    v dir2",
8444                "        > subdir2",
8445                "          file3.txt",
8446                "          file4.txt",
8447                "      file5.txt  <== selected",
8448            ],
8449            "Should select prev entry at root level"
8450        );
8451    }
8452
8453    #[gpui::test]
8454    async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) {
8455        init_test_with_editor(cx);
8456
8457        let fs = FakeFs::new(cx.executor().clone());
8458        fs.insert_tree(
8459            "/root",
8460            json!({
8461                "dir1": {
8462                    "subdir1": {
8463                        "a.txt": "",
8464                        "b.txt": ""
8465                    },
8466                    "file1.txt": "",
8467                },
8468                "dir2": {
8469                    "subdir2": {
8470                        "c.txt": "",
8471                        "d.txt": ""
8472                    },
8473                    "file2.txt": "",
8474                },
8475                "file3.txt": "",
8476            }),
8477        )
8478        .await;
8479
8480        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8481        let workspace =
8482            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8483        let cx = &mut VisualTestContext::from_window(*workspace, cx);
8484        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8485
8486        toggle_expand_dir(&panel, "root/dir1", cx);
8487        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8488        toggle_expand_dir(&panel, "root/dir2", cx);
8489        toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8490
8491        // Test Case 1: Select and delete nested directory with parent
8492        cx.simulate_modifiers_change(gpui::Modifiers {
8493            control: true,
8494            ..Default::default()
8495        });
8496        select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8497        select_path_with_mark(&panel, "root/dir1", cx);
8498
8499        assert_eq!(
8500            visible_entries_as_strings(&panel, 0..15, cx),
8501            &[
8502                "v root",
8503                "    v dir1  <== selected  <== marked",
8504                "        v subdir1  <== marked",
8505                "              a.txt",
8506                "              b.txt",
8507                "          file1.txt",
8508                "    v dir2",
8509                "        v subdir2",
8510                "              c.txt",
8511                "              d.txt",
8512                "          file2.txt",
8513                "      file3.txt",
8514            ],
8515            "Initial state before deleting nested directory with parent"
8516        );
8517
8518        submit_deletion(&panel, cx);
8519        assert_eq!(
8520            visible_entries_as_strings(&panel, 0..15, cx),
8521            &[
8522                "v root",
8523                "    v dir2  <== selected",
8524                "        v subdir2",
8525                "              c.txt",
8526                "              d.txt",
8527                "          file2.txt",
8528                "      file3.txt",
8529            ],
8530            "Should select next directory after deleting directory with parent"
8531        );
8532
8533        // Test Case 2: Select mixed files and directories across levels
8534        select_path_with_mark(&panel, "root/dir2/subdir2/c.txt", cx);
8535        select_path_with_mark(&panel, "root/dir2/file2.txt", cx);
8536        select_path_with_mark(&panel, "root/file3.txt", cx);
8537
8538        assert_eq!(
8539            visible_entries_as_strings(&panel, 0..15, cx),
8540            &[
8541                "v root",
8542                "    v dir2",
8543                "        v subdir2",
8544                "              c.txt  <== marked",
8545                "              d.txt",
8546                "          file2.txt  <== marked",
8547                "      file3.txt  <== selected  <== marked",
8548            ],
8549            "Initial state before deleting"
8550        );
8551
8552        submit_deletion(&panel, cx);
8553        assert_eq!(
8554            visible_entries_as_strings(&panel, 0..15, cx),
8555            &[
8556                "v root",
8557                "    v dir2  <== selected",
8558                "        v subdir2",
8559                "              d.txt",
8560            ],
8561            "Should select sibling directory"
8562        );
8563    }
8564
8565    #[gpui::test]
8566    async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) {
8567        init_test_with_editor(cx);
8568
8569        let fs = FakeFs::new(cx.executor().clone());
8570        fs.insert_tree(
8571            "/root",
8572            json!({
8573                "dir1": {
8574                    "subdir1": {
8575                        "a.txt": "",
8576                        "b.txt": ""
8577                    },
8578                    "file1.txt": "",
8579                },
8580                "dir2": {
8581                    "subdir2": {
8582                        "c.txt": "",
8583                        "d.txt": ""
8584                    },
8585                    "file2.txt": "",
8586                },
8587                "file3.txt": "",
8588                "file4.txt": "",
8589            }),
8590        )
8591        .await;
8592
8593        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8594        let workspace =
8595            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8596        let cx = &mut VisualTestContext::from_window(*workspace, cx);
8597        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8598
8599        toggle_expand_dir(&panel, "root/dir1", cx);
8600        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8601        toggle_expand_dir(&panel, "root/dir2", cx);
8602        toggle_expand_dir(&panel, "root/dir2/subdir2", cx);
8603
8604        // Test Case 1: Select all root files and directories
8605        cx.simulate_modifiers_change(gpui::Modifiers {
8606            control: true,
8607            ..Default::default()
8608        });
8609        select_path_with_mark(&panel, "root/dir1", cx);
8610        select_path_with_mark(&panel, "root/dir2", cx);
8611        select_path_with_mark(&panel, "root/file3.txt", cx);
8612        select_path_with_mark(&panel, "root/file4.txt", cx);
8613        assert_eq!(
8614            visible_entries_as_strings(&panel, 0..20, cx),
8615            &[
8616                "v root",
8617                "    v dir1  <== marked",
8618                "        v subdir1",
8619                "              a.txt",
8620                "              b.txt",
8621                "          file1.txt",
8622                "    v dir2  <== marked",
8623                "        v subdir2",
8624                "              c.txt",
8625                "              d.txt",
8626                "          file2.txt",
8627                "      file3.txt  <== marked",
8628                "      file4.txt  <== selected  <== marked",
8629            ],
8630            "State before deleting all contents"
8631        );
8632
8633        submit_deletion(&panel, cx);
8634        assert_eq!(
8635            visible_entries_as_strings(&panel, 0..20, cx),
8636            &["v root  <== selected"],
8637            "Only empty root directory should remain after deleting all contents"
8638        );
8639    }
8640
8641    #[gpui::test]
8642    async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) {
8643        init_test_with_editor(cx);
8644
8645        let fs = FakeFs::new(cx.executor().clone());
8646        fs.insert_tree(
8647            "/root",
8648            json!({
8649                "dir1": {
8650                    "subdir1": {
8651                        "file_a.txt": "content a",
8652                        "file_b.txt": "content b",
8653                    },
8654                    "subdir2": {
8655                        "file_c.txt": "content c",
8656                    },
8657                    "file1.txt": "content 1",
8658                },
8659                "dir2": {
8660                    "file2.txt": "content 2",
8661                },
8662            }),
8663        )
8664        .await;
8665
8666        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8667        let workspace =
8668            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8669        let cx = &mut VisualTestContext::from_window(*workspace, cx);
8670        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8671
8672        toggle_expand_dir(&panel, "root/dir1", cx);
8673        toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
8674        toggle_expand_dir(&panel, "root/dir2", cx);
8675        cx.simulate_modifiers_change(gpui::Modifiers {
8676            control: true,
8677            ..Default::default()
8678        });
8679
8680        // Test Case 1: Select parent directory, subdirectory, and a file inside the subdirectory
8681        select_path_with_mark(&panel, "root/dir1", cx);
8682        select_path_with_mark(&panel, "root/dir1/subdir1", cx);
8683        select_path_with_mark(&panel, "root/dir1/subdir1/file_a.txt", cx);
8684
8685        assert_eq!(
8686            visible_entries_as_strings(&panel, 0..20, cx),
8687            &[
8688                "v root",
8689                "    v dir1  <== marked",
8690                "        v subdir1  <== marked",
8691                "              file_a.txt  <== selected  <== marked",
8692                "              file_b.txt",
8693                "        > subdir2",
8694                "          file1.txt",
8695                "    v dir2",
8696                "          file2.txt",
8697            ],
8698            "State with parent dir, subdir, and file selected"
8699        );
8700        submit_deletion(&panel, cx);
8701        assert_eq!(
8702            visible_entries_as_strings(&panel, 0..20, cx),
8703            &["v root", "    v dir2  <== selected", "          file2.txt",],
8704            "Only dir2 should remain after deletion"
8705        );
8706    }
8707
8708    #[gpui::test]
8709    async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) {
8710        init_test_with_editor(cx);
8711
8712        let fs = FakeFs::new(cx.executor().clone());
8713        // First worktree
8714        fs.insert_tree(
8715            "/root1",
8716            json!({
8717                "dir1": {
8718                    "file1.txt": "content 1",
8719                    "file2.txt": "content 2",
8720                },
8721                "dir2": {
8722                    "file3.txt": "content 3",
8723                },
8724            }),
8725        )
8726        .await;
8727
8728        // Second worktree
8729        fs.insert_tree(
8730            "/root2",
8731            json!({
8732                "dir3": {
8733                    "file4.txt": "content 4",
8734                    "file5.txt": "content 5",
8735                },
8736                "file6.txt": "content 6",
8737            }),
8738        )
8739        .await;
8740
8741        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
8742        let workspace =
8743            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8744        let cx = &mut VisualTestContext::from_window(*workspace, cx);
8745        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8746
8747        // Expand all directories for testing
8748        toggle_expand_dir(&panel, "root1/dir1", cx);
8749        toggle_expand_dir(&panel, "root1/dir2", cx);
8750        toggle_expand_dir(&panel, "root2/dir3", cx);
8751
8752        // Test Case 1: Delete files across different worktrees
8753        cx.simulate_modifiers_change(gpui::Modifiers {
8754            control: true,
8755            ..Default::default()
8756        });
8757        select_path_with_mark(&panel, "root1/dir1/file1.txt", cx);
8758        select_path_with_mark(&panel, "root2/dir3/file4.txt", cx);
8759
8760        assert_eq!(
8761            visible_entries_as_strings(&panel, 0..20, cx),
8762            &[
8763                "v root1",
8764                "    v dir1",
8765                "          file1.txt  <== marked",
8766                "          file2.txt",
8767                "    v dir2",
8768                "          file3.txt",
8769                "v root2",
8770                "    v dir3",
8771                "          file4.txt  <== selected  <== marked",
8772                "          file5.txt",
8773                "      file6.txt",
8774            ],
8775            "Initial state with files selected from different worktrees"
8776        );
8777
8778        submit_deletion(&panel, cx);
8779        assert_eq!(
8780            visible_entries_as_strings(&panel, 0..20, cx),
8781            &[
8782                "v root1",
8783                "    v dir1",
8784                "          file2.txt",
8785                "    v dir2",
8786                "          file3.txt",
8787                "v root2",
8788                "    v dir3",
8789                "          file5.txt  <== selected",
8790                "      file6.txt",
8791            ],
8792            "Should select next file in the last worktree after deletion"
8793        );
8794
8795        // Test Case 2: Delete directories from different worktrees
8796        select_path_with_mark(&panel, "root1/dir1", cx);
8797        select_path_with_mark(&panel, "root2/dir3", cx);
8798
8799        assert_eq!(
8800            visible_entries_as_strings(&panel, 0..20, cx),
8801            &[
8802                "v root1",
8803                "    v dir1  <== marked",
8804                "          file2.txt",
8805                "    v dir2",
8806                "          file3.txt",
8807                "v root2",
8808                "    v dir3  <== selected  <== marked",
8809                "          file5.txt",
8810                "      file6.txt",
8811            ],
8812            "State with directories marked from different worktrees"
8813        );
8814
8815        submit_deletion(&panel, cx);
8816        assert_eq!(
8817            visible_entries_as_strings(&panel, 0..20, cx),
8818            &[
8819                "v root1",
8820                "    v dir2",
8821                "          file3.txt",
8822                "v root2",
8823                "      file6.txt  <== selected",
8824            ],
8825            "Should select remaining file in last worktree after directory deletion"
8826        );
8827
8828        // Test Case 4: Delete all remaining files except roots
8829        select_path_with_mark(&panel, "root1/dir2/file3.txt", cx);
8830        select_path_with_mark(&panel, "root2/file6.txt", cx);
8831
8832        assert_eq!(
8833            visible_entries_as_strings(&panel, 0..20, cx),
8834            &[
8835                "v root1",
8836                "    v dir2",
8837                "          file3.txt  <== marked",
8838                "v root2",
8839                "      file6.txt  <== selected  <== marked",
8840            ],
8841            "State with all remaining files marked"
8842        );
8843
8844        submit_deletion(&panel, cx);
8845        assert_eq!(
8846            visible_entries_as_strings(&panel, 0..20, cx),
8847            &["v root1", "    v dir2", "v root2  <== selected"],
8848            "Second parent root should be selected after deleting"
8849        );
8850    }
8851
8852    #[gpui::test]
8853    async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext) {
8854        init_test_with_editor(cx);
8855
8856        let fs = FakeFs::new(cx.executor().clone());
8857        fs.insert_tree(
8858            "/root",
8859            json!({
8860                "dir1": {
8861                    "file1.txt": "",
8862                    "file2.txt": "",
8863                    "file3.txt": "",
8864                },
8865                "dir2": {
8866                    "file4.txt": "",
8867                    "file5.txt": "",
8868                },
8869            }),
8870        )
8871        .await;
8872
8873        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
8874        let workspace =
8875            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8876        let cx = &mut VisualTestContext::from_window(*workspace, cx);
8877        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8878
8879        toggle_expand_dir(&panel, "root/dir1", cx);
8880        toggle_expand_dir(&panel, "root/dir2", cx);
8881
8882        cx.simulate_modifiers_change(gpui::Modifiers {
8883            control: true,
8884            ..Default::default()
8885        });
8886
8887        select_path_with_mark(&panel, "root/dir1/file2.txt", cx);
8888        select_path(&panel, "root/dir1/file1.txt", cx);
8889
8890        assert_eq!(
8891            visible_entries_as_strings(&panel, 0..15, cx),
8892            &[
8893                "v root",
8894                "    v dir1",
8895                "          file1.txt  <== selected",
8896                "          file2.txt  <== marked",
8897                "          file3.txt",
8898                "    v dir2",
8899                "          file4.txt",
8900                "          file5.txt",
8901            ],
8902            "Initial state with one marked entry and different selection"
8903        );
8904
8905        // Delete should operate on the selected entry (file1.txt)
8906        submit_deletion(&panel, cx);
8907        assert_eq!(
8908            visible_entries_as_strings(&panel, 0..15, cx),
8909            &[
8910                "v root",
8911                "    v dir1",
8912                "          file2.txt  <== selected  <== marked",
8913                "          file3.txt",
8914                "    v dir2",
8915                "          file4.txt",
8916                "          file5.txt",
8917            ],
8918            "Should delete selected file, not marked file"
8919        );
8920
8921        select_path_with_mark(&panel, "root/dir1/file3.txt", cx);
8922        select_path_with_mark(&panel, "root/dir2/file4.txt", cx);
8923        select_path(&panel, "root/dir2/file5.txt", cx);
8924
8925        assert_eq!(
8926            visible_entries_as_strings(&panel, 0..15, cx),
8927            &[
8928                "v root",
8929                "    v dir1",
8930                "          file2.txt  <== marked",
8931                "          file3.txt  <== marked",
8932                "    v dir2",
8933                "          file4.txt  <== marked",
8934                "          file5.txt  <== selected",
8935            ],
8936            "Initial state with multiple marked entries and different selection"
8937        );
8938
8939        // Delete should operate on all marked entries, ignoring the selection
8940        submit_deletion(&panel, cx);
8941        assert_eq!(
8942            visible_entries_as_strings(&panel, 0..15, cx),
8943            &[
8944                "v root",
8945                "    v dir1",
8946                "    v dir2",
8947                "          file5.txt  <== selected",
8948            ],
8949            "Should delete all marked files, leaving only the selected file"
8950        );
8951    }
8952
8953    #[gpui::test]
8954    async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestAppContext) {
8955        init_test_with_editor(cx);
8956
8957        let fs = FakeFs::new(cx.executor().clone());
8958        fs.insert_tree(
8959            "/root_b",
8960            json!({
8961                "dir1": {
8962                    "file1.txt": "content 1",
8963                    "file2.txt": "content 2",
8964                },
8965            }),
8966        )
8967        .await;
8968
8969        fs.insert_tree(
8970            "/root_c",
8971            json!({
8972                "dir2": {},
8973            }),
8974        )
8975        .await;
8976
8977        let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await;
8978        let workspace =
8979            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
8980        let cx = &mut VisualTestContext::from_window(*workspace, cx);
8981        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
8982
8983        toggle_expand_dir(&panel, "root_b/dir1", cx);
8984        toggle_expand_dir(&panel, "root_c/dir2", cx);
8985
8986        cx.simulate_modifiers_change(gpui::Modifiers {
8987            control: true,
8988            ..Default::default()
8989        });
8990        select_path_with_mark(&panel, "root_b/dir1/file1.txt", cx);
8991        select_path_with_mark(&panel, "root_b/dir1/file2.txt", cx);
8992
8993        assert_eq!(
8994            visible_entries_as_strings(&panel, 0..20, cx),
8995            &[
8996                "v root_b",
8997                "    v dir1",
8998                "          file1.txt  <== marked",
8999                "          file2.txt  <== selected  <== marked",
9000                "v root_c",
9001                "    v dir2",
9002            ],
9003            "Initial state with files marked in root_b"
9004        );
9005
9006        submit_deletion(&panel, cx);
9007        assert_eq!(
9008            visible_entries_as_strings(&panel, 0..20, cx),
9009            &[
9010                "v root_b",
9011                "    v dir1  <== selected",
9012                "v root_c",
9013                "    v dir2",
9014            ],
9015            "After deletion in root_b as it's last deletion, selection should be in root_b"
9016        );
9017
9018        select_path_with_mark(&panel, "root_c/dir2", cx);
9019
9020        submit_deletion(&panel, cx);
9021        assert_eq!(
9022            visible_entries_as_strings(&panel, 0..20, cx),
9023            &["v root_b", "    v dir1", "v root_c  <== selected",],
9024            "After deleting from root_c, it should remain in root_c"
9025        );
9026    }
9027
9028    fn toggle_expand_dir(
9029        panel: &Entity<ProjectPanel>,
9030        path: impl AsRef<Path>,
9031        cx: &mut VisualTestContext,
9032    ) {
9033        let path = path.as_ref();
9034        panel.update_in(cx, |panel, window, cx| {
9035            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9036                let worktree = worktree.read(cx);
9037                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9038                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9039                    panel.toggle_expanded(entry_id, window, cx);
9040                    return;
9041                }
9042            }
9043            panic!("no worktree for path {:?}", path);
9044        });
9045    }
9046
9047    #[gpui::test]
9048    async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
9049        init_test_with_editor(cx);
9050
9051        let fs = FakeFs::new(cx.executor().clone());
9052        fs.insert_tree(
9053            path!("/root"),
9054            json!({
9055                ".gitignore": "**/ignored_dir\n**/ignored_nested",
9056                "dir1": {
9057                    "empty1": {
9058                        "empty2": {
9059                            "empty3": {
9060                                "file.txt": ""
9061                            }
9062                        }
9063                    },
9064                    "subdir1": {
9065                        "file1.txt": "",
9066                        "file2.txt": "",
9067                        "ignored_nested": {
9068                            "ignored_file.txt": ""
9069                        }
9070                    },
9071                    "ignored_dir": {
9072                        "subdir": {
9073                            "deep_file.txt": ""
9074                        }
9075                    }
9076                }
9077            }),
9078        )
9079        .await;
9080
9081        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
9082        let workspace =
9083            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9084        let cx = &mut VisualTestContext::from_window(*workspace, cx);
9085
9086        // Test 1: When auto-fold is enabled
9087        cx.update(|_, cx| {
9088            let settings = *ProjectPanelSettings::get_global(cx);
9089            ProjectPanelSettings::override_global(
9090                ProjectPanelSettings {
9091                    auto_fold_dirs: true,
9092                    ..settings
9093                },
9094                cx,
9095            );
9096        });
9097
9098        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9099
9100        assert_eq!(
9101            visible_entries_as_strings(&panel, 0..20, cx),
9102            &["v root", "    > dir1", "      .gitignore",],
9103            "Initial state should show collapsed root structure"
9104        );
9105
9106        toggle_expand_dir(&panel, "root/dir1", cx);
9107        assert_eq!(
9108            visible_entries_as_strings(&panel, 0..20, cx),
9109            &[
9110                separator!("v root"),
9111                separator!("    v dir1  <== selected"),
9112                separator!("        > empty1/empty2/empty3"),
9113                separator!("        > ignored_dir"),
9114                separator!("        > subdir1"),
9115                separator!("      .gitignore"),
9116            ],
9117            "Should show first level with auto-folded dirs and ignored dir visible"
9118        );
9119
9120        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9121        panel.update(cx, |panel, cx| {
9122            let project = panel.project.read(cx);
9123            let worktree = project.worktrees(cx).next().unwrap().read(cx);
9124            panel.expand_all_for_entry(worktree.id(), entry_id, cx);
9125            panel.update_visible_entries(None, cx);
9126        });
9127        cx.run_until_parked();
9128
9129        assert_eq!(
9130            visible_entries_as_strings(&panel, 0..20, cx),
9131            &[
9132                separator!("v root"),
9133                separator!("    v dir1  <== selected"),
9134                separator!("        v empty1"),
9135                separator!("            v empty2"),
9136                separator!("                v empty3"),
9137                separator!("                      file.txt"),
9138                separator!("        > ignored_dir"),
9139                separator!("        v subdir1"),
9140                separator!("            > ignored_nested"),
9141                separator!("              file1.txt"),
9142                separator!("              file2.txt"),
9143                separator!("      .gitignore"),
9144            ],
9145            "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
9146        );
9147
9148        // Test 2: When auto-fold is disabled
9149        cx.update(|_, cx| {
9150            let settings = *ProjectPanelSettings::get_global(cx);
9151            ProjectPanelSettings::override_global(
9152                ProjectPanelSettings {
9153                    auto_fold_dirs: false,
9154                    ..settings
9155                },
9156                cx,
9157            );
9158        });
9159
9160        panel.update_in(cx, |panel, window, cx| {
9161            panel.collapse_all_entries(&CollapseAllEntries, window, cx);
9162        });
9163
9164        toggle_expand_dir(&panel, "root/dir1", cx);
9165        assert_eq!(
9166            visible_entries_as_strings(&panel, 0..20, cx),
9167            &[
9168                separator!("v root"),
9169                separator!("    v dir1  <== selected"),
9170                separator!("        > empty1"),
9171                separator!("        > ignored_dir"),
9172                separator!("        > subdir1"),
9173                separator!("      .gitignore"),
9174            ],
9175            "With auto-fold disabled: should show all directories separately"
9176        );
9177
9178        let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9179        panel.update(cx, |panel, cx| {
9180            let project = panel.project.read(cx);
9181            let worktree = project.worktrees(cx).next().unwrap().read(cx);
9182            panel.expand_all_for_entry(worktree.id(), entry_id, cx);
9183            panel.update_visible_entries(None, cx);
9184        });
9185        cx.run_until_parked();
9186
9187        assert_eq!(
9188            visible_entries_as_strings(&panel, 0..20, cx),
9189            &[
9190                separator!("v root"),
9191                separator!("    v dir1  <== selected"),
9192                separator!("        v empty1"),
9193                separator!("            v empty2"),
9194                separator!("                v empty3"),
9195                separator!("                      file.txt"),
9196                separator!("        > ignored_dir"),
9197                separator!("        v subdir1"),
9198                separator!("            > ignored_nested"),
9199                separator!("              file1.txt"),
9200                separator!("              file2.txt"),
9201                separator!("      .gitignore"),
9202            ],
9203            "After expand_all without auto-fold: should expand all dirs normally, \
9204         expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
9205        );
9206
9207        // Test 3: When explicitly called on ignored directory
9208        let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
9209        panel.update(cx, |panel, cx| {
9210            let project = panel.project.read(cx);
9211            let worktree = project.worktrees(cx).next().unwrap().read(cx);
9212            panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
9213            panel.update_visible_entries(None, cx);
9214        });
9215        cx.run_until_parked();
9216
9217        assert_eq!(
9218            visible_entries_as_strings(&panel, 0..20, cx),
9219            &[
9220                separator!("v root"),
9221                separator!("    v dir1  <== selected"),
9222                separator!("        v empty1"),
9223                separator!("            v empty2"),
9224                separator!("                v empty3"),
9225                separator!("                      file.txt"),
9226                separator!("        v ignored_dir"),
9227                separator!("            v subdir"),
9228                separator!("                  deep_file.txt"),
9229                separator!("        v subdir1"),
9230                separator!("            > ignored_nested"),
9231                separator!("              file1.txt"),
9232                separator!("              file2.txt"),
9233                separator!("      .gitignore"),
9234            ],
9235            "After expand_all on ignored_dir: should expand all contents of the ignored directory"
9236        );
9237    }
9238
9239    #[gpui::test]
9240    async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
9241        init_test(cx);
9242
9243        let fs = FakeFs::new(cx.executor().clone());
9244        fs.insert_tree(
9245            path!("/root"),
9246            json!({
9247                "dir1": {
9248                    "subdir1": {
9249                        "nested1": {
9250                            "file1.txt": "",
9251                            "file2.txt": ""
9252                        },
9253                    },
9254                    "subdir2": {
9255                        "file4.txt": ""
9256                    }
9257                },
9258                "dir2": {
9259                    "single_file": {
9260                        "file5.txt": ""
9261                    }
9262                }
9263            }),
9264        )
9265        .await;
9266
9267        let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
9268        let workspace =
9269            cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
9270        let cx = &mut VisualTestContext::from_window(*workspace, cx);
9271
9272        // Test 1: Basic collapsing
9273        {
9274            let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9275
9276            toggle_expand_dir(&panel, "root/dir1", cx);
9277            toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9278            toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9279            toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
9280
9281            assert_eq!(
9282                visible_entries_as_strings(&panel, 0..20, cx),
9283                &[
9284                    separator!("v root"),
9285                    separator!("    v dir1"),
9286                    separator!("        v subdir1"),
9287                    separator!("            v nested1"),
9288                    separator!("                  file1.txt"),
9289                    separator!("                  file2.txt"),
9290                    separator!("        v subdir2  <== selected"),
9291                    separator!("              file4.txt"),
9292                    separator!("    > dir2"),
9293                ],
9294                "Initial state with everything expanded"
9295            );
9296
9297            let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9298            panel.update(cx, |panel, cx| {
9299                let project = panel.project.read(cx);
9300                let worktree = project.worktrees(cx).next().unwrap().read(cx);
9301                panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9302                panel.update_visible_entries(None, cx);
9303            });
9304
9305            assert_eq!(
9306                visible_entries_as_strings(&panel, 0..20, cx),
9307                &["v root", "    > dir1", "    > dir2",],
9308                "All subdirs under dir1 should be collapsed"
9309            );
9310        }
9311
9312        // Test 2: With auto-fold enabled
9313        {
9314            cx.update(|_, cx| {
9315                let settings = *ProjectPanelSettings::get_global(cx);
9316                ProjectPanelSettings::override_global(
9317                    ProjectPanelSettings {
9318                        auto_fold_dirs: true,
9319                        ..settings
9320                    },
9321                    cx,
9322                );
9323            });
9324
9325            let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9326
9327            toggle_expand_dir(&panel, "root/dir1", cx);
9328            toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9329            toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9330
9331            assert_eq!(
9332                visible_entries_as_strings(&panel, 0..20, cx),
9333                &[
9334                    separator!("v root"),
9335                    separator!("    v dir1"),
9336                    separator!("        v subdir1/nested1  <== selected"),
9337                    separator!("              file1.txt"),
9338                    separator!("              file2.txt"),
9339                    separator!("        > subdir2"),
9340                    separator!("    > dir2/single_file"),
9341                ],
9342                "Initial state with some dirs expanded"
9343            );
9344
9345            let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9346            panel.update(cx, |panel, cx| {
9347                let project = panel.project.read(cx);
9348                let worktree = project.worktrees(cx).next().unwrap().read(cx);
9349                panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9350            });
9351
9352            toggle_expand_dir(&panel, "root/dir1", cx);
9353
9354            assert_eq!(
9355                visible_entries_as_strings(&panel, 0..20, cx),
9356                &[
9357                    separator!("v root"),
9358                    separator!("    v dir1  <== selected"),
9359                    separator!("        > subdir1/nested1"),
9360                    separator!("        > subdir2"),
9361                    separator!("    > dir2/single_file"),
9362                ],
9363                "Subdirs should be collapsed and folded with auto-fold enabled"
9364            );
9365        }
9366
9367        // Test 3: With auto-fold disabled
9368        {
9369            cx.update(|_, cx| {
9370                let settings = *ProjectPanelSettings::get_global(cx);
9371                ProjectPanelSettings::override_global(
9372                    ProjectPanelSettings {
9373                        auto_fold_dirs: false,
9374                        ..settings
9375                    },
9376                    cx,
9377                );
9378            });
9379
9380            let panel = workspace.update(cx, ProjectPanel::new).unwrap();
9381
9382            toggle_expand_dir(&panel, "root/dir1", cx);
9383            toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
9384            toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
9385
9386            assert_eq!(
9387                visible_entries_as_strings(&panel, 0..20, cx),
9388                &[
9389                    separator!("v root"),
9390                    separator!("    v dir1"),
9391                    separator!("        v subdir1"),
9392                    separator!("            v nested1  <== selected"),
9393                    separator!("                  file1.txt"),
9394                    separator!("                  file2.txt"),
9395                    separator!("        > subdir2"),
9396                    separator!("    > dir2"),
9397                ],
9398                "Initial state with some dirs expanded and auto-fold disabled"
9399            );
9400
9401            let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
9402            panel.update(cx, |panel, cx| {
9403                let project = panel.project.read(cx);
9404                let worktree = project.worktrees(cx).next().unwrap().read(cx);
9405                panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
9406            });
9407
9408            toggle_expand_dir(&panel, "root/dir1", cx);
9409
9410            assert_eq!(
9411                visible_entries_as_strings(&panel, 0..20, cx),
9412                &[
9413                    separator!("v root"),
9414                    separator!("    v dir1  <== selected"),
9415                    separator!("        > subdir1"),
9416                    separator!("        > subdir2"),
9417                    separator!("    > dir2"),
9418                ],
9419                "Subdirs should be collapsed but not folded with auto-fold disabled"
9420            );
9421        }
9422    }
9423
9424    fn select_path(
9425        panel: &Entity<ProjectPanel>,
9426        path: impl AsRef<Path>,
9427        cx: &mut VisualTestContext,
9428    ) {
9429        let path = path.as_ref();
9430        panel.update(cx, |panel, cx| {
9431            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9432                let worktree = worktree.read(cx);
9433                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9434                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9435                    panel.selection = Some(crate::SelectedEntry {
9436                        worktree_id: worktree.id(),
9437                        entry_id,
9438                    });
9439                    return;
9440                }
9441            }
9442            panic!("no worktree for path {:?}", path);
9443        });
9444    }
9445
9446    fn select_path_with_mark(
9447        panel: &Entity<ProjectPanel>,
9448        path: impl AsRef<Path>,
9449        cx: &mut VisualTestContext,
9450    ) {
9451        let path = path.as_ref();
9452        panel.update(cx, |panel, cx| {
9453            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9454                let worktree = worktree.read(cx);
9455                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9456                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
9457                    let entry = crate::SelectedEntry {
9458                        worktree_id: worktree.id(),
9459                        entry_id,
9460                    };
9461                    if !panel.marked_entries.contains(&entry) {
9462                        panel.marked_entries.insert(entry);
9463                    }
9464                    panel.selection = Some(entry);
9465                    return;
9466                }
9467            }
9468            panic!("no worktree for path {:?}", path);
9469        });
9470    }
9471
9472    fn find_project_entry(
9473        panel: &Entity<ProjectPanel>,
9474        path: impl AsRef<Path>,
9475        cx: &mut VisualTestContext,
9476    ) -> Option<ProjectEntryId> {
9477        let path = path.as_ref();
9478        panel.update(cx, |panel, cx| {
9479            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
9480                let worktree = worktree.read(cx);
9481                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
9482                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
9483                }
9484            }
9485            panic!("no worktree for path {path:?}");
9486        })
9487    }
9488
9489    fn visible_entries_as_strings(
9490        panel: &Entity<ProjectPanel>,
9491        range: Range<usize>,
9492        cx: &mut VisualTestContext,
9493    ) -> Vec<String> {
9494        let mut result = Vec::new();
9495        let mut project_entries = HashSet::default();
9496        let mut has_editor = false;
9497
9498        panel.update_in(cx, |panel, window, cx| {
9499            panel.for_each_visible_entry(range, window, cx, |project_entry, details, _, _| {
9500                if details.is_editing {
9501                    assert!(!has_editor, "duplicate editor entry");
9502                    has_editor = true;
9503                } else {
9504                    assert!(
9505                        project_entries.insert(project_entry),
9506                        "duplicate project entry {:?} {:?}",
9507                        project_entry,
9508                        details
9509                    );
9510                }
9511
9512                let indent = "    ".repeat(details.depth);
9513                let icon = if details.kind.is_dir() {
9514                    if details.is_expanded {
9515                        "v "
9516                    } else {
9517                        "> "
9518                    }
9519                } else {
9520                    "  "
9521                };
9522                let name = if details.is_editing {
9523                    format!("[EDITOR: '{}']", details.filename)
9524                } else if details.is_processing {
9525                    format!("[PROCESSING: '{}']", details.filename)
9526                } else {
9527                    details.filename.clone()
9528                };
9529                let selected = if details.is_selected {
9530                    "  <== selected"
9531                } else {
9532                    ""
9533                };
9534                let marked = if details.is_marked {
9535                    "  <== marked"
9536                } else {
9537                    ""
9538                };
9539
9540                result.push(format!("{indent}{icon}{name}{selected}{marked}"));
9541            });
9542        });
9543
9544        result
9545    }
9546
9547    fn init_test(cx: &mut TestAppContext) {
9548        cx.update(|cx| {
9549            let settings_store = SettingsStore::test(cx);
9550            cx.set_global(settings_store);
9551            init_settings(cx);
9552            theme::init(theme::LoadThemes::JustBase, cx);
9553            language::init(cx);
9554            editor::init_settings(cx);
9555            crate::init(cx);
9556            workspace::init_settings(cx);
9557            client::init_settings(cx);
9558            Project::init_settings(cx);
9559
9560            cx.update_global::<SettingsStore, _>(|store, cx| {
9561                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9562                    project_panel_settings.auto_fold_dirs = Some(false);
9563                });
9564                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9565                    worktree_settings.file_scan_exclusions = Some(Vec::new());
9566                });
9567            });
9568        });
9569    }
9570
9571    fn init_test_with_editor(cx: &mut TestAppContext) {
9572        cx.update(|cx| {
9573            let app_state = AppState::test(cx);
9574            theme::init(theme::LoadThemes::JustBase, cx);
9575            init_settings(cx);
9576            language::init(cx);
9577            editor::init(cx);
9578            crate::init(cx);
9579            workspace::init(app_state.clone(), cx);
9580            Project::init_settings(cx);
9581
9582            cx.update_global::<SettingsStore, _>(|store, cx| {
9583                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
9584                    project_panel_settings.auto_fold_dirs = Some(false);
9585                });
9586                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
9587                    worktree_settings.file_scan_exclusions = Some(Vec::new());
9588                });
9589            });
9590        });
9591    }
9592
9593    fn ensure_single_file_is_opened(
9594        window: &WindowHandle<Workspace>,
9595        expected_path: &str,
9596        cx: &mut TestAppContext,
9597    ) {
9598        window
9599            .update(cx, |workspace, _, cx| {
9600                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
9601                assert_eq!(worktrees.len(), 1);
9602                let worktree_id = worktrees[0].read(cx).id();
9603
9604                let open_project_paths = workspace
9605                    .panes()
9606                    .iter()
9607                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9608                    .collect::<Vec<_>>();
9609                assert_eq!(
9610                    open_project_paths,
9611                    vec![ProjectPath {
9612                        worktree_id,
9613                        path: Arc::from(Path::new(expected_path))
9614                    }],
9615                    "Should have opened file, selected in project panel"
9616                );
9617            })
9618            .unwrap();
9619    }
9620
9621    fn submit_deletion(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9622        assert!(
9623            !cx.has_pending_prompt(),
9624            "Should have no prompts before the deletion"
9625        );
9626        panel.update_in(cx, |panel, window, cx| {
9627            panel.delete(&Delete { skip_prompt: false }, window, cx)
9628        });
9629        assert!(
9630            cx.has_pending_prompt(),
9631            "Should have a prompt after the deletion"
9632        );
9633        cx.simulate_prompt_answer("Delete");
9634        assert!(
9635            !cx.has_pending_prompt(),
9636            "Should have no prompts after prompt was replied to"
9637        );
9638        cx.executor().run_until_parked();
9639    }
9640
9641    fn submit_deletion_skipping_prompt(panel: &Entity<ProjectPanel>, cx: &mut VisualTestContext) {
9642        assert!(
9643            !cx.has_pending_prompt(),
9644            "Should have no prompts before the deletion"
9645        );
9646        panel.update_in(cx, |panel, window, cx| {
9647            panel.delete(&Delete { skip_prompt: true }, window, cx)
9648        });
9649        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
9650        cx.executor().run_until_parked();
9651    }
9652
9653    fn ensure_no_open_items_and_panes(
9654        workspace: &WindowHandle<Workspace>,
9655        cx: &mut VisualTestContext,
9656    ) {
9657        assert!(
9658            !cx.has_pending_prompt(),
9659            "Should have no prompts after deletion operation closes the file"
9660        );
9661        workspace
9662            .read_with(cx, |workspace, cx| {
9663                let open_project_paths = workspace
9664                    .panes()
9665                    .iter()
9666                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
9667                    .collect::<Vec<_>>();
9668                assert!(
9669                    open_project_paths.is_empty(),
9670                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
9671                );
9672            })
9673            .unwrap();
9674    }
9675
9676    struct TestProjectItemView {
9677        focus_handle: FocusHandle,
9678        path: ProjectPath,
9679    }
9680
9681    struct TestProjectItem {
9682        path: ProjectPath,
9683    }
9684
9685    impl project::ProjectItem for TestProjectItem {
9686        fn try_open(
9687            _project: &Entity<Project>,
9688            path: &ProjectPath,
9689            cx: &mut App,
9690        ) -> Option<Task<gpui::Result<Entity<Self>>>> {
9691            let path = path.clone();
9692            Some(cx.spawn(|mut cx| async move { cx.new(|_| Self { path }) }))
9693        }
9694
9695        fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
9696            None
9697        }
9698
9699        fn project_path(&self, _: &App) -> Option<ProjectPath> {
9700            Some(self.path.clone())
9701        }
9702
9703        fn is_dirty(&self) -> bool {
9704            false
9705        }
9706    }
9707
9708    impl ProjectItem for TestProjectItemView {
9709        type Item = TestProjectItem;
9710
9711        fn for_project_item(
9712            _: Entity<Project>,
9713            project_item: Entity<Self::Item>,
9714            _: &mut Window,
9715            cx: &mut Context<Self>,
9716        ) -> Self
9717        where
9718            Self: Sized,
9719        {
9720            Self {
9721                path: project_item.update(cx, |project_item, _| project_item.path.clone()),
9722                focus_handle: cx.focus_handle(),
9723            }
9724        }
9725    }
9726
9727    impl Item for TestProjectItemView {
9728        type Event = ();
9729    }
9730
9731    impl EventEmitter<()> for TestProjectItemView {}
9732
9733    impl Focusable for TestProjectItemView {
9734        fn focus_handle(&self, _: &App) -> FocusHandle {
9735            self.focus_handle.clone()
9736        }
9737    }
9738
9739    impl Render for TestProjectItemView {
9740        fn render(&mut self, _window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
9741            Empty
9742        }
9743    }
9744}