project_panel.rs

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