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