project_panel.rs

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