project_panel.rs

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