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