project_panel.rs

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