project_panel.rs

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