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