project_panel.rs

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