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