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