project_panel.rs

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