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