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