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