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