project_panel.rs

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