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