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