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