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