project_panel.rs

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