project_panel.rs

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