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: View>(
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::<Self, _>::new(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_id())
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_id())
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_id())
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::<ProjectPanel, _>::new(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::<Self, _>::new(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: f32, cx: &mut ViewContext<Self>) {
1655        self.width = Some(size);
1656        self.serialize(cx);
1657        cx.notify();
1658    }
1659
1660    fn icon_path(&self) -> &'static str {
1661        "icons/folder_tree_16.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::{TestAppContext, ViewHandle};
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.add_window(|cx| Workspace::test_new(project.clone(), cx));
1760        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1761        assert_eq!(
1762            visible_entries_as_strings(&panel, 0..50, cx),
1763            &[
1764                "v root1",
1765                "    > .git",
1766                "    > a",
1767                "    > b",
1768                "    > C",
1769                "      .dockerignore",
1770                "v root2",
1771                "    > d",
1772                "    > e",
1773            ]
1774        );
1775
1776        toggle_expand_dir(&panel, "root1/b", cx);
1777        assert_eq!(
1778            visible_entries_as_strings(&panel, 0..50, cx),
1779            &[
1780                "v root1",
1781                "    > .git",
1782                "    > a",
1783                "    v b  <== selected",
1784                "        > 3",
1785                "        > 4",
1786                "    > C",
1787                "      .dockerignore",
1788                "v root2",
1789                "    > d",
1790                "    > e",
1791            ]
1792        );
1793
1794        assert_eq!(
1795            visible_entries_as_strings(&panel, 6..9, cx),
1796            &[
1797                //
1798                "    > C",
1799                "      .dockerignore",
1800                "v root2",
1801            ]
1802        );
1803    }
1804
1805    #[gpui::test(iterations = 30)]
1806    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1807        init_test(cx);
1808
1809        let fs = FakeFs::new(cx.background());
1810        fs.insert_tree(
1811            "/root1",
1812            json!({
1813                ".dockerignore": "",
1814                ".git": {
1815                    "HEAD": "",
1816                },
1817                "a": {
1818                    "0": { "q": "", "r": "", "s": "" },
1819                    "1": { "t": "", "u": "" },
1820                    "2": { "v": "", "w": "", "x": "", "y": "" },
1821                },
1822                "b": {
1823                    "3": { "Q": "" },
1824                    "4": { "R": "", "S": "", "T": "", "U": "" },
1825                },
1826                "C": {
1827                    "5": {},
1828                    "6": { "V": "", "W": "" },
1829                    "7": { "X": "" },
1830                    "8": { "Y": {}, "Z": "" }
1831                }
1832            }),
1833        )
1834        .await;
1835        fs.insert_tree(
1836            "/root2",
1837            json!({
1838                "d": {
1839                    "9": ""
1840                },
1841                "e": {}
1842            }),
1843        )
1844        .await;
1845
1846        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1847        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1848        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1849
1850        select_path(&panel, "root1", cx);
1851        assert_eq!(
1852            visible_entries_as_strings(&panel, 0..10, cx),
1853            &[
1854                "v root1  <== selected",
1855                "    > .git",
1856                "    > a",
1857                "    > b",
1858                "    > C",
1859                "      .dockerignore",
1860                "v root2",
1861                "    > d",
1862                "    > e",
1863            ]
1864        );
1865
1866        // Add a file with the root folder selected. The filename editor is placed
1867        // before the first file in the root folder.
1868        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1869        cx.read_window(window_id, |cx| {
1870            let panel = panel.read(cx);
1871            assert!(panel.filename_editor.is_focused(cx));
1872        });
1873        assert_eq!(
1874            visible_entries_as_strings(&panel, 0..10, cx),
1875            &[
1876                "v root1",
1877                "    > .git",
1878                "    > a",
1879                "    > b",
1880                "    > C",
1881                "      [EDITOR: '']  <== selected",
1882                "      .dockerignore",
1883                "v root2",
1884                "    > d",
1885                "    > e",
1886            ]
1887        );
1888
1889        let confirm = panel.update(cx, |panel, cx| {
1890            panel
1891                .filename_editor
1892                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1893            panel.confirm(&Confirm, cx).unwrap()
1894        });
1895        assert_eq!(
1896            visible_entries_as_strings(&panel, 0..10, cx),
1897            &[
1898                "v root1",
1899                "    > .git",
1900                "    > a",
1901                "    > b",
1902                "    > C",
1903                "      [PROCESSING: 'the-new-filename']  <== selected",
1904                "      .dockerignore",
1905                "v root2",
1906                "    > d",
1907                "    > e",
1908            ]
1909        );
1910
1911        confirm.await.unwrap();
1912        assert_eq!(
1913            visible_entries_as_strings(&panel, 0..10, cx),
1914            &[
1915                "v root1",
1916                "    > .git",
1917                "    > a",
1918                "    > b",
1919                "    > C",
1920                "      .dockerignore",
1921                "      the-new-filename  <== selected",
1922                "v root2",
1923                "    > d",
1924                "    > e",
1925            ]
1926        );
1927
1928        select_path(&panel, "root1/b", cx);
1929        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1930        assert_eq!(
1931            visible_entries_as_strings(&panel, 0..10, cx),
1932            &[
1933                "v root1",
1934                "    > .git",
1935                "    > a",
1936                "    v b",
1937                "        > 3",
1938                "        > 4",
1939                "          [EDITOR: '']  <== selected",
1940                "    > C",
1941                "      .dockerignore",
1942                "      the-new-filename",
1943            ]
1944        );
1945
1946        panel
1947            .update(cx, |panel, cx| {
1948                panel
1949                    .filename_editor
1950                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
1951                panel.confirm(&Confirm, cx).unwrap()
1952            })
1953            .await
1954            .unwrap();
1955        assert_eq!(
1956            visible_entries_as_strings(&panel, 0..10, cx),
1957            &[
1958                "v root1",
1959                "    > .git",
1960                "    > a",
1961                "    v b",
1962                "        > 3",
1963                "        > 4",
1964                "          another-filename.txt  <== selected",
1965                "    > C",
1966                "      .dockerignore",
1967                "      the-new-filename",
1968            ]
1969        );
1970
1971        select_path(&panel, "root1/b/another-filename.txt", cx);
1972        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1973        assert_eq!(
1974            visible_entries_as_strings(&panel, 0..10, cx),
1975            &[
1976                "v root1",
1977                "    > .git",
1978                "    > a",
1979                "    v b",
1980                "        > 3",
1981                "        > 4",
1982                "          [EDITOR: 'another-filename.txt']  <== selected",
1983                "    > C",
1984                "      .dockerignore",
1985                "      the-new-filename",
1986            ]
1987        );
1988
1989        let confirm = panel.update(cx, |panel, cx| {
1990            panel.filename_editor.update(cx, |editor, cx| {
1991                let file_name_selections = editor.selections.all::<usize>(cx);
1992                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
1993                let file_name_selection = &file_name_selections[0];
1994                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
1995                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
1996
1997                editor.set_text("a-different-filename.tar.gz", cx)
1998            });
1999            panel.confirm(&Confirm, cx).unwrap()
2000        });
2001        assert_eq!(
2002            visible_entries_as_strings(&panel, 0..10, cx),
2003            &[
2004                "v root1",
2005                "    > .git",
2006                "    > a",
2007                "    v b",
2008                "        > 3",
2009                "        > 4",
2010                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2011                "    > C",
2012                "      .dockerignore",
2013                "      the-new-filename",
2014            ]
2015        );
2016
2017        confirm.await.unwrap();
2018        assert_eq!(
2019            visible_entries_as_strings(&panel, 0..10, cx),
2020            &[
2021                "v root1",
2022                "    > .git",
2023                "    > a",
2024                "    v b",
2025                "        > 3",
2026                "        > 4",
2027                "          a-different-filename.tar.gz  <== selected",
2028                "    > C",
2029                "      .dockerignore",
2030                "      the-new-filename",
2031            ]
2032        );
2033
2034        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2035        assert_eq!(
2036            visible_entries_as_strings(&panel, 0..10, cx),
2037            &[
2038                "v root1",
2039                "    > .git",
2040                "    > a",
2041                "    v b",
2042                "        > 3",
2043                "        > 4",
2044                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2045                "    > C",
2046                "      .dockerignore",
2047                "      the-new-filename",
2048            ]
2049        );
2050
2051        panel.update(cx, |panel, cx| {
2052            panel.filename_editor.update(cx, |editor, cx| {
2053                let file_name_selections = editor.selections.all::<usize>(cx);
2054                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2055                let file_name_selection = &file_name_selections[0];
2056                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2057                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");
2058
2059            });
2060            panel.cancel(&Cancel, cx)
2061        });
2062
2063        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2064        assert_eq!(
2065            visible_entries_as_strings(&panel, 0..10, cx),
2066            &[
2067                "v root1",
2068                "    > .git",
2069                "    > a",
2070                "    v b",
2071                "        > [EDITOR: '']  <== selected",
2072                "        > 3",
2073                "        > 4",
2074                "          a-different-filename.tar.gz",
2075                "    > C",
2076                "      .dockerignore",
2077            ]
2078        );
2079
2080        let confirm = panel.update(cx, |panel, cx| {
2081            panel
2082                .filename_editor
2083                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2084            panel.confirm(&Confirm, cx).unwrap()
2085        });
2086        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2087        assert_eq!(
2088            visible_entries_as_strings(&panel, 0..10, cx),
2089            &[
2090                "v root1",
2091                "    > .git",
2092                "    > a",
2093                "    v b",
2094                "        > [PROCESSING: 'new-dir']",
2095                "        > 3  <== selected",
2096                "        > 4",
2097                "          a-different-filename.tar.gz",
2098                "    > C",
2099                "      .dockerignore",
2100            ]
2101        );
2102
2103        confirm.await.unwrap();
2104        assert_eq!(
2105            visible_entries_as_strings(&panel, 0..10, cx),
2106            &[
2107                "v root1",
2108                "    > .git",
2109                "    > a",
2110                "    v b",
2111                "        > 3  <== selected",
2112                "        > 4",
2113                "        > new-dir",
2114                "          a-different-filename.tar.gz",
2115                "    > C",
2116                "      .dockerignore",
2117            ]
2118        );
2119
2120        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2121        assert_eq!(
2122            visible_entries_as_strings(&panel, 0..10, cx),
2123            &[
2124                "v root1",
2125                "    > .git",
2126                "    > a",
2127                "    v b",
2128                "        > [EDITOR: '3']  <== selected",
2129                "        > 4",
2130                "        > new-dir",
2131                "          a-different-filename.tar.gz",
2132                "    > C",
2133                "      .dockerignore",
2134            ]
2135        );
2136
2137        // Dismiss the rename editor when it loses focus.
2138        workspace.update(cx, |_, cx| cx.focus_self());
2139        assert_eq!(
2140            visible_entries_as_strings(&panel, 0..10, cx),
2141            &[
2142                "v root1",
2143                "    > .git",
2144                "    > a",
2145                "    v b",
2146                "        > 3  <== selected",
2147                "        > 4",
2148                "        > new-dir",
2149                "          a-different-filename.tar.gz",
2150                "    > C",
2151                "      .dockerignore",
2152            ]
2153        );
2154    }
2155
2156    #[gpui::test(iterations = 30)]
2157    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2158        init_test(cx);
2159
2160        let fs = FakeFs::new(cx.background());
2161        fs.insert_tree(
2162            "/root1",
2163            json!({
2164                ".dockerignore": "",
2165                ".git": {
2166                    "HEAD": "",
2167                },
2168                "a": {
2169                    "0": { "q": "", "r": "", "s": "" },
2170                    "1": { "t": "", "u": "" },
2171                    "2": { "v": "", "w": "", "x": "", "y": "" },
2172                },
2173                "b": {
2174                    "3": { "Q": "" },
2175                    "4": { "R": "", "S": "", "T": "", "U": "" },
2176                },
2177                "C": {
2178                    "5": {},
2179                    "6": { "V": "", "W": "" },
2180                    "7": { "X": "" },
2181                    "8": { "Y": {}, "Z": "" }
2182                }
2183            }),
2184        )
2185        .await;
2186        fs.insert_tree(
2187            "/root2",
2188            json!({
2189                "d": {
2190                    "9": ""
2191                },
2192                "e": {}
2193            }),
2194        )
2195        .await;
2196
2197        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2198        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2199        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2200
2201        select_path(&panel, "root1", cx);
2202        assert_eq!(
2203            visible_entries_as_strings(&panel, 0..10, cx),
2204            &[
2205                "v root1  <== selected",
2206                "    > .git",
2207                "    > a",
2208                "    > b",
2209                "    > C",
2210                "      .dockerignore",
2211                "v root2",
2212                "    > d",
2213                "    > e",
2214            ]
2215        );
2216
2217        // Add a file with the root folder selected. The filename editor is placed
2218        // before the first file in the root folder.
2219        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2220        cx.read_window(window_id, |cx| {
2221            let panel = panel.read(cx);
2222            assert!(panel.filename_editor.is_focused(cx));
2223        });
2224        assert_eq!(
2225            visible_entries_as_strings(&panel, 0..10, cx),
2226            &[
2227                "v root1",
2228                "    > .git",
2229                "    > a",
2230                "    > b",
2231                "    > C",
2232                "      [EDITOR: '']  <== selected",
2233                "      .dockerignore",
2234                "v root2",
2235                "    > d",
2236                "    > e",
2237            ]
2238        );
2239
2240        let confirm = panel.update(cx, |panel, cx| {
2241            panel.filename_editor.update(cx, |editor, cx| {
2242                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2243            });
2244            panel.confirm(&Confirm, cx).unwrap()
2245        });
2246
2247        assert_eq!(
2248            visible_entries_as_strings(&panel, 0..10, cx),
2249            &[
2250                "v root1",
2251                "    > .git",
2252                "    > a",
2253                "    > b",
2254                "    > C",
2255                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2256                "      .dockerignore",
2257                "v root2",
2258                "    > d",
2259                "    > e",
2260            ]
2261        );
2262
2263        confirm.await.unwrap();
2264        assert_eq!(
2265            visible_entries_as_strings(&panel, 0..13, cx),
2266            &[
2267                "v root1",
2268                "    > .git",
2269                "    > a",
2270                "    > b",
2271                "    v bdir1",
2272                "        v dir2",
2273                "              the-new-filename  <== selected",
2274                "    > C",
2275                "      .dockerignore",
2276                "v root2",
2277                "    > d",
2278                "    > e",
2279            ]
2280        );
2281    }
2282
2283    #[gpui::test]
2284    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2285        init_test(cx);
2286
2287        let fs = FakeFs::new(cx.background());
2288        fs.insert_tree(
2289            "/root1",
2290            json!({
2291                "one.two.txt": "",
2292                "one.txt": ""
2293            }),
2294        )
2295        .await;
2296
2297        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2298        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2299        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2300
2301        panel.update(cx, |panel, cx| {
2302            panel.select_next(&Default::default(), cx);
2303            panel.select_next(&Default::default(), cx);
2304        });
2305
2306        assert_eq!(
2307            visible_entries_as_strings(&panel, 0..50, cx),
2308            &[
2309                //
2310                "v root1",
2311                "      one.two.txt  <== selected",
2312                "      one.txt",
2313            ]
2314        );
2315
2316        // Regression test - file name is created correctly when
2317        // the copied file's name contains multiple dots.
2318        panel.update(cx, |panel, cx| {
2319            panel.copy(&Default::default(), cx);
2320            panel.paste(&Default::default(), cx);
2321        });
2322        cx.foreground().run_until_parked();
2323
2324        assert_eq!(
2325            visible_entries_as_strings(&panel, 0..50, cx),
2326            &[
2327                //
2328                "v root1",
2329                "      one.two copy.txt",
2330                "      one.two.txt  <== selected",
2331                "      one.txt",
2332            ]
2333        );
2334
2335        panel.update(cx, |panel, cx| {
2336            panel.paste(&Default::default(), cx);
2337        });
2338        cx.foreground().run_until_parked();
2339
2340        assert_eq!(
2341            visible_entries_as_strings(&panel, 0..50, cx),
2342            &[
2343                //
2344                "v root1",
2345                "      one.two copy 1.txt",
2346                "      one.two copy.txt",
2347                "      one.two.txt  <== selected",
2348                "      one.txt",
2349            ]
2350        );
2351    }
2352
2353    #[gpui::test]
2354    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2355        init_test_with_editor(cx);
2356
2357        let fs = FakeFs::new(cx.background());
2358        fs.insert_tree(
2359            "/src",
2360            json!({
2361                "test": {
2362                    "first.rs": "// First Rust file",
2363                    "second.rs": "// Second Rust file",
2364                    "third.rs": "// Third Rust file",
2365                }
2366            }),
2367        )
2368        .await;
2369
2370        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2371        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2372        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2373
2374        toggle_expand_dir(&panel, "src/test", cx);
2375        select_path(&panel, "src/test/first.rs", cx);
2376        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2377        cx.foreground().run_until_parked();
2378        assert_eq!(
2379            visible_entries_as_strings(&panel, 0..10, cx),
2380            &[
2381                "v src",
2382                "    v test",
2383                "          first.rs  <== selected",
2384                "          second.rs",
2385                "          third.rs"
2386            ]
2387        );
2388        ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
2389
2390        submit_deletion(window_id, &panel, cx);
2391        assert_eq!(
2392            visible_entries_as_strings(&panel, 0..10, cx),
2393            &[
2394                "v src",
2395                "    v test",
2396                "          second.rs",
2397                "          third.rs"
2398            ],
2399            "Project panel should have no deleted file, no other file is selected in it"
2400        );
2401        ensure_no_open_items_and_panes(window_id, &workspace, cx);
2402
2403        select_path(&panel, "src/test/second.rs", cx);
2404        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2405        cx.foreground().run_until_parked();
2406        assert_eq!(
2407            visible_entries_as_strings(&panel, 0..10, cx),
2408            &[
2409                "v src",
2410                "    v test",
2411                "          second.rs  <== selected",
2412                "          third.rs"
2413            ]
2414        );
2415        ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
2416
2417        cx.update_window(window_id, |cx| {
2418            let active_items = workspace
2419                .read(cx)
2420                .panes()
2421                .iter()
2422                .filter_map(|pane| pane.read(cx).active_item())
2423                .collect::<Vec<_>>();
2424            assert_eq!(active_items.len(), 1);
2425            let open_editor = active_items
2426                .into_iter()
2427                .next()
2428                .unwrap()
2429                .downcast::<Editor>()
2430                .expect("Open item should be an editor");
2431            open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2432        });
2433        submit_deletion(window_id, &panel, cx);
2434        assert_eq!(
2435            visible_entries_as_strings(&panel, 0..10, cx),
2436            &["v src", "    v test", "          third.rs"],
2437            "Project panel should have no deleted file, with one last file remaining"
2438        );
2439        ensure_no_open_items_and_panes(window_id, &workspace, cx);
2440    }
2441
2442    #[gpui::test]
2443    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2444        init_test_with_editor(cx);
2445
2446        let fs = FakeFs::new(cx.background());
2447        fs.insert_tree(
2448            "/src",
2449            json!({
2450                "test": {
2451                    "first.rs": "// First Rust file",
2452                    "second.rs": "// Second Rust file",
2453                    "third.rs": "// Third Rust file",
2454                }
2455            }),
2456        )
2457        .await;
2458
2459        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2460        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2461        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2462
2463        select_path(&panel, "src/", cx);
2464        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2465        cx.foreground().run_until_parked();
2466        assert_eq!(
2467            visible_entries_as_strings(&panel, 0..10, cx),
2468            &["v src  <== selected", "    > test"]
2469        );
2470        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2471        cx.read_window(window_id, |cx| {
2472            let panel = panel.read(cx);
2473            assert!(panel.filename_editor.is_focused(cx));
2474        });
2475        assert_eq!(
2476            visible_entries_as_strings(&panel, 0..10, cx),
2477            &["v src", "    > [EDITOR: '']  <== selected", "    > test"]
2478        );
2479        panel.update(cx, |panel, cx| {
2480            panel
2481                .filename_editor
2482                .update(cx, |editor, cx| editor.set_text("test", cx));
2483            assert!(
2484                panel.confirm(&Confirm, cx).is_none(),
2485                "Should not allow to confirm on conflicting new directory name"
2486            )
2487        });
2488        assert_eq!(
2489            visible_entries_as_strings(&panel, 0..10, cx),
2490            &["v src", "    > test"],
2491            "File list should be unchanged after failed folder create confirmation"
2492        );
2493
2494        select_path(&panel, "src/test/", cx);
2495        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2496        cx.foreground().run_until_parked();
2497        assert_eq!(
2498            visible_entries_as_strings(&panel, 0..10, cx),
2499            &["v src", "    > test  <== selected"]
2500        );
2501        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2502        cx.read_window(window_id, |cx| {
2503            let panel = panel.read(cx);
2504            assert!(panel.filename_editor.is_focused(cx));
2505        });
2506        assert_eq!(
2507            visible_entries_as_strings(&panel, 0..10, cx),
2508            &[
2509                "v src",
2510                "    v test",
2511                "          [EDITOR: '']  <== selected",
2512                "          first.rs",
2513                "          second.rs",
2514                "          third.rs"
2515            ]
2516        );
2517        panel.update(cx, |panel, cx| {
2518            panel
2519                .filename_editor
2520                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2521            assert!(
2522                panel.confirm(&Confirm, cx).is_none(),
2523                "Should not allow to confirm on conflicting new file name"
2524            )
2525        });
2526        assert_eq!(
2527            visible_entries_as_strings(&panel, 0..10, cx),
2528            &[
2529                "v src",
2530                "    v test",
2531                "          first.rs",
2532                "          second.rs",
2533                "          third.rs"
2534            ],
2535            "File list should be unchanged after failed file create confirmation"
2536        );
2537
2538        select_path(&panel, "src/test/first.rs", cx);
2539        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2540        cx.foreground().run_until_parked();
2541        assert_eq!(
2542            visible_entries_as_strings(&panel, 0..10, cx),
2543            &[
2544                "v src",
2545                "    v test",
2546                "          first.rs  <== selected",
2547                "          second.rs",
2548                "          third.rs"
2549            ],
2550        );
2551        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2552        cx.read_window(window_id, |cx| {
2553            let panel = panel.read(cx);
2554            assert!(panel.filename_editor.is_focused(cx));
2555        });
2556        assert_eq!(
2557            visible_entries_as_strings(&panel, 0..10, cx),
2558            &[
2559                "v src",
2560                "    v test",
2561                "          [EDITOR: 'first.rs']  <== selected",
2562                "          second.rs",
2563                "          third.rs"
2564            ]
2565        );
2566        panel.update(cx, |panel, cx| {
2567            panel
2568                .filename_editor
2569                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2570            assert!(
2571                panel.confirm(&Confirm, cx).is_none(),
2572                "Should not allow to confirm on conflicting file rename"
2573            )
2574        });
2575        assert_eq!(
2576            visible_entries_as_strings(&panel, 0..10, cx),
2577            &[
2578                "v src",
2579                "    v test",
2580                "          first.rs  <== selected",
2581                "          second.rs",
2582                "          third.rs"
2583            ],
2584            "File list should be unchanged after failed rename confirmation"
2585        );
2586    }
2587
2588    #[gpui::test]
2589    async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2590        init_test_with_editor(cx);
2591
2592        let fs = FakeFs::new(cx.background());
2593        fs.insert_tree(
2594            "/src",
2595            json!({
2596                "test": {
2597                    "first.rs": "// First Rust file",
2598                    "second.rs": "// Second Rust file",
2599                    "third.rs": "// Third Rust file",
2600                }
2601            }),
2602        )
2603        .await;
2604
2605        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2606        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2607        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2608
2609        let new_search_events_count = Arc::new(AtomicUsize::new(0));
2610        let _subscription = panel.update(cx, |_, cx| {
2611            let subcription_count = Arc::clone(&new_search_events_count);
2612            cx.subscribe(&cx.handle(), move |_, _, event, _| {
2613                if matches!(event, Event::NewSearchInDirectory { .. }) {
2614                    subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2615                }
2616            })
2617        });
2618
2619        toggle_expand_dir(&panel, "src/test", cx);
2620        select_path(&panel, "src/test/first.rs", cx);
2621        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2622        cx.foreground().run_until_parked();
2623        assert_eq!(
2624            visible_entries_as_strings(&panel, 0..10, cx),
2625            &[
2626                "v src",
2627                "    v test",
2628                "          first.rs  <== selected",
2629                "          second.rs",
2630                "          third.rs"
2631            ]
2632        );
2633        panel.update(cx, |panel, cx| {
2634            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2635        });
2636        assert_eq!(
2637            new_search_events_count.load(atomic::Ordering::SeqCst),
2638            0,
2639            "Should not trigger new search in directory when called on a file"
2640        );
2641
2642        select_path(&panel, "src/test", cx);
2643        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2644        cx.foreground().run_until_parked();
2645        assert_eq!(
2646            visible_entries_as_strings(&panel, 0..10, cx),
2647            &[
2648                "v src",
2649                "    v test  <== selected",
2650                "          first.rs",
2651                "          second.rs",
2652                "          third.rs"
2653            ]
2654        );
2655        panel.update(cx, |panel, cx| {
2656            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2657        });
2658        assert_eq!(
2659            new_search_events_count.load(atomic::Ordering::SeqCst),
2660            1,
2661            "Should trigger new search in directory when called on a directory"
2662        );
2663    }
2664
2665    #[gpui::test]
2666    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2667        init_test_with_editor(cx);
2668
2669        let fs = FakeFs::new(cx.background());
2670        fs.insert_tree(
2671            "/project_root",
2672            json!({
2673                "dir_1": {
2674                    "nested_dir": {
2675                        "file_a.py": "# File contents",
2676                        "file_b.py": "# File contents",
2677                        "file_c.py": "# File contents",
2678                    },
2679                    "file_1.py": "# File contents",
2680                    "file_2.py": "# File contents",
2681                    "file_3.py": "# File contents",
2682                },
2683                "dir_2": {
2684                    "file_1.py": "# File contents",
2685                    "file_2.py": "# File contents",
2686                    "file_3.py": "# File contents",
2687                }
2688            }),
2689        )
2690        .await;
2691
2692        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2693        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2694        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2695
2696        panel.update(cx, |panel, cx| {
2697            panel.collapse_all_entries(&CollapseAllEntries, cx)
2698        });
2699        cx.foreground().run_until_parked();
2700        assert_eq!(
2701            visible_entries_as_strings(&panel, 0..10, cx),
2702            &["v project_root", "    > dir_1", "    > dir_2",]
2703        );
2704
2705        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2706        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2707        cx.foreground().run_until_parked();
2708        assert_eq!(
2709            visible_entries_as_strings(&panel, 0..10, cx),
2710            &[
2711                "v project_root",
2712                "    v dir_1  <== selected",
2713                "        > nested_dir",
2714                "          file_1.py",
2715                "          file_2.py",
2716                "          file_3.py",
2717                "    > dir_2",
2718            ]
2719        );
2720    }
2721
2722    fn toggle_expand_dir(
2723        panel: &ViewHandle<ProjectPanel>,
2724        path: impl AsRef<Path>,
2725        cx: &mut TestAppContext,
2726    ) {
2727        let path = path.as_ref();
2728        panel.update(cx, |panel, cx| {
2729            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2730                let worktree = worktree.read(cx);
2731                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2732                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2733                    panel.toggle_expanded(entry_id, cx);
2734                    return;
2735                }
2736            }
2737            panic!("no worktree for path {:?}", path);
2738        });
2739    }
2740
2741    fn select_path(
2742        panel: &ViewHandle<ProjectPanel>,
2743        path: impl AsRef<Path>,
2744        cx: &mut TestAppContext,
2745    ) {
2746        let path = path.as_ref();
2747        panel.update(cx, |panel, cx| {
2748            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2749                let worktree = worktree.read(cx);
2750                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2751                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2752                    panel.selection = Some(Selection {
2753                        worktree_id: worktree.id(),
2754                        entry_id,
2755                    });
2756                    return;
2757                }
2758            }
2759            panic!("no worktree for path {:?}", path);
2760        });
2761    }
2762
2763    fn visible_entries_as_strings(
2764        panel: &ViewHandle<ProjectPanel>,
2765        range: Range<usize>,
2766        cx: &mut TestAppContext,
2767    ) -> Vec<String> {
2768        let mut result = Vec::new();
2769        let mut project_entries = HashSet::new();
2770        let mut has_editor = false;
2771
2772        panel.update(cx, |panel, cx| {
2773            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2774                if details.is_editing {
2775                    assert!(!has_editor, "duplicate editor entry");
2776                    has_editor = true;
2777                } else {
2778                    assert!(
2779                        project_entries.insert(project_entry),
2780                        "duplicate project entry {:?} {:?}",
2781                        project_entry,
2782                        details
2783                    );
2784                }
2785
2786                let indent = "    ".repeat(details.depth);
2787                let icon = if details.kind.is_dir() {
2788                    if details.is_expanded {
2789                        "v "
2790                    } else {
2791                        "> "
2792                    }
2793                } else {
2794                    "  "
2795                };
2796                let name = if details.is_editing {
2797                    format!("[EDITOR: '{}']", details.filename)
2798                } else if details.is_processing {
2799                    format!("[PROCESSING: '{}']", details.filename)
2800                } else {
2801                    details.filename.clone()
2802                };
2803                let selected = if details.is_selected {
2804                    "  <== selected"
2805                } else {
2806                    ""
2807                };
2808                result.push(format!("{indent}{icon}{name}{selected}"));
2809            });
2810        });
2811
2812        result
2813    }
2814
2815    fn init_test(cx: &mut TestAppContext) {
2816        cx.foreground().forbid_parking();
2817        cx.update(|cx| {
2818            cx.set_global(SettingsStore::test(cx));
2819            init_settings(cx);
2820            theme::init((), cx);
2821            language::init(cx);
2822            editor::init_settings(cx);
2823            crate::init((), cx);
2824            workspace::init_settings(cx);
2825            Project::init_settings(cx);
2826        });
2827    }
2828
2829    fn init_test_with_editor(cx: &mut TestAppContext) {
2830        cx.foreground().forbid_parking();
2831        cx.update(|cx| {
2832            let app_state = AppState::test(cx);
2833            theme::init((), cx);
2834            init_settings(cx);
2835            language::init(cx);
2836            editor::init(cx);
2837            pane::init(cx);
2838            crate::init((), cx);
2839            workspace::init(app_state.clone(), cx);
2840            Project::init_settings(cx);
2841        });
2842    }
2843
2844    fn ensure_single_file_is_opened(
2845        window_id: usize,
2846        workspace: &ViewHandle<Workspace>,
2847        expected_path: &str,
2848        cx: &mut TestAppContext,
2849    ) {
2850        cx.read_window(window_id, |cx| {
2851            let workspace = workspace.read(cx);
2852            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2853            assert_eq!(worktrees.len(), 1);
2854            let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2855
2856            let open_project_paths = workspace
2857                .panes()
2858                .iter()
2859                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2860                .collect::<Vec<_>>();
2861            assert_eq!(
2862                open_project_paths,
2863                vec![ProjectPath {
2864                    worktree_id,
2865                    path: Arc::from(Path::new(expected_path))
2866                }],
2867                "Should have opened file, selected in project panel"
2868            );
2869        });
2870    }
2871
2872    fn submit_deletion(
2873        window_id: usize,
2874        panel: &ViewHandle<ProjectPanel>,
2875        cx: &mut TestAppContext,
2876    ) {
2877        assert!(
2878            !cx.has_pending_prompt(window_id),
2879            "Should have no prompts before the deletion"
2880        );
2881        panel.update(cx, |panel, cx| {
2882            panel
2883                .delete(&Delete, cx)
2884                .expect("Deletion start")
2885                .detach_and_log_err(cx);
2886        });
2887        assert!(
2888            cx.has_pending_prompt(window_id),
2889            "Should have a prompt after the deletion"
2890        );
2891        cx.simulate_prompt_answer(window_id, 0);
2892        assert!(
2893            !cx.has_pending_prompt(window_id),
2894            "Should have no prompts after prompt was replied to"
2895        );
2896        cx.foreground().run_until_parked();
2897    }
2898
2899    fn ensure_no_open_items_and_panes(
2900        window_id: usize,
2901        workspace: &ViewHandle<Workspace>,
2902        cx: &mut TestAppContext,
2903    ) {
2904        assert!(
2905            !cx.has_pending_prompt(window_id),
2906            "Should have no prompts after deletion operation closes the file"
2907        );
2908        cx.read_window(window_id, |cx| {
2909            let open_project_paths = workspace
2910                .read(cx)
2911                .panes()
2912                .iter()
2913                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2914                .collect::<Vec<_>>();
2915            assert!(
2916                open_project_paths.is_empty(),
2917                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2918            );
2919        });
2920    }
2921}
2922// TODO - a workspace command?