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, FocusableView,
  13    InteractiveComponent, Model, MouseButton, ParentComponent, Pixels, Point, PromptLevel, Render,
  14    Stateful, StatefulInteractiveComponent, Styled, Task, UniformListScrollHandle, View,
  15    ViewContext, 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, ResultExt, TryFutureExt};
  36use workspace::{
  37    dock::{DockPosition, PanelEvent},
  38    Workspace,
  39};
  40
  41const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
  42const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  43
  44pub struct ProjectPanel {
  45    project: Model<Project>,
  46    fs: Arc<dyn Fs>,
  47    list: UniformListScrollHandle,
  48    focus_handle: FocusHandle,
  49    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  50    last_worktree_root_id: Option<ProjectEntryId>,
  51    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  52    selection: Option<Selection>,
  53    edit_state: Option<EditState>,
  54    filename_editor: View<Editor>,
  55    clipboard_entry: Option<ClipboardEntry>,
  56    _dragged_entry_destination: Option<Arc<Path>>,
  57    _workspace: WeakView<Workspace>,
  58    has_focus: bool,
  59    width: Option<f32>,
  60    pending_serialization: Task<Option<()>>,
  61}
  62
  63#[derive(Copy, Clone, Debug)]
  64struct Selection {
  65    worktree_id: WorktreeId,
  66    entry_id: ProjectEntryId,
  67}
  68
  69#[derive(Clone, Debug)]
  70struct EditState {
  71    worktree_id: WorktreeId,
  72    entry_id: ProjectEntryId,
  73    is_new_entry: bool,
  74    is_dir: bool,
  75    processing_filename: Option<String>,
  76}
  77
  78#[derive(Copy, Clone)]
  79pub enum ClipboardEntry {
  80    Copied {
  81        worktree_id: WorktreeId,
  82        entry_id: ProjectEntryId,
  83    },
  84    Cut {
  85        worktree_id: WorktreeId,
  86        entry_id: ProjectEntryId,
  87    },
  88}
  89
  90#[derive(Debug, PartialEq, Eq)]
  91pub struct EntryDetails {
  92    filename: String,
  93    icon: Option<Arc<str>>,
  94    path: Arc<Path>,
  95    depth: usize,
  96    kind: EntryKind,
  97    is_ignored: bool,
  98    is_expanded: bool,
  99    is_selected: bool,
 100    is_editing: bool,
 101    is_processing: bool,
 102    is_cut: bool,
 103    git_status: Option<GitFileStatus>,
 104}
 105
 106actions!(
 107    ExpandSelectedEntry,
 108    CollapseSelectedEntry,
 109    CollapseAllEntries,
 110    NewDirectory,
 111    NewFile,
 112    Copy,
 113    CopyPath,
 114    CopyRelativePath,
 115    RevealInFinder,
 116    OpenInTerminal,
 117    Cut,
 118    Paste,
 119    Delete,
 120    Rename,
 121    Open,
 122    ToggleFocus,
 123    NewSearchInDirectory,
 124);
 125
 126pub fn init_settings(cx: &mut AppContext) {
 127    ProjectPanelSettings::register(cx);
 128}
 129
 130pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
 131    init_settings(cx);
 132    file_associations::init(assets, cx);
 133
 134    cx.observe_new_views(|workspace: &mut Workspace, _| {
 135        workspace.register_action(|workspace, _: &ToggleFocus, cx| {
 136            workspace.toggle_panel_focus::<ProjectPanel>(cx);
 137        });
 138    })
 139    .detach();
 140}
 141
 142#[derive(Debug)]
 143pub enum Event {
 144    OpenedEntry {
 145        entry_id: ProjectEntryId,
 146        focus_opened_item: bool,
 147    },
 148    SplitEntry {
 149        entry_id: ProjectEntryId,
 150    },
 151    DockPositionChanged,
 152    Focus,
 153    NewSearchInDirectory {
 154        dir_entry: Entry,
 155    },
 156    ActivatePanel,
 157}
 158
 159#[derive(Serialize, Deserialize)]
 160struct SerializedProjectPanel {
 161    width: Option<f32>,
 162}
 163
 164impl ProjectPanel {
 165    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 166        let project = workspace.project().clone();
 167        let project_panel = cx.build_view(|cx: &mut ViewContext<Self>| {
 168            cx.observe(&project, |this, _, cx| {
 169                this.update_visible_entries(None, cx);
 170                cx.notify();
 171            })
 172            .detach();
 173            let focus_handle = cx.focus_handle();
 174
 175            cx.on_focus(&focus_handle, Self::focus_in).detach();
 176            cx.on_blur(&focus_handle, Self::focus_out).detach();
 177
 178            cx.subscribe(&project, |this, project, event, cx| match event {
 179                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 180                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
 181                    {
 182                        this.expand_entry(worktree_id, *entry_id, cx);
 183                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
 184                        this.autoscroll(cx);
 185                        cx.notify();
 186                    }
 187                }
 188                project::Event::ActivateProjectPanel => {
 189                    cx.emit(Event::ActivatePanel);
 190                }
 191                project::Event::WorktreeRemoved(id) => {
 192                    this.expanded_dir_ids.remove(id);
 193                    this.update_visible_entries(None, cx);
 194                    cx.notify();
 195                }
 196                _ => {}
 197            })
 198            .detach();
 199
 200            let filename_editor = cx.build_view(|cx| Editor::single_line(cx));
 201
 202            cx.subscribe(&filename_editor, |this, _, event, cx| match event {
 203                editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
 204                    this.autoscroll(cx);
 205                }
 206                editor::Event::Blurred => {
 207                    if this
 208                        .edit_state
 209                        .as_ref()
 210                        .map_or(false, |state| state.processing_filename.is_none())
 211                    {
 212                        this.edit_state = None;
 213                        this.update_visible_entries(None, cx);
 214                    }
 215                }
 216                _ => {}
 217            })
 218            .detach();
 219
 220            // cx.observe_global::<FileAssociations, _>(|_, cx| {
 221            //     cx.notify();
 222            // })
 223            // .detach();
 224
 225            let mut this = Self {
 226                project: project.clone(),
 227                fs: workspace.app_state().fs.clone(),
 228                list: UniformListScrollHandle::new(),
 229                focus_handle,
 230                visible_entries: Default::default(),
 231                last_worktree_root_id: Default::default(),
 232                expanded_dir_ids: Default::default(),
 233                selection: None,
 234                edit_state: None,
 235                filename_editor,
 236                clipboard_entry: None,
 237                // context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
 238                _dragged_entry_destination: None,
 239                _workspace: workspace.weak_handle(),
 240                has_focus: false,
 241                width: None,
 242                pending_serialization: Task::ready(None),
 243            };
 244            this.update_visible_entries(None, cx);
 245
 246            // Update the dock position when the setting changes.
 247            // todo!()
 248            // let mut old_dock_position = this.position(cx);
 249            // cx.observe_global::<SettingsStore, _>(move |this, cx| {
 250            //     let new_dock_position = this.position(cx);
 251            //     if new_dock_position != old_dock_position {
 252            //         old_dock_position = new_dock_position;
 253            //         cx.emit(Event::DockPositionChanged);
 254            //     }
 255            // })
 256            // .detach();
 257
 258            this
 259        });
 260
 261        cx.subscribe(&project_panel, {
 262            let project_panel = project_panel.downgrade();
 263            move |workspace, _, event, cx| match event {
 264                &Event::OpenedEntry {
 265                    entry_id,
 266                    focus_opened_item,
 267                } => {
 268                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 269                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 270                            workspace
 271                                .open_path(
 272                                    ProjectPath {
 273                                        worktree_id: worktree.read(cx).id(),
 274                                        path: entry.path.clone(),
 275                                    },
 276                                    None,
 277                                    focus_opened_item,
 278                                    cx,
 279                                )
 280                                .detach_and_log_err(cx);
 281                            if !focus_opened_item {
 282                                if let Some(project_panel) = project_panel.upgrade() {
 283                                    let focus_handle = project_panel.read(cx).focus_handle.clone();
 284                                    cx.focus(&focus_handle);
 285                                }
 286                            }
 287                        }
 288                    }
 289                }
 290                &Event::SplitEntry { entry_id } => {
 291                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 292                        if let Some(_entry) = worktree.read(cx).entry_for_id(entry_id) {
 293                            // workspace
 294                            //     .split_path(
 295                            //         ProjectPath {
 296                            //             worktree_id: worktree.read(cx).id(),
 297                            //             path: entry.path.clone(),
 298                            //         },
 299                            //         cx,
 300                            //     )
 301                            //     .detach_and_log_err(cx);
 302                        }
 303                    }
 304                }
 305                _ => {}
 306            }
 307        })
 308        .detach();
 309
 310        project_panel
 311    }
 312
 313    pub async fn load(
 314        workspace: WeakView<Workspace>,
 315        mut cx: AsyncWindowContext,
 316    ) -> Result<View<Self>> {
 317        let serialized_panel = cx
 318            .background_executor()
 319            .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
 320            .await
 321            .map_err(|e| anyhow!("Failed to load project panel: {}", e))
 322            .log_err()
 323            .flatten()
 324            .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
 325            .transpose()
 326            .log_err()
 327            .flatten();
 328
 329        workspace.update(&mut cx, |workspace, cx| {
 330            let panel = ProjectPanel::new(workspace, cx);
 331            if let Some(serialized_panel) = serialized_panel {
 332                panel.update(cx, |panel, cx| {
 333                    panel.width = serialized_panel.width;
 334                    cx.notify();
 335                });
 336            }
 337            panel
 338        })
 339    }
 340
 341    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 342        let width = self.width;
 343        self.pending_serialization = cx.background_executor().spawn(
 344            async move {
 345                KEY_VALUE_STORE
 346                    .write_kvp(
 347                        PROJECT_PANEL_KEY.into(),
 348                        serde_json::to_string(&SerializedProjectPanel { width })?,
 349                    )
 350                    .await?;
 351                anyhow::Ok(())
 352            }
 353            .log_err(),
 354        );
 355    }
 356
 357    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 358        if !self.has_focus {
 359            self.has_focus = true;
 360            cx.emit(Event::Focus);
 361        }
 362    }
 363
 364    fn focus_out(&mut self, _: &mut ViewContext<Self>) {
 365        self.has_focus = false;
 366    }
 367
 368    fn deploy_context_menu(
 369        &mut self,
 370        _position: Point<Pixels>,
 371        _entry_id: ProjectEntryId,
 372        _cx: &mut ViewContext<Self>,
 373    ) {
 374        todo!()
 375        //     let project = self.project.read(cx);
 376
 377        //     let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 378        //         id
 379        //     } else {
 380        //         return;
 381        //     };
 382
 383        //     self.selection = Some(Selection {
 384        //         worktree_id,
 385        //         entry_id,
 386        //     });
 387
 388        //     let mut menu_entries = Vec::new();
 389        //     if let Some((worktree, entry)) = self.selected_entry(cx) {
 390        //         let is_root = Some(entry) == worktree.root_entry();
 391        //         if !project.is_remote() {
 392        //             menu_entries.push(ContextMenuItem::action(
 393        //                 "Add Folder to Project",
 394        //                 workspace::AddFolderToProject,
 395        //             ));
 396        //             if is_root {
 397        //                 let project = self.project.clone();
 398        //                 menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
 399        //                     project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
 400        //                 }));
 401        //             }
 402        //         }
 403        //         menu_entries.push(ContextMenuItem::action("New File", NewFile));
 404        //         menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
 405        //         menu_entries.push(ContextMenuItem::Separator);
 406        //         menu_entries.push(ContextMenuItem::action("Cut", Cut));
 407        //         menu_entries.push(ContextMenuItem::action("Copy", Copy));
 408        //         if let Some(clipboard_entry) = self.clipboard_entry {
 409        //             if clipboard_entry.worktree_id() == worktree.id() {
 410        //                 menu_entries.push(ContextMenuItem::action("Paste", Paste));
 411        //             }
 412        //         }
 413        //         menu_entries.push(ContextMenuItem::Separator);
 414        //         menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
 415        //         menu_entries.push(ContextMenuItem::action(
 416        //             "Copy Relative Path",
 417        //             CopyRelativePath,
 418        //         ));
 419
 420        //         if entry.is_dir() {
 421        //             menu_entries.push(ContextMenuItem::Separator);
 422        //         }
 423        //         menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
 424        //         if entry.is_dir() {
 425        //             menu_entries.push(ContextMenuItem::action("Open in Terminal", OpenInTerminal));
 426        //             menu_entries.push(ContextMenuItem::action(
 427        //                 "Search Inside",
 428        //                 NewSearchInDirectory,
 429        //             ));
 430        //         }
 431
 432        //         menu_entries.push(ContextMenuItem::Separator);
 433        //         menu_entries.push(ContextMenuItem::action("Rename", Rename));
 434        //         if !is_root {
 435        //             menu_entries.push(ContextMenuItem::action("Delete", Delete));
 436        //         }
 437        //     }
 438
 439        //     // self.context_menu.update(cx, |menu, cx| {
 440        //     //     menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
 441        //     // });
 442
 443        //     cx.notify();
 444    }
 445
 446    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 447        if let Some((worktree, entry)) = self.selected_entry(cx) {
 448            if entry.is_dir() {
 449                let worktree_id = worktree.id();
 450                let entry_id = entry.id;
 451                let expanded_dir_ids =
 452                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 453                        expanded_dir_ids
 454                    } else {
 455                        return;
 456                    };
 457
 458                match expanded_dir_ids.binary_search(&entry_id) {
 459                    Ok(_) => self.select_next(&SelectNext, cx),
 460                    Err(ix) => {
 461                        self.project.update(cx, |project, cx| {
 462                            project.expand_entry(worktree_id, entry_id, cx);
 463                        });
 464
 465                        expanded_dir_ids.insert(ix, entry_id);
 466                        self.update_visible_entries(None, cx);
 467                        cx.notify();
 468                    }
 469                }
 470            }
 471        }
 472    }
 473
 474    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 475        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 476            let worktree_id = worktree.id();
 477            let expanded_dir_ids =
 478                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 479                    expanded_dir_ids
 480                } else {
 481                    return;
 482                };
 483
 484            loop {
 485                let entry_id = entry.id;
 486                match expanded_dir_ids.binary_search(&entry_id) {
 487                    Ok(ix) => {
 488                        expanded_dir_ids.remove(ix);
 489                        self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 490                        cx.notify();
 491                        break;
 492                    }
 493                    Err(_) => {
 494                        if let Some(parent_entry) =
 495                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 496                        {
 497                            entry = parent_entry;
 498                        } else {
 499                            break;
 500                        }
 501                    }
 502                }
 503            }
 504        }
 505    }
 506
 507    pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
 508        self.expanded_dir_ids.clear();
 509        self.update_visible_entries(None, cx);
 510        cx.notify();
 511    }
 512
 513    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 514        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 515            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 516                self.project.update(cx, |project, cx| {
 517                    match expanded_dir_ids.binary_search(&entry_id) {
 518                        Ok(ix) => {
 519                            expanded_dir_ids.remove(ix);
 520                        }
 521                        Err(ix) => {
 522                            project.expand_entry(worktree_id, entry_id, cx);
 523                            expanded_dir_ids.insert(ix, entry_id);
 524                        }
 525                    }
 526                });
 527                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 528                cx.focus(&self.focus_handle);
 529                cx.notify();
 530            }
 531        }
 532    }
 533
 534    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 535        if let Some(selection) = self.selection {
 536            let (mut worktree_ix, mut entry_ix, _) =
 537                self.index_for_selection(selection).unwrap_or_default();
 538            if entry_ix > 0 {
 539                entry_ix -= 1;
 540            } else if worktree_ix > 0 {
 541                worktree_ix -= 1;
 542                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 543            } else {
 544                return;
 545            }
 546
 547            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 548            self.selection = Some(Selection {
 549                worktree_id: *worktree_id,
 550                entry_id: worktree_entries[entry_ix].id,
 551            });
 552            self.autoscroll(cx);
 553            cx.notify();
 554        } else {
 555            self.select_first(cx);
 556        }
 557    }
 558
 559    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 560        if let Some(task) = self.confirm_edit(cx) {
 561            task.detach_and_log_err(cx);
 562        }
 563    }
 564
 565    fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
 566        if let Some((_, entry)) = self.selected_entry(cx) {
 567            if entry.is_file() {
 568                self.open_entry(entry.id, true, cx);
 569            }
 570        }
 571    }
 572
 573    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 574        let edit_state = self.edit_state.as_mut()?;
 575        cx.focus(&self.focus_handle);
 576
 577        let worktree_id = edit_state.worktree_id;
 578        let is_new_entry = edit_state.is_new_entry;
 579        let is_dir = edit_state.is_dir;
 580        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 581        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 582        let filename = self.filename_editor.read(cx).text(cx);
 583
 584        let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
 585        let edit_task;
 586        let edited_entry_id;
 587        if is_new_entry {
 588            self.selection = Some(Selection {
 589                worktree_id,
 590                entry_id: NEW_ENTRY_ID,
 591            });
 592            let new_path = entry.path.join(&filename.trim_start_matches("/"));
 593            if path_already_exists(new_path.as_path()) {
 594                return None;
 595            }
 596
 597            edited_entry_id = NEW_ENTRY_ID;
 598            edit_task = self.project.update(cx, |project, cx| {
 599                project.create_entry((worktree_id, &new_path), is_dir, cx)
 600            })?;
 601        } else {
 602            let new_path = if let Some(parent) = entry.path.clone().parent() {
 603                parent.join(&filename)
 604            } else {
 605                filename.clone().into()
 606            };
 607            if path_already_exists(new_path.as_path()) {
 608                return None;
 609            }
 610
 611            edited_entry_id = entry.id;
 612            edit_task = self.project.update(cx, |project, cx| {
 613                project.rename_entry(entry.id, new_path.as_path(), cx)
 614            })?;
 615        };
 616
 617        edit_state.processing_filename = Some(filename);
 618        cx.notify();
 619
 620        Some(cx.spawn(|this, mut cx| async move {
 621            let new_entry = edit_task.await;
 622            this.update(&mut cx, |this, cx| {
 623                this.edit_state.take();
 624                cx.notify();
 625            })?;
 626
 627            let new_entry = new_entry?;
 628            this.update(&mut cx, |this, cx| {
 629                if let Some(selection) = &mut this.selection {
 630                    if selection.entry_id == edited_entry_id {
 631                        selection.worktree_id = worktree_id;
 632                        selection.entry_id = new_entry.id;
 633                        this.expand_to_selection(cx);
 634                    }
 635                }
 636                this.update_visible_entries(None, cx);
 637                if is_new_entry && !is_dir {
 638                    this.open_entry(new_entry.id, true, cx);
 639                }
 640                cx.notify();
 641            })?;
 642            Ok(())
 643        }))
 644    }
 645
 646    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 647        self.edit_state = None;
 648        self.update_visible_entries(None, cx);
 649        cx.focus(&self.focus_handle);
 650        cx.notify();
 651    }
 652
 653    fn open_entry(
 654        &mut self,
 655        entry_id: ProjectEntryId,
 656        focus_opened_item: bool,
 657        cx: &mut ViewContext<Self>,
 658    ) {
 659        cx.emit(Event::OpenedEntry {
 660            entry_id,
 661            focus_opened_item,
 662        });
 663    }
 664
 665    fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 666        cx.emit(Event::SplitEntry { entry_id });
 667    }
 668
 669    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 670        self.add_entry(false, cx)
 671    }
 672
 673    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 674        self.add_entry(true, cx)
 675    }
 676
 677    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 678        if let Some(Selection {
 679            worktree_id,
 680            entry_id,
 681        }) = self.selection
 682        {
 683            let directory_id;
 684            if let Some((worktree, expanded_dir_ids)) = self
 685                .project
 686                .read(cx)
 687                .worktree_for_id(worktree_id, cx)
 688                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 689            {
 690                let worktree = worktree.read(cx);
 691                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 692                    loop {
 693                        if entry.is_dir() {
 694                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 695                                expanded_dir_ids.insert(ix, entry.id);
 696                            }
 697                            directory_id = entry.id;
 698                            break;
 699                        } else {
 700                            if let Some(parent_path) = entry.path.parent() {
 701                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 702                                    entry = parent_entry;
 703                                    continue;
 704                                }
 705                            }
 706                            return;
 707                        }
 708                    }
 709                } else {
 710                    return;
 711                };
 712            } else {
 713                return;
 714            };
 715
 716            self.edit_state = Some(EditState {
 717                worktree_id,
 718                entry_id: directory_id,
 719                is_new_entry: true,
 720                is_dir,
 721                processing_filename: None,
 722            });
 723            self.filename_editor.update(cx, |editor, cx| {
 724                editor.clear(cx);
 725                editor.focus(cx);
 726            });
 727            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 728            self.autoscroll(cx);
 729            cx.notify();
 730        }
 731    }
 732
 733    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 734        if let Some(Selection {
 735            worktree_id,
 736            entry_id,
 737        }) = self.selection
 738        {
 739            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 740                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 741                    self.edit_state = Some(EditState {
 742                        worktree_id,
 743                        entry_id,
 744                        is_new_entry: false,
 745                        is_dir: entry.is_dir(),
 746                        processing_filename: None,
 747                    });
 748                    let file_name = entry
 749                        .path
 750                        .file_name()
 751                        .map(|s| s.to_string_lossy())
 752                        .unwrap_or_default()
 753                        .to_string();
 754                    let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
 755                    let selection_end =
 756                        file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
 757                    self.filename_editor.update(cx, |editor, cx| {
 758                        editor.set_text(file_name, cx);
 759                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 760                            s.select_ranges([0..selection_end])
 761                        });
 762                        editor.focus(cx);
 763                    });
 764                    self.update_visible_entries(None, cx);
 765                    self.autoscroll(cx);
 766                    cx.notify();
 767                }
 768            }
 769
 770            // cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
 771            //     drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
 772            // })
 773        }
 774    }
 775
 776    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
 777        maybe!({
 778            let Selection { entry_id, .. } = self.selection?;
 779            let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 780            let file_name = path.file_name()?;
 781
 782            let answer = cx.prompt(
 783                PromptLevel::Info,
 784                &format!("Delete {file_name:?}?"),
 785                &["Delete", "Cancel"],
 786            );
 787
 788            cx.spawn(|this, mut cx| async move {
 789                if answer.await != Ok(0) {
 790                    return Ok(());
 791                }
 792                this.update(&mut cx, |this, cx| {
 793                    this.project
 794                        .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 795                        .ok_or_else(|| anyhow!("no such entry"))
 796                })??
 797                .await
 798            })
 799            .detach_and_log_err(cx);
 800            Some(())
 801        });
 802    }
 803
 804    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 805        if let Some(selection) = self.selection {
 806            let (mut worktree_ix, mut entry_ix, _) =
 807                self.index_for_selection(selection).unwrap_or_default();
 808            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 809                if entry_ix + 1 < worktree_entries.len() {
 810                    entry_ix += 1;
 811                } else {
 812                    worktree_ix += 1;
 813                    entry_ix = 0;
 814                }
 815            }
 816
 817            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 818                if let Some(entry) = worktree_entries.get(entry_ix) {
 819                    self.selection = Some(Selection {
 820                        worktree_id: *worktree_id,
 821                        entry_id: entry.id,
 822                    });
 823                    self.autoscroll(cx);
 824                    cx.notify();
 825                }
 826            }
 827        } else {
 828            self.select_first(cx);
 829        }
 830    }
 831
 832    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 833        let worktree = self
 834            .visible_entries
 835            .first()
 836            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 837        if let Some(worktree) = worktree {
 838            let worktree = worktree.read(cx);
 839            let worktree_id = worktree.id();
 840            if let Some(root_entry) = worktree.root_entry() {
 841                self.selection = Some(Selection {
 842                    worktree_id,
 843                    entry_id: root_entry.id,
 844                });
 845                self.autoscroll(cx);
 846                cx.notify();
 847            }
 848        }
 849    }
 850
 851    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
 852        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 853            self.list.scroll_to_item(index);
 854            cx.notify();
 855        }
 856    }
 857
 858    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 859        if let Some((worktree, entry)) = self.selected_entry(cx) {
 860            self.clipboard_entry = Some(ClipboardEntry::Cut {
 861                worktree_id: worktree.id(),
 862                entry_id: entry.id,
 863            });
 864            cx.notify();
 865        }
 866    }
 867
 868    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 869        if let Some((worktree, entry)) = self.selected_entry(cx) {
 870            self.clipboard_entry = Some(ClipboardEntry::Copied {
 871                worktree_id: worktree.id(),
 872                entry_id: entry.id,
 873            });
 874            cx.notify();
 875        }
 876    }
 877
 878    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
 879        maybe!({
 880            let (worktree, entry) = self.selected_entry(cx)?;
 881            let clipboard_entry = self.clipboard_entry?;
 882            if clipboard_entry.worktree_id() != worktree.id() {
 883                return None;
 884            }
 885
 886            let clipboard_entry_file_name = self
 887                .project
 888                .read(cx)
 889                .path_for_entry(clipboard_entry.entry_id(), cx)?
 890                .path
 891                .file_name()?
 892                .to_os_string();
 893
 894            let mut new_path = entry.path.to_path_buf();
 895            if entry.is_file() {
 896                new_path.pop();
 897            }
 898
 899            new_path.push(&clipboard_entry_file_name);
 900            let extension = new_path.extension().map(|e| e.to_os_string());
 901            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 902            let mut ix = 0;
 903            while worktree.entry_for_path(&new_path).is_some() {
 904                new_path.pop();
 905
 906                let mut new_file_name = file_name_without_extension.to_os_string();
 907                new_file_name.push(" copy");
 908                if ix > 0 {
 909                    new_file_name.push(format!(" {}", ix));
 910                }
 911                if let Some(extension) = extension.as_ref() {
 912                    new_file_name.push(".");
 913                    new_file_name.push(extension);
 914                }
 915
 916                new_path.push(new_file_name);
 917                ix += 1;
 918            }
 919
 920            if clipboard_entry.is_cut() {
 921                if let Some(task) = self.project.update(cx, |project, cx| {
 922                    project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 923                }) {
 924                    task.detach_and_log_err(cx);
 925                }
 926            } else if let Some(task) = self.project.update(cx, |project, cx| {
 927                project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 928            }) {
 929                task.detach_and_log_err(cx);
 930            }
 931
 932            Some(())
 933        });
 934    }
 935
 936    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 937        if let Some((worktree, entry)) = self.selected_entry(cx) {
 938            cx.write_to_clipboard(ClipboardItem::new(
 939                worktree
 940                    .abs_path()
 941                    .join(&entry.path)
 942                    .to_string_lossy()
 943                    .to_string(),
 944            ));
 945        }
 946    }
 947
 948    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
 949        if let Some((_, entry)) = self.selected_entry(cx) {
 950            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
 951        }
 952    }
 953
 954    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
 955        if let Some((worktree, entry)) = self.selected_entry(cx) {
 956            cx.reveal_path(&worktree.abs_path().join(&entry.path));
 957        }
 958    }
 959
 960    fn open_in_terminal(&mut self, _: &OpenInTerminal, _cx: &mut ViewContext<Self>) {
 961        todo!()
 962        // if let Some((worktree, entry)) = self.selected_entry(cx) {
 963        //     let window = cx.window();
 964        //     let view_id = cx.view_id();
 965        //     let path = worktree.abs_path().join(&entry.path);
 966
 967        //     cx.app_context()
 968        //         .spawn(|mut cx| async move {
 969        //             window.dispatch_action(
 970        //                 view_id,
 971        //                 &workspace::OpenTerminal {
 972        //                     working_directory: path,
 973        //                 },
 974        //                 &mut cx,
 975        //             );
 976        //         })
 977        //         .detach();
 978        // }
 979    }
 980
 981    pub fn new_search_in_directory(
 982        &mut self,
 983        _: &NewSearchInDirectory,
 984        cx: &mut ViewContext<Self>,
 985    ) {
 986        if let Some((_, entry)) = self.selected_entry(cx) {
 987            if entry.is_dir() {
 988                cx.emit(Event::NewSearchInDirectory {
 989                    dir_entry: entry.clone(),
 990                });
 991            }
 992        }
 993    }
 994
 995    // todo!()
 996    // fn move_entry(
 997    //     &mut self,
 998    //     entry_to_move: ProjectEntryId,
 999    //     destination: ProjectEntryId,
