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