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