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