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