project_panel.rs

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