project_panel.rs

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