project_panel.rs

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