project_panel.rs

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