project_panel.rs

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