project_panel.rs

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