project_panel.rs

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