project_panel.rs

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