project_panel.rs

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