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