project_panel.rs

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