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