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