project_panel.rs

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