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_remote() && 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                    });
1682                }
1683                if expanded_dir_ids.binary_search(&entry.id).is_err()
1684                    && entry_iter.advance_to_sibling()
1685                {
1686                    continue;
1687                }
1688                entry_iter.advance();
1689            }
1690
1691            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1692            project::sort_worktree_entries(&mut visible_worktree_entries);
1693            self.visible_entries
1694                .push((worktree_id, visible_worktree_entries, OnceCell::new()));
1695        }
1696
1697        if let Some((worktree_id, entry_id)) = new_selected_entry {
1698            self.selection = Some(SelectedEntry {
1699                worktree_id,
1700                entry_id,
1701            });
1702            if cx.modifiers().shift {
1703                self.marked_entries.insert(SelectedEntry {
1704                    worktree_id,
1705                    entry_id,
1706                });
1707            }
1708        }
1709    }
1710
1711    fn expand_entry(
1712        &mut self,
1713        worktree_id: WorktreeId,
1714        entry_id: ProjectEntryId,
1715        cx: &mut ViewContext<Self>,
1716    ) {
1717        self.project.update(cx, |project, cx| {
1718            if let Some((worktree, expanded_dir_ids)) = project
1719                .worktree_for_id(worktree_id, cx)
1720                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1721            {
1722                project.expand_entry(worktree_id, entry_id, cx);
1723                let worktree = worktree.read(cx);
1724
1725                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1726                    loop {
1727                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1728                            expanded_dir_ids.insert(ix, entry.id);
1729                        }
1730
1731                        if let Some(parent_entry) =
1732                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1733                        {
1734                            entry = parent_entry;
1735                        } else {
1736                            break;
1737                        }
1738                    }
1739                }
1740            }
1741        });
1742    }
1743
1744    fn drop_external_files(
1745        &mut self,
1746        paths: &[PathBuf],
1747        entry_id: ProjectEntryId,
1748        cx: &mut ViewContext<Self>,
1749    ) {
1750        let mut paths: Vec<Arc<Path>> = paths
1751            .into_iter()
1752            .map(|path| Arc::from(path.clone()))
1753            .collect();
1754
1755        let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
1756
1757        let Some((target_directory, worktree)) = maybe!({
1758            let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
1759            let entry = worktree.read(cx).entry_for_id(entry_id)?;
1760            let path = worktree.read(cx).absolutize(&entry.path).ok()?;
1761            let target_directory = if path.is_dir() {
1762                path
1763            } else {
1764                path.parent()?.to_path_buf()
1765            };
1766            Some((target_directory, worktree))
1767        }) else {
1768            return;
1769        };
1770
1771        let mut paths_to_replace = Vec::new();
1772        for path in &paths {
1773            if let Some(name) = path.file_name() {
1774                let mut target_path = target_directory.clone();
1775                target_path.push(name);
1776                if target_path.exists() {
1777                    paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
1778                }
1779            }
1780        }
1781
1782        cx.spawn(|this, mut cx| {
1783            async move {
1784                for (filename, original_path) in &paths_to_replace {
1785                    let answer = cx
1786                        .prompt(
1787                            PromptLevel::Info,
1788                            format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
1789                            None,
1790                            &["Replace", "Cancel"],
1791                        )
1792                        .await?;
1793                    if answer == 1 {
1794                        if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
1795                            paths.remove(item_idx);
1796                        }
1797                    }
1798                }
1799
1800                if paths.is_empty() {
1801                    return Ok(());
1802                }
1803
1804                let task = worktree.update(&mut cx, |worktree, cx| {
1805                    worktree.copy_external_entries(target_directory, paths, true, cx)
1806                })?;
1807
1808                let opened_entries = task.await?;
1809                this.update(&mut cx, |this, cx| {
1810                    if open_file_after_drop && !opened_entries.is_empty() {
1811                        this.open_entry(opened_entries[0], true, true, false, cx);
1812                    }
1813                })
1814            }
1815            .log_err()
1816        })
1817        .detach();
1818    }
1819
1820    fn drag_onto(
1821        &mut self,
1822        selections: &DraggedSelection,
1823        target_entry_id: ProjectEntryId,
1824        is_file: bool,
1825        cx: &mut ViewContext<Self>,
1826    ) {
1827        let should_copy = cx.modifiers().alt;
1828        if should_copy {
1829            let _ = maybe!({
1830                let project = self.project.read(cx);
1831                let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
1832                let target_entry = target_worktree
1833                    .read(cx)
1834                    .entry_for_id(target_entry_id)?
1835                    .clone();
1836                for selection in selections.items() {
1837                    let new_path = self.create_paste_path(
1838                        &selection,
1839                        (target_worktree.clone(), &target_entry),
1840                        cx,
1841                    )?;
1842                    self.project
1843                        .update(cx, |project, cx| {
1844                            project.copy_entry(selection.entry_id, new_path, cx)
1845                        })
1846                        .detach_and_log_err(cx)
1847                }
1848
1849                Some(())
1850            });
1851        } else {
1852            for selection in selections.items() {
1853                self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
1854            }
1855        }
1856    }
1857
1858    fn for_each_visible_entry(
1859        &self,
1860        range: Range<usize>,
1861        cx: &mut ViewContext<ProjectPanel>,
1862        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1863    ) {
1864        let mut ix = 0;
1865        for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
1866            if ix >= range.end {
1867                return;
1868            }
1869
1870            if ix + visible_worktree_entries.len() <= range.start {
1871                ix += visible_worktree_entries.len();
1872                continue;
1873            }
1874
1875            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1876            let (git_status_setting, show_file_icons, show_folder_icons) = {
1877                let settings = ProjectPanelSettings::get_global(cx);
1878                (
1879                    settings.git_status,
1880                    settings.file_icons,
1881                    settings.folder_icons,
1882                )
1883            };
1884            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1885                let snapshot = worktree.read(cx).snapshot();
1886                let root_name = OsStr::new(snapshot.root_name());
1887                let expanded_entry_ids = self
1888                    .expanded_dir_ids
1889                    .get(&snapshot.id())
1890                    .map(Vec::as_slice)
1891                    .unwrap_or(&[]);
1892
1893                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1894                let entries = entries_paths.get_or_init(|| {
1895                    visible_worktree_entries
1896                        .iter()
1897                        .map(|e| (e.path.clone()))
1898                        .collect()
1899                });
1900                for entry in visible_worktree_entries[entry_range].iter() {
1901                    let status = git_status_setting.then(|| entry.git_status).flatten();
1902                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1903                    let icon = match entry.kind {
1904                        EntryKind::File => {
1905                            if show_file_icons {
1906                                FileIcons::get_icon(&entry.path, cx)
1907                            } else {
1908                                None
1909                            }
1910                        }
1911                        _ => {
1912                            if show_folder_icons {
1913                                FileIcons::get_folder_icon(is_expanded, cx)
1914                            } else {
1915                                FileIcons::get_chevron_icon(is_expanded, cx)
1916                            }
1917                        }
1918                    };
1919
1920                    let (depth, difference) =
1921                        ProjectPanel::calculate_depth_and_difference(entry, &entries);
1922
1923                    let filename = match difference {
1924                        diff if diff > 1 => entry
1925                            .path
1926                            .iter()
1927                            .skip(entry.path.components().count() - diff)
1928                            .collect::<PathBuf>()
1929                            .to_str()
1930                            .unwrap_or_default()
1931                            .to_string(),
1932                        _ => entry
1933                            .path
1934                            .file_name()
1935                            .map(|name| name.to_string_lossy().into_owned())
1936                            .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
1937                    };
1938                    let selection = SelectedEntry {
1939                        worktree_id: snapshot.id(),
1940                        entry_id: entry.id,
1941                    };
1942                    let mut details = EntryDetails {
1943                        filename,
1944                        icon,
1945                        path: entry.path.clone(),
1946                        depth,
1947                        kind: entry.kind,
1948                        is_ignored: entry.is_ignored,
1949                        is_expanded,
1950                        is_selected: self.selection == Some(selection),
1951                        is_marked: self.marked_entries.contains(&selection),
1952                        is_editing: false,
1953                        is_processing: false,
1954                        is_cut: self
1955                            .clipboard
1956                            .as_ref()
1957                            .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
1958                        git_status: status,
1959                        is_private: entry.is_private,
1960                        worktree_id: *worktree_id,
1961                        canonical_path: entry.canonical_path.clone(),
1962                    };
1963
1964                    if let Some(edit_state) = &self.edit_state {
1965                        let is_edited_entry = if edit_state.is_new_entry {
1966                            entry.id == NEW_ENTRY_ID
1967                        } else {
1968                            entry.id == edit_state.entry_id
1969                        };
1970
1971                        if is_edited_entry {
1972                            if let Some(processing_filename) = &edit_state.processing_filename {
1973                                details.is_processing = true;
1974                                details.filename.clear();
1975                                details.filename.push_str(processing_filename);
1976                            } else {
1977                                if edit_state.is_new_entry {
1978                                    details.filename.clear();
1979                                }
1980                                details.is_editing = true;
1981                            }
1982                        }
1983                    }
1984
1985                    callback(entry.id, details, cx);
1986                }
1987            }
1988            ix = end_ix;
1989        }
1990    }
1991
1992    fn calculate_depth_and_difference(
1993        entry: &Entry,
1994        visible_worktree_entries: &HashSet<Arc<Path>>,
1995    ) -> (usize, usize) {
1996        let (depth, difference) = entry
1997            .path
1998            .ancestors()
1999            .skip(1) // Skip the entry itself
2000            .find_map(|ancestor| {
2001                if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
2002                    let entry_path_components_count = entry.path.components().count();
2003                    let parent_path_components_count = parent_entry.components().count();
2004                    let difference = entry_path_components_count - parent_path_components_count;
2005                    let depth = parent_entry
2006                        .ancestors()
2007                        .skip(1)
2008                        .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
2009                        .count();
2010                    Some((depth + 1, difference))
2011                } else {
2012                    None
2013                }
2014            })
2015            .unwrap_or((0, 0));
2016
2017        (depth, difference)
2018    }
2019
2020    fn render_entry(
2021        &self,
2022        entry_id: ProjectEntryId,
2023        details: EntryDetails,
2024        cx: &mut ViewContext<Self>,
2025    ) -> Stateful<Div> {
2026        let kind = details.kind;
2027        let settings = ProjectPanelSettings::get_global(cx);
2028        let show_editor = details.is_editing && !details.is_processing;
2029        let selection = SelectedEntry {
2030            worktree_id: details.worktree_id,
2031            entry_id,
2032        };
2033        let is_marked = self.marked_entries.contains(&selection);
2034        let is_active = self
2035            .selection
2036            .map_or(false, |selection| selection.entry_id == entry_id);
2037        let width = self.size(cx);
2038        let filename_text_color =
2039            entry_git_aware_label_color(details.git_status, details.is_ignored, is_marked);
2040        let file_name = details.filename.clone();
2041        let mut icon = details.icon.clone();
2042        if settings.file_icons && show_editor && details.kind.is_file() {
2043            let filename = self.filename_editor.read(cx).text(cx);
2044            if filename.len() > 2 {
2045                icon = FileIcons::get_icon(Path::new(&filename), cx);
2046            }
2047        }
2048
2049        let canonical_path = details
2050            .canonical_path
2051            .as_ref()
2052            .map(|f| f.to_string_lossy().to_string());
2053        let path = details.path.clone();
2054
2055        let depth = details.depth;
2056        let worktree_id = details.worktree_id;
2057        let selections = Arc::new(self.marked_entries.clone());
2058
2059        let dragged_selection = DraggedSelection {
2060            active_selection: selection,
2061            marked_selections: selections,
2062        };
2063        div()
2064            .id(entry_id.to_proto() as usize)
2065            .on_drag_move::<ExternalPaths>(cx.listener(
2066                move |this, event: &DragMoveEvent<ExternalPaths>, cx| {
2067                    if event.bounds.contains(&event.event.position) {
2068                        if this.last_external_paths_drag_over_entry == Some(entry_id) {
2069                            return;
2070                        }
2071                        this.last_external_paths_drag_over_entry = Some(entry_id);
2072                        this.marked_entries.clear();
2073
2074                        let Some((worktree, path, entry)) = maybe!({
2075                            let worktree = this
2076                                .project
2077                                .read(cx)
2078                                .worktree_for_id(selection.worktree_id, cx)?;
2079                            let worktree = worktree.read(cx);
2080                            let abs_path = worktree.absolutize(&path).log_err()?;
2081                            let path = if abs_path.is_dir() {
2082                                path.as_ref()
2083                            } else {
2084                                path.parent()?
2085                            };
2086                            let entry = worktree.entry_for_path(path)?;
2087                            Some((worktree, path, entry))
2088                        }) else {
2089                            return;
2090                        };
2091
2092                        this.marked_entries.insert(SelectedEntry {
2093                            entry_id: entry.id,
2094                            worktree_id: worktree.id(),
2095                        });
2096
2097                        for entry in worktree.child_entries(path) {
2098                            this.marked_entries.insert(SelectedEntry {
2099                                entry_id: entry.id,
2100                                worktree_id: worktree.id(),
2101                            });
2102                        }
2103
2104                        cx.notify();
2105                    }
2106                },
2107            ))
2108            .on_drop(
2109                cx.listener(move |this, external_paths: &ExternalPaths, cx| {
2110                    this.last_external_paths_drag_over_entry = None;
2111                    this.marked_entries.clear();
2112                    this.drop_external_files(external_paths.paths(), entry_id, cx);
2113                    cx.stop_propagation();
2114                }),
2115            )
2116            .on_drag(dragged_selection, move |selection, cx| {
2117                cx.new_view(|_| DraggedProjectEntryView {
2118                    details: details.clone(),
2119                    width,
2120                    selection: selection.active_selection,
2121                    selections: selection.marked_selections.clone(),
2122                })
2123            })
2124            .drag_over::<DraggedSelection>(|style, _, cx| {
2125                style.bg(cx.theme().colors().drop_target_background)
2126            })
2127            .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
2128                this.drag_onto(selections, entry_id, kind.is_file(), cx);
2129            }))
2130            .child(
2131                ListItem::new(entry_id.to_proto() as usize)
2132                    .indent_level(depth)
2133                    .indent_step_size(px(settings.indent_size))
2134                    .selected(is_marked || is_active)
2135                    .when_some(canonical_path, |this, path| {
2136                        this.end_slot::<AnyElement>(
2137                            div()
2138                                .id("symlink_icon")
2139                                .pr_3()
2140                                .tooltip(move |cx| {
2141                                    Tooltip::text(format!("{path} • Symbolic Link"), cx)
2142                                })
2143                                .child(
2144                                    Icon::new(IconName::ArrowUpRight)
2145                                        .size(IconSize::Indicator)
2146                                        .color(filename_text_color),
2147                                )
2148                                .into_any_element(),
2149                        )
2150                    })
2151                    .child(if let Some(icon) = &icon {
2152                        h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
2153                    } else {
2154                        h_flex()
2155                            .size(IconSize::default().rems())
2156                            .invisible()
2157                            .flex_none()
2158                    })
2159                    .child(
2160                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
2161                            h_flex().h_6().w_full().child(editor.clone())
2162                        } else {
2163                            h_flex().h_6().child(
2164                                Label::new(file_name)
2165                                    .single_line()
2166                                    .color(filename_text_color),
2167                            )
2168                        }
2169                        .ml_1(),
2170                    )
2171                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
2172                        if event.down.button == MouseButton::Right || event.down.first_mouse {
2173                            return;
2174                        }
2175                        if !show_editor {
2176                            cx.stop_propagation();
2177
2178                            if let Some(selection) =
2179                                this.selection.filter(|_| event.down.modifiers.shift)
2180                            {
2181                                let current_selection = this.index_for_selection(selection);
2182                                let target_selection = this.index_for_selection(SelectedEntry {
2183                                    entry_id,
2184                                    worktree_id,
2185                                });
2186                                if let Some(((_, _, source_index), (_, _, target_index))) =
2187                                    current_selection.zip(target_selection)
2188                                {
2189                                    let range_start = source_index.min(target_index);
2190                                    let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
2191                                    let mut new_selections = BTreeSet::new();
2192                                    this.for_each_visible_entry(
2193                                        range_start..range_end,
2194                                        cx,
2195                                        |entry_id, details, _| {
2196                                            new_selections.insert(SelectedEntry {
2197                                                entry_id,
2198                                                worktree_id: details.worktree_id,
2199                                            });
2200                                        },
2201                                    );
2202
2203                                    this.marked_entries = this
2204                                        .marked_entries
2205                                        .union(&new_selections)
2206                                        .cloned()
2207                                        .collect();
2208
2209                                    this.selection = Some(SelectedEntry {
2210                                        entry_id,
2211                                        worktree_id,
2212                                    });
2213                                    // Ensure that the current entry is selected.
2214                                    this.marked_entries.insert(SelectedEntry {
2215                                        entry_id,
2216                                        worktree_id,
2217                                    });
2218                                }
2219                            } else if event.down.modifiers.secondary() {
2220                                if event.down.click_count > 1 {
2221                                    this.split_entry(entry_id, cx);
2222                                } else if !this.marked_entries.insert(selection) {
2223                                    this.marked_entries.remove(&selection);
2224                                }
2225                            } else if kind.is_dir() {
2226                                this.toggle_expanded(entry_id, cx);
2227                            } else {
2228                                let click_count = event.up.click_count;
2229                                this.open_entry(
2230                                    entry_id,
2231                                    cx.modifiers().secondary(),
2232                                    click_count > 1,
2233                                    click_count == 1,
2234                                    cx,
2235                                );
2236                            }
2237                        }
2238                    }))
2239                    .on_secondary_mouse_down(cx.listener(
2240                        move |this, event: &MouseDownEvent, cx| {
2241                            // Stop propagation to prevent the catch-all context menu for the project
2242                            // panel from being deployed.
2243                            cx.stop_propagation();
2244                            this.deploy_context_menu(event.position, entry_id, cx);
2245                        },
2246                    )),
2247            )
2248            .border_1()
2249            .border_r_2()
2250            .rounded_none()
2251            .hover(|style| {
2252                if is_active {
2253                    style
2254                } else {
2255                    let hover_color = cx.theme().colors().ghost_element_hover;
2256                    style.bg(hover_color).border_color(hover_color)
2257                }
2258            })
2259            .when(is_marked || is_active, |this| {
2260                let colors = cx.theme().colors();
2261                this.when(is_marked, |this| this.bg(colors.ghost_element_selected))
2262                    .border_color(colors.ghost_element_selected)
2263            })
2264            .when(
2265                is_active && self.focus_handle.contains_focused(cx),
2266                |this| this.border_color(Color::Selected.color(cx)),
2267            )
2268    }
2269
2270    fn render_scrollbar(
2271        &self,
2272        items_count: usize,
2273        cx: &mut ViewContext<Self>,
2274    ) -> Option<Stateful<Div>> {
2275        let settings = ProjectPanelSettings::get_global(cx);
2276        if settings.scrollbar.show == ShowScrollbar::Never {
2277            return None;
2278        }
2279        let scroll_handle = self.scroll_handle.0.borrow();
2280
2281        let height = scroll_handle
2282            .last_item_height
2283            .filter(|_| self.show_scrollbar || self.scrollbar_drag_thumb_offset.get().is_some())?;
2284
2285        let total_list_length = height.0 as f64 * items_count as f64;
2286        let current_offset = scroll_handle.base_handle.offset().y.0.min(0.).abs() as f64;
2287        let mut percentage = current_offset / total_list_length;
2288        let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.height.0 as f64)
2289            / total_list_length;
2290        // Uniform scroll handle might briefly report an offset greater than the length of a list;
2291        // in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable.
2292        let overshoot = (end_offset - 1.).clamp(0., 1.);
2293        if overshoot > 0. {
2294            percentage -= overshoot;
2295        }
2296        const MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT: f64 = 0.005;
2297        if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT > 1.0 || end_offset > total_list_length
2298        {
2299            return None;
2300        }
2301        if total_list_length < scroll_handle.base_handle.bounds().size.height.0 as f64 {
2302            return None;
2303        }
2304        let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT, 1.);
2305        Some(
2306            div()
2307                .occlude()
2308                .id("project-panel-scroll")
2309                .on_mouse_move(cx.listener(|_, _, cx| {
2310                    cx.notify();
2311                    cx.stop_propagation()
2312                }))
2313                .on_hover(|_, cx| {
2314                    cx.stop_propagation();
2315                })
2316                .on_any_mouse_down(|_, cx| {
2317                    cx.stop_propagation();
2318                })
2319                .on_mouse_up(
2320                    MouseButton::Left,
2321                    cx.listener(|this, _, cx| {
2322                        if this.scrollbar_drag_thumb_offset.get().is_none()
2323                            && !this.focus_handle.contains_focused(cx)
2324                        {
2325                            this.hide_scrollbar(cx);
2326                            cx.notify();
2327                        }
2328
2329                        cx.stop_propagation();
2330                    }),
2331                )
2332                .on_scroll_wheel(cx.listener(|_, _, cx| {
2333                    cx.notify();
2334                }))
2335                .h_full()
2336                .absolute()
2337                .right_0()
2338                .top_0()
2339                .bottom_0()
2340                .w(px(12.))
2341                .cursor_default()
2342                .child(ProjectPanelScrollbar::new(
2343                    percentage as f32..end_offset as f32,
2344                    self.scroll_handle.clone(),
2345                    self.scrollbar_drag_thumb_offset.clone(),
2346                    cx.view().clone().into(),
2347                    items_count,
2348                )),
2349        )
2350    }
2351
2352    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
2353        let mut dispatch_context = KeyContext::new_with_defaults();
2354        dispatch_context.add("ProjectPanel");
2355        dispatch_context.add("menu");
2356
2357        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
2358            "editing"
2359        } else {
2360            "not_editing"
2361        };
2362
2363        dispatch_context.add(identifier);
2364        dispatch_context
2365    }
2366
2367    fn should_autohide_scrollbar(cx: &AppContext) -> bool {
2368        cx.try_global::<ScrollbarAutoHide>()
2369            .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0)
2370    }
2371
2372    fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
2373        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
2374        if !Self::should_autohide_scrollbar(cx) {
2375            return;
2376        }
2377        self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
2378            cx.background_executor()
2379                .timer(SCROLLBAR_SHOW_INTERVAL)
2380                .await;
2381            panel
2382                .update(&mut cx, |panel, cx| {
2383                    panel.show_scrollbar = false;
2384                    cx.notify();
2385                })
2386                .log_err();
2387        }))
2388    }
2389
2390    fn reveal_entry(
2391        &mut self,
2392        project: Model<Project>,
2393        entry_id: ProjectEntryId,
2394        skip_ignored: bool,
2395        cx: &mut ViewContext<'_, ProjectPanel>,
2396    ) {
2397        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
2398            let worktree = worktree.read(cx);
2399            if skip_ignored
2400                && worktree
2401                    .entry_for_id(entry_id)
2402                    .map_or(true, |entry| entry.is_ignored)
2403            {
2404                return;
2405            }
2406
2407            let worktree_id = worktree.id();
2408            self.marked_entries.clear();
2409            self.expand_entry(worktree_id, entry_id, cx);
2410            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
2411            self.autoscroll(cx);
2412            cx.notify();
2413        }
2414    }
2415}
2416
2417impl Render for ProjectPanel {
2418    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
2419        let has_worktree = self.visible_entries.len() != 0;
2420        let project = self.project.read(cx);
2421
2422        if has_worktree {
2423            let items_count = self
2424                .visible_entries
2425                .iter()
2426                .map(|(_, worktree_entries, _)| worktree_entries.len())
2427                .sum();
2428
2429            h_flex()
2430                .id("project-panel")
2431                .group("project-panel")
2432                .size_full()
2433                .relative()
2434                .on_hover(cx.listener(|this, hovered, cx| {
2435                    if *hovered {
2436                        this.show_scrollbar = true;
2437                        this.hide_scrollbar_task.take();
2438                        cx.notify();
2439                    } else if !this.focus_handle.contains_focused(cx) {
2440                        this.hide_scrollbar(cx);
2441                    }
2442                }))
2443                .key_context(self.dispatch_context(cx))
2444                .on_action(cx.listener(Self::select_next))
2445                .on_action(cx.listener(Self::select_prev))
2446                .on_action(cx.listener(Self::select_first))
2447                .on_action(cx.listener(Self::select_last))
2448                .on_action(cx.listener(Self::select_parent))
2449                .on_action(cx.listener(Self::expand_selected_entry))
2450                .on_action(cx.listener(Self::collapse_selected_entry))
2451                .on_action(cx.listener(Self::collapse_all_entries))
2452                .on_action(cx.listener(Self::open))
2453                .on_action(cx.listener(Self::open_permanent))
2454                .on_action(cx.listener(Self::confirm))
2455                .on_action(cx.listener(Self::cancel))
2456                .on_action(cx.listener(Self::copy_path))
2457                .on_action(cx.listener(Self::copy_relative_path))
2458                .on_action(cx.listener(Self::new_search_in_directory))
2459                .on_action(cx.listener(Self::unfold_directory))
2460                .on_action(cx.listener(Self::fold_directory))
2461                .when(!project.is_read_only(), |el| {
2462                    el.on_action(cx.listener(Self::new_file))
2463                        .on_action(cx.listener(Self::new_directory))
2464                        .on_action(cx.listener(Self::rename))
2465                        .on_action(cx.listener(Self::delete))
2466                        .on_action(cx.listener(Self::trash))
2467                        .on_action(cx.listener(Self::cut))
2468                        .on_action(cx.listener(Self::copy))
2469                        .on_action(cx.listener(Self::paste))
2470                        .on_action(cx.listener(Self::duplicate))
2471                        .on_click(cx.listener(|this, event: &gpui::ClickEvent, cx| {
2472                            if event.up.click_count > 1 {
2473                                if let Some(entry_id) = this.last_worktree_root_id {
2474                                    let project = this.project.read(cx);
2475
2476                                    let worktree_id = if let Some(worktree) =
2477                                        project.worktree_for_entry(entry_id, cx)
2478                                    {
2479                                        worktree.read(cx).id()
2480                                    } else {
2481                                        return;
2482                                    };
2483
2484                                    this.selection = Some(SelectedEntry {
2485                                        worktree_id,
2486                                        entry_id,
2487                                    });
2488
2489                                    this.new_file(&NewFile, cx);
2490                                }
2491                            }
2492                        }))
2493                })
2494                .when(project.is_local(), |el| {
2495                    el.on_action(cx.listener(Self::reveal_in_finder))
2496                        .on_action(cx.listener(Self::open_in_terminal))
2497                })
2498                .on_mouse_down(
2499                    MouseButton::Right,
2500                    cx.listener(move |this, event: &MouseDownEvent, cx| {
2501                        // When deploying the context menu anywhere below the last project entry,
2502                        // act as if the user clicked the root of the last worktree.
2503                        if let Some(entry_id) = this.last_worktree_root_id {
2504                            this.deploy_context_menu(event.position, entry_id, cx);
2505                        }
2506                    }),
2507                )
2508                .track_focus(&self.focus_handle)
2509                .child(
2510                    uniform_list(cx.view().clone(), "entries", items_count, {
2511                        |this, range, cx| {
2512                            let mut items = Vec::new();
2513                            this.for_each_visible_entry(range, cx, |id, details, cx| {
2514                                items.push(this.render_entry(id, details, cx));
2515                            });
2516                            items
2517                        }
2518                    })
2519                    .size_full()
2520                    .with_sizing_behavior(ListSizingBehavior::Infer)
2521                    .track_scroll(self.scroll_handle.clone()),
2522                )
2523                .children(self.render_scrollbar(items_count, cx))
2524                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2525                    deferred(
2526                        anchored()
2527                            .position(*position)
2528                            .anchor(gpui::AnchorCorner::TopLeft)
2529                            .child(menu.clone()),
2530                    )
2531                    .with_priority(1)
2532                }))
2533        } else {
2534            v_flex()
2535                .id("empty-project_panel")
2536                .size_full()
2537                .p_4()
2538                .track_focus(&self.focus_handle)
2539                .child(
2540                    Button::new("open_project", "Open a project")
2541                        .style(ButtonStyle::Filled)
2542                        .full_width()
2543                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
2544                        .on_click(cx.listener(|this, _, cx| {
2545                            this.workspace
2546                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
2547                                .log_err();
2548                        })),
2549                )
2550                .drag_over::<ExternalPaths>(|style, _, cx| {
2551                    style.bg(cx.theme().colors().drop_target_background)
2552                })
2553                .on_drop(
2554                    cx.listener(move |this, external_paths: &ExternalPaths, cx| {
2555                        this.last_external_paths_drag_over_entry = None;
2556                        this.marked_entries.clear();
2557                        if let Some(task) = this
2558                            .workspace
2559                            .update(cx, |workspace, cx| {
2560                                workspace.open_workspace_for_paths(
2561                                    true,
2562                                    external_paths.paths().to_owned(),
2563                                    cx,
2564                                )
2565                            })
2566                            .log_err()
2567                        {
2568                            task.detach_and_log_err(cx);
2569                        }
2570                        cx.stop_propagation();
2571                    }),
2572                )
2573        }
2574    }
2575}
2576
2577impl Render for DraggedProjectEntryView {
2578    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2579        let settings = ProjectPanelSettings::get_global(cx);
2580        let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
2581        h_flex().font(ui_font).map(|this| {
2582            if self.selections.contains(&self.selection) {
2583                this.flex_shrink()
2584                    .p_1()
2585                    .items_end()
2586                    .rounded_md()
2587                    .child(self.selections.len().to_string())
2588            } else {
2589                this.bg(cx.theme().colors().background).w(self.width).child(
2590                    ListItem::new(self.selection.entry_id.to_proto() as usize)
2591                        .indent_level(self.details.depth)
2592                        .indent_step_size(px(settings.indent_size))
2593                        .child(if let Some(icon) = &self.details.icon {
2594                            div().child(Icon::from_path(icon.clone()))
2595                        } else {
2596                            div()
2597                        })
2598                        .child(Label::new(self.details.filename.clone())),
2599                )
2600            }
2601        })
2602    }
2603}
2604
2605impl EventEmitter<Event> for ProjectPanel {}
2606
2607impl EventEmitter<PanelEvent> for ProjectPanel {}
2608
2609impl Panel for ProjectPanel {
2610    fn position(&self, cx: &WindowContext) -> DockPosition {
2611        match ProjectPanelSettings::get_global(cx).dock {
2612            ProjectPanelDockPosition::Left => DockPosition::Left,
2613            ProjectPanelDockPosition::Right => DockPosition::Right,
2614        }
2615    }
2616
2617    fn position_is_valid(&self, position: DockPosition) -> bool {
2618        matches!(position, DockPosition::Left | DockPosition::Right)
2619    }
2620
2621    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2622        settings::update_settings_file::<ProjectPanelSettings>(
2623            self.fs.clone(),
2624            cx,
2625            move |settings, _| {
2626                let dock = match position {
2627                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
2628                    DockPosition::Right => ProjectPanelDockPosition::Right,
2629                };
2630                settings.dock = Some(dock);
2631            },
2632        );
2633    }
2634
2635    fn size(&self, cx: &WindowContext) -> Pixels {
2636        self.width
2637            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
2638    }
2639
2640    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2641        self.width = size;
2642        self.serialize(cx);
2643        cx.notify();
2644    }
2645
2646    fn icon(&self, cx: &WindowContext) -> Option<IconName> {
2647        ProjectPanelSettings::get_global(cx)
2648            .button
2649            .then(|| IconName::FileTree)
2650    }
2651
2652    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2653        Some("Project Panel")
2654    }
2655
2656    fn toggle_action(&self) -> Box<dyn Action> {
2657        Box::new(ToggleFocus)
2658    }
2659
2660    fn persistent_name() -> &'static str {
2661        "Project Panel"
2662    }
2663
2664    fn starts_open(&self, cx: &WindowContext) -> bool {
2665        let project = &self.project.read(cx);
2666        project.dev_server_project_id().is_some()
2667            || project.visible_worktrees(cx).any(|tree| {
2668                tree.read(cx)
2669                    .root_entry()
2670                    .map_or(false, |entry| entry.is_dir())
2671            })
2672    }
2673}
2674
2675impl FocusableView for ProjectPanel {
2676    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
2677        self.focus_handle.clone()
2678    }
2679}
2680
2681impl ClipboardEntry {
2682    fn is_cut(&self) -> bool {
2683        matches!(self, Self::Cut { .. })
2684    }
2685
2686    fn items(&self) -> &BTreeSet<SelectedEntry> {
2687        match self {
2688            ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
2689        }
2690    }
2691}
2692
2693#[cfg(test)]
2694mod tests {
2695    use super::*;
2696    use collections::HashSet;
2697    use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
2698    use pretty_assertions::assert_eq;
2699    use project::{FakeFs, WorktreeSettings};
2700    use serde_json::json;
2701    use settings::SettingsStore;
2702    use std::path::{Path, PathBuf};
2703    use workspace::{
2704        item::{Item, ProjectItem},
2705        register_project_item, AppState,
2706    };
2707
2708    #[gpui::test]
2709    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
2710        init_test(cx);
2711
2712        let fs = FakeFs::new(cx.executor().clone());
2713        fs.insert_tree(
2714            "/root1",
2715            json!({
2716                ".dockerignore": "",
2717                ".git": {
2718                    "HEAD": "",
2719                },
2720                "a": {
2721                    "0": { "q": "", "r": "", "s": "" },
2722                    "1": { "t": "", "u": "" },
2723                    "2": { "v": "", "w": "", "x": "", "y": "" },
2724                },
2725                "b": {
2726                    "3": { "Q": "" },
2727                    "4": { "R": "", "S": "", "T": "", "U": "" },
2728                },
2729                "C": {
2730                    "5": {},
2731                    "6": { "V": "", "W": "" },
2732                    "7": { "X": "" },
2733                    "8": { "Y": {}, "Z": "" }
2734                }
2735            }),
2736        )
2737        .await;
2738        fs.insert_tree(
2739            "/root2",
2740            json!({
2741                "d": {
2742                    "9": ""
2743                },
2744                "e": {}
2745            }),
2746        )
2747        .await;
2748
2749        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2750        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2751        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2752        let panel = workspace
2753            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2754            .unwrap();
2755        assert_eq!(
2756            visible_entries_as_strings(&panel, 0..50, cx),
2757            &[
2758                "v root1",
2759                "    > .git",
2760                "    > a",
2761                "    > b",
2762                "    > C",
2763                "      .dockerignore",
2764                "v root2",
2765                "    > d",
2766                "    > e",
2767            ]
2768        );
2769
2770        toggle_expand_dir(&panel, "root1/b", cx);
2771        assert_eq!(
2772            visible_entries_as_strings(&panel, 0..50, cx),
2773            &[
2774                "v root1",
2775                "    > .git",
2776                "    > a",
2777                "    v b  <== selected",
2778                "        > 3",
2779                "        > 4",
2780                "    > C",
2781                "      .dockerignore",
2782                "v root2",
2783                "    > d",
2784                "    > e",
2785            ]
2786        );
2787
2788        assert_eq!(
2789            visible_entries_as_strings(&panel, 6..9, cx),
2790            &[
2791                //
2792                "    > C",
2793                "      .dockerignore",
2794                "v root2",
2795            ]
2796        );
2797    }
2798
2799    #[gpui::test]
2800    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
2801        init_test(cx);
2802        cx.update(|cx| {
2803            cx.update_global::<SettingsStore, _>(|store, cx| {
2804                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
2805                    worktree_settings.file_scan_exclusions =
2806                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
2807                });
2808            });
2809        });
2810
2811        let fs = FakeFs::new(cx.background_executor.clone());
2812        fs.insert_tree(
2813            "/root1",
2814            json!({
2815                ".dockerignore": "",
2816                ".git": {
2817                    "HEAD": "",
2818                },
2819                "a": {
2820                    "0": { "q": "", "r": "", "s": "" },
2821                    "1": { "t": "", "u": "" },
2822                    "2": { "v": "", "w": "", "x": "", "y": "" },
2823                },
2824                "b": {
2825                    "3": { "Q": "" },
2826                    "4": { "R": "", "S": "", "T": "", "U": "" },
2827                },
2828                "C": {
2829                    "5": {},
2830                    "6": { "V": "", "W": "" },
2831                    "7": { "X": "" },
2832                    "8": { "Y": {}, "Z": "" }
2833                }
2834            }),
2835        )
2836        .await;
2837        fs.insert_tree(
2838            "/root2",
2839            json!({
2840                "d": {
2841                    "4": ""
2842                },
2843                "e": {}
2844            }),
2845        )
2846        .await;
2847
2848        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2849        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2850        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2851        let panel = workspace
2852            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2853            .unwrap();
2854        assert_eq!(
2855            visible_entries_as_strings(&panel, 0..50, cx),
2856            &[
2857                "v root1",
2858                "    > a",
2859                "    > b",
2860                "    > C",
2861                "      .dockerignore",
2862                "v root2",
2863                "    > d",
2864                "    > e",
2865            ]
2866        );
2867
2868        toggle_expand_dir(&panel, "root1/b", cx);
2869        assert_eq!(
2870            visible_entries_as_strings(&panel, 0..50, cx),
2871            &[
2872                "v root1",
2873                "    > a",
2874                "    v b  <== selected",
2875                "        > 3",
2876                "    > C",
2877                "      .dockerignore",
2878                "v root2",
2879                "    > d",
2880                "    > e",
2881            ]
2882        );
2883
2884        toggle_expand_dir(&panel, "root2/d", cx);
2885        assert_eq!(
2886            visible_entries_as_strings(&panel, 0..50, cx),
2887            &[
2888                "v root1",
2889                "    > a",
2890                "    v b",
2891                "        > 3",
2892                "    > C",
2893                "      .dockerignore",
2894                "v root2",
2895                "    v d  <== selected",
2896                "    > e",
2897            ]
2898        );
2899
2900        toggle_expand_dir(&panel, "root2/e", cx);
2901        assert_eq!(
2902            visible_entries_as_strings(&panel, 0..50, cx),
2903            &[
2904                "v root1",
2905                "    > a",
2906                "    v b",
2907                "        > 3",
2908                "    > C",
2909                "      .dockerignore",
2910                "v root2",
2911                "    v d",
2912                "    v e  <== selected",
2913            ]
2914        );
2915    }
2916
2917    #[gpui::test]
2918    async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
2919        init_test(cx);
2920
2921        let fs = FakeFs::new(cx.executor().clone());
2922        fs.insert_tree(
2923            "/root1",
2924            json!({
2925                "dir_1": {
2926                    "nested_dir_1": {
2927                        "nested_dir_2": {
2928                            "nested_dir_3": {
2929                                "file_a.java": "// File contents",
2930                                "file_b.java": "// File contents",
2931                                "file_c.java": "// File contents",
2932                                "nested_dir_4": {
2933                                    "nested_dir_5": {
2934                                        "file_d.java": "// File contents",
2935                                    }
2936                                }
2937                            }
2938                        }
2939                    }
2940                }
2941            }),
2942        )
2943        .await;
2944        fs.insert_tree(
2945            "/root2",
2946            json!({
2947                "dir_2": {
2948                    "file_1.java": "// File contents",
2949                }
2950            }),
2951        )
2952        .await;
2953
2954        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2955        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2956        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2957        cx.update(|cx| {
2958            let settings = *ProjectPanelSettings::get_global(cx);
2959            ProjectPanelSettings::override_global(
2960                ProjectPanelSettings {
2961                    auto_fold_dirs: true,
2962                    ..settings
2963                },
2964                cx,
2965            );
2966        });
2967        let panel = workspace
2968            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2969            .unwrap();
2970        assert_eq!(
2971            visible_entries_as_strings(&panel, 0..10, cx),
2972            &[
2973                "v root1",
2974                "    > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2975                "v root2",
2976                "    > dir_2",
2977            ]
2978        );
2979
2980        toggle_expand_dir(
2981            &panel,
2982            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2983            cx,
2984        );
2985        assert_eq!(
2986            visible_entries_as_strings(&panel, 0..10, cx),
2987            &[
2988                "v root1",
2989                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3  <== selected",
2990                "        > nested_dir_4/nested_dir_5",
2991                "          file_a.java",
2992                "          file_b.java",
2993                "          file_c.java",
2994                "v root2",
2995                "    > dir_2",
2996            ]
2997        );
2998
2999        toggle_expand_dir(
3000            &panel,
3001            "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
3002            cx,
3003        );
3004        assert_eq!(
3005            visible_entries_as_strings(&panel, 0..10, cx),
3006            &[
3007                "v root1",
3008                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3009                "        v nested_dir_4/nested_dir_5  <== selected",
3010                "              file_d.java",
3011                "          file_a.java",
3012                "          file_b.java",
3013                "          file_c.java",
3014                "v root2",
3015                "    > dir_2",
3016            ]
3017        );
3018        toggle_expand_dir(&panel, "root2/dir_2", cx);
3019        assert_eq!(
3020            visible_entries_as_strings(&panel, 0..10, cx),
3021            &[
3022                "v root1",
3023                "    v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3024                "        v nested_dir_4/nested_dir_5",
3025                "              file_d.java",
3026                "          file_a.java",
3027                "          file_b.java",
3028                "          file_c.java",
3029                "v root2",
3030                "    v dir_2  <== selected",
3031                "          file_1.java",
3032            ]
3033        );
3034    }
3035
3036    #[gpui::test(iterations = 30)]
3037    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
3038        init_test(cx);
3039
3040        let fs = FakeFs::new(cx.executor().clone());
3041        fs.insert_tree(
3042            "/root1",
3043            json!({
3044                ".dockerignore": "",
3045                ".git": {
3046                    "HEAD": "",
3047                },
3048                "a": {
3049                    "0": { "q": "", "r": "", "s": "" },
3050                    "1": { "t": "", "u": "" },
3051                    "2": { "v": "", "w": "", "x": "", "y": "" },
3052                },
3053                "b": {
3054                    "3": { "Q": "" },
3055                    "4": { "R": "", "S": "", "T": "", "U": "" },
3056                },
3057                "C": {
3058                    "5": {},
3059                    "6": { "V": "", "W": "" },
3060                    "7": { "X": "" },
3061                    "8": { "Y": {}, "Z": "" }
3062                }
3063            }),
3064        )
3065        .await;
3066        fs.insert_tree(
3067            "/root2",
3068            json!({
3069                "d": {
3070                    "9": ""
3071                },
3072                "e": {}
3073            }),
3074        )
3075        .await;
3076
3077        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3078        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3079        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3080        let panel = workspace
3081            .update(cx, |workspace, cx| {
3082                let panel = ProjectPanel::new(workspace, cx);
3083                workspace.add_panel(panel.clone(), cx);
3084                panel
3085            })
3086            .unwrap();
3087
3088        select_path(&panel, "root1", cx);
3089        assert_eq!(
3090            visible_entries_as_strings(&panel, 0..10, cx),
3091            &[
3092                "v root1  <== selected",
3093                "    > .git",
3094                "    > a",
3095                "    > b",
3096                "    > C",
3097                "      .dockerignore",
3098                "v root2",
3099                "    > d",
3100                "    > e",
3101            ]
3102        );
3103
3104        // Add a file with the root folder selected. The filename editor is placed
3105        // before the first file in the root folder.
3106        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3107        panel.update(cx, |panel, cx| {
3108            assert!(panel.filename_editor.read(cx).is_focused(cx));
3109        });
3110        assert_eq!(
3111            visible_entries_as_strings(&panel, 0..10, cx),
3112            &[
3113                "v root1",
3114                "    > .git",
3115                "    > a",
3116                "    > b",
3117                "    > C",
3118                "      [EDITOR: '']  <== selected",
3119                "      .dockerignore",
3120                "v root2",
3121                "    > d",
3122                "    > e",
3123            ]
3124        );
3125
3126        let confirm = panel.update(cx, |panel, cx| {
3127            panel
3128                .filename_editor
3129                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
3130            panel.confirm_edit(cx).unwrap()
3131        });
3132        assert_eq!(
3133            visible_entries_as_strings(&panel, 0..10, cx),
3134            &[
3135                "v root1",
3136                "    > .git",
3137                "    > a",
3138                "    > b",
3139                "    > C",
3140                "      [PROCESSING: 'the-new-filename']  <== selected",
3141                "      .dockerignore",
3142                "v root2",
3143                "    > d",
3144                "    > e",
3145            ]
3146        );
3147
3148        confirm.await.unwrap();
3149        assert_eq!(
3150            visible_entries_as_strings(&panel, 0..10, cx),
3151            &[
3152                "v root1",
3153                "    > .git",
3154                "    > a",
3155                "    > b",
3156                "    > C",
3157                "      .dockerignore",
3158                "      the-new-filename  <== selected  <== marked",
3159                "v root2",
3160                "    > d",
3161                "    > e",
3162            ]
3163        );
3164
3165        select_path(&panel, "root1/b", cx);
3166        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3167        assert_eq!(
3168            visible_entries_as_strings(&panel, 0..10, cx),
3169            &[
3170                "v root1",
3171                "    > .git",
3172                "    > a",
3173                "    v b",
3174                "        > 3",
3175                "        > 4",
3176                "          [EDITOR: '']  <== selected",
3177                "    > C",
3178                "      .dockerignore",
3179                "      the-new-filename",
3180            ]
3181        );
3182
3183        panel
3184            .update(cx, |panel, cx| {
3185                panel
3186                    .filename_editor
3187                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
3188                panel.confirm_edit(cx).unwrap()
3189            })
3190            .await
3191            .unwrap();
3192        assert_eq!(
3193            visible_entries_as_strings(&panel, 0..10, cx),
3194            &[
3195                "v root1",
3196                "    > .git",
3197                "    > a",
3198                "    v b",
3199                "        > 3",
3200                "        > 4",
3201                "          another-filename.txt  <== selected  <== marked",
3202                "    > C",
3203                "      .dockerignore",
3204                "      the-new-filename",
3205            ]
3206        );
3207
3208        select_path(&panel, "root1/b/another-filename.txt", cx);
3209        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3210        assert_eq!(
3211            visible_entries_as_strings(&panel, 0..10, cx),
3212            &[
3213                "v root1",
3214                "    > .git",
3215                "    > a",
3216                "    v b",
3217                "        > 3",
3218                "        > 4",
3219                "          [EDITOR: 'another-filename.txt']  <== selected  <== marked",
3220                "    > C",
3221                "      .dockerignore",
3222                "      the-new-filename",
3223            ]
3224        );
3225
3226        let confirm = panel.update(cx, |panel, cx| {
3227            panel.filename_editor.update(cx, |editor, cx| {
3228                let file_name_selections = editor.selections.all::<usize>(cx);
3229                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3230                let file_name_selection = &file_name_selections[0];
3231                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3232                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
3233
3234                editor.set_text("a-different-filename.tar.gz", cx)
3235            });
3236            panel.confirm_edit(cx).unwrap()
3237        });
3238        assert_eq!(
3239            visible_entries_as_strings(&panel, 0..10, cx),
3240            &[
3241                "v root1",
3242                "    > .git",
3243                "    > a",
3244                "    v b",
3245                "        > 3",
3246                "        > 4",
3247                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected  <== marked",
3248                "    > C",
3249                "      .dockerignore",
3250                "      the-new-filename",
3251            ]
3252        );
3253
3254        confirm.await.unwrap();
3255        assert_eq!(
3256            visible_entries_as_strings(&panel, 0..10, cx),
3257            &[
3258                "v root1",
3259                "    > .git",
3260                "    > a",
3261                "    v b",
3262                "        > 3",
3263                "        > 4",
3264                "          a-different-filename.tar.gz  <== selected",
3265                "    > C",
3266                "      .dockerignore",
3267                "      the-new-filename",
3268            ]
3269        );
3270
3271        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3272        assert_eq!(
3273            visible_entries_as_strings(&panel, 0..10, cx),
3274            &[
3275                "v root1",
3276                "    > .git",
3277                "    > a",
3278                "    v b",
3279                "        > 3",
3280                "        > 4",
3281                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
3282                "    > C",
3283                "      .dockerignore",
3284                "      the-new-filename",
3285            ]
3286        );
3287
3288        panel.update(cx, |panel, cx| {
3289            panel.filename_editor.update(cx, |editor, cx| {
3290                let file_name_selections = editor.selections.all::<usize>(cx);
3291                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3292                let file_name_selection = &file_name_selections[0];
3293                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3294                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..");
3295
3296            });
3297            panel.cancel(&menu::Cancel, cx)
3298        });
3299
3300        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3301        assert_eq!(
3302            visible_entries_as_strings(&panel, 0..10, cx),
3303            &[
3304                "v root1",
3305                "    > .git",
3306                "    > a",
3307                "    v b",
3308                "        > [EDITOR: '']  <== selected",
3309                "        > 3",
3310                "        > 4",
3311                "          a-different-filename.tar.gz",
3312                "    > C",
3313                "      .dockerignore",
3314            ]
3315        );
3316
3317        let confirm = panel.update(cx, |panel, cx| {
3318            panel
3319                .filename_editor
3320                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
3321            panel.confirm_edit(cx).unwrap()
3322        });
3323        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
3324        assert_eq!(
3325            visible_entries_as_strings(&panel, 0..10, cx),
3326            &[
3327                "v root1",
3328                "    > .git",
3329                "    > a",
3330                "    v b",
3331                "        > [PROCESSING: 'new-dir']",
3332                "        > 3  <== selected",
3333                "        > 4",
3334                "          a-different-filename.tar.gz",
3335                "    > C",
3336                "      .dockerignore",
3337            ]
3338        );
3339
3340        confirm.await.unwrap();
3341        assert_eq!(
3342            visible_entries_as_strings(&panel, 0..10, cx),
3343            &[
3344                "v root1",
3345                "    > .git",
3346                "    > a",
3347                "    v b",
3348                "        > 3  <== selected",
3349                "        > 4",
3350                "        > new-dir",
3351                "          a-different-filename.tar.gz",
3352                "    > C",
3353                "      .dockerignore",
3354            ]
3355        );
3356
3357        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
3358        assert_eq!(
3359            visible_entries_as_strings(&panel, 0..10, cx),
3360            &[
3361                "v root1",
3362                "    > .git",
3363                "    > a",
3364                "    v b",
3365                "        > [EDITOR: '3']  <== selected",
3366                "        > 4",
3367                "        > new-dir",
3368                "          a-different-filename.tar.gz",
3369                "    > C",
3370                "      .dockerignore",
3371            ]
3372        );
3373
3374        // Dismiss the rename editor when it loses focus.
3375        workspace.update(cx, |_, cx| cx.blur()).unwrap();
3376        assert_eq!(
3377            visible_entries_as_strings(&panel, 0..10, cx),
3378            &[
3379                "v root1",
3380                "    > .git",
3381                "    > a",
3382                "    v b",
3383                "        > 3  <== selected",
3384                "        > 4",
3385                "        > new-dir",
3386                "          a-different-filename.tar.gz",
3387                "    > C",
3388                "      .dockerignore",
3389            ]
3390        );
3391    }
3392
3393    #[gpui::test(iterations = 10)]
3394    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
3395        init_test(cx);
3396
3397        let fs = FakeFs::new(cx.executor().clone());
3398        fs.insert_tree(
3399            "/root1",
3400            json!({
3401                ".dockerignore": "",
3402                ".git": {
3403                    "HEAD": "",
3404                },
3405                "a": {
3406                    "0": { "q": "", "r": "", "s": "" },
3407                    "1": { "t": "", "u": "" },
3408                    "2": { "v": "", "w": "", "x": "", "y": "" },
3409                },
3410                "b": {
3411                    "3": { "Q": "" },
3412                    "4": { "R": "", "S": "", "T": "", "U": "" },
3413                },
3414                "C": {
3415                    "5": {},
3416                    "6": { "V": "", "W": "" },
3417                    "7": { "X": "" },
3418                    "8": { "Y": {}, "Z": "" }
3419                }
3420            }),
3421        )
3422        .await;
3423        fs.insert_tree(
3424            "/root2",
3425            json!({
3426                "d": {
3427                    "9": ""
3428                },
3429                "e": {}
3430            }),
3431        )
3432        .await;
3433
3434        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3435        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3436        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3437        let panel = workspace
3438            .update(cx, |workspace, cx| {
3439                let panel = ProjectPanel::new(workspace, cx);
3440                workspace.add_panel(panel.clone(), cx);
3441                panel
3442            })
3443            .unwrap();
3444
3445        select_path(&panel, "root1", cx);
3446        assert_eq!(
3447            visible_entries_as_strings(&panel, 0..10, cx),
3448            &[
3449                "v root1  <== selected",
3450                "    > .git",
3451                "    > a",
3452                "    > b",
3453                "    > C",
3454                "      .dockerignore",
3455                "v root2",
3456                "    > d",
3457                "    > e",
3458            ]
3459        );
3460
3461        // Add a file with the root folder selected. The filename editor is placed
3462        // before the first file in the root folder.
3463        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3464        panel.update(cx, |panel, cx| {
3465            assert!(panel.filename_editor.read(cx).is_focused(cx));
3466        });
3467        assert_eq!(
3468            visible_entries_as_strings(&panel, 0..10, cx),
3469            &[
3470                "v root1",
3471                "    > .git",
3472                "    > a",
3473                "    > b",
3474                "    > C",
3475                "      [EDITOR: '']  <== selected",
3476                "      .dockerignore",
3477                "v root2",
3478                "    > d",
3479                "    > e",
3480            ]
3481        );
3482
3483        let confirm = panel.update(cx, |panel, cx| {
3484            panel.filename_editor.update(cx, |editor, cx| {
3485                editor.set_text("/bdir1/dir2/the-new-filename", cx)
3486            });
3487            panel.confirm_edit(cx).unwrap()
3488        });
3489
3490        assert_eq!(
3491            visible_entries_as_strings(&panel, 0..10, cx),
3492            &[
3493                "v root1",
3494                "    > .git",
3495                "    > a",
3496                "    > b",
3497                "    > C",
3498                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
3499                "      .dockerignore",
3500                "v root2",
3501                "    > d",
3502                "    > e",
3503            ]
3504        );
3505
3506        confirm.await.unwrap();
3507        assert_eq!(
3508            visible_entries_as_strings(&panel, 0..13, cx),
3509            &[
3510                "v root1",
3511                "    > .git",
3512                "    > a",
3513                "    > b",
3514                "    v bdir1",
3515                "        v dir2",
3516                "              the-new-filename  <== selected  <== marked",
3517                "    > C",
3518                "      .dockerignore",
3519                "v root2",
3520                "    > d",
3521                "    > e",
3522            ]
3523        );
3524    }
3525
3526    #[gpui::test]
3527    async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
3528        init_test(cx);
3529
3530        let fs = FakeFs::new(cx.executor().clone());
3531        fs.insert_tree(
3532            "/root1",
3533            json!({
3534                ".dockerignore": "",
3535                ".git": {
3536                    "HEAD": "",
3537                },
3538            }),
3539        )
3540        .await;
3541
3542        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3543        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3544        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3545        let panel = workspace
3546            .update(cx, |workspace, cx| {
3547                let panel = ProjectPanel::new(workspace, cx);
3548                workspace.add_panel(panel.clone(), cx);
3549                panel
3550            })
3551            .unwrap();
3552
3553        select_path(&panel, "root1", cx);
3554        assert_eq!(
3555            visible_entries_as_strings(&panel, 0..10, cx),
3556            &["v root1  <== selected", "    > .git", "      .dockerignore",]
3557        );
3558
3559        // Add a file with the root folder selected. The filename editor is placed
3560        // before the first file in the root folder.
3561        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3562        panel.update(cx, |panel, cx| {
3563            assert!(panel.filename_editor.read(cx).is_focused(cx));
3564        });
3565        assert_eq!(
3566            visible_entries_as_strings(&panel, 0..10, cx),
3567            &[
3568                "v root1",
3569                "    > .git",
3570                "      [EDITOR: '']  <== selected",
3571                "      .dockerignore",
3572            ]
3573        );
3574
3575        let confirm = panel.update(cx, |panel, cx| {
3576            panel
3577                .filename_editor
3578                .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
3579            panel.confirm_edit(cx).unwrap()
3580        });
3581
3582        assert_eq!(
3583            visible_entries_as_strings(&panel, 0..10, cx),
3584            &[
3585                "v root1",
3586                "    > .git",
3587                "      [PROCESSING: '/new_dir/']  <== selected",
3588                "      .dockerignore",
3589            ]
3590        );
3591
3592        confirm.await.unwrap();
3593        assert_eq!(
3594            visible_entries_as_strings(&panel, 0..13, cx),
3595            &[
3596                "v root1",
3597                "    > .git",
3598                "    v new_dir  <== selected",
3599                "      .dockerignore",
3600            ]
3601        );
3602    }
3603
3604    #[gpui::test]
3605    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
3606        init_test(cx);
3607
3608        let fs = FakeFs::new(cx.executor().clone());
3609        fs.insert_tree(
3610            "/root1",
3611            json!({
3612                "one.two.txt": "",
3613                "one.txt": ""
3614            }),
3615        )
3616        .await;
3617
3618        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3619        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3620        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3621        let panel = workspace
3622            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3623            .unwrap();
3624
3625        panel.update(cx, |panel, cx| {
3626            panel.select_next(&Default::default(), cx);
3627            panel.select_next(&Default::default(), cx);
3628        });
3629
3630        assert_eq!(
3631            visible_entries_as_strings(&panel, 0..50, cx),
3632            &[
3633                //
3634                "v root1",
3635                "      one.two.txt  <== selected",
3636                "      one.txt",
3637            ]
3638        );
3639
3640        // Regression test - file name is created correctly when
3641        // the copied file's name contains multiple dots.
3642        panel.update(cx, |panel, cx| {
3643            panel.copy(&Default::default(), cx);
3644            panel.paste(&Default::default(), cx);
3645        });
3646        cx.executor().run_until_parked();
3647
3648        assert_eq!(
3649            visible_entries_as_strings(&panel, 0..50, cx),
3650            &[
3651                //
3652                "v root1",
3653                "      one.two copy.txt  <== selected",
3654                "      one.two.txt",
3655                "      one.txt",
3656            ]
3657        );
3658
3659        panel.update(cx, |panel, cx| {
3660            panel.paste(&Default::default(), cx);
3661        });
3662        cx.executor().run_until_parked();
3663
3664        assert_eq!(
3665            visible_entries_as_strings(&panel, 0..50, cx),
3666            &[
3667                //
3668                "v root1",
3669                "      one.two copy 1.txt  <== selected",
3670                "      one.two copy.txt",
3671                "      one.two.txt",
3672                "      one.txt",
3673            ]
3674        );
3675    }
3676
3677    #[gpui::test]
3678    async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
3679        init_test(cx);
3680
3681        let fs = FakeFs::new(cx.executor().clone());
3682        fs.insert_tree(
3683            "/root",
3684            json!({
3685                "a": {
3686                    "one.txt": "",
3687                    "two.txt": "",
3688                    "inner_dir": {
3689                        "three.txt": "",
3690                        "four.txt": "",
3691                    }
3692                },
3693                "b": {}
3694            }),
3695        )
3696        .await;
3697
3698        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3699        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3700        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3701        let panel = workspace
3702            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3703            .unwrap();
3704
3705        select_path(&panel, "root/a", cx);
3706        panel.update(cx, |panel, cx| {
3707            panel.copy(&Default::default(), cx);
3708            panel.select_next(&Default::default(), cx);
3709            panel.paste(&Default::default(), cx);
3710        });
3711        cx.executor().run_until_parked();
3712
3713        let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
3714        assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
3715
3716        let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
3717        assert_ne!(
3718            pasted_dir_file, None,
3719            "Pasted directory file should have an entry"
3720        );
3721
3722        let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
3723        assert_ne!(
3724            pasted_dir_inner_dir, None,
3725            "Directories inside pasted directory should have an entry"
3726        );
3727
3728        toggle_expand_dir(&panel, "root/b/a", cx);
3729        toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
3730
3731        assert_eq!(
3732            visible_entries_as_strings(&panel, 0..50, cx),
3733            &[
3734                //
3735                "v root",
3736                "    > a",
3737                "    v b",
3738                "        v a",
3739                "            v inner_dir  <== selected",
3740                "                  four.txt",
3741                "                  three.txt",
3742                "              one.txt",
3743                "              two.txt",
3744            ]
3745        );
3746
3747        select_path(&panel, "root", cx);
3748        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3749        cx.executor().run_until_parked();
3750        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3751        cx.executor().run_until_parked();
3752        assert_eq!(
3753            visible_entries_as_strings(&panel, 0..50, cx),
3754            &[
3755                //
3756                "v root",
3757                "    > a",
3758                "    v a copy",
3759                "        > a  <== selected",
3760                "        > inner_dir",
3761                "          one.txt",
3762                "          two.txt",
3763                "    v b",
3764                "        v a",
3765                "            v inner_dir",
3766                "                  four.txt",
3767                "                  three.txt",
3768                "              one.txt",
3769                "              two.txt"
3770            ]
3771        );
3772    }
3773
3774    #[gpui::test]
3775    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
3776        init_test_with_editor(cx);
3777
3778        let fs = FakeFs::new(cx.executor().clone());
3779        fs.insert_tree(
3780            "/src",
3781            json!({
3782                "test": {
3783                    "first.rs": "// First Rust file",
3784                    "second.rs": "// Second Rust file",
3785                    "third.rs": "// Third Rust file",
3786                }
3787            }),
3788        )
3789        .await;
3790
3791        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3792        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3793        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3794        let panel = workspace
3795            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3796            .unwrap();
3797
3798        toggle_expand_dir(&panel, "src/test", cx);
3799        select_path(&panel, "src/test/first.rs", cx);
3800        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3801        cx.executor().run_until_parked();
3802        assert_eq!(
3803            visible_entries_as_strings(&panel, 0..10, cx),
3804            &[
3805                "v src",
3806                "    v test",
3807                "          first.rs  <== selected",
3808                "          second.rs",
3809                "          third.rs"
3810            ]
3811        );
3812        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
3813
3814        submit_deletion(&panel, cx);
3815        assert_eq!(
3816            visible_entries_as_strings(&panel, 0..10, cx),
3817            &[
3818                "v src",
3819                "    v test",
3820                "          second.rs",
3821                "          third.rs"
3822            ],
3823            "Project panel should have no deleted file, no other file is selected in it"
3824        );
3825        ensure_no_open_items_and_panes(&workspace, cx);
3826
3827        select_path(&panel, "src/test/second.rs", cx);
3828        panel.update(cx, |panel, cx| panel.open(&Open, cx));
3829        cx.executor().run_until_parked();
3830        assert_eq!(
3831            visible_entries_as_strings(&panel, 0..10, cx),
3832            &[
3833                "v src",
3834                "    v test",
3835                "          second.rs  <== selected",
3836                "          third.rs"
3837            ]
3838        );
3839        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
3840
3841        workspace
3842            .update(cx, |workspace, cx| {
3843                let active_items = workspace
3844                    .panes()
3845                    .iter()
3846                    .filter_map(|pane| pane.read(cx).active_item())
3847                    .collect::<Vec<_>>();
3848                assert_eq!(active_items.len(), 1);
3849                let open_editor = active_items
3850                    .into_iter()
3851                    .next()
3852                    .unwrap()
3853                    .downcast::<Editor>()
3854                    .expect("Open item should be an editor");
3855                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
3856            })
3857            .unwrap();
3858        submit_deletion_skipping_prompt(&panel, cx);
3859        assert_eq!(
3860            visible_entries_as_strings(&panel, 0..10, cx),
3861            &["v src", "    v test", "          third.rs"],
3862            "Project panel should have no deleted file, with one last file remaining"
3863        );
3864        ensure_no_open_items_and_panes(&workspace, cx);
3865    }
3866
3867    #[gpui::test]
3868    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
3869        init_test_with_editor(cx);
3870
3871        let fs = FakeFs::new(cx.executor().clone());
3872        fs.insert_tree(
3873            "/src",
3874            json!({
3875                "test": {
3876                    "first.rs": "// First Rust file",
3877                    "second.rs": "// Second Rust file",
3878                    "third.rs": "// Third Rust file",
3879                }
3880            }),
3881        )
3882        .await;
3883
3884        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3885        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3886        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3887        let panel = workspace
3888            .update(cx, |workspace, cx| {
3889                let panel = ProjectPanel::new(workspace, cx);
3890                workspace.add_panel(panel.clone(), cx);
3891                panel
3892            })
3893            .unwrap();
3894
3895        select_path(&panel, "src/", cx);
3896        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3897        cx.executor().run_until_parked();
3898        assert_eq!(
3899            visible_entries_as_strings(&panel, 0..10, cx),
3900            &[
3901                //
3902                "v src  <== selected",
3903                "    > test"
3904            ]
3905        );
3906        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3907        panel.update(cx, |panel, cx| {
3908            assert!(panel.filename_editor.read(cx).is_focused(cx));
3909        });
3910        assert_eq!(
3911            visible_entries_as_strings(&panel, 0..10, cx),
3912            &[
3913                //
3914                "v src",
3915                "    > [EDITOR: '']  <== selected",
3916                "    > test"
3917            ]
3918        );
3919        panel.update(cx, |panel, cx| {
3920            panel
3921                .filename_editor
3922                .update(cx, |editor, cx| editor.set_text("test", cx));
3923            assert!(
3924                panel.confirm_edit(cx).is_none(),
3925                "Should not allow to confirm on conflicting new directory name"
3926            )
3927        });
3928        assert_eq!(
3929            visible_entries_as_strings(&panel, 0..10, cx),
3930            &[
3931                //
3932                "v src",
3933                "    > test"
3934            ],
3935            "File list should be unchanged after failed folder create confirmation"
3936        );
3937
3938        select_path(&panel, "src/test/", cx);
3939        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3940        cx.executor().run_until_parked();
3941        assert_eq!(
3942            visible_entries_as_strings(&panel, 0..10, cx),
3943            &[
3944                //
3945                "v src",
3946                "    > test  <== selected"
3947            ]
3948        );
3949        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3950        panel.update(cx, |panel, cx| {
3951            assert!(panel.filename_editor.read(cx).is_focused(cx));
3952        });
3953        assert_eq!(
3954            visible_entries_as_strings(&panel, 0..10, cx),
3955            &[
3956                "v src",
3957                "    v test",
3958                "          [EDITOR: '']  <== selected",
3959                "          first.rs",
3960                "          second.rs",
3961                "          third.rs"
3962            ]
3963        );
3964        panel.update(cx, |panel, cx| {
3965            panel
3966                .filename_editor
3967                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
3968            assert!(
3969                panel.confirm_edit(cx).is_none(),
3970                "Should not allow to confirm on conflicting new file name"
3971            )
3972        });
3973        assert_eq!(
3974            visible_entries_as_strings(&panel, 0..10, cx),
3975            &[
3976                "v src",
3977                "    v test",
3978                "          first.rs",
3979                "          second.rs",
3980                "          third.rs"
3981            ],
3982            "File list should be unchanged after failed file create confirmation"
3983        );
3984
3985        select_path(&panel, "src/test/first.rs", cx);
3986        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3987        cx.executor().run_until_parked();
3988        assert_eq!(
3989            visible_entries_as_strings(&panel, 0..10, cx),
3990            &[
3991                "v src",
3992                "    v test",
3993                "          first.rs  <== selected",
3994                "          second.rs",
3995                "          third.rs"
3996            ],
3997        );
3998        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3999        panel.update(cx, |panel, cx| {
4000            assert!(panel.filename_editor.read(cx).is_focused(cx));
4001        });
4002        assert_eq!(
4003            visible_entries_as_strings(&panel, 0..10, cx),
4004            &[
4005                "v src",
4006                "    v test",
4007                "          [EDITOR: 'first.rs']  <== selected",
4008                "          second.rs",
4009                "          third.rs"
4010            ]
4011        );
4012        panel.update(cx, |panel, cx| {
4013            panel
4014                .filename_editor
4015                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
4016            assert!(
4017                panel.confirm_edit(cx).is_none(),
4018                "Should not allow to confirm on conflicting file rename"
4019            )
4020        });
4021        assert_eq!(
4022            visible_entries_as_strings(&panel, 0..10, cx),
4023            &[
4024                "v src",
4025                "    v test",
4026                "          first.rs  <== selected",
4027                "          second.rs",
4028                "          third.rs"
4029            ],
4030            "File list should be unchanged after failed rename confirmation"
4031        );
4032    }
4033
4034    #[gpui::test]
4035    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
4036        init_test_with_editor(cx);
4037
4038        let fs = FakeFs::new(cx.executor().clone());
4039        fs.insert_tree(
4040            "/project_root",
4041            json!({
4042                "dir_1": {
4043                    "nested_dir": {
4044                        "file_a.py": "# File contents",
4045                    }
4046                },
4047                "file_1.py": "# File contents",
4048            }),
4049        )
4050        .await;
4051
4052        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4053        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4054        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4055        let panel = workspace
4056            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4057            .unwrap();
4058
4059        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4060        cx.executor().run_until_parked();
4061        select_path(&panel, "project_root/dir_1", cx);
4062        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4063        select_path(&panel, "project_root/dir_1/nested_dir", cx);
4064        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4065        panel.update(cx, |panel, cx| panel.open(&Open, cx));
4066        cx.executor().run_until_parked();
4067        assert_eq!(
4068            visible_entries_as_strings(&panel, 0..10, cx),
4069            &[
4070                "v project_root",
4071                "    v dir_1",
4072                "        > nested_dir  <== selected",
4073                "      file_1.py",
4074            ]
4075        );
4076    }
4077
4078    #[gpui::test]
4079    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
4080        init_test_with_editor(cx);
4081
4082        let fs = FakeFs::new(cx.executor().clone());
4083        fs.insert_tree(
4084            "/project_root",
4085            json!({
4086                "dir_1": {
4087                    "nested_dir": {
4088                        "file_a.py": "# File contents",
4089                        "file_b.py": "# File contents",
4090                        "file_c.py": "# File contents",
4091                    },
4092                    "file_1.py": "# File contents",
4093                    "file_2.py": "# File contents",
4094                    "file_3.py": "# File contents",
4095                },
4096                "dir_2": {
4097                    "file_1.py": "# File contents",
4098                    "file_2.py": "# File contents",
4099                    "file_3.py": "# File contents",
4100                }
4101            }),
4102        )
4103        .await;
4104
4105        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4106        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4107        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4108        let panel = workspace
4109            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4110            .unwrap();
4111
4112        panel.update(cx, |panel, cx| {
4113            panel.collapse_all_entries(&CollapseAllEntries, cx)
4114        });
4115        cx.executor().run_until_parked();
4116        assert_eq!(
4117            visible_entries_as_strings(&panel, 0..10, cx),
4118            &["v project_root", "    > dir_1", "    > dir_2",]
4119        );
4120
4121        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
4122        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4123        cx.executor().run_until_parked();
4124        assert_eq!(
4125            visible_entries_as_strings(&panel, 0..10, cx),
4126            &[
4127                "v project_root",
4128                "    v dir_1  <== selected",
4129                "        > nested_dir",
4130                "          file_1.py",
4131                "          file_2.py",
4132                "          file_3.py",
4133                "    > dir_2",
4134            ]
4135        );
4136    }
4137
4138    #[gpui::test]
4139    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
4140        init_test(cx);
4141
4142        let fs = FakeFs::new(cx.executor().clone());
4143        fs.as_fake().insert_tree("/root", json!({})).await;
4144        let project = Project::test(fs, ["/root".as_ref()], cx).await;
4145        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4146        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4147        let panel = workspace
4148            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4149            .unwrap();
4150
4151        // Make a new buffer with no backing file
4152        workspace
4153            .update(cx, |workspace, cx| {
4154                Editor::new_file(workspace, &Default::default(), cx)
4155            })
4156            .unwrap();
4157
4158        cx.executor().run_until_parked();
4159
4160        // "Save as" the buffer, creating a new backing file for it
4161        let save_task = workspace
4162            .update(cx, |workspace, cx| {
4163                workspace.save_active_item(workspace::SaveIntent::Save, cx)
4164            })
4165            .unwrap();
4166
4167        cx.executor().run_until_parked();
4168        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
4169        save_task.await.unwrap();
4170
4171        // Rename the file
4172        select_path(&panel, "root/new", cx);
4173        assert_eq!(
4174            visible_entries_as_strings(&panel, 0..10, cx),
4175            &["v root", "      new  <== selected"]
4176        );
4177        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4178        panel.update(cx, |panel, cx| {
4179            panel
4180                .filename_editor
4181                .update(cx, |editor, cx| editor.set_text("newer", cx));
4182        });
4183        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4184
4185        cx.executor().run_until_parked();
4186        assert_eq!(
4187            visible_entries_as_strings(&panel, 0..10, cx),
4188            &["v root", "      newer  <== selected"]
4189        );
4190
4191        workspace
4192            .update(cx, |workspace, cx| {
4193                workspace.save_active_item(workspace::SaveIntent::Save, cx)
4194            })
4195            .unwrap()
4196            .await
4197            .unwrap();
4198
4199        cx.executor().run_until_parked();
4200        // assert that saving the file doesn't restore "new"
4201        assert_eq!(
4202            visible_entries_as_strings(&panel, 0..10, cx),
4203            &["v root", "      newer  <== selected"]
4204        );
4205    }
4206
4207    #[gpui::test]
4208    async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
4209        init_test_with_editor(cx);
4210        let fs = FakeFs::new(cx.executor().clone());
4211        fs.insert_tree(
4212            "/project_root",
4213            json!({
4214                "dir_1": {
4215                    "nested_dir": {
4216                        "file_a.py": "# File contents",
4217                    }
4218                },
4219                "file_1.py": "# File contents",
4220            }),
4221        )
4222        .await;
4223
4224        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4225        let worktree_id =
4226            cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
4227        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4228        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4229        let panel = workspace
4230            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4231            .unwrap();
4232        cx.update(|cx| {
4233            panel.update(cx, |this, cx| {
4234                this.select_next(&Default::default(), cx);
4235                this.expand_selected_entry(&Default::default(), cx);
4236                this.expand_selected_entry(&Default::default(), cx);
4237                this.select_next(&Default::default(), cx);
4238                this.expand_selected_entry(&Default::default(), cx);
4239                this.select_next(&Default::default(), cx);
4240            })
4241        });
4242        assert_eq!(
4243            visible_entries_as_strings(&panel, 0..10, cx),
4244            &[
4245                "v project_root",
4246                "    v dir_1",
4247                "        v nested_dir",
4248                "              file_a.py  <== selected",
4249                "      file_1.py",
4250            ]
4251        );
4252        let modifiers_with_shift = gpui::Modifiers {
4253            shift: true,
4254            ..Default::default()
4255        };
4256        cx.simulate_modifiers_change(modifiers_with_shift);
4257        cx.update(|cx| {
4258            panel.update(cx, |this, cx| {
4259                this.select_next(&Default::default(), cx);
4260            })
4261        });
4262        assert_eq!(
4263            visible_entries_as_strings(&panel, 0..10, cx),
4264            &[
4265                "v project_root",
4266                "    v dir_1",
4267                "        v nested_dir",
4268                "              file_a.py",
4269                "      file_1.py  <== selected  <== marked",
4270            ]
4271        );
4272        cx.update(|cx| {
4273            panel.update(cx, |this, cx| {
4274                this.select_prev(&Default::default(), cx);
4275            })
4276        });
4277        assert_eq!(
4278            visible_entries_as_strings(&panel, 0..10, cx),
4279            &[
4280                "v project_root",
4281                "    v dir_1",
4282                "        v nested_dir",
4283                "              file_a.py  <== selected  <== marked",
4284                "      file_1.py  <== marked",
4285            ]
4286        );
4287        cx.update(|cx| {
4288            panel.update(cx, |this, cx| {
4289                let drag = DraggedSelection {
4290                    active_selection: this.selection.unwrap(),
4291                    marked_selections: Arc::new(this.marked_entries.clone()),
4292                };
4293                let target_entry = this
4294                    .project
4295                    .read(cx)
4296                    .entry_for_path(&(worktree_id, "").into(), cx)
4297                    .unwrap();
4298                this.drag_onto(&drag, target_entry.id, false, cx);
4299            });
4300        });
4301        cx.run_until_parked();
4302        assert_eq!(
4303            visible_entries_as_strings(&panel, 0..10, cx),
4304            &[
4305                "v project_root",
4306                "    v dir_1",
4307                "        v nested_dir",
4308                "      file_1.py  <== marked",
4309                "      file_a.py  <== selected  <== marked",
4310            ]
4311        );
4312        // ESC clears out all marks
4313        cx.update(|cx| {
4314            panel.update(cx, |this, cx| {
4315                this.cancel(&menu::Cancel, cx);
4316            })
4317        });
4318        assert_eq!(
4319            visible_entries_as_strings(&panel, 0..10, cx),
4320            &[
4321                "v project_root",
4322                "    v dir_1",
4323                "        v nested_dir",
4324                "      file_1.py",
4325                "      file_a.py  <== selected",
4326            ]
4327        );
4328        // ESC clears out all marks
4329        cx.update(|cx| {
4330            panel.update(cx, |this, cx| {
4331                this.select_prev(&SelectPrev, cx);
4332                this.select_next(&SelectNext, cx);
4333            })
4334        });
4335        assert_eq!(
4336            visible_entries_as_strings(&panel, 0..10, cx),
4337            &[
4338                "v project_root",
4339                "    v dir_1",
4340                "        v nested_dir",
4341                "      file_1.py  <== marked",
4342                "      file_a.py  <== selected  <== marked",
4343            ]
4344        );
4345        cx.simulate_modifiers_change(Default::default());
4346        cx.update(|cx| {
4347            panel.update(cx, |this, cx| {
4348                this.cut(&Cut, cx);
4349                this.select_prev(&SelectPrev, cx);
4350                this.select_prev(&SelectPrev, cx);
4351
4352                this.paste(&Paste, cx);
4353                // this.expand_selected_entry(&ExpandSelectedEntry, cx);
4354            })
4355        });
4356        cx.run_until_parked();
4357        assert_eq!(
4358            visible_entries_as_strings(&panel, 0..10, cx),
4359            &[
4360                "v project_root",
4361                "    v dir_1",
4362                "        v nested_dir  <== selected",
4363                "              file_1.py  <== marked",
4364                "              file_a.py  <== marked",
4365            ]
4366        );
4367        cx.simulate_modifiers_change(modifiers_with_shift);
4368        cx.update(|cx| {
4369            panel.update(cx, |this, cx| {
4370                this.expand_selected_entry(&Default::default(), cx);
4371                this.select_next(&SelectNext, cx);
4372                this.select_next(&SelectNext, cx);
4373            })
4374        });
4375        submit_deletion(&panel, cx);
4376        assert_eq!(
4377            visible_entries_as_strings(&panel, 0..10, cx),
4378            &["v project_root", "    v dir_1", "        v nested_dir",]
4379        );
4380    }
4381    #[gpui::test]
4382    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4383        init_test_with_editor(cx);
4384        cx.update(|cx| {
4385            cx.update_global::<SettingsStore, _>(|store, cx| {
4386                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4387                    worktree_settings.file_scan_exclusions = Some(Vec::new());
4388                });
4389                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4390                    project_panel_settings.auto_reveal_entries = Some(false)
4391                });
4392            })
4393        });
4394
4395        let fs = FakeFs::new(cx.background_executor.clone());
4396        fs.insert_tree(
4397            "/project_root",
4398            json!({
4399                ".git": {},
4400                ".gitignore": "**/gitignored_dir",
4401                "dir_1": {
4402                    "file_1.py": "# File 1_1 contents",
4403                    "file_2.py": "# File 1_2 contents",
4404                    "file_3.py": "# File 1_3 contents",
4405                    "gitignored_dir": {
4406                        "file_a.py": "# File contents",
4407                        "file_b.py": "# File contents",
4408                        "file_c.py": "# File contents",
4409                    },
4410                },
4411                "dir_2": {
4412                    "file_1.py": "# File 2_1 contents",
4413                    "file_2.py": "# File 2_2 contents",
4414                    "file_3.py": "# File 2_3 contents",
4415                }
4416            }),
4417        )
4418        .await;
4419
4420        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4421        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4422        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4423        let panel = workspace
4424            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4425            .unwrap();
4426
4427        assert_eq!(
4428            visible_entries_as_strings(&panel, 0..20, cx),
4429            &[
4430                "v project_root",
4431                "    > .git",
4432                "    > dir_1",
4433                "    > dir_2",
4434                "      .gitignore",
4435            ]
4436        );
4437
4438        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4439            .expect("dir 1 file is not ignored and should have an entry");
4440        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4441            .expect("dir 2 file is not ignored and should have an entry");
4442        let gitignored_dir_file =
4443            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4444        assert_eq!(
4445            gitignored_dir_file, None,
4446            "File in the gitignored dir should not have an entry before its dir is toggled"
4447        );
4448
4449        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4450        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4451        cx.executor().run_until_parked();
4452        assert_eq!(
4453            visible_entries_as_strings(&panel, 0..20, cx),
4454            &[
4455                "v project_root",
4456                "    > .git",
4457                "    v dir_1",
4458                "        v gitignored_dir  <== selected",
4459                "              file_a.py",
4460                "              file_b.py",
4461                "              file_c.py",
4462                "          file_1.py",
4463                "          file_2.py",
4464                "          file_3.py",
4465                "    > dir_2",
4466                "      .gitignore",
4467            ],
4468            "Should show gitignored dir file list in the project panel"
4469        );
4470        let gitignored_dir_file =
4471            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4472                .expect("after gitignored dir got opened, a file entry should be present");
4473
4474        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4475        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4476        assert_eq!(
4477            visible_entries_as_strings(&panel, 0..20, cx),
4478            &[
4479                "v project_root",
4480                "    > .git",
4481                "    > dir_1  <== selected",
4482                "    > dir_2",
4483                "      .gitignore",
4484            ],
4485            "Should hide all dir contents again and prepare for the auto reveal test"
4486        );
4487
4488        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4489            panel.update(cx, |panel, cx| {
4490                panel.project.update(cx, |_, cx| {
4491                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4492                })
4493            });
4494            cx.run_until_parked();
4495            assert_eq!(
4496                visible_entries_as_strings(&panel, 0..20, cx),
4497                &[
4498                    "v project_root",
4499                    "    > .git",
4500                    "    > dir_1  <== selected",
4501                    "    > dir_2",
4502                    "      .gitignore",
4503                ],
4504                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4505            );
4506        }
4507
4508        cx.update(|cx| {
4509            cx.update_global::<SettingsStore, _>(|store, cx| {
4510                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4511                    project_panel_settings.auto_reveal_entries = Some(true)
4512                });
4513            })
4514        });
4515
4516        panel.update(cx, |panel, cx| {
4517            panel.project.update(cx, |_, cx| {
4518                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4519            })
4520        });
4521        cx.run_until_parked();
4522        assert_eq!(
4523            visible_entries_as_strings(&panel, 0..20, cx),
4524            &[
4525                "v project_root",
4526                "    > .git",
4527                "    v dir_1",
4528                "        > gitignored_dir",
4529                "          file_1.py  <== selected",
4530                "          file_2.py",
4531                "          file_3.py",
4532                "    > dir_2",
4533                "      .gitignore",
4534            ],
4535            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4536        );
4537
4538        panel.update(cx, |panel, cx| {
4539            panel.project.update(cx, |_, cx| {
4540                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4541            })
4542        });
4543        cx.run_until_parked();
4544        assert_eq!(
4545            visible_entries_as_strings(&panel, 0..20, cx),
4546            &[
4547                "v project_root",
4548                "    > .git",
4549                "    v dir_1",
4550                "        > gitignored_dir",
4551                "          file_1.py",
4552                "          file_2.py",
4553                "          file_3.py",
4554                "    v dir_2",
4555                "          file_1.py  <== selected",
4556                "          file_2.py",
4557                "          file_3.py",
4558                "      .gitignore",
4559            ],
4560            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4561        );
4562
4563        panel.update(cx, |panel, cx| {
4564            panel.project.update(cx, |_, cx| {
4565                cx.emit(project::Event::ActiveEntryChanged(Some(
4566                    gitignored_dir_file,
4567                )))
4568            })
4569        });
4570        cx.run_until_parked();
4571        assert_eq!(
4572            visible_entries_as_strings(&panel, 0..20, cx),
4573            &[
4574                "v project_root",
4575                "    > .git",
4576                "    v dir_1",
4577                "        > gitignored_dir",
4578                "          file_1.py",
4579                "          file_2.py",
4580                "          file_3.py",
4581                "    v dir_2",
4582                "          file_1.py  <== selected",
4583                "          file_2.py",
4584                "          file_3.py",
4585                "      .gitignore",
4586            ],
4587            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4588        );
4589
4590        panel.update(cx, |panel, cx| {
4591            panel.project.update(cx, |_, cx| {
4592                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4593            })
4594        });
4595        cx.run_until_parked();
4596        assert_eq!(
4597            visible_entries_as_strings(&panel, 0..20, cx),
4598            &[
4599                "v project_root",
4600                "    > .git",
4601                "    v dir_1",
4602                "        v gitignored_dir",
4603                "              file_a.py  <== selected",
4604                "              file_b.py",
4605                "              file_c.py",
4606                "          file_1.py",
4607                "          file_2.py",
4608                "          file_3.py",
4609                "    v dir_2",
4610                "          file_1.py",
4611                "          file_2.py",
4612                "          file_3.py",
4613                "      .gitignore",
4614            ],
4615            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4616        );
4617    }
4618
4619    #[gpui::test]
4620    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
4621        init_test_with_editor(cx);
4622        cx.update(|cx| {
4623            cx.update_global::<SettingsStore, _>(|store, cx| {
4624                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4625                    worktree_settings.file_scan_exclusions = Some(Vec::new());
4626                });
4627                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4628                    project_panel_settings.auto_reveal_entries = Some(false)
4629                });
4630            })
4631        });
4632
4633        let fs = FakeFs::new(cx.background_executor.clone());
4634        fs.insert_tree(
4635            "/project_root",
4636            json!({
4637                ".git": {},
4638                ".gitignore": "**/gitignored_dir",
4639                "dir_1": {
4640                    "file_1.py": "# File 1_1 contents",
4641                    "file_2.py": "# File 1_2 contents",
4642                    "file_3.py": "# File 1_3 contents",
4643                    "gitignored_dir": {
4644                        "file_a.py": "# File contents",
4645                        "file_b.py": "# File contents",
4646                        "file_c.py": "# File contents",
4647                    },
4648                },
4649                "dir_2": {
4650                    "file_1.py": "# File 2_1 contents",
4651                    "file_2.py": "# File 2_2 contents",
4652                    "file_3.py": "# File 2_3 contents",
4653                }
4654            }),
4655        )
4656        .await;
4657
4658        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4659        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4660        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4661        let panel = workspace
4662            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4663            .unwrap();
4664
4665        assert_eq!(
4666            visible_entries_as_strings(&panel, 0..20, cx),
4667            &[
4668                "v project_root",
4669                "    > .git",
4670                "    > dir_1",
4671                "    > dir_2",
4672                "      .gitignore",
4673            ]
4674        );
4675
4676        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4677            .expect("dir 1 file is not ignored and should have an entry");
4678        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4679            .expect("dir 2 file is not ignored and should have an entry");
4680        let gitignored_dir_file =
4681            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4682        assert_eq!(
4683            gitignored_dir_file, None,
4684            "File in the gitignored dir should not have an entry before its dir is toggled"
4685        );
4686
4687        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4688        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4689        cx.run_until_parked();
4690        assert_eq!(
4691            visible_entries_as_strings(&panel, 0..20, cx),
4692            &[
4693                "v project_root",
4694                "    > .git",
4695                "    v dir_1",
4696                "        v gitignored_dir  <== selected",
4697                "              file_a.py",
4698                "              file_b.py",
4699                "              file_c.py",
4700                "          file_1.py",
4701                "          file_2.py",
4702                "          file_3.py",
4703                "    > dir_2",
4704                "      .gitignore",
4705            ],
4706            "Should show gitignored dir file list in the project panel"
4707        );
4708        let gitignored_dir_file =
4709            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4710                .expect("after gitignored dir got opened, a file entry should be present");
4711
4712        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4713        toggle_expand_dir(&panel, "project_root/dir_1", cx);
4714        assert_eq!(
4715            visible_entries_as_strings(&panel, 0..20, cx),
4716            &[
4717                "v project_root",
4718                "    > .git",
4719                "    > dir_1  <== selected",
4720                "    > dir_2",
4721                "      .gitignore",
4722            ],
4723            "Should hide all dir contents again and prepare for the explicit reveal test"
4724        );
4725
4726        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4727            panel.update(cx, |panel, cx| {
4728                panel.project.update(cx, |_, cx| {
4729                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4730                })
4731            });
4732            cx.run_until_parked();
4733            assert_eq!(
4734                visible_entries_as_strings(&panel, 0..20, cx),
4735                &[
4736                    "v project_root",
4737                    "    > .git",
4738                    "    > dir_1  <== selected",
4739                    "    > dir_2",
4740                    "      .gitignore",
4741                ],
4742                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4743            );
4744        }
4745
4746        panel.update(cx, |panel, cx| {
4747            panel.project.update(cx, |_, cx| {
4748                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
4749            })
4750        });
4751        cx.run_until_parked();
4752        assert_eq!(
4753            visible_entries_as_strings(&panel, 0..20, cx),
4754            &[
4755                "v project_root",
4756                "    > .git",
4757                "    v dir_1",
4758                "        > gitignored_dir",
4759                "          file_1.py  <== selected",
4760                "          file_2.py",
4761                "          file_3.py",
4762                "    > dir_2",
4763                "      .gitignore",
4764            ],
4765            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
4766        );
4767
4768        panel.update(cx, |panel, cx| {
4769            panel.project.update(cx, |_, cx| {
4770                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
4771            })
4772        });
4773        cx.run_until_parked();
4774        assert_eq!(
4775            visible_entries_as_strings(&panel, 0..20, cx),
4776            &[
4777                "v project_root",
4778                "    > .git",
4779                "    v dir_1",
4780                "        > gitignored_dir",
4781                "          file_1.py",
4782                "          file_2.py",
4783                "          file_3.py",
4784                "    v dir_2",
4785                "          file_1.py  <== selected",
4786                "          file_2.py",
4787                "          file_3.py",
4788                "      .gitignore",
4789            ],
4790            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
4791        );
4792
4793        panel.update(cx, |panel, cx| {
4794            panel.project.update(cx, |_, cx| {
4795                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4796            })
4797        });
4798        cx.run_until_parked();
4799        assert_eq!(
4800            visible_entries_as_strings(&panel, 0..20, cx),
4801            &[
4802                "v project_root",
4803                "    > .git",
4804                "    v dir_1",
4805                "        v gitignored_dir",
4806                "              file_a.py  <== selected",
4807                "              file_b.py",
4808                "              file_c.py",
4809                "          file_1.py",
4810                "          file_2.py",
4811                "          file_3.py",
4812                "    v dir_2",
4813                "          file_1.py",
4814                "          file_2.py",
4815                "          file_3.py",
4816                "      .gitignore",
4817            ],
4818            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
4819        );
4820    }
4821
4822    #[gpui::test]
4823    async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
4824        init_test(cx);
4825        cx.update(|cx| {
4826            cx.update_global::<SettingsStore, _>(|store, cx| {
4827                store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
4828                    project_settings.file_scan_exclusions =
4829                        Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
4830                });
4831            });
4832        });
4833
4834        cx.update(|cx| {
4835            register_project_item::<TestProjectItemView>(cx);
4836        });
4837
4838        let fs = FakeFs::new(cx.executor().clone());
4839        fs.insert_tree(
4840            "/root1",
4841            json!({
4842                ".dockerignore": "",
4843                ".git": {
4844                    "HEAD": "",
4845                },
4846            }),
4847        )
4848        .await;
4849
4850        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4851        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4852        let cx = &mut VisualTestContext::from_window(*workspace, cx);
4853        let panel = workspace
4854            .update(cx, |workspace, cx| {
4855                let panel = ProjectPanel::new(workspace, cx);
4856                workspace.add_panel(panel.clone(), cx);
4857                panel
4858            })
4859            .unwrap();
4860
4861        select_path(&panel, "root1", cx);
4862        assert_eq!(
4863            visible_entries_as_strings(&panel, 0..10, cx),
4864            &["v root1  <== selected", "      .dockerignore",]
4865        );
4866        workspace
4867            .update(cx, |workspace, cx| {
4868                assert!(
4869                    workspace.active_item(cx).is_none(),
4870                    "Should have no active items in the beginning"
4871                );
4872            })
4873            .unwrap();
4874
4875        let excluded_file_path = ".git/COMMIT_EDITMSG";
4876        let excluded_dir_path = "excluded_dir";
4877
4878        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4879        panel.update(cx, |panel, cx| {
4880            assert!(panel.filename_editor.read(cx).is_focused(cx));
4881        });
4882        panel
4883            .update(cx, |panel, cx| {
4884                panel
4885                    .filename_editor
4886                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4887                panel.confirm_edit(cx).unwrap()
4888            })
4889            .await
4890            .unwrap();
4891
4892        assert_eq!(
4893            visible_entries_as_strings(&panel, 0..13, cx),
4894            &["v root1", "      .dockerignore"],
4895            "Excluded dir should not be shown after opening a file in it"
4896        );
4897        panel.update(cx, |panel, cx| {
4898            assert!(
4899                !panel.filename_editor.read(cx).is_focused(cx),
4900                "Should have closed the file name editor"
4901            );
4902        });
4903        workspace
4904            .update(cx, |workspace, cx| {
4905                let active_entry_path = workspace
4906                    .active_item(cx)
4907                    .expect("should have opened and activated the excluded item")
4908                    .act_as::<TestProjectItemView>(cx)
4909                    .expect(
4910                        "should have opened the corresponding project item for the excluded item",
4911                    )
4912                    .read(cx)
4913                    .path
4914                    .clone();
4915                assert_eq!(
4916                    active_entry_path.path.as_ref(),
4917                    Path::new(excluded_file_path),
4918                    "Should open the excluded file"
4919                );
4920
4921                assert!(
4922                    workspace.notification_ids().is_empty(),
4923                    "Should have no notifications after opening an excluded file"
4924                );
4925            })
4926            .unwrap();
4927        assert!(
4928            fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
4929            "Should have created the excluded file"
4930        );
4931
4932        select_path(&panel, "root1", cx);
4933        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4934        panel.update(cx, |panel, cx| {
4935            assert!(panel.filename_editor.read(cx).is_focused(cx));
4936        });
4937        panel
4938            .update(cx, |panel, cx| {
4939                panel
4940                    .filename_editor
4941                    .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4942                panel.confirm_edit(cx).unwrap()
4943            })
4944            .await
4945            .unwrap();
4946
4947        assert_eq!(
4948            visible_entries_as_strings(&panel, 0..13, cx),
4949            &["v root1", "      .dockerignore"],
4950            "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
4951        );
4952        panel.update(cx, |panel, cx| {
4953            assert!(
4954                !panel.filename_editor.read(cx).is_focused(cx),
4955                "Should have closed the file name editor"
4956            );
4957        });
4958        workspace
4959            .update(cx, |workspace, cx| {
4960                let notifications = workspace.notification_ids();
4961                assert_eq!(
4962                    notifications.len(),
4963                    1,
4964                    "Should receive one notification with the error message"
4965                );
4966                workspace.dismiss_notification(notifications.first().unwrap(), cx);
4967                assert!(workspace.notification_ids().is_empty());
4968            })
4969            .unwrap();
4970
4971        select_path(&panel, "root1", cx);
4972        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4973        panel.update(cx, |panel, cx| {
4974            assert!(panel.filename_editor.read(cx).is_focused(cx));
4975        });
4976        panel
4977            .update(cx, |panel, cx| {
4978                panel
4979                    .filename_editor
4980                    .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
4981                panel.confirm_edit(cx).unwrap()
4982            })
4983            .await
4984            .unwrap();
4985
4986        assert_eq!(
4987            visible_entries_as_strings(&panel, 0..13, cx),
4988            &["v root1", "      .dockerignore"],
4989            "Should not change the project panel after trying to create an excluded directory"
4990        );
4991        panel.update(cx, |panel, cx| {
4992            assert!(
4993                !panel.filename_editor.read(cx).is_focused(cx),
4994                "Should have closed the file name editor"
4995            );
4996        });
4997        workspace
4998            .update(cx, |workspace, cx| {
4999                let notifications = workspace.notification_ids();
5000                assert_eq!(
5001                    notifications.len(),
5002                    1,
5003                    "Should receive one notification explaining that no directory is actually shown"
5004                );
5005                workspace.dismiss_notification(notifications.first().unwrap(), cx);
5006                assert!(workspace.notification_ids().is_empty());
5007            })
5008            .unwrap();
5009        assert!(
5010            fs.is_dir(Path::new("/root1/excluded_dir")).await,
5011            "Should have created the excluded directory"
5012        );
5013    }
5014
5015    fn toggle_expand_dir(
5016        panel: &View<ProjectPanel>,
5017        path: impl AsRef<Path>,
5018        cx: &mut VisualTestContext,
5019    ) {
5020        let path = path.as_ref();
5021        panel.update(cx, |panel, cx| {
5022            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5023                let worktree = worktree.read(cx);
5024                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5025                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5026                    panel.toggle_expanded(entry_id, cx);
5027                    return;
5028                }
5029            }
5030            panic!("no worktree for path {:?}", path);
5031        });
5032    }
5033
5034    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
5035        let path = path.as_ref();
5036        panel.update(cx, |panel, cx| {
5037            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5038                let worktree = worktree.read(cx);
5039                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5040                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5041                    panel.selection = Some(crate::SelectedEntry {
5042                        worktree_id: worktree.id(),
5043                        entry_id,
5044                    });
5045                    return;
5046                }
5047            }
5048            panic!("no worktree for path {:?}", path);
5049        });
5050    }
5051
5052    fn find_project_entry(
5053        panel: &View<ProjectPanel>,
5054        path: impl AsRef<Path>,
5055        cx: &mut VisualTestContext,
5056    ) -> Option<ProjectEntryId> {
5057        let path = path.as_ref();
5058        panel.update(cx, |panel, cx| {
5059            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5060                let worktree = worktree.read(cx);
5061                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5062                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
5063                }
5064            }
5065            panic!("no worktree for path {path:?}");
5066        })
5067    }
5068
5069    fn visible_entries_as_strings(
5070        panel: &View<ProjectPanel>,
5071        range: Range<usize>,
5072        cx: &mut VisualTestContext,
5073    ) -> Vec<String> {
5074        let mut result = Vec::new();
5075        let mut project_entries = HashSet::default();
5076        let mut has_editor = false;
5077
5078        panel.update(cx, |panel, cx| {
5079            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
5080                if details.is_editing {
5081                    assert!(!has_editor, "duplicate editor entry");
5082                    has_editor = true;
5083                } else {
5084                    assert!(
5085                        project_entries.insert(project_entry),
5086                        "duplicate project entry {:?} {:?}",
5087                        project_entry,
5088                        details
5089                    );
5090                }
5091
5092                let indent = "    ".repeat(details.depth);
5093                let icon = if details.kind.is_dir() {
5094                    if details.is_expanded {
5095                        "v "
5096                    } else {
5097                        "> "
5098                    }
5099                } else {
5100                    "  "
5101                };
5102                let name = if details.is_editing {
5103                    format!("[EDITOR: '{}']", details.filename)
5104                } else if details.is_processing {
5105                    format!("[PROCESSING: '{}']", details.filename)
5106                } else {
5107                    details.filename.clone()
5108                };
5109                let selected = if details.is_selected {
5110                    "  <== selected"
5111                } else {
5112                    ""
5113                };
5114                let marked = if details.is_marked {
5115                    "  <== marked"
5116                } else {
5117                    ""
5118                };
5119
5120                result.push(format!("{indent}{icon}{name}{selected}{marked}"));
5121            });
5122        });
5123
5124        result
5125    }
5126
5127    fn init_test(cx: &mut TestAppContext) {
5128        cx.update(|cx| {
5129            let settings_store = SettingsStore::test(cx);
5130            cx.set_global(settings_store);
5131            init_settings(cx);
5132            theme::init(theme::LoadThemes::JustBase, cx);
5133            language::init(cx);
5134            editor::init_settings(cx);
5135            crate::init((), cx);
5136            workspace::init_settings(cx);
5137            client::init_settings(cx);
5138            Project::init_settings(cx);
5139
5140            cx.update_global::<SettingsStore, _>(|store, cx| {
5141                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5142                    project_panel_settings.auto_fold_dirs = Some(false);
5143                });
5144                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5145                    worktree_settings.file_scan_exclusions = Some(Vec::new());
5146                });
5147            });
5148        });
5149    }
5150
5151    fn init_test_with_editor(cx: &mut TestAppContext) {
5152        cx.update(|cx| {
5153            let app_state = AppState::test(cx);
5154            theme::init(theme::LoadThemes::JustBase, cx);
5155            init_settings(cx);
5156            language::init(cx);
5157            editor::init(cx);
5158            crate::init((), cx);
5159            workspace::init(app_state.clone(), cx);
5160            Project::init_settings(cx);
5161
5162            cx.update_global::<SettingsStore, _>(|store, cx| {
5163                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5164                    project_panel_settings.auto_fold_dirs = Some(false);
5165                });
5166                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5167                    worktree_settings.file_scan_exclusions = Some(Vec::new());
5168                });
5169            });
5170        });
5171    }
5172
5173    fn ensure_single_file_is_opened(
5174        window: &WindowHandle<Workspace>,
5175        expected_path: &str,
5176        cx: &mut TestAppContext,
5177    ) {
5178        window
5179            .update(cx, |workspace, cx| {
5180                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
5181                assert_eq!(worktrees.len(), 1);
5182                let worktree_id = worktrees[0].read(cx).id();
5183
5184                let open_project_paths = workspace
5185                    .panes()
5186                    .iter()
5187                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5188                    .collect::<Vec<_>>();
5189                assert_eq!(
5190                    open_project_paths,
5191                    vec![ProjectPath {
5192                        worktree_id,
5193                        path: Arc::from(Path::new(expected_path))
5194                    }],
5195                    "Should have opened file, selected in project panel"
5196                );
5197            })
5198            .unwrap();
5199    }
5200
5201    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
5202        assert!(
5203            !cx.has_pending_prompt(),
5204            "Should have no prompts before the deletion"
5205        );
5206        panel.update(cx, |panel, cx| {
5207            panel.delete(&Delete { skip_prompt: false }, cx)
5208        });
5209        assert!(
5210            cx.has_pending_prompt(),
5211            "Should have a prompt after the deletion"
5212        );
5213        cx.simulate_prompt_answer(0);
5214        assert!(
5215            !cx.has_pending_prompt(),
5216            "Should have no prompts after prompt was replied to"
5217        );
5218        cx.executor().run_until_parked();
5219    }
5220
5221    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
5222        assert!(
5223            !cx.has_pending_prompt(),
5224            "Should have no prompts before the deletion"
5225        );
5226        panel.update(cx, |panel, cx| {
5227            panel.delete(&Delete { skip_prompt: true }, cx)
5228        });
5229        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
5230        cx.executor().run_until_parked();
5231    }
5232
5233    fn ensure_no_open_items_and_panes(
5234        workspace: &WindowHandle<Workspace>,
5235        cx: &mut VisualTestContext,
5236    ) {
5237        assert!(
5238            !cx.has_pending_prompt(),
5239            "Should have no prompts after deletion operation closes the file"
5240        );
5241        workspace
5242            .read_with(cx, |workspace, cx| {
5243                let open_project_paths = workspace
5244                    .panes()
5245                    .iter()
5246                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5247                    .collect::<Vec<_>>();
5248                assert!(
5249                    open_project_paths.is_empty(),
5250                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
5251                );
5252            })
5253            .unwrap();
5254    }
5255
5256    struct TestProjectItemView {
5257        focus_handle: FocusHandle,
5258        path: ProjectPath,
5259    }
5260
5261    struct TestProjectItem {
5262        path: ProjectPath,
5263    }
5264
5265    impl project::Item for TestProjectItem {
5266        fn try_open(
5267            _project: &Model<Project>,
5268            path: &ProjectPath,
5269            cx: &mut AppContext,
5270        ) -> Option<Task<gpui::Result<Model<Self>>>> {
5271            let path = path.clone();
5272            Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
5273        }
5274
5275        fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
5276            None
5277        }
5278
5279        fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
5280            Some(self.path.clone())
5281        }
5282    }
5283
5284    impl ProjectItem for TestProjectItemView {
5285        type Item = TestProjectItem;
5286
5287        fn for_project_item(
5288            _: Model<Project>,
5289            project_item: Model<Self::Item>,
5290            cx: &mut ViewContext<Self>,
5291        ) -> Self
5292        where
5293            Self: Sized,
5294        {
5295            Self {
5296                path: project_item.update(cx, |project_item, _| project_item.path.clone()),
5297                focus_handle: cx.focus_handle(),
5298            }
5299        }
5300    }
5301
5302    impl Item for TestProjectItemView {
5303        type Event = ();
5304    }
5305
5306    impl EventEmitter<()> for TestProjectItemView {}
5307
5308    impl FocusableView for TestProjectItemView {
5309        fn focus_handle(&self, _: &AppContext) -> FocusHandle {
5310            self.focus_handle.clone()
5311        }
5312    }
5313
5314    impl Render for TestProjectItemView {
5315        fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
5316            Empty
5317        }
5318    }
5319}