1000    //     destination_is_file: bool,
1001    //     cx: &mut ViewContext<Self>,
1002    // ) {
1003    //     let destination_worktree = self.project.update(cx, |project, cx| {
1004    //         let entry_path = project.path_for_entry(entry_to_move, cx)?;
1005    //         let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1006
1007    //         let mut destination_path = destination_entry_path.as_ref();
1008    //         if destination_is_file {
1009    //             destination_path = destination_path.parent()?;
1010    //         }
1011
1012    //         let mut new_path = destination_path.to_path_buf();
1013    //         new_path.push(entry_path.path.file_name()?);
1014    //         if new_path != entry_path.path.as_ref() {
1015    //             let task = project.rename_entry(entry_to_move, new_path, cx)?;
1016    //             cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1017    //         }
1018
1019    //         Some(project.worktree_id_for_entry(destination, cx)?)
1020    //     });
1021
1022    //     if let Some(destination_worktree) = destination_worktree {
1023    //         self.expand_entry(destination_worktree, destination, cx);
1024    //     }
1025    // }
1026
1027    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1028        let mut entry_index = 0;
1029        let mut visible_entries_index = 0;
1030        for (worktree_index, (worktree_id, worktree_entries)) in
1031            self.visible_entries.iter().enumerate()
1032        {
1033            if *worktree_id == selection.worktree_id {
1034                for entry in worktree_entries {
1035                    if entry.id == selection.entry_id {
1036                        return Some((worktree_index, entry_index, visible_entries_index));
1037                    } else {
1038                        visible_entries_index += 1;
1039                        entry_index += 1;
1040                    }
1041                }
1042                break;
1043            } else {
1044                visible_entries_index += worktree_entries.len();
1045            }
1046        }
1047        None
1048    }
1049
1050    pub fn selected_entry<'a>(
1051        &self,
1052        cx: &'a AppContext,
1053    ) -> Option<(&'a Worktree, &'a project::Entry)> {
1054        let (worktree, entry) = self.selected_entry_handle(cx)?;
1055        Some((worktree.read(cx), entry))
1056    }
1057
1058    fn selected_entry_handle<'a>(
1059        &self,
1060        cx: &'a AppContext,
1061    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1062        let selection = self.selection?;
1063        let project = self.project.read(cx);
1064        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1065        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1066        Some((worktree, entry))
1067    }
1068
1069    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1070        let (worktree, entry) = self.selected_entry(cx)?;
1071        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1072
1073        for path in entry.path.ancestors() {
1074            let Some(entry) = worktree.entry_for_path(path) else {
1075                continue;
1076            };
1077            if entry.is_dir() {
1078                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1079                    expanded_dir_ids.insert(idx, entry.id);
1080                }
1081            }
1082        }
1083
1084        Some(())
1085    }
1086
1087    fn update_visible_entries(
1088        &mut self,
1089        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1090        cx: &mut ViewContext<Self>,
1091    ) {
1092        let project = self.project.read(cx);
1093        self.last_worktree_root_id = project
1094            .visible_worktrees(cx)
1095            .rev()
1096            .next()
1097            .and_then(|worktree| worktree.read(cx).root_entry())
1098            .map(|entry| entry.id);
1099
1100        self.visible_entries.clear();
1101        for worktree in project.visible_worktrees(cx) {
1102            let snapshot = worktree.read(cx).snapshot();
1103            let worktree_id = snapshot.id();
1104
1105            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1106                hash_map::Entry::Occupied(e) => e.into_mut(),
1107                hash_map::Entry::Vacant(e) => {
1108                    // The first time a worktree's root entry becomes available,
1109                    // mark that root entry as expanded.
1110                    if let Some(entry) = snapshot.root_entry() {
1111                        e.insert(vec![entry.id]).as_slice()
1112                    } else {
1113                        &[]
1114                    }
1115                }
1116            };
1117
1118            let mut new_entry_parent_id = None;
1119            let mut new_entry_kind = EntryKind::Dir;
1120            if let Some(edit_state) = &self.edit_state {
1121                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1122                    new_entry_parent_id = Some(edit_state.entry_id);
1123                    new_entry_kind = if edit_state.is_dir {
1124                        EntryKind::Dir
1125                    } else {
1126                        EntryKind::File(Default::default())
1127                    };
1128                }
1129            }
1130
1131            let mut visible_worktree_entries = Vec::new();
1132            let mut entry_iter = snapshot.entries(true);
1133
1134            while let Some(entry) = entry_iter.entry() {
1135                visible_worktree_entries.push(entry.clone());
1136                if Some(entry.id) == new_entry_parent_id {
1137                    visible_worktree_entries.push(Entry {
1138                        id: NEW_ENTRY_ID,
1139                        kind: new_entry_kind,
1140                        path: entry.path.join("\0").into(),
1141                        inode: 0,
1142                        mtime: entry.mtime,
1143                        is_symlink: false,
1144                        is_ignored: false,
1145                        is_external: false,
1146                        git_status: entry.git_status,
1147                    });
1148                }
1149                if expanded_dir_ids.binary_search(&entry.id).is_err()
1150                    && entry_iter.advance_to_sibling()
1151                {
1152                    continue;
1153                }
1154                entry_iter.advance();
1155            }
1156
1157            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1158
1159            visible_worktree_entries.sort_by(|entry_a, entry_b| {
1160                let mut components_a = entry_a.path.components().peekable();
1161                let mut components_b = entry_b.path.components().peekable();
1162                loop {
1163                    match (components_a.next(), components_b.next()) {
1164                        (Some(component_a), Some(component_b)) => {
1165                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1166                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1167                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1168                                let name_a =
1169                                    UniCase::new(component_a.as_os_str().to_string_lossy());
1170                                let name_b =
1171                                    UniCase::new(component_b.as_os_str().to_string_lossy());
1172                                name_a.cmp(&name_b)
1173                            });
1174                            if !ordering.is_eq() {
1175                                return ordering;
1176                            }
1177                        }
1178                        (Some(_), None) => break Ordering::Greater,
1179                        (None, Some(_)) => break Ordering::Less,
1180                        (None, None) => break Ordering::Equal,
1181                    }
1182                }
1183            });
1184            self.visible_entries
1185                .push((worktree_id, visible_worktree_entries));
1186        }
1187
1188        if let Some((worktree_id, entry_id)) = new_selected_entry {
1189            self.selection = Some(Selection {
1190                worktree_id,
1191                entry_id,
1192            });
1193        }
1194    }
1195
1196    fn expand_entry(
1197        &mut self,
1198        worktree_id: WorktreeId,
1199        entry_id: ProjectEntryId,
1200        cx: &mut ViewContext<Self>,
1201    ) {
1202        self.project.update(cx, |project, cx| {
1203            if let Some((worktree, expanded_dir_ids)) = project
1204                .worktree_for_id(worktree_id, cx)
1205                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1206            {
1207                project.expand_entry(worktree_id, entry_id, cx);
1208                let worktree = worktree.read(cx);
1209
1210                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1211                    loop {
1212                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1213                            expanded_dir_ids.insert(ix, entry.id);
1214                        }
1215
1216                        if let Some(parent_entry) =
1217                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1218                        {
1219                            entry = parent_entry;
1220                        } else {
1221                            break;
1222                        }
1223                    }
1224                }
1225            }
1226        });
1227    }
1228
1229    fn for_each_visible_entry(
1230        &self,
1231        range: Range<usize>,
1232        cx: &mut ViewContext<ProjectPanel>,
1233        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1234    ) {
1235        let mut ix = 0;
1236        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1237            if ix >= range.end {
1238                return;
1239            }
1240
1241            if ix + visible_worktree_entries.len() <= range.start {
1242                ix += visible_worktree_entries.len();
1243                continue;
1244            }
1245
1246            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1247            let (git_status_setting, show_file_icons, show_folder_icons) = {
1248                let settings = ProjectPanelSettings::get_global(cx);
1249                (
1250                    settings.git_status,
1251                    settings.file_icons,
1252                    settings.folder_icons,
1253                )
1254            };
1255            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1256                let snapshot = worktree.read(cx).snapshot();
1257                let root_name = OsStr::new(snapshot.root_name());
1258                let expanded_entry_ids = self
1259                    .expanded_dir_ids
1260                    .get(&snapshot.id())
1261                    .map(Vec::as_slice)
1262                    .unwrap_or(&[]);
1263
1264                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1265                for entry in visible_worktree_entries[entry_range].iter() {
1266                    let status = git_status_setting.then(|| entry.git_status).flatten();
1267                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1268                    let icon = match entry.kind {
1269                        EntryKind::File(_) => {
1270                            if show_file_icons {
1271                                Some(FileAssociations::get_icon(&entry.path, cx))
1272                            } else {
1273                                None
1274                            }
1275                        }
1276                        _ => {
1277                            if show_folder_icons {
1278                                Some(FileAssociations::get_folder_icon(is_expanded, cx))
1279                            } else {
1280                                Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1281                            }
1282                        }
1283                    };
1284
1285                    let mut details = EntryDetails {
1286                        filename: entry
1287                            .path
1288                            .file_name()
1289                            .unwrap_or(root_name)
1290                            .to_string_lossy()
1291                            .to_string(),
1292                        icon,
1293                        path: entry.path.clone(),
1294                        depth: entry.path.components().count(),
1295                        kind: entry.kind,
1296                        is_ignored: entry.is_ignored,
1297                        is_expanded,
1298                        is_selected: self.selection.map_or(false, |e| {
1299                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1300                        }),
1301                        is_editing: false,
1302                        is_processing: false,
1303                        is_cut: self
1304                            .clipboard_entry
1305                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1306                        git_status: status,
1307                    };
1308
1309                    if let Some(edit_state) = &self.edit_state {
1310                        let is_edited_entry = if edit_state.is_new_entry {
1311                            entry.id == NEW_ENTRY_ID
1312                        } else {
1313                            entry.id == edit_state.entry_id
1314                        };
1315
1316                        if is_edited_entry {
1317                            if let Some(processing_filename) = &edit_state.processing_filename {
1318                                details.is_processing = true;
1319                                details.filename.clear();
1320                                details.filename.push_str(processing_filename);
1321                            } else {
1322                                if edit_state.is_new_entry {
1323                                    details.filename.clear();
1324                                }
1325                                details.is_editing = true;
1326                            }
1327                        }
1328                    }
1329
1330                    callback(entry.id, details, cx);
1331                }
1332            }
1333            ix = end_ix;
1334        }
1335    }
1336
1337    fn render_entry_visual_element(
1338        details: &EntryDetails,
1339        editor: Option<&View<Editor>>,
1340        padding: Pixels,
1341        cx: &mut ViewContext<Self>,
1342    ) -> Div<Self> {
1343        let show_editor = details.is_editing && !details.is_processing;
1344
1345        let theme = cx.theme();
1346        let filename_text_color = details
1347            .git_status
1348            .as_ref()
1349            .map(|status| match status {
1350                GitFileStatus::Added => theme.status().created,
1351                GitFileStatus::Modified => theme.status().modified,
1352                GitFileStatus::Conflict => theme.status().conflict,
1353            })
1354            .unwrap_or(theme.status().info);
1355
1356        h_stack()
1357            .child(if let Some(icon) = &details.icon {
1358                div().child(IconElement::from_path(icon.to_string()))
1359            } else {
1360                div()
1361            })
1362            .child(
1363                if let (Some(editor), true) = (editor, show_editor) {
1364                    div().w_full().child(editor.clone())
1365                } else {
1366                    div()
1367                        .text_color(filename_text_color)
1368                        .child(Label::new(details.filename.clone()))
1369                }
1370                .ml_1(),
1371            )
1372            .pl(padding)
1373    }
1374
1375    fn render_entry(
1376        &self,
1377        entry_id: ProjectEntryId,
1378        details: EntryDetails,
1379        // dragged_entry_destination: &mut Option<Arc<Path>>,
1380        cx: &mut ViewContext<Self>,
1381    ) -> Stateful<Self, Div<Self>> {
1382        let kind = details.kind;
1383        let settings = ProjectPanelSettings::get_global(cx);
1384        const INDENT_SIZE: Pixels = px(16.0);
1385        let padding = INDENT_SIZE + details.depth as f32 * px(settings.indent_size);
1386        let show_editor = details.is_editing && !details.is_processing;
1387        let is_selected = self
1388            .selection
1389            .map_or(false, |selection| selection.entry_id == entry_id);
1390
1391        Self::render_entry_visual_element(&details, Some(&self.filename_editor), padding, cx)
1392            .id(entry_id.to_proto() as usize)
1393            .w_full()
1394            .cursor_pointer()
1395            .when(is_selected, |this| {
1396                this.bg(cx.theme().colors().element_selected)
1397            })
1398            .hover(|style| style.bg(cx.theme().colors().element_hover))
1399            .on_click(move |this, event, cx| {
1400                if !show_editor {
1401                    if kind.is_dir() {
1402                        this.toggle_expanded(entry_id, cx);
1403                    } else {
1404                        if event.down.modifiers.command {
1405                            this.split_entry(entry_id, cx);
1406                        } else {
1407                            this.open_entry(entry_id, event.up.click_count > 1, cx);
1408                        }
1409                    }
1410                }
1411            })
1412            .on_mouse_down(MouseButton::Right, move |this, event, cx| {
1413                this.deploy_context_menu(event.position, entry_id, cx);
1414            })
1415        // .on_drop::<ProjectEntryId>(|this, event, cx| {
1416        //     this.move_entry(
1417        //         *dragged_entry,
1418        //         entry_id,
1419        //         matches!(details.kind, EntryKind::File(_)),
1420        //         cx,
1421        //     );
1422        // })
1423    }
1424}
1425
1426impl Render for ProjectPanel {
1427    type Element = Focusable<Self, Stateful<Self, Div<Self>>>;
1428
1429    fn render(&mut self, _cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1430        let has_worktree = self.visible_entries.len() != 0;
1431
1432        if has_worktree {
1433            div()
1434                .id("project-panel")
1435                .size_full()
1436                .key_context("ProjectPanel")
1437                .on_action(Self::select_next)
1438                .on_action(Self::select_prev)
1439                .on_action(Self::expand_selected_entry)
1440                .on_action(Self::collapse_selected_entry)
1441                .on_action(Self::collapse_all_entries)
1442                .on_action(Self::new_file)
1443                .on_action(Self::new_directory)
1444                .on_action(Self::rename)
1445                .on_action(Self::delete)
1446                .on_action(Self::confirm)
1447                .on_action(Self::open_file)
1448                .on_action(Self::cancel)
1449                .on_action(Self::cut)
1450                .on_action(Self::copy)
1451                .on_action(Self::copy_path)
1452                .on_action(Self::copy_relative_path)
1453                .on_action(Self::paste)
1454                .on_action(Self::reveal_in_finder)
1455                .on_action(Self::open_in_terminal)
1456                .on_action(Self::new_search_in_directory)
1457                .track_focus(&self.focus_handle)
1458                .child(
1459                    uniform_list(
1460                        "entries",
1461                        self.visible_entries
1462                            .iter()
1463                            .map(|(_, worktree_entries)| worktree_entries.len())
1464                            .sum(),
1465                        |this: &mut Self, range, cx| {
1466                            let mut items = Vec::new();
1467                            this.for_each_visible_entry(range, cx, |id, details, cx| {
1468                                items.push(this.render_entry(id, details, cx));
1469                            });
1470                            items
1471                        },
1472                    )
1473                    .size_full()
1474                    .track_scroll(self.list.clone()),
1475                )
1476        } else {
1477            v_stack()
1478                .id("empty-project_panel")
1479                .track_focus(&self.focus_handle)
1480        }
1481    }
1482}
1483
1484impl EventEmitter<Event> for ProjectPanel {}
1485
1486impl EventEmitter<PanelEvent> for ProjectPanel {}
1487
1488impl workspace::dock::Panel for ProjectPanel {
1489    fn position(&self, cx: &WindowContext) -> DockPosition {
1490        match ProjectPanelSettings::get_global(cx).dock {
1491            ProjectPanelDockPosition::Left => DockPosition::Left,
1492            ProjectPanelDockPosition::Right => DockPosition::Right,
1493        }
1494    }
1495
1496    fn position_is_valid(&self, position: DockPosition) -> bool {
1497        matches!(position, DockPosition::Left | DockPosition::Right)
1498    }
1499
1500    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1501        settings::update_settings_file::<ProjectPanelSettings>(
1502            self.fs.clone(),
1503            cx,
1504            move |settings| {
1505                let dock = match position {
1506                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1507                    DockPosition::Right => ProjectPanelDockPosition::Right,
1508                };
1509                settings.dock = Some(dock);
1510            },
1511        );
1512    }
1513
1514    fn size(&self, cx: &WindowContext) -> f32 {
1515        self.width
1516            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1517    }
1518
1519    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1520        self.width = size;
1521        self.serialize(cx);
1522        cx.notify();
1523    }
1524
1525    fn icon(&self, _: &WindowContext) -> Option<ui::Icon> {
1526        Some(ui::Icon::FileTree)
1527    }
1528
1529    fn toggle_action(&self) -> Box<dyn Action> {
1530        Box::new(ToggleFocus)
1531    }
1532
1533    fn has_focus(&self, _: &WindowContext) -> bool {
1534        self.has_focus
1535    }
1536
1537    fn persistent_name() -> &'static str {
1538        "Project Panel"
1539    }
1540}
1541
1542impl FocusableView for ProjectPanel {
1543    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1544        self.focus_handle.clone()
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::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(theme::LoadThemes::JustBase, 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(theme::LoadThemes::JustBase, cx);
2802            init_settings(cx);
2803            language::init(cx);
2804            editor::init(cx);
2805            crate::init((), cx);
2806            workspace::init(app_state.clone(), cx);
2807            Project::init_settings(cx);
2808        });
2809    }
2810
2811    fn ensure_single_file_is_opened(
2812        window: &WindowHandle<Workspace>,
2813        expected_path: &str,
2814        cx: &mut TestAppContext,
2815    ) {
2816        window
2817            .update(cx, |workspace, cx| {
2818                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2819                assert_eq!(worktrees.len(), 1);
2820                let worktree_id = worktrees[0].read(cx).id();
2821
2822                let open_project_paths = workspace
2823                    .panes()
2824                    .iter()
2825                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2826                    .collect::<Vec<_>>();
2827                assert_eq!(
2828                    open_project_paths,
2829                    vec![ProjectPath {
2830                        worktree_id,
2831                        path: Arc::from(Path::new(expected_path))
2832                    }],
2833                    "Should have opened file, selected in project panel"
2834                );
2835            })
2836            .unwrap();
2837    }
2838
2839    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
2840        assert!(
2841            !cx.has_pending_prompt(),
2842            "Should have no prompts before the deletion"
2843        );
2844        panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
2845        assert!(
2846            cx.has_pending_prompt(),
2847            "Should have a prompt after the deletion"
2848        );
2849        cx.simulate_prompt_answer(0);
2850        assert!(
2851            !cx.has_pending_prompt(),
2852            "Should have no prompts after prompt was replied to"
2853        );
2854        cx.executor().run_until_parked();
2855    }
2856
2857    fn ensure_no_open_items_and_panes(
2858        workspace: &WindowHandle<Workspace>,
2859        cx: &mut VisualTestContext,
2860    ) {
2861        assert!(
2862            !cx.has_pending_prompt(),
2863            "Should have no prompts after deletion operation closes the file"
2864        );
2865        workspace
2866            .read_with(cx, |workspace, cx| {
2867                let open_project_paths = workspace
2868                    .panes()
2869                    .iter()
2870                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2871                    .collect::<Vec<_>>();
2872                assert!(
2873                    open_project_paths.is_empty(),
2874                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2875                );
2876            })
2877            .unwrap();
2878    }
2879}