project_panel.rs

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