project_panel.rs

   1pub mod file_associations;
   2mod project_panel_settings;
   3use settings::{Settings, SettingsStore};
   4
   5use db::kvp::KEY_VALUE_STORE;
   6use editor::{scroll::autoscroll::Autoscroll, Cancel, Editor};
   7use file_associations::FileAssociations;
   8
   9use anyhow::{anyhow, Result};
  10use gpui::{
  11    actions, div, overlay, px, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext,
  12    ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle, Focusable, FocusableView,
  13    InteractiveElement, KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels,
  14    Point, PromptLevel, Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle,
  15    View, ViewContext, VisualContext as _, WeakView, WindowContext,
  16};
  17use menu::{Confirm, SelectNext, SelectPrev};
  18use project::{
  19    repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
  20    Worktree, WorktreeId,
  21};
  22use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
  23use serde::{Deserialize, Serialize};
  24use std::{
  25    cmp::Ordering,
  26    collections::{hash_map, HashMap},
  27    ffi::OsStr,
  28    ops::Range,
  29    path::Path,
  30    sync::Arc,
  31};
  32use theme::ThemeSettings;
  33use ui::{prelude::*, v_stack, ContextMenu, IconElement, Label, ListItem};
  34use unicase::UniCase;
  35use util::{maybe, ResultExt, TryFutureExt};
  36use workspace::{
  37    dock::{DockPosition, Panel, PanelEvent},
  38    Workspace,
  39};
  40
  41const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
  42const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  43
  44pub struct ProjectPanel {
  45    project: Model<Project>,
  46    fs: Arc<dyn Fs>,
  47    list: UniformListScrollHandle,
  48    focus_handle: FocusHandle,
  49    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  50    last_worktree_root_id: Option<ProjectEntryId>,
  51    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  52    selection: Option<Selection>,
  53    context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
  54    edit_state: Option<EditState>,
  55    filename_editor: View<Editor>,
  56    clipboard_entry: Option<ClipboardEntry>,
  57    _dragged_entry_destination: Option<Arc<Path>>,
  58    _workspace: WeakView<Workspace>,
  59    width: Option<Pixels>,
  60    pending_serialization: Task<Option<()>>,
  61}
  62
  63#[derive(Copy, Clone, Debug)]
  64struct Selection {
  65    worktree_id: WorktreeId,
  66    entry_id: ProjectEntryId,
  67}
  68
  69#[derive(Clone, Debug)]
  70struct EditState {
  71    worktree_id: WorktreeId,
  72    entry_id: ProjectEntryId,
  73    is_new_entry: bool,
  74    is_dir: bool,
  75    processing_filename: Option<String>,
  76}
  77
  78#[derive(Copy, Clone)]
  79pub enum ClipboardEntry {
  80    Copied {
  81        worktree_id: WorktreeId,
  82        entry_id: ProjectEntryId,
  83    },
  84    Cut {
  85        worktree_id: WorktreeId,
  86        entry_id: ProjectEntryId,
  87    },
  88}
  89
  90#[derive(Debug, PartialEq, Eq, Clone)]
  91pub struct EntryDetails {
  92    filename: String,
  93    icon: Option<Arc<str>>,
  94    path: Arc<Path>,
  95    depth: usize,
  96    kind: EntryKind,
  97    is_ignored: bool,
  98    is_expanded: bool,
  99    is_selected: bool,
 100    is_editing: bool,
 101    is_processing: bool,
 102    is_cut: bool,
 103    git_status: Option<GitFileStatus>,
 104}
 105
 106actions!(
 107    project_panel,
 108    [
 109        ExpandSelectedEntry,
 110        CollapseSelectedEntry,
 111        CollapseAllEntries,
 112        NewDirectory,
 113        NewFile,
 114        Copy,
 115        CopyPath,
 116        CopyRelativePath,
 117        RevealInFinder,
 118        OpenInTerminal,
 119        Cut,
 120        Paste,
 121        Delete,
 122        Rename,
 123        Open,
 124        ToggleFocus,
 125        NewSearchInDirectory,
 126    ]
 127);
 128
 129pub fn init_settings(cx: &mut AppContext) {
 130    ProjectPanelSettings::register(cx);
 131}
 132
 133pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
 134    init_settings(cx);
 135    file_associations::init(assets, cx);
 136
 137    cx.observe_new_views(|workspace: &mut Workspace, _| {
 138        workspace.register_action(|workspace, _: &ToggleFocus, cx| {
 139            workspace.toggle_panel_focus::<ProjectPanel>(cx);
 140        });
 141    })
 142    .detach();
 143}
 144
 145#[derive(Debug)]
 146pub enum Event {
 147    OpenedEntry {
 148        entry_id: ProjectEntryId,
 149        focus_opened_item: bool,
 150    },
 151    SplitEntry {
 152        entry_id: ProjectEntryId,
 153    },
 154    Focus,
 155    NewSearchInDirectory {
 156        dir_entry: Entry,
 157    },
 158    ActivatePanel,
 159}
 160
 161#[derive(Serialize, Deserialize)]
 162struct SerializedProjectPanel {
 163    width: Option<f32>,
 164}
 165
 166struct DraggedProjectEntryView {
 167    entry_id: ProjectEntryId,
 168    details: EntryDetails,
 169    width: Pixels,
 170}
 171
 172impl ProjectPanel {
 173    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
 174        let project = workspace.project().clone();
 175        let project_panel = cx.build_view(|cx: &mut ViewContext<Self>| {
 176            cx.observe(&project, |this, _, cx| {
 177                this.update_visible_entries(None, cx);
 178                cx.notify();
 179            })
 180            .detach();
 181            let focus_handle = cx.focus_handle();
 182
 183            cx.on_focus(&focus_handle, Self::focus_in).detach();
 184
 185            cx.subscribe(&project, |this, project, event, cx| match event {
 186                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 187                    if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
 188                        this.reveal_entry(project, *entry_id, true, cx);
 189                    }
 190                }
 191                project::Event::RevealInProjectPanel(entry_id) => {
 192                    this.reveal_entry(project, *entry_id, false, cx);
 193                    cx.emit(Event::ActivatePanel);
 194                }
 195                project::Event::ActivateProjectPanel => {
 196                    cx.emit(Event::ActivatePanel);
 197                }
 198                project::Event::WorktreeRemoved(id) => {
 199                    this.expanded_dir_ids.remove(id);
 200                    this.update_visible_entries(None, cx);
 201                    cx.notify();
 202                }
 203                _ => {}
 204            })
 205            .detach();
 206
 207            let filename_editor = cx.build_view(|cx| Editor::single_line(cx));
 208
 209            cx.subscribe(&filename_editor, |this, _, event, cx| match event {
 210                editor::EditorEvent::BufferEdited
 211                | editor::EditorEvent::SelectionsChanged { .. } => {
 212                    this.autoscroll(cx);
 213                }
 214                editor::EditorEvent::Blurred => {
 215                    if this
 216                        .edit_state
 217                        .as_ref()
 218                        .map_or(false, |state| state.processing_filename.is_none())
 219                    {
 220                        this.edit_state = None;
 221                        this.update_visible_entries(None, cx);
 222                    }
 223                }
 224                _ => {}
 225            })
 226            .detach();
 227
 228            // cx.observe_global::<FileAssociations, _>(|_, cx| {
 229            //     cx.notify();
 230            // })
 231            // .detach();
 232
 233            let mut this = Self {
 234                project: project.clone(),
 235                fs: workspace.app_state().fs.clone(),
 236                list: UniformListScrollHandle::new(),
 237                focus_handle,
 238                visible_entries: Default::default(),
 239                last_worktree_root_id: Default::default(),
 240                expanded_dir_ids: Default::default(),
 241                selection: None,
 242                edit_state: None,
 243                context_menu: None,
 244                filename_editor,
 245                clipboard_entry: None,
 246                _dragged_entry_destination: None,
 247                _workspace: workspace.weak_handle(),
 248                width: None,
 249                pending_serialization: Task::ready(None),
 250            };
 251            this.update_visible_entries(None, cx);
 252
 253            // Update the dock position when the setting changes.
 254            let mut old_dock_position = this.position(cx);
 255            ProjectPanelSettings::register(cx);
 256            cx.observe_global::<SettingsStore>(move |this, cx| {
 257                let new_dock_position = this.position(cx);
 258                if new_dock_position != old_dock_position {
 259                    old_dock_position = new_dock_position;
 260                    cx.emit(PanelEvent::ChangePosition);
 261                }
 262            })
 263            .detach();
 264
 265            this
 266        });
 267
 268        cx.subscribe(&project_panel, {
 269            let project_panel = project_panel.downgrade();
 270            move |workspace, _, event, cx| match event {
 271                &Event::OpenedEntry {
 272                    entry_id,
 273                    focus_opened_item,
 274                } => {
 275                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 276                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 277                            workspace
 278                                .open_path(
 279                                    ProjectPath {
 280                                        worktree_id: worktree.read(cx).id(),
 281                                        path: entry.path.clone(),
 282                                    },
 283                                    None,
 284                                    focus_opened_item,
 285                                    cx,
 286                                )
 287                                .detach_and_log_err(cx);
 288                            if !focus_opened_item {
 289                                if let Some(project_panel) = project_panel.upgrade() {
 290                                    let focus_handle = project_panel.read(cx).focus_handle.clone();
 291                                    cx.focus(&focus_handle);
 292                                }
 293                            }
 294                        }
 295                    }
 296                }
 297                &Event::SplitEntry { entry_id } => {
 298                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 299                        if let Some(_entry) = worktree.read(cx).entry_for_id(entry_id) {
 300                            // workspace
 301                            //     .split_path(
 302                            //         ProjectPath {
 303                            //             worktree_id: worktree.read(cx).id(),
 304                            //             path: entry.path.clone(),
 305                            //         },
 306                            //         cx,
 307                            //     )
 308                            //     .detach_and_log_err(cx);
 309                        }
 310                    }
 311                }
 312                _ => {}
 313            }
 314        })
 315        .detach();
 316
 317        project_panel
 318    }
 319
 320    pub async fn load(
 321        workspace: WeakView<Workspace>,
 322        mut cx: AsyncWindowContext,
 323    ) -> Result<View<Self>> {
 324        let serialized_panel = cx
 325            .background_executor()
 326            .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
 327            .await
 328            .map_err(|e| anyhow!("Failed to load project panel: {}", e))
 329            .log_err()
 330            .flatten()
 331            .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
 332            .transpose()
 333            .log_err()
 334            .flatten();
 335
 336        workspace.update(&mut cx, |workspace, cx| {
 337            let panel = ProjectPanel::new(workspace, cx);
 338            if let Some(serialized_panel) = serialized_panel {
 339                panel.update(cx, |panel, cx| {
 340                    panel.width = serialized_panel.width.map(px);
 341                    cx.notify();
 342                });
 343            }
 344            panel
 345        })
 346    }
 347
 348    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 349        let width = self.width;
 350        self.pending_serialization = cx.background_executor().spawn(
 351            async move {
 352                KEY_VALUE_STORE
 353                    .write_kvp(
 354                        PROJECT_PANEL_KEY.into(),
 355                        serde_json::to_string(&SerializedProjectPanel {
 356                            width: width.map(|p| p.0),
 357                        })?,
 358                    )
 359                    .await?;
 360                anyhow::Ok(())
 361            }
 362            .log_err(),
 363        );
 364    }
 365
 366    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
 367        if !self.focus_handle.contains_focused(cx) {
 368            cx.emit(Event::Focus);
 369        }
 370    }
 371
 372    fn deploy_context_menu(
 373        &mut self,
 374        position: Point<Pixels>,
 375        entry_id: ProjectEntryId,
 376        cx: &mut ViewContext<Self>,
 377    ) {
 378        let this = cx.view().clone();
 379        let project = self.project.read(cx);
 380
 381        let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 382            id
 383        } else {
 384            return;
 385        };
 386
 387        self.selection = Some(Selection {
 388            worktree_id,
 389            entry_id,
 390        });
 391
 392        if let Some((worktree, entry)) = self.selected_entry(cx) {
 393            let is_root = Some(entry) == worktree.root_entry();
 394            let is_dir = entry.is_dir();
 395            let worktree_id = worktree.id();
 396            let is_local = project.is_local();
 397
 398            let context_menu = ContextMenu::build(cx, |mut menu, cx| {
 399                if is_local {
 400                    menu = menu.action(
 401                        "Add Folder to Project",
 402                        Box::new(workspace::AddFolderToProject),
 403                    );
 404                    if is_root {
 405                        menu = menu.entry(
 406                            "Remove from Project",
 407                            cx.handler_for(&this, move |this, cx| {
 408                                this.project.update(cx, |project, cx| {
 409                                    project.remove_worktree(worktree_id, cx)
 410                                });
 411                            }),
 412                        );
 413                    }
 414                }
 415
 416                menu = menu
 417                    .action("New File", Box::new(NewFile))
 418                    .action("New Folder", Box::new(NewDirectory))
 419                    .separator()
 420                    .action("Cut", Box::new(Cut))
 421                    .action("Copy", Box::new(Copy));
 422
 423                if let Some(clipboard_entry) = self.clipboard_entry {
 424                    if clipboard_entry.worktree_id() == worktree_id {
 425                        menu = menu.action("Paste", Box::new(Paste));
 426                    }
 427                }
 428
 429                menu = menu
 430                    .separator()
 431                    .action("Copy Path", Box::new(CopyPath))
 432                    .action("Copy Relative Path", Box::new(CopyRelativePath))
 433                    .separator()
 434                    .action("Reveal in Finder", Box::new(RevealInFinder));
 435
 436                if is_dir {
 437                    menu = menu
 438                        .action("Open in Terminal", Box::new(OpenInTerminal))
 439                        .action("Search Inside", Box::new(NewSearchInDirectory))
 440                }
 441
 442                menu = menu.separator().action("Rename", Box::new(Rename));
 443
 444                if !is_root {
 445                    menu = menu.action("Delete", Box::new(Delete));
 446                }
 447
 448                menu
 449            });
 450
 451            cx.focus_view(&context_menu);
 452            let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
 453                this.context_menu.take();
 454                cx.notify();
 455            });
 456            self.context_menu = Some((context_menu, position, subscription));
 457        }
 458
 459        cx.notify();
 460    }
 461
 462    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 463        if let Some((worktree, entry)) = self.selected_entry(cx) {
 464            if entry.is_dir() {
 465                let worktree_id = worktree.id();
 466                let entry_id = entry.id;
 467                let expanded_dir_ids =
 468                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 469                        expanded_dir_ids
 470                    } else {
 471                        return;
 472                    };
 473
 474                match expanded_dir_ids.binary_search(&entry_id) {
 475                    Ok(_) => self.select_next(&SelectNext, cx),
 476                    Err(ix) => {
 477                        self.project.update(cx, |project, cx| {
 478                            project.expand_entry(worktree_id, entry_id, cx);
 479                        });
 480
 481                        expanded_dir_ids.insert(ix, entry_id);
 482                        self.update_visible_entries(None, cx);
 483                        cx.notify();
 484                    }
 485                }
 486            }
 487        }
 488    }
 489
 490    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 491        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 492            let worktree_id = worktree.id();
 493            let expanded_dir_ids =
 494                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 495                    expanded_dir_ids
 496                } else {
 497                    return;
 498                };
 499
 500            loop {
 501                let entry_id = entry.id;
 502                match expanded_dir_ids.binary_search(&entry_id) {
 503                    Ok(ix) => {
 504                        expanded_dir_ids.remove(ix);
 505                        self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 506                        cx.notify();
 507                        break;
 508                    }
 509                    Err(_) => {
 510                        if let Some(parent_entry) =
 511                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 512                        {
 513                            entry = parent_entry;
 514                        } else {
 515                            break;
 516                        }
 517                    }
 518                }
 519            }
 520        }
 521    }
 522
 523    pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
 524        self.expanded_dir_ids.clear();
 525        self.update_visible_entries(None, cx);
 526        cx.notify();
 527    }
 528
 529    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 530        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 531            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 532                self.project.update(cx, |project, cx| {
 533                    match expanded_dir_ids.binary_search(&entry_id) {
 534                        Ok(ix) => {
 535                            expanded_dir_ids.remove(ix);
 536                        }
 537                        Err(ix) => {
 538                            project.expand_entry(worktree_id, entry_id, cx);
 539                            expanded_dir_ids.insert(ix, entry_id);
 540                        }
 541                    }
 542                });
 543                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 544                cx.focus(&self.focus_handle);
 545                cx.notify();
 546            }
 547        }
 548    }
 549
 550    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 551        if let Some(selection) = self.selection {
 552            let (mut worktree_ix, mut entry_ix, _) =
 553                self.index_for_selection(selection).unwrap_or_default();
 554            if entry_ix > 0 {
 555                entry_ix -= 1;
 556            } else if worktree_ix > 0 {
 557                worktree_ix -= 1;
 558                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 559            } else {
 560                return;
 561            }
 562
 563            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 564            self.selection = Some(Selection {
 565                worktree_id: *worktree_id,
 566                entry_id: worktree_entries[entry_ix].id,
 567            });
 568            self.autoscroll(cx);
 569            cx.notify();
 570        } else {
 571            self.select_first(cx);
 572        }
 573    }
 574
 575    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
 576        if let Some(task) = self.confirm_edit(cx) {
 577            task.detach_and_log_err(cx);
 578        }
 579    }
 580
 581    fn open_file(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
 582        if let Some((_, entry)) = self.selected_entry(cx) {
 583            if entry.is_file() {
 584                self.open_entry(entry.id, true, cx);
 585            }
 586        }
 587    }
 588
 589    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 590        let edit_state = self.edit_state.as_mut()?;
 591        cx.focus(&self.focus_handle);
 592
 593        let worktree_id = edit_state.worktree_id;
 594        let is_new_entry = edit_state.is_new_entry;
 595        let is_dir = edit_state.is_dir;
 596        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 597        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 598        let filename = self.filename_editor.read(cx).text(cx);
 599
 600        let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
 601        let edit_task;
 602        let edited_entry_id;
 603        if is_new_entry {
 604            self.selection = Some(Selection {
 605                worktree_id,
 606                entry_id: NEW_ENTRY_ID,
 607            });
 608            let new_path = entry.path.join(&filename.trim_start_matches("/"));
 609            if path_already_exists(new_path.as_path()) {
 610                return None;
 611            }
 612
 613            edited_entry_id = NEW_ENTRY_ID;
 614            edit_task = self.project.update(cx, |project, cx| {
 615                project.create_entry((worktree_id, &new_path), is_dir, cx)
 616            });
 617        } else {
 618            let new_path = if let Some(parent) = entry.path.clone().parent() {
 619                parent.join(&filename)
 620            } else {
 621                filename.clone().into()
 622            };
 623            if path_already_exists(new_path.as_path()) {
 624                return None;
 625            }
 626
 627            edited_entry_id = entry.id;
 628            edit_task = self.project.update(cx, |project, cx| {
 629                project.rename_entry(entry.id, new_path.as_path(), cx)
 630            });
 631        };
 632
 633        edit_state.processing_filename = Some(filename);
 634        cx.notify();
 635
 636        Some(cx.spawn(|this, mut cx| async move {
 637            let new_entry = edit_task.await;
 638            this.update(&mut cx, |this, cx| {
 639                this.edit_state.take();
 640                cx.notify();
 641            })?;
 642
 643            if let Some(new_entry) = new_entry? {
 644                this.update(&mut cx, |this, cx| {
 645                    if let Some(selection) = &mut this.selection {
 646                        if selection.entry_id == edited_entry_id {
 647                            selection.worktree_id = worktree_id;
 648                            selection.entry_id = new_entry.id;
 649                            this.expand_to_selection(cx);
 650                        }
 651                    }
 652                    this.update_visible_entries(None, cx);
 653                    if is_new_entry && !is_dir {
 654                        this.open_entry(new_entry.id, true, cx);
 655                    }
 656                    cx.notify();
 657                })?;
 658            }
 659            Ok(())
 660        }))
 661    }
 662
 663    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 664        self.edit_state = None;
 665        self.update_visible_entries(None, cx);
 666        cx.focus(&self.focus_handle);
 667        cx.notify();
 668    }
 669
 670    fn open_entry(
 671        &mut self,
 672        entry_id: ProjectEntryId,
 673        focus_opened_item: bool,
 674        cx: &mut ViewContext<Self>,
 675    ) {
 676        cx.emit(Event::OpenedEntry {
 677            entry_id,
 678            focus_opened_item,
 679        });
 680    }
 681
 682    fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 683        cx.emit(Event::SplitEntry { entry_id });
 684    }
 685
 686    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 687        self.add_entry(false, cx)
 688    }
 689
 690    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 691        self.add_entry(true, cx)
 692    }
 693
 694    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 695        if let Some(Selection {
 696            worktree_id,
 697            entry_id,
 698        }) = self.selection
 699        {
 700            let directory_id;
 701            if let Some((worktree, expanded_dir_ids)) = self
 702                .project
 703                .read(cx)
 704                .worktree_for_id(worktree_id, cx)
 705                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 706            {
 707                let worktree = worktree.read(cx);
 708                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 709                    loop {
 710                        if entry.is_dir() {
 711                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 712                                expanded_dir_ids.insert(ix, entry.id);
 713                            }
 714                            directory_id = entry.id;
 715                            break;
 716                        } else {
 717                            if let Some(parent_path) = entry.path.parent() {
 718                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 719                                    entry = parent_entry;
 720                                    continue;
 721                                }
 722                            }
 723                            return;
 724                        }
 725                    }
 726                } else {
 727                    return;
 728                };
 729            } else {
 730                return;
 731            };
 732
 733            self.edit_state = Some(EditState {
 734                worktree_id,
 735                entry_id: directory_id,
 736                is_new_entry: true,
 737                is_dir,
 738                processing_filename: None,
 739            });
 740            self.filename_editor.update(cx, |editor, cx| {
 741                editor.clear(cx);
 742                editor.focus(cx);
 743            });
 744            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 745            self.autoscroll(cx);
 746            cx.notify();
 747        }
 748    }
 749
 750    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 751        if let Some(Selection {
 752            worktree_id,
 753            entry_id,
 754        }) = self.selection
 755        {
 756            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 757                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 758                    self.edit_state = Some(EditState {
 759                        worktree_id,
 760                        entry_id,
 761                        is_new_entry: false,
 762                        is_dir: entry.is_dir(),
 763                        processing_filename: None,
 764                    });
 765                    let file_name = entry
 766                        .path
 767                        .file_name()
 768                        .map(|s| s.to_string_lossy())
 769                        .unwrap_or_default()
 770                        .to_string();
 771                    let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
 772                    let selection_end =
 773                        file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
 774                    self.filename_editor.update(cx, |editor, cx| {
 775                        editor.set_text(file_name, cx);
 776                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 777                            s.select_ranges([0..selection_end])
 778                        });
 779                        editor.focus(cx);
 780                    });
 781                    self.update_visible_entries(None, cx);
 782                    self.autoscroll(cx);
 783                    cx.notify();
 784                }
 785            }
 786
 787            // cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
 788            //     drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
 789            // })
 790        }
 791    }
 792
 793    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) {
 794        maybe!({
 795            let Selection { entry_id, .. } = self.selection?;
 796            let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 797            let file_name = path.file_name()?;
 798
 799            let answer = cx.prompt(
 800                PromptLevel::Info,
 801                &format!("Delete {file_name:?}?"),
 802                &["Delete", "Cancel"],
 803            );
 804
 805            cx.spawn(|this, mut cx| async move {
 806                if answer.await != Ok(0) {
 807                    return Ok(());
 808                }
 809                this.update(&mut cx, |this, cx| {
 810                    this.project
 811                        .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 812                        .ok_or_else(|| anyhow!("no such entry"))
 813                })??
 814                .await
 815            })
 816            .detach_and_log_err(cx);
 817            Some(())
 818        });
 819    }
 820
 821    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 822        if let Some(selection) = self.selection {
 823            let (mut worktree_ix, mut entry_ix, _) =
 824                self.index_for_selection(selection).unwrap_or_default();
 825            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 826                if entry_ix + 1 < worktree_entries.len() {
 827                    entry_ix += 1;
 828                } else {
 829                    worktree_ix += 1;
 830                    entry_ix = 0;
 831                }
 832            }
 833
 834            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 835                if let Some(entry) = worktree_entries.get(entry_ix) {
 836                    self.selection = Some(Selection {
 837                        worktree_id: *worktree_id,
 838                        entry_id: entry.id,
 839                    });
 840                    self.autoscroll(cx);
 841                    cx.notify();
 842                }
 843            }
 844        } else {
 845            self.select_first(cx);
 846        }
 847    }
 848
 849    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 850        let worktree = self
 851            .visible_entries
 852            .first()
 853            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 854        if let Some(worktree) = worktree {
 855            let worktree = worktree.read(cx);
 856            let worktree_id = worktree.id();
 857            if let Some(root_entry) = worktree.root_entry() {
 858                self.selection = Some(Selection {
 859                    worktree_id,
 860                    entry_id: root_entry.id,
 861                });
 862                self.autoscroll(cx);
 863                cx.notify();
 864            }
 865        }
 866    }
 867
 868    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
 869        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 870            self.list.scroll_to_item(index);
 871            cx.notify();
 872        }
 873    }
 874
 875    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 876        if let Some((worktree, entry)) = self.selected_entry(cx) {
 877            self.clipboard_entry = Some(ClipboardEntry::Cut {
 878                worktree_id: worktree.id(),
 879                entry_id: entry.id,
 880            });
 881            cx.notify();
 882        }
 883    }
 884
 885    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 886        if let Some((worktree, entry)) = self.selected_entry(cx) {
 887            self.clipboard_entry = Some(ClipboardEntry::Copied {
 888                worktree_id: worktree.id(),
 889                entry_id: entry.id,
 890            });
 891            cx.notify();
 892        }
 893    }
 894
 895    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
 896        maybe!({
 897            let (worktree, entry) = self.selected_entry(cx)?;
 898            let clipboard_entry = self.clipboard_entry?;
 899            if clipboard_entry.worktree_id() != worktree.id() {
 900                return None;
 901            }
 902
 903            let clipboard_entry_file_name = self
 904                .project
 905                .read(cx)
 906                .path_for_entry(clipboard_entry.entry_id(), cx)?
 907                .path
 908                .file_name()?
 909                .to_os_string();
 910
 911            let mut new_path = entry.path.to_path_buf();
 912            if entry.is_file() {
 913                new_path.pop();
 914            }
 915
 916            new_path.push(&clipboard_entry_file_name);
 917            let extension = new_path.extension().map(|e| e.to_os_string());
 918            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 919            let mut ix = 0;
 920            while worktree.entry_for_path(&new_path).is_some() {
 921                new_path.pop();
 922
 923                let mut new_file_name = file_name_without_extension.to_os_string();
 924                new_file_name.push(" copy");
 925                if ix > 0 {
 926                    new_file_name.push(format!(" {}", ix));
 927                }
 928                if let Some(extension) = extension.as_ref() {
 929                    new_file_name.push(".");
 930                    new_file_name.push(extension);
 931                }
 932
 933                new_path.push(new_file_name);
 934                ix += 1;
 935            }
 936
 937            if clipboard_entry.is_cut() {
 938                self.project
 939                    .update(cx, |project, cx| {
 940                        project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 941                    })
 942                    .detach_and_log_err(cx)
 943            } else {
 944                self.project
 945                    .update(cx, |project, cx| {
 946                        project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 947                    })
 948                    .detach_and_log_err(cx)
 949            }
 950
 951            Some(())
 952        });
 953    }
 954
 955    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 956        if let Some((worktree, entry)) = self.selected_entry(cx) {
 957            cx.write_to_clipboard(ClipboardItem::new(
 958                worktree
 959                    .abs_path()
 960                    .join(&entry.path)
 961                    .to_string_lossy()
 962                    .to_string(),
 963            ));
 964        }
 965    }
 966
 967    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
 968        if let Some((_, entry)) = self.selected_entry(cx) {
 969            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
 970        }
 971    }
 972
 973    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
 974        if let Some((worktree, entry)) = self.selected_entry(cx) {
 975            cx.reveal_path(&worktree.abs_path().join(&entry.path));
 976        }
 977    }
 978
 979    fn open_in_terminal(&mut self, _: &OpenInTerminal, _cx: &mut ViewContext<Self>) {
 980        todo!()
 981        // if let Some((worktree, entry)) = self.selected_entry(cx) {
 982        //     let window = cx.window();
 983        //     let view_id = cx.view_id();
 984        //     let path = worktree.abs_path().join(&entry.path);
 985
 986        //     cx.app_context()
 987        //         .spawn(|mut cx| async move {
 988        //             window.dispatch_action(
 989        //                 view_id,
 990        //                 &workspace::OpenTerminal {
 991        //                     working_directory: path,
 992        //                 },
 993        //                 &mut cx,
 994        //             );
 995        //         })
 996        //         .detach();
 997        // }
 998    }
 999
1000    pub fn new_search_in_directory(
1001        &mut self,
1002        _: &NewSearchInDirectory,
1003        cx: &mut ViewContext<Self>,
1004    ) {
1005        if let Some((_, entry)) = self.selected_entry(cx) {
1006            if entry.is_dir() {
1007                cx.emit(Event::NewSearchInDirectory {
1008                    dir_entry: entry.clone(),
1009                });
1010            }
1011        }
1012    }
1013
1014    fn move_entry(
1015        &mut self,
1016        entry_to_move: ProjectEntryId,
1017        destination: ProjectEntryId,
1018        destination_is_file: bool,
1019        cx: &mut ViewContext<Self>,
1020    ) {
1021        let destination_worktree = self.project.update(cx, |project, cx| {
1022            let entry_path = project.path_for_entry(entry_to_move, cx)?;
1023            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1024
1025            let mut destination_path = destination_entry_path.as_ref();
1026            if destination_is_file {
1027                destination_path = destination_path.parent()?;
1028            }
1029
1030            let mut new_path = destination_path.to_path_buf();
1031            new_path.push(entry_path.path.file_name()?);
1032            if new_path != entry_path.path.as_ref() {
1033                let task = project.rename_entry(entry_to_move, new_path, cx);
1034                cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1035            }
1036
1037            Some(project.worktree_id_for_entry(destination, cx)?)
1038        });
1039
1040        if let Some(destination_worktree) = destination_worktree {
1041            self.expand_entry(destination_worktree, destination, cx);
1042        }
1043    }
1044
1045    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1046        let mut entry_index = 0;
1047        let mut visible_entries_index = 0;
1048        for (worktree_index, (worktree_id, worktree_entries)) in
1049            self.visible_entries.iter().enumerate()
1050        {
1051            if *worktree_id == selection.worktree_id {
1052                for entry in worktree_entries {
1053                    if entry.id == selection.entry_id {
1054                        return Some((worktree_index, entry_index, visible_entries_index));
1055                    } else {
1056                        visible_entries_index += 1;
1057                        entry_index += 1;
1058                    }
1059                }
1060                break;
1061            } else {
1062                visible_entries_index += worktree_entries.len();
1063            }
1064        }
1065        None
1066    }
1067
1068    pub fn selected_entry<'a>(
1069        &self,
1070        cx: &'a AppContext,
1071    ) -> Option<(&'a Worktree, &'a project::Entry)> {
1072        let (worktree, entry) = self.selected_entry_handle(cx)?;
1073        Some((worktree.read(cx), entry))
1074    }
1075
1076    fn selected_entry_handle<'a>(
1077        &self,
1078        cx: &'a AppContext,
1079    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1080        let selection = self.selection?;
1081        let project = self.project.read(cx);
1082        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1083        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1084        Some((worktree, entry))
1085    }
1086
1087    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1088        let (worktree, entry) = self.selected_entry(cx)?;
1089        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1090
1091        for path in entry.path.ancestors() {
1092            let Some(entry) = worktree.entry_for_path(path) else {
1093                continue;
1094            };
1095            if entry.is_dir() {
1096                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1097                    expanded_dir_ids.insert(idx, entry.id);
1098                }
1099            }
1100        }
1101
1102        Some(())
1103    }
1104
1105    fn update_visible_entries(
1106        &mut self,
1107        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1108        cx: &mut ViewContext<Self>,
1109    ) {
1110        let project = self.project.read(cx);
1111        self.last_worktree_root_id = project
1112            .visible_worktrees(cx)
1113            .rev()
1114            .next()
1115            .and_then(|worktree| worktree.read(cx).root_entry())
1116            .map(|entry| entry.id);
1117
1118        self.visible_entries.clear();
1119        for worktree in project.visible_worktrees(cx) {
1120            let snapshot = worktree.read(cx).snapshot();
1121            let worktree_id = snapshot.id();
1122
1123            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1124                hash_map::Entry::Occupied(e) => e.into_mut(),
1125                hash_map::Entry::Vacant(e) => {
1126                    // The first time a worktree's root entry becomes available,
1127                    // mark that root entry as expanded.
1128                    if let Some(entry) = snapshot.root_entry() {
1129                        e.insert(vec![entry.id]).as_slice()
1130                    } else {
1131                        &[]
1132                    }
1133                }
1134            };
1135
1136            let mut new_entry_parent_id = None;
1137            let mut new_entry_kind = EntryKind::Dir;
1138            if let Some(edit_state) = &self.edit_state {
1139                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1140                    new_entry_parent_id = Some(edit_state.entry_id);
1141                    new_entry_kind = if edit_state.is_dir {
1142                        EntryKind::Dir
1143                    } else {
1144                        EntryKind::File(Default::default())
1145                    };
1146                }
1147            }
1148
1149            let mut visible_worktree_entries = Vec::new();
1150            let mut entry_iter = snapshot.entries(true);
1151
1152            while let Some(entry) = entry_iter.entry() {
1153                visible_worktree_entries.push(entry.clone());
1154                if Some(entry.id) == new_entry_parent_id {
1155                    visible_worktree_entries.push(Entry {
1156                        id: NEW_ENTRY_ID,
1157                        kind: new_entry_kind,
1158                        path: entry.path.join("\0").into(),
1159                        inode: 0,
1160                        mtime: entry.mtime,
1161                        is_symlink: false,
1162                        is_ignored: false,
1163                        is_external: false,
1164                        git_status: entry.git_status,
1165                    });
1166                }
1167                if expanded_dir_ids.binary_search(&entry.id).is_err()
1168                    && entry_iter.advance_to_sibling()
1169                {
1170                    continue;
1171                }
1172                entry_iter.advance();
1173            }
1174
1175            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1176
1177            visible_worktree_entries.sort_by(|entry_a, entry_b| {
1178                let mut components_a = entry_a.path.components().peekable();
1179                let mut components_b = entry_b.path.components().peekable();
1180                loop {
1181                    match (components_a.next(), components_b.next()) {
1182                        (Some(component_a), Some(component_b)) => {
1183                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1184                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1185                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1186                                let name_a =
1187                                    UniCase::new(component_a.as_os_str().to_string_lossy());
1188                                let name_b =
1189                                    UniCase::new(component_b.as_os_str().to_string_lossy());
1190                                name_a.cmp(&name_b)
1191                            });
1192                            if !ordering.is_eq() {
1193                                return ordering;
1194                            }
1195                        }
1196                        (Some(_), None) => break Ordering::Greater,
1197                        (None, Some(_)) => break Ordering::Less,
1198                        (None, None) => break Ordering::Equal,
1199                    }
1200                }
1201            });
1202            self.visible_entries
1203                .push((worktree_id, visible_worktree_entries));
1204        }
1205
1206        if let Some((worktree_id, entry_id)) = new_selected_entry {
1207            self.selection = Some(Selection {
1208                worktree_id,
1209                entry_id,
1210            });
1211        }
1212    }
1213
1214    fn expand_entry(
1215        &mut self,
1216        worktree_id: WorktreeId,
1217        entry_id: ProjectEntryId,
1218        cx: &mut ViewContext<Self>,
1219    ) {
1220        self.project.update(cx, |project, cx| {
1221            if let Some((worktree, expanded_dir_ids)) = project
1222                .worktree_for_id(worktree_id, cx)
1223                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1224            {
1225                project.expand_entry(worktree_id, entry_id, cx);
1226                let worktree = worktree.read(cx);
1227
1228                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1229                    loop {
1230                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1231                            expanded_dir_ids.insert(ix, entry.id);
1232                        }
1233
1234                        if let Some(parent_entry) =
1235                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1236                        {
1237                            entry = parent_entry;
1238                        } else {
1239                            break;
1240                        }
1241                    }
1242                }
1243            }
1244        });
1245    }
1246
1247    fn for_each_visible_entry(
1248        &self,
1249        range: Range<usize>,
1250        cx: &mut ViewContext<ProjectPanel>,
1251        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1252    ) {
1253        let mut ix = 0;
1254        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1255            if ix >= range.end {
1256                return;
1257            }
1258
1259            if ix + visible_worktree_entries.len() <= range.start {
1260                ix += visible_worktree_entries.len();
1261                continue;
1262            }
1263
1264            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1265            let (git_status_setting, show_file_icons, show_folder_icons) = {
1266                let settings = ProjectPanelSettings::get_global(cx);
1267                (
1268                    settings.git_status,
1269                    settings.file_icons,
1270                    settings.folder_icons,
1271                )
1272            };
1273            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1274                let snapshot = worktree.read(cx).snapshot();
1275                let root_name = OsStr::new(snapshot.root_name());
1276                let expanded_entry_ids = self
1277                    .expanded_dir_ids
1278                    .get(&snapshot.id())
1279                    .map(Vec::as_slice)
1280                    .unwrap_or(&[]);
1281
1282                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1283                for entry in visible_worktree_entries[entry_range].iter() {
1284                    let status = git_status_setting.then(|| entry.git_status).flatten();
1285                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1286                    let icon = match entry.kind {
1287                        EntryKind::File(_) => {
1288                            if show_file_icons {
1289                                FileAssociations::get_icon(&entry.path, cx)
1290                            } else {
1291                                None
1292                            }
1293                        }
1294                        _ => {
1295                            if show_folder_icons {
1296                                FileAssociations::get_folder_icon(is_expanded, cx)
1297                            } else {
1298                                FileAssociations::get_chevron_icon(is_expanded, cx)
1299                            }
1300                        }
1301                    };
1302
1303                    let mut details = EntryDetails {
1304                        filename: entry
1305                            .path
1306                            .file_name()
1307                            .unwrap_or(root_name)
1308                            .to_string_lossy()
1309                            .to_string(),
1310                        icon,
1311                        path: entry.path.clone(),
1312                        depth: entry.path.components().count(),
1313                        kind: entry.kind,
1314                        is_ignored: entry.is_ignored,
1315                        is_expanded,
1316                        is_selected: self.selection.map_or(false, |e| {
1317                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1318                        }),
1319                        is_editing: false,
1320                        is_processing: false,
1321                        is_cut: self
1322                            .clipboard_entry
1323                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1324                        git_status: status,
1325                    };
1326
1327                    if let Some(edit_state) = &self.edit_state {
1328                        let is_edited_entry = if edit_state.is_new_entry {
1329                            entry.id == NEW_ENTRY_ID
1330                        } else {
1331                            entry.id == edit_state.entry_id
1332                        };
1333
1334                        if is_edited_entry {
1335                            if let Some(processing_filename) = &edit_state.processing_filename {
1336                                details.is_processing = true;
1337                                details.filename.clear();
1338                                details.filename.push_str(processing_filename);
1339                            } else {
1340                                if edit_state.is_new_entry {
1341                                    details.filename.clear();
1342                                }
1343                                details.is_editing = true;
1344                            }
1345                        }
1346                    }
1347
1348                    callback(entry.id, details, cx);
1349                }
1350            }
1351            ix = end_ix;
1352        }
1353    }
1354
1355    fn render_entry(
1356        &self,
1357        entry_id: ProjectEntryId,
1358        details: EntryDetails,
1359        cx: &mut ViewContext<Self>,
1360    ) -> Stateful<Div> {
1361        let kind = details.kind;
1362        let settings = ProjectPanelSettings::get_global(cx);
1363        let show_editor = details.is_editing && !details.is_processing;
1364        let is_selected = self
1365            .selection
1366            .map_or(false, |selection| selection.entry_id == entry_id);
1367        let width = self.width.unwrap_or(px(0.));
1368
1369        let theme = cx.theme();
1370        let filename_text_color = details
1371            .git_status
1372            .as_ref()
1373            .map(|status| match status {
1374                GitFileStatus::Added => theme.status().created,
1375                GitFileStatus::Modified => theme.status().modified,
1376                GitFileStatus::Conflict => theme.status().conflict,
1377            })
1378            .unwrap_or(theme.status().info);
1379
1380        let file_name = details.filename.clone();
1381        let icon = details.icon.clone();
1382        let depth = details.depth;
1383        div()
1384            .id(entry_id.to_proto() as usize)
1385            .on_drag(entry_id, move |entry_id, cx| {
1386                cx.build_view(|_| DraggedProjectEntryView {
1387                    details: details.clone(),
1388                    width,
1389                    entry_id: *entry_id,
1390                })
1391            })
1392            .drag_over::<ProjectEntryId>(|style| {
1393                style.bg(cx.theme().colors().drop_target_background)
1394            })
1395            .on_drop(cx.listener(move |this, dragged_id: &ProjectEntryId, cx| {
1396                this.move_entry(*dragged_id, entry_id, kind.is_file(), cx);
1397            }))
1398            .child(
1399                ListItem::new(entry_id.to_proto() as usize)
1400                    .indent_level(depth)
1401                    .indent_step_size(px(settings.indent_size))
1402                    .selected(is_selected)
1403                    .child(if let Some(icon) = &icon {
1404                        div().child(IconElement::from_path(icon.to_string()).color(Color::Muted))
1405                    } else {
1406                        div()
1407                    })
1408                    .child(
1409                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1410                            div().h_full().w_full().child(editor.clone())
1411                        } else {
1412                            div()
1413                                .text_color(filename_text_color)
1414                                .child(Label::new(file_name))
1415                        }
1416                        .ml_1(),
1417                    )
1418                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1419                        if event.down.button == MouseButton::Right {
1420                            return;
1421                        }
1422                        if !show_editor {
1423                            if kind.is_dir() {
1424                                this.toggle_expanded(entry_id, cx);
1425                            } else {
1426                                if event.down.modifiers.command {
1427                                    this.split_entry(entry_id, cx);
1428                                } else {
1429                                    this.open_entry(entry_id, event.up.click_count > 1, cx);
1430                                }
1431                            }
1432                        }
1433                    }))
1434                    .on_secondary_mouse_down(cx.listener(
1435                        move |this, event: &MouseDownEvent, cx| {
1436                            this.deploy_context_menu(event.position, entry_id, cx);
1437                        },
1438                    )),
1439            )
1440    }
1441
1442    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1443        let mut dispatch_context = KeyContext::default();
1444        dispatch_context.add("ProjectPanel");
1445        dispatch_context.add("menu");
1446
1447        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1448            "editing"
1449        } else {
1450            "not_editing"
1451        };
1452
1453        dispatch_context.add(identifier);
1454        dispatch_context
1455    }
1456
1457    fn reveal_entry(
1458        &mut self,
1459        project: Model<Project>,
1460        entry_id: ProjectEntryId,
1461        skip_ignored: bool,
1462        cx: &mut ViewContext<'_, ProjectPanel>,
1463    ) {
1464        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1465            let worktree = worktree.read(cx);
1466            if skip_ignored
1467                && worktree
1468                    .entry_for_id(entry_id)
1469                    .map_or(true, |entry| entry.is_ignored)
1470            {
1471                return;
1472            }
1473
1474            let worktree_id = worktree.id();
1475            self.expand_entry(worktree_id, entry_id, cx);
1476            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1477            self.autoscroll(cx);
1478            cx.notify();
1479        }
1480    }
1481}
1482
1483impl Render for ProjectPanel {
1484    type Element = Focusable<Stateful<Div>>;
1485
1486    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> Self::Element {
1487        let has_worktree = self.visible_entries.len() != 0;
1488
1489        if has_worktree {
1490            div()
1491                .id("project-panel")
1492                .size_full()
1493                .relative()
1494                .key_context(self.dispatch_context(cx))
1495                .on_action(cx.listener(Self::select_next))
1496                .on_action(cx.listener(Self::select_prev))
1497                .on_action(cx.listener(Self::expand_selected_entry))
1498                .on_action(cx.listener(Self::collapse_selected_entry))
1499                .on_action(cx.listener(Self::collapse_all_entries))
1500                .on_action(cx.listener(Self::new_file))
1501                .on_action(cx.listener(Self::new_directory))
1502                .on_action(cx.listener(Self::rename))
1503                .on_action(cx.listener(Self::delete))
1504                .on_action(cx.listener(Self::confirm))
1505                .on_action(cx.listener(Self::open_file))
1506                .on_action(cx.listener(Self::cancel))
1507                .on_action(cx.listener(Self::cut))
1508                .on_action(cx.listener(Self::copy))
1509                .on_action(cx.listener(Self::copy_path))
1510                .on_action(cx.listener(Self::copy_relative_path))
1511                .on_action(cx.listener(Self::paste))
1512                .on_action(cx.listener(Self::reveal_in_finder))
1513                .on_action(cx.listener(Self::open_in_terminal))
1514                .on_action(cx.listener(Self::new_search_in_directory))
1515                .track_focus(&self.focus_handle)
1516                .child(
1517                    uniform_list(
1518                        cx.view().clone(),
1519                        "entries",
1520                        self.visible_entries
1521                            .iter()
1522                            .map(|(_, worktree_entries)| worktree_entries.len())
1523                            .sum(),
1524                        {
1525                            |this, range, cx| {
1526                                let mut items = Vec::new();
1527                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1528                                    items.push(this.render_entry(id, details, cx));
1529                                });
1530                                items
1531                            }
1532                        },
1533                    )
1534                    .size_full()
1535                    .track_scroll(self.list.clone()),
1536                )
1537                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1538                    overlay()
1539                        .position(*position)
1540                        .anchor(gpui::AnchorCorner::TopLeft)
1541                        .child(menu.clone())
1542                }))
1543        } else {
1544            v_stack()
1545                .id("empty-project_panel")
1546                .track_focus(&self.focus_handle)
1547        }
1548    }
1549}
1550
1551impl Render for DraggedProjectEntryView {
1552    type Element = Div;
1553
1554    fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
1555        let settings = ProjectPanelSettings::get_global(cx);
1556        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1557        h_stack()
1558            .font(ui_font)
1559            .bg(cx.theme().colors().background)
1560            .w(self.width)
1561            .child(
1562                ListItem::new(self.entry_id.to_proto() as usize)
1563                    .indent_level(self.details.depth)
1564                    .indent_step_size(px(settings.indent_size))
1565                    .child(if let Some(icon) = &self.details.icon {
1566                        div().child(IconElement::from_path(icon.to_string()))
1567                    } else {
1568                        div()
1569                    })
1570                    .child(Label::new(self.details.filename.clone())),
1571            )
1572    }
1573}
1574
1575impl EventEmitter<Event> for ProjectPanel {}
1576
1577impl EventEmitter<PanelEvent> for ProjectPanel {}
1578
1579impl Panel for ProjectPanel {
1580    fn position(&self, cx: &WindowContext) -> DockPosition {
1581        match ProjectPanelSettings::get_global(cx).dock {
1582            ProjectPanelDockPosition::Left => DockPosition::Left,
1583            ProjectPanelDockPosition::Right => DockPosition::Right,
1584        }
1585    }
1586
1587    fn position_is_valid(&self, position: DockPosition) -> bool {
1588        matches!(position, DockPosition::Left | DockPosition::Right)
1589    }
1590
1591    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1592        settings::update_settings_file::<ProjectPanelSettings>(
1593            self.fs.clone(),
1594            cx,
1595            move |settings| {
1596                let dock = match position {
1597                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1598                    DockPosition::Right => ProjectPanelDockPosition::Right,
1599                };
1600                settings.dock = Some(dock);
1601            },
1602        );
1603    }
1604
1605    fn size(&self, cx: &WindowContext) -> f32 {
1606        self.width.map_or_else(
1607            || ProjectPanelSettings::get_global(cx).default_width,
1608            |width| width.0,
1609        )
1610    }
1611
1612    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1613        self.width = size.map(px);
1614        self.serialize(cx);
1615        cx.notify();
1616    }
1617
1618    fn icon(&self, _: &WindowContext) -> Option<ui::Icon> {
1619        Some(ui::Icon::FileTree)
1620    }
1621
1622    fn toggle_action(&self) -> Box<dyn Action> {
1623        Box::new(ToggleFocus)
1624    }
1625
1626    fn persistent_name() -> &'static str {
1627        "Project Panel"
1628    }
1629}
1630
1631impl FocusableView for ProjectPanel {
1632    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1633        self.focus_handle.clone()
1634    }
1635}
1636
1637impl ClipboardEntry {
1638    fn is_cut(&self) -> bool {
1639        matches!(self, Self::Cut { .. })
1640    }
1641
1642    fn entry_id(&self) -> ProjectEntryId {
1643        match self {
1644            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1645                *entry_id
1646            }
1647        }
1648    }
1649
1650    fn worktree_id(&self) -> WorktreeId {
1651        match self {
1652            ClipboardEntry::Copied { worktree_id, .. }
1653            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1654        }
1655    }
1656}
1657
1658#[cfg(test)]
1659mod tests {
1660    use super::*;
1661    use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1662    use pretty_assertions::assert_eq;
1663    use project::{project_settings::ProjectSettings, FakeFs};
1664    use serde_json::json;
1665    use settings::SettingsStore;
1666    use std::{
1667        collections::HashSet,
1668        path::{Path, PathBuf},
1669        sync::atomic::{self, AtomicUsize},
1670    };
1671    use workspace::AppState;
1672
1673    #[gpui::test]
1674    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1675        init_test(cx);
1676
1677        let fs = FakeFs::new(cx.executor().clone());
1678        fs.insert_tree(
1679            "/root1",
1680            json!({
1681                ".dockerignore": "",
1682                ".git": {
1683                    "HEAD": "",
1684                },
1685                "a": {
1686                    "0": { "q": "", "r": "", "s": "" },
1687                    "1": { "t": "", "u": "" },
1688                    "2": { "v": "", "w": "", "x": "", "y": "" },
1689                },
1690                "b": {
1691                    "3": { "Q": "" },
1692                    "4": { "R": "", "S": "", "T": "", "U": "" },
1693                },
1694                "C": {
1695                    "5": {},
1696                    "6": { "V": "", "W": "" },
1697                    "7": { "X": "" },
1698                    "8": { "Y": {}, "Z": "" }
1699                }
1700            }),
1701        )
1702        .await;
1703        fs.insert_tree(
1704            "/root2",
1705            json!({
1706                "d": {
1707                    "9": ""
1708                },
1709                "e": {}
1710            }),
1711        )
1712        .await;
1713
1714        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1715        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1716        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1717        let panel = workspace
1718            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1719            .unwrap();
1720        assert_eq!(
1721            visible_entries_as_strings(&panel, 0..50, cx),
1722            &[
1723                "v root1",
1724                "    > .git",
1725                "    > a",
1726                "    > b",
1727                "    > C",
1728                "      .dockerignore",
1729                "v root2",
1730                "    > d",
1731                "    > e",
1732            ]
1733        );
1734
1735        toggle_expand_dir(&panel, "root1/b", cx);
1736        assert_eq!(
1737            visible_entries_as_strings(&panel, 0..50, cx),
1738            &[
1739                "v root1",
1740                "    > .git",
1741                "    > a",
1742                "    v b  <== selected",
1743                "        > 3",
1744                "        > 4",
1745                "    > C",
1746                "      .dockerignore",
1747                "v root2",
1748                "    > d",
1749                "    > e",
1750            ]
1751        );
1752
1753        assert_eq!(
1754            visible_entries_as_strings(&panel, 6..9, cx),
1755            &[
1756                //
1757                "    > C",
1758                "      .dockerignore",
1759                "v root2",
1760            ]
1761        );
1762    }
1763
1764    #[gpui::test]
1765    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1766        init_test(cx);
1767        cx.update(|cx| {
1768            cx.update_global::<SettingsStore, _>(|store, cx| {
1769                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1770                    project_settings.file_scan_exclusions =
1771                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1772                });
1773            });
1774        });
1775
1776        let fs = FakeFs::new(cx.background_executor.clone());
1777        fs.insert_tree(
1778            "/root1",
1779            json!({
1780                ".dockerignore": "",
1781                ".git": {
1782                    "HEAD": "",
1783                },
1784                "a": {
1785                    "0": { "q": "", "r": "", "s": "" },
1786                    "1": { "t": "", "u": "" },
1787                    "2": { "v": "", "w": "", "x": "", "y": "" },
1788                },
1789                "b": {
1790                    "3": { "Q": "" },
1791                    "4": { "R": "", "S": "", "T": "", "U": "" },
1792                },
1793                "C": {
1794                    "5": {},
1795                    "6": { "V": "", "W": "" },
1796                    "7": { "X": "" },
1797                    "8": { "Y": {}, "Z": "" }
1798                }
1799            }),
1800        )
1801        .await;
1802        fs.insert_tree(
1803            "/root2",
1804            json!({
1805                "d": {
1806                    "4": ""
1807                },
1808                "e": {}
1809            }),
1810        )
1811        .await;
1812
1813        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1814        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1815        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1816        let panel = workspace
1817            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1818            .unwrap();
1819        assert_eq!(
1820            visible_entries_as_strings(&panel, 0..50, cx),
1821            &[
1822                "v root1",
1823                "    > a",
1824                "    > b",
1825                "    > C",
1826                "      .dockerignore",
1827                "v root2",
1828                "    > d",
1829                "    > e",
1830            ]
1831        );
1832
1833        toggle_expand_dir(&panel, "root1/b", cx);
1834        assert_eq!(
1835            visible_entries_as_strings(&panel, 0..50, cx),
1836            &[
1837                "v root1",
1838                "    > a",
1839                "    v b  <== selected",
1840                "        > 3",
1841                "    > C",
1842                "      .dockerignore",
1843                "v root2",
1844                "    > d",
1845                "    > e",
1846            ]
1847        );
1848
1849        toggle_expand_dir(&panel, "root2/d", cx);
1850        assert_eq!(
1851            visible_entries_as_strings(&panel, 0..50, cx),
1852            &[
1853                "v root1",
1854                "    > a",
1855                "    v b",
1856                "        > 3",
1857                "    > C",
1858                "      .dockerignore",
1859                "v root2",
1860                "    v d  <== selected",
1861                "    > e",
1862            ]
1863        );
1864
1865        toggle_expand_dir(&panel, "root2/e", cx);
1866        assert_eq!(
1867            visible_entries_as_strings(&panel, 0..50, cx),
1868            &[
1869                "v root1",
1870                "    > a",
1871                "    v b",
1872                "        > 3",
1873                "    > C",
1874                "      .dockerignore",
1875                "v root2",
1876                "    v d",
1877                "    v e  <== selected",
1878            ]
1879        );
1880    }
1881
1882    #[gpui::test(iterations = 30)]
1883    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1884        init_test(cx);
1885
1886        let fs = FakeFs::new(cx.executor().clone());
1887        fs.insert_tree(
1888            "/root1",
1889            json!({
1890                ".dockerignore": "",
1891                ".git": {
1892                    "HEAD": "",
1893                },
1894                "a": {
1895                    "0": { "q": "", "r": "", "s": "" },
1896                    "1": { "t": "", "u": "" },
1897                    "2": { "v": "", "w": "", "x": "", "y": "" },
1898                },
1899                "b": {
1900                    "3": { "Q": "" },
1901                    "4": { "R": "", "S": "", "T": "", "U": "" },
1902                },
1903                "C": {
1904                    "5": {},
1905                    "6": { "V": "", "W": "" },
1906                    "7": { "X": "" },
1907                    "8": { "Y": {}, "Z": "" }
1908                }
1909            }),
1910        )
1911        .await;
1912        fs.insert_tree(
1913            "/root2",
1914            json!({
1915                "d": {
1916                    "9": ""
1917                },
1918                "e": {}
1919            }),
1920        )
1921        .await;
1922
1923        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1924        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1925        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1926        let panel = workspace
1927            .update(cx, |workspace, cx| {
1928                let panel = ProjectPanel::new(workspace, cx);
1929                workspace.add_panel(panel.clone(), cx);
1930                workspace.toggle_dock(panel.read(cx).position(cx), cx);
1931                panel
1932            })
1933            .unwrap();
1934
1935        select_path(&panel, "root1", cx);
1936        assert_eq!(
1937            visible_entries_as_strings(&panel, 0..10, cx),
1938            &[
1939                "v root1  <== selected",
1940                "    > .git",
1941                "    > a",
1942                "    > b",
1943                "    > C",
1944                "      .dockerignore",
1945                "v root2",
1946                "    > d",
1947                "    > e",
1948            ]
1949        );
1950
1951        // Add a file with the root folder selected. The filename editor is placed
1952        // before the first file in the root folder.
1953        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1954        panel.update(cx, |panel, cx| {
1955            assert!(panel.filename_editor.read(cx).is_focused(cx));
1956        });
1957        assert_eq!(
1958            visible_entries_as_strings(&panel, 0..10, cx),
1959            &[
1960                "v root1",
1961                "    > .git",
1962                "    > a",
1963                "    > b",
1964                "    > C",
1965                "      [EDITOR: '']  <== selected",
1966                "      .dockerignore",
1967                "v root2",
1968                "    > d",
1969                "    > e",
1970            ]
1971        );
1972
1973        let confirm = panel.update(cx, |panel, cx| {
1974            panel
1975                .filename_editor
1976                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1977            panel.confirm_edit(cx).unwrap()
1978        });
1979        assert_eq!(
1980            visible_entries_as_strings(&panel, 0..10, cx),
1981            &[
1982                "v root1",
1983                "    > .git",
1984                "    > a",
1985                "    > b",
1986                "    > C",
1987                "      [PROCESSING: 'the-new-filename']  <== selected",
1988                "      .dockerignore",
1989                "v root2",
1990                "    > d",
1991                "    > e",
1992            ]
1993        );
1994
1995        confirm.await.unwrap();
1996        assert_eq!(
1997            visible_entries_as_strings(&panel, 0..10, cx),
1998            &[
1999                "v root1",
2000                "    > .git",
2001                "    > a",
2002                "    > b",
2003                "    > C",
2004                "      .dockerignore",
2005                "      the-new-filename  <== selected",
2006                "v root2",
2007                "    > d",
2008                "    > e",
2009            ]
2010        );
2011
2012        select_path(&panel, "root1/b", cx);
2013        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2014        assert_eq!(
2015            visible_entries_as_strings(&panel, 0..10, cx),
2016            &[
2017                "v root1",
2018                "    > .git",
2019                "    > a",
2020                "    v b",
2021                "        > 3",
2022                "        > 4",
2023                "          [EDITOR: '']  <== selected",
2024                "    > C",
2025                "      .dockerignore",
2026                "      the-new-filename",
2027            ]
2028        );
2029
2030        panel
2031            .update(cx, |panel, cx| {
2032                panel
2033                    .filename_editor
2034                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2035                panel.confirm_edit(cx).unwrap()
2036            })
2037            .await
2038            .unwrap();
2039        assert_eq!(
2040            visible_entries_as_strings(&panel, 0..10, cx),
2041            &[
2042                "v root1",
2043                "    > .git",
2044                "    > a",
2045                "    v b",
2046                "        > 3",
2047                "        > 4",
2048                "          another-filename.txt  <== selected",
2049                "    > C",
2050                "      .dockerignore",
2051                "      the-new-filename",
2052            ]
2053        );
2054
2055        select_path(&panel, "root1/b/another-filename.txt", cx);
2056        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2057        assert_eq!(
2058            visible_entries_as_strings(&panel, 0..10, cx),
2059            &[
2060                "v root1",
2061                "    > .git",
2062                "    > a",
2063                "    v b",
2064                "        > 3",
2065                "        > 4",
2066                "          [EDITOR: 'another-filename.txt']  <== selected",
2067                "    > C",
2068                "      .dockerignore",
2069                "      the-new-filename",
2070            ]
2071        );
2072
2073        let confirm = panel.update(cx, |panel, cx| {
2074            panel.filename_editor.update(cx, |editor, cx| {
2075                let file_name_selections = editor.selections.all::<usize>(cx);
2076                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2077                let file_name_selection = &file_name_selections[0];
2078                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2079                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2080
2081                editor.set_text("a-different-filename.tar.gz", cx)
2082            });
2083            panel.confirm_edit(cx).unwrap()
2084        });
2085        assert_eq!(
2086            visible_entries_as_strings(&panel, 0..10, cx),
2087            &[
2088                "v root1",
2089                "    > .git",
2090                "    > a",
2091                "    v b",
2092                "        > 3",
2093                "        > 4",
2094                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2095                "    > C",
2096                "      .dockerignore",
2097                "      the-new-filename",
2098            ]
2099        );
2100
2101        confirm.await.unwrap();
2102        assert_eq!(
2103            visible_entries_as_strings(&panel, 0..10, cx),
2104            &[
2105                "v root1",
2106                "    > .git",
2107                "    > a",
2108                "    v b",
2109                "        > 3",
2110                "        > 4",
2111                "          a-different-filename.tar.gz  <== selected",
2112                "    > C",
2113                "      .dockerignore",
2114                "      the-new-filename",
2115            ]
2116        );
2117
2118        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2119        assert_eq!(
2120            visible_entries_as_strings(&panel, 0..10, cx),
2121            &[
2122                "v root1",
2123                "    > .git",
2124                "    > a",
2125                "    v b",
2126                "        > 3",
2127                "        > 4",
2128                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2129                "    > C",
2130                "      .dockerignore",
2131                "      the-new-filename",
2132            ]
2133        );
2134
2135        panel.update(cx, |panel, cx| {
2136            panel.filename_editor.update(cx, |editor, cx| {
2137                let file_name_selections = editor.selections.all::<usize>(cx);
2138                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2139                let file_name_selection = &file_name_selections[0];
2140                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2141                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..");
2142
2143            });
2144            panel.cancel(&Cancel, cx)
2145        });
2146
2147        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2148        assert_eq!(
2149            visible_entries_as_strings(&panel, 0..10, cx),
2150            &[
2151                "v root1",
2152                "    > .git",
2153                "    > a",
2154                "    v b",
2155                "        > [EDITOR: '']  <== selected",
2156                "        > 3",
2157                "        > 4",
2158                "          a-different-filename.tar.gz",
2159                "    > C",
2160                "      .dockerignore",
2161            ]
2162        );
2163
2164        let confirm = panel.update(cx, |panel, cx| {
2165            panel
2166                .filename_editor
2167                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2168            panel.confirm_edit(cx).unwrap()
2169        });
2170        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2171        assert_eq!(
2172            visible_entries_as_strings(&panel, 0..10, cx),
2173            &[
2174                "v root1",
2175                "    > .git",
2176                "    > a",
2177                "    v b",
2178                "        > [PROCESSING: 'new-dir']",
2179                "        > 3  <== selected",
2180                "        > 4",
2181                "          a-different-filename.tar.gz",
2182                "    > C",
2183                "      .dockerignore",
2184            ]
2185        );
2186
2187        confirm.await.unwrap();
2188        assert_eq!(
2189            visible_entries_as_strings(&panel, 0..10, cx),
2190            &[
2191                "v root1",
2192                "    > .git",
2193                "    > a",
2194                "    v b",
2195                "        > 3  <== selected",
2196                "        > 4",
2197                "        > new-dir",
2198                "          a-different-filename.tar.gz",
2199                "    > C",
2200                "      .dockerignore",
2201            ]
2202        );
2203
2204        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2205        assert_eq!(
2206            visible_entries_as_strings(&panel, 0..10, cx),
2207            &[
2208                "v root1",
2209                "    > .git",
2210                "    > a",
2211                "    v b",
2212                "        > [EDITOR: '3']  <== selected",
2213                "        > 4",
2214                "        > new-dir",
2215                "          a-different-filename.tar.gz",
2216                "    > C",
2217                "      .dockerignore",
2218            ]
2219        );
2220
2221        // Dismiss the rename editor when it loses focus.
2222        workspace.update(cx, |_, cx| cx.blur()).unwrap();
2223        assert_eq!(
2224            visible_entries_as_strings(&panel, 0..10, cx),
2225            &[
2226                "v root1",
2227                "    > .git",
2228                "    > a",
2229                "    v b",
2230                "        > 3  <== selected",
2231                "        > 4",
2232                "        > new-dir",
2233                "          a-different-filename.tar.gz",
2234                "    > C",
2235                "      .dockerignore",
2236            ]
2237        );
2238    }
2239
2240    #[gpui::test(iterations = 10)]
2241    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2242        init_test(cx);
2243
2244        let fs = FakeFs::new(cx.executor().clone());
2245        fs.insert_tree(
2246            "/root1",
2247            json!({
2248                ".dockerignore": "",
2249                ".git": {
2250                    "HEAD": "",
2251                },
2252                "a": {
2253                    "0": { "q": "", "r": "", "s": "" },
2254                    "1": { "t": "", "u": "" },
2255                    "2": { "v": "", "w": "", "x": "", "y": "" },
2256                },
2257                "b": {
2258                    "3": { "Q": "" },
2259                    "4": { "R": "", "S": "", "T": "", "U": "" },
2260                },
2261                "C": {
2262                    "5": {},
2263                    "6": { "V": "", "W": "" },
2264                    "7": { "X": "" },
2265                    "8": { "Y": {}, "Z": "" }
2266                }
2267            }),
2268        )
2269        .await;
2270        fs.insert_tree(
2271            "/root2",
2272            json!({
2273                "d": {
2274                    "9": ""
2275                },
2276                "e": {}
2277            }),
2278        )
2279        .await;
2280
2281        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2282        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2283        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2284        let panel = workspace
2285            .update(cx, |workspace, cx| {
2286                let panel = ProjectPanel::new(workspace, cx);
2287                workspace.add_panel(panel.clone(), cx);
2288                workspace.toggle_dock(panel.read(cx).position(cx), cx);
2289                panel
2290            })
2291            .unwrap();
2292
2293        select_path(&panel, "root1", cx);
2294        assert_eq!(
2295            visible_entries_as_strings(&panel, 0..10, cx),
2296            &[
2297                "v root1  <== selected",
2298                "    > .git",
2299                "    > a",
2300                "    > b",
2301                "    > C",
2302                "      .dockerignore",
2303                "v root2",
2304                "    > d",
2305                "    > e",
2306            ]
2307        );
2308
2309        // Add a file with the root folder selected. The filename editor is placed
2310        // before the first file in the root folder.
2311        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2312        panel.update(cx, |panel, cx| {
2313            assert!(panel.filename_editor.read(cx).is_focused(cx));
2314        });
2315        assert_eq!(
2316            visible_entries_as_strings(&panel, 0..10, cx),
2317            &[
2318                "v root1",
2319                "    > .git",
2320                "    > a",
2321                "    > b",
2322                "    > C",
2323                "      [EDITOR: '']  <== selected",
2324                "      .dockerignore",
2325                "v root2",
2326                "    > d",
2327                "    > e",
2328            ]
2329        );
2330
2331        let confirm = panel.update(cx, |panel, cx| {
2332            panel.filename_editor.update(cx, |editor, cx| {
2333                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2334            });
2335            panel.confirm_edit(cx).unwrap()
2336        });
2337
2338        assert_eq!(
2339            visible_entries_as_strings(&panel, 0..10, cx),
2340            &[
2341                "v root1",
2342                "    > .git",
2343                "    > a",
2344                "    > b",
2345                "    > C",
2346                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2347                "      .dockerignore",
2348                "v root2",
2349                "    > d",
2350                "    > e",
2351            ]
2352        );
2353
2354        confirm.await.unwrap();
2355        assert_eq!(
2356            visible_entries_as_strings(&panel, 0..13, cx),
2357            &[
2358                "v root1",
2359                "    > .git",
2360                "    > a",
2361                "    > b",
2362                "    v bdir1",
2363                "        v dir2",
2364                "              the-new-filename  <== selected",
2365                "    > C",
2366                "      .dockerignore",
2367                "v root2",
2368                "    > d",
2369                "    > e",
2370            ]
2371        );
2372    }
2373
2374    #[gpui::test]
2375    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2376        init_test(cx);
2377
2378        let fs = FakeFs::new(cx.executor().clone());
2379        fs.insert_tree(
2380            "/root1",
2381            json!({
2382                "one.two.txt": "",
2383                "one.txt": ""
2384            }),
2385        )
2386        .await;
2387
2388        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2389        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2390        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2391        let panel = workspace
2392            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2393            .unwrap();
2394
2395        panel.update(cx, |panel, cx| {
2396            panel.select_next(&Default::default(), cx);
2397            panel.select_next(&Default::default(), cx);
2398        });
2399
2400        assert_eq!(
2401            visible_entries_as_strings(&panel, 0..50, cx),
2402            &[
2403                //
2404                "v root1",
2405                "      one.two.txt  <== selected",
2406                "      one.txt",
2407            ]
2408        );
2409
2410        // Regression test - file name is created correctly when
2411        // the copied file's name contains multiple dots.
2412        panel.update(cx, |panel, cx| {
2413            panel.copy(&Default::default(), cx);
2414            panel.paste(&Default::default(), cx);
2415        });
2416        cx.executor().run_until_parked();
2417
2418        assert_eq!(
2419            visible_entries_as_strings(&panel, 0..50, cx),
2420            &[
2421                //
2422                "v root1",
2423                "      one.two copy.txt",
2424                "      one.two.txt  <== selected",
2425                "      one.txt",
2426            ]
2427        );
2428
2429        panel.update(cx, |panel, cx| {
2430            panel.paste(&Default::default(), cx);
2431        });
2432        cx.executor().run_until_parked();
2433
2434        assert_eq!(
2435            visible_entries_as_strings(&panel, 0..50, cx),
2436            &[
2437                //
2438                "v root1",
2439                "      one.two copy 1.txt",
2440                "      one.two copy.txt",
2441                "      one.two.txt  <== selected",
2442                "      one.txt",
2443            ]
2444        );
2445    }
2446
2447    #[gpui::test]
2448    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2449        init_test_with_editor(cx);
2450
2451        let fs = FakeFs::new(cx.executor().clone());
2452        fs.insert_tree(
2453            "/src",
2454            json!({
2455                "test": {
2456                    "first.rs": "// First Rust file",
2457                    "second.rs": "// Second Rust file",
2458                    "third.rs": "// Third Rust file",
2459                }
2460            }),
2461        )
2462        .await;
2463
2464        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2465        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2466        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2467        let panel = workspace
2468            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2469            .unwrap();
2470
2471        toggle_expand_dir(&panel, "src/test", cx);
2472        select_path(&panel, "src/test/first.rs", cx);
2473        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2474        cx.executor().run_until_parked();
2475        assert_eq!(
2476            visible_entries_as_strings(&panel, 0..10, cx),
2477            &[
2478                "v src",
2479                "    v test",
2480                "          first.rs  <== selected",
2481                "          second.rs",
2482                "          third.rs"
2483            ]
2484        );
2485        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2486
2487        submit_deletion(&panel, cx);
2488        assert_eq!(
2489            visible_entries_as_strings(&panel, 0..10, cx),
2490            &[
2491                "v src",
2492                "    v test",
2493                "          second.rs",
2494                "          third.rs"
2495            ],
2496            "Project panel should have no deleted file, no other file is selected in it"
2497        );
2498        ensure_no_open_items_and_panes(&workspace, cx);
2499
2500        select_path(&panel, "src/test/second.rs", cx);
2501        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2502        cx.executor().run_until_parked();
2503        assert_eq!(
2504            visible_entries_as_strings(&panel, 0..10, cx),
2505            &[
2506                "v src",
2507                "    v test",
2508                "          second.rs  <== selected",
2509                "          third.rs"
2510            ]
2511        );
2512        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2513
2514        workspace
2515            .update(cx, |workspace, cx| {
2516                let active_items = workspace
2517                    .panes()
2518                    .iter()
2519                    .filter_map(|pane| pane.read(cx).active_item())
2520                    .collect::<Vec<_>>();
2521                assert_eq!(active_items.len(), 1);
2522                let open_editor = active_items
2523                    .into_iter()
2524                    .next()
2525                    .unwrap()
2526                    .downcast::<Editor>()
2527                    .expect("Open item should be an editor");
2528                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2529            })
2530            .unwrap();
2531        submit_deletion(&panel, cx);
2532        assert_eq!(
2533            visible_entries_as_strings(&panel, 0..10, cx),
2534            &["v src", "    v test", "          third.rs"],
2535            "Project panel should have no deleted file, with one last file remaining"
2536        );
2537        ensure_no_open_items_and_panes(&workspace, cx);
2538    }
2539
2540    #[gpui::test]
2541    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2542        init_test_with_editor(cx);
2543
2544        let fs = FakeFs::new(cx.executor().clone());
2545        fs.insert_tree(
2546            "/src",
2547            json!({
2548                "test": {
2549                    "first.rs": "// First Rust file",
2550                    "second.rs": "// Second Rust file",
2551                    "third.rs": "// Third Rust file",
2552                }
2553            }),
2554        )
2555        .await;
2556
2557        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2558        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2559        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2560        let panel = workspace
2561            .update(cx, |workspace, cx| {
2562                let panel = ProjectPanel::new(workspace, cx);
2563                workspace.add_panel(panel.clone(), cx);
2564                workspace.toggle_dock(panel.read(cx).position(cx), cx);
2565                panel
2566            })
2567            .unwrap();
2568
2569        select_path(&panel, "src/", cx);
2570        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2571        cx.executor().run_until_parked();
2572        assert_eq!(
2573            visible_entries_as_strings(&panel, 0..10, cx),
2574            &[
2575                //
2576                "v src  <== selected",
2577                "    > test"
2578            ]
2579        );
2580        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2581        panel.update(cx, |panel, cx| {
2582            assert!(panel.filename_editor.read(cx).is_focused(cx));
2583        });
2584        assert_eq!(
2585            visible_entries_as_strings(&panel, 0..10, cx),
2586            &[
2587                //
2588                "v src",
2589                "    > [EDITOR: '']  <== selected",
2590                "    > test"
2591            ]
2592        );
2593        panel.update(cx, |panel, cx| {
2594            panel
2595                .filename_editor
2596                .update(cx, |editor, cx| editor.set_text("test", cx));
2597            assert!(
2598                panel.confirm_edit(cx).is_none(),
2599                "Should not allow to confirm on conflicting new directory name"
2600            )
2601        });
2602        assert_eq!(
2603            visible_entries_as_strings(&panel, 0..10, cx),
2604            &[
2605                //
2606                "v src",
2607                "    > test"
2608            ],
2609            "File list should be unchanged after failed folder create confirmation"
2610        );
2611
2612        select_path(&panel, "src/test/", cx);
2613        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2614        cx.executor().run_until_parked();
2615        assert_eq!(
2616            visible_entries_as_strings(&panel, 0..10, cx),
2617            &[
2618                //
2619                "v src",
2620                "    > test  <== selected"
2621            ]
2622        );
2623        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2624        panel.update(cx, |panel, cx| {
2625            assert!(panel.filename_editor.read(cx).is_focused(cx));
2626        });
2627        assert_eq!(
2628            visible_entries_as_strings(&panel, 0..10, cx),
2629            &[
2630                "v src",
2631                "    v test",
2632                "          [EDITOR: '']  <== selected",
2633                "          first.rs",
2634                "          second.rs",
2635                "          third.rs"
2636            ]
2637        );
2638        panel.update(cx, |panel, cx| {
2639            panel
2640                .filename_editor
2641                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2642            assert!(
2643                panel.confirm_edit(cx).is_none(),
2644                "Should not allow to confirm on conflicting new file name"
2645            )
2646        });
2647        assert_eq!(
2648            visible_entries_as_strings(&panel, 0..10, cx),
2649            &[
2650                "v src",
2651                "    v test",
2652                "          first.rs",
2653                "          second.rs",
2654                "          third.rs"
2655            ],
2656            "File list should be unchanged after failed file create confirmation"
2657        );
2658
2659        select_path(&panel, "src/test/first.rs", cx);
2660        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2661        cx.executor().run_until_parked();
2662        assert_eq!(
2663            visible_entries_as_strings(&panel, 0..10, cx),
2664            &[
2665                "v src",
2666                "    v test",
2667                "          first.rs  <== selected",
2668                "          second.rs",
2669                "          third.rs"
2670            ],
2671        );
2672        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2673        panel.update(cx, |panel, cx| {
2674            assert!(panel.filename_editor.read(cx).is_focused(cx));
2675        });
2676        assert_eq!(
2677            visible_entries_as_strings(&panel, 0..10, cx),
2678            &[
2679                "v src",
2680                "    v test",
2681                "          [EDITOR: 'first.rs']  <== selected",
2682                "          second.rs",
2683                "          third.rs"
2684            ]
2685        );
2686        panel.update(cx, |panel, cx| {
2687            panel
2688                .filename_editor
2689                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2690            assert!(
2691                panel.confirm_edit(cx).is_none(),
2692                "Should not allow to confirm on conflicting file rename"
2693            )
2694        });
2695        assert_eq!(
2696            visible_entries_as_strings(&panel, 0..10, cx),
2697            &[
2698                "v src",
2699                "    v test",
2700                "          first.rs  <== selected",
2701                "          second.rs",
2702                "          third.rs"
2703            ],
2704            "File list should be unchanged after failed rename confirmation"
2705        );
2706    }
2707
2708    #[gpui::test]
2709    async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2710        init_test_with_editor(cx);
2711
2712        let fs = FakeFs::new(cx.executor().clone());
2713        fs.insert_tree(
2714            "/src",
2715            json!({
2716                "test": {
2717                    "first.rs": "// First Rust file",
2718                    "second.rs": "// Second Rust file",
2719                    "third.rs": "// Third Rust file",
2720                }
2721            }),
2722        )
2723        .await;
2724
2725        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2726        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2727        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2728        let panel = workspace
2729            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2730            .unwrap();
2731
2732        let new_search_events_count = Arc::new(AtomicUsize::new(0));
2733        let _subscription = panel.update(cx, |_, cx| {
2734            let subcription_count = Arc::clone(&new_search_events_count);
2735            let view = cx.view().clone();
2736            cx.subscribe(&view, move |_, _, event, _| {
2737                if matches!(event, Event::NewSearchInDirectory { .. }) {
2738                    subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2739                }
2740            })
2741        });
2742
2743        toggle_expand_dir(&panel, "src/test", cx);
2744        select_path(&panel, "src/test/first.rs", cx);
2745        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2746        cx.executor().run_until_parked();
2747        assert_eq!(
2748            visible_entries_as_strings(&panel, 0..10, cx),
2749            &[
2750                "v src",
2751                "    v test",
2752                "          first.rs  <== selected",
2753                "          second.rs",
2754                "          third.rs"
2755            ]
2756        );
2757        panel.update(cx, |panel, cx| {
2758            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2759        });
2760        assert_eq!(
2761            new_search_events_count.load(atomic::Ordering::SeqCst),
2762            0,
2763            "Should not trigger new search in directory when called on a file"
2764        );
2765
2766        select_path(&panel, "src/test", cx);
2767        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2768        cx.executor().run_until_parked();
2769        assert_eq!(
2770            visible_entries_as_strings(&panel, 0..10, cx),
2771            &[
2772                "v src",
2773                "    v test  <== selected",
2774                "          first.rs",
2775                "          second.rs",
2776                "          third.rs"
2777            ]
2778        );
2779        panel.update(cx, |panel, cx| {
2780            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2781        });
2782        assert_eq!(
2783            new_search_events_count.load(atomic::Ordering::SeqCst),
2784            1,
2785            "Should trigger new search in directory when called on a directory"
2786        );
2787    }
2788
2789    #[gpui::test]
2790    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2791        init_test_with_editor(cx);
2792
2793        let fs = FakeFs::new(cx.executor().clone());
2794        fs.insert_tree(
2795            "/project_root",
2796            json!({
2797                "dir_1": {
2798                    "nested_dir": {
2799                        "file_a.py": "# File contents",
2800                        "file_b.py": "# File contents",
2801                        "file_c.py": "# File contents",
2802                    },
2803                    "file_1.py": "# File contents",
2804                    "file_2.py": "# File contents",
2805                    "file_3.py": "# File contents",
2806                },
2807                "dir_2": {
2808                    "file_1.py": "# File contents",
2809                    "file_2.py": "# File contents",
2810                    "file_3.py": "# File contents",
2811                }
2812            }),
2813        )
2814        .await;
2815
2816        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2817        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2818        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2819        let panel = workspace
2820            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2821            .unwrap();
2822
2823        panel.update(cx, |panel, cx| {
2824            panel.collapse_all_entries(&CollapseAllEntries, cx)
2825        });
2826        cx.executor().run_until_parked();
2827        assert_eq!(
2828            visible_entries_as_strings(&panel, 0..10, cx),
2829            &["v project_root", "    > dir_1", "    > dir_2",]
2830        );
2831
2832        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2833        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2834        cx.executor().run_until_parked();
2835        assert_eq!(
2836            visible_entries_as_strings(&panel, 0..10, cx),
2837            &[
2838                "v project_root",
2839                "    v dir_1  <== selected",
2840                "        > nested_dir",
2841                "          file_1.py",
2842                "          file_2.py",
2843                "          file_3.py",
2844                "    > dir_2",
2845            ]
2846        );
2847    }
2848
2849    #[gpui::test]
2850    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2851        init_test(cx);
2852
2853        let fs = FakeFs::new(cx.executor().clone());
2854        fs.as_fake().insert_tree("/root", json!({})).await;
2855        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2856        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2857        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2858        let panel = workspace
2859            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2860            .unwrap();
2861
2862        // Make a new buffer with no backing file
2863        workspace
2864            .update(cx, |workspace, cx| {
2865                Editor::new_file(workspace, &Default::default(), cx)
2866            })
2867            .unwrap();
2868
2869        // "Save as"" the buffer, creating a new backing file for it
2870        let save_task = workspace
2871            .update(cx, |workspace, cx| {
2872                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2873            })
2874            .unwrap();
2875
2876        cx.executor().run_until_parked();
2877        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2878        save_task.await.unwrap();
2879
2880        // Rename the file
2881        select_path(&panel, "root/new", cx);
2882        assert_eq!(
2883            visible_entries_as_strings(&panel, 0..10, cx),
2884            &["v root", "      new  <== selected"]
2885        );
2886        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2887        panel.update(cx, |panel, cx| {
2888            panel
2889                .filename_editor
2890                .update(cx, |editor, cx| editor.set_text("newer", cx));
2891        });
2892        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2893
2894        cx.executor().run_until_parked();
2895        assert_eq!(
2896            visible_entries_as_strings(&panel, 0..10, cx),
2897            &["v root", "      newer  <== selected"]
2898        );
2899
2900        workspace
2901            .update(cx, |workspace, cx| {
2902                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2903            })
2904            .unwrap()
2905            .await
2906            .unwrap();
2907
2908        cx.executor().run_until_parked();
2909        // assert that saving the file doesn't restore "new"
2910        assert_eq!(
2911            visible_entries_as_strings(&panel, 0..10, cx),
2912            &["v root", "      newer  <== selected"]
2913        );
2914    }
2915
2916    #[gpui::test]
2917    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
2918        init_test_with_editor(cx);
2919        cx.update(|cx| {
2920            cx.update_global::<SettingsStore, _>(|store, cx| {
2921                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2922                    project_settings.file_scan_exclusions = Some(Vec::new());
2923                });
2924                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2925                    project_panel_settings.auto_reveal_entries = Some(false)
2926                });
2927            })
2928        });
2929
2930        let fs = FakeFs::new(cx.background_executor.clone());
2931        fs.insert_tree(
2932            "/project_root",
2933            json!({
2934                ".git": {},
2935                ".gitignore": "**/gitignored_dir",
2936                "dir_1": {
2937                    "file_1.py": "# File 1_1 contents",
2938                    "file_2.py": "# File 1_2 contents",
2939                    "file_3.py": "# File 1_3 contents",
2940                    "gitignored_dir": {
2941                        "file_a.py": "# File contents",
2942                        "file_b.py": "# File contents",
2943                        "file_c.py": "# File contents",
2944                    },
2945                },
2946                "dir_2": {
2947                    "file_1.py": "# File 2_1 contents",
2948                    "file_2.py": "# File 2_2 contents",
2949                    "file_3.py": "# File 2_3 contents",
2950                }
2951            }),
2952        )
2953        .await;
2954
2955        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2956        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2957        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2958        let panel = workspace
2959            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2960            .unwrap();
2961
2962        assert_eq!(
2963            visible_entries_as_strings(&panel, 0..20, cx),
2964            &[
2965                "v project_root",
2966                "    > .git",
2967                "    > dir_1",
2968                "    > dir_2",
2969                "      .gitignore",
2970            ]
2971        );
2972
2973        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
2974            .expect("dir 1 file is not ignored and should have an entry");
2975        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
2976            .expect("dir 2 file is not ignored and should have an entry");
2977        let gitignored_dir_file =
2978            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
2979        assert_eq!(
2980            gitignored_dir_file, None,
2981            "File in the gitignored dir should not have an entry before its dir is toggled"
2982        );
2983
2984        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2985        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2986        cx.executor().run_until_parked();
2987        assert_eq!(
2988            visible_entries_as_strings(&panel, 0..20, cx),
2989            &[
2990                "v project_root",
2991                "    > .git",
2992                "    v dir_1",
2993                "        v gitignored_dir  <== selected",
2994                "              file_a.py",
2995                "              file_b.py",
2996                "              file_c.py",
2997                "          file_1.py",
2998                "          file_2.py",
2999                "          file_3.py",
3000                "    > dir_2",
3001                "      .gitignore",
3002            ],
3003            "Should show gitignored dir file list in the project panel"
3004        );
3005        let gitignored_dir_file =
3006            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3007                .expect("after gitignored dir got opened, a file entry should be present");
3008
3009        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3010        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3011        assert_eq!(
3012            visible_entries_as_strings(&panel, 0..20, cx),
3013            &[
3014                "v project_root",
3015                "    > .git",
3016                "    > dir_1  <== selected",
3017                "    > dir_2",
3018                "      .gitignore",
3019            ],
3020            "Should hide all dir contents again and prepare for the auto reveal test"
3021        );
3022
3023        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3024            panel.update(cx, |panel, cx| {
3025                panel.project.update(cx, |_, cx| {
3026                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3027                })
3028            });
3029            cx.run_until_parked();
3030            assert_eq!(
3031                visible_entries_as_strings(&panel, 0..20, cx),
3032                &[
3033                    "v project_root",
3034                    "    > .git",
3035                    "    > dir_1  <== selected",
3036                    "    > dir_2",
3037                    "      .gitignore",
3038                ],
3039                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3040            );
3041        }
3042
3043        cx.update(|cx| {
3044            cx.update_global::<SettingsStore, _>(|store, cx| {
3045                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3046                    project_panel_settings.auto_reveal_entries = Some(true)
3047                });
3048            })
3049        });
3050
3051        panel.update(cx, |panel, cx| {
3052            panel.project.update(cx, |_, cx| {
3053                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3054            })
3055        });
3056        cx.run_until_parked();
3057        assert_eq!(
3058            visible_entries_as_strings(&panel, 0..20, cx),
3059            &[
3060                "v project_root",
3061                "    > .git",
3062                "    v dir_1",
3063                "        > gitignored_dir",
3064                "          file_1.py  <== selected",
3065                "          file_2.py",
3066                "          file_3.py",
3067                "    > dir_2",
3068                "      .gitignore",
3069            ],
3070            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3071        );
3072
3073        panel.update(cx, |panel, cx| {
3074            panel.project.update(cx, |_, cx| {
3075                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3076            })
3077        });
3078        cx.run_until_parked();
3079        assert_eq!(
3080            visible_entries_as_strings(&panel, 0..20, cx),
3081            &[
3082                "v project_root",
3083                "    > .git",
3084                "    v dir_1",
3085                "        > gitignored_dir",
3086                "          file_1.py",
3087                "          file_2.py",
3088                "          file_3.py",
3089                "    v dir_2",
3090                "          file_1.py  <== selected",
3091                "          file_2.py",
3092                "          file_3.py",
3093                "      .gitignore",
3094            ],
3095            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3096        );
3097
3098        panel.update(cx, |panel, cx| {
3099            panel.project.update(cx, |_, cx| {
3100                cx.emit(project::Event::ActiveEntryChanged(Some(
3101                    gitignored_dir_file,
3102                )))
3103            })
3104        });
3105        cx.run_until_parked();
3106        assert_eq!(
3107            visible_entries_as_strings(&panel, 0..20, cx),
3108            &[
3109                "v project_root",
3110                "    > .git",
3111                "    v dir_1",
3112                "        > gitignored_dir",
3113                "          file_1.py",
3114                "          file_2.py",
3115                "          file_3.py",
3116                "    v dir_2",
3117                "          file_1.py  <== selected",
3118                "          file_2.py",
3119                "          file_3.py",
3120                "      .gitignore",
3121            ],
3122            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3123        );
3124
3125        panel.update(cx, |panel, cx| {
3126            panel.project.update(cx, |_, cx| {
3127                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3128            })
3129        });
3130        cx.run_until_parked();
3131        assert_eq!(
3132            visible_entries_as_strings(&panel, 0..20, cx),
3133            &[
3134                "v project_root",
3135                "    > .git",
3136                "    v dir_1",
3137                "        v gitignored_dir",
3138                "              file_a.py  <== selected",
3139                "              file_b.py",
3140                "              file_c.py",
3141                "          file_1.py",
3142                "          file_2.py",
3143                "          file_3.py",
3144                "    v dir_2",
3145                "          file_1.py",
3146                "          file_2.py",
3147                "          file_3.py",
3148                "      .gitignore",
3149            ],
3150            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3151        );
3152    }
3153
3154    #[gpui::test]
3155    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3156        init_test_with_editor(cx);
3157        cx.update(|cx| {
3158            cx.update_global::<SettingsStore, _>(|store, cx| {
3159                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3160                    project_settings.file_scan_exclusions = Some(Vec::new());
3161                });
3162                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3163                    project_panel_settings.auto_reveal_entries = Some(false)
3164                });
3165            })
3166        });
3167
3168        let fs = FakeFs::new(cx.background_executor.clone());
3169        fs.insert_tree(
3170            "/project_root",
3171            json!({
3172                ".git": {},
3173                ".gitignore": "**/gitignored_dir",
3174                "dir_1": {
3175                    "file_1.py": "# File 1_1 contents",
3176                    "file_2.py": "# File 1_2 contents",
3177                    "file_3.py": "# File 1_3 contents",
3178                    "gitignored_dir": {
3179                        "file_a.py": "# File contents",
3180                        "file_b.py": "# File contents",
3181                        "file_c.py": "# File contents",
3182                    },
3183                },
3184                "dir_2": {
3185                    "file_1.py": "# File 2_1 contents",
3186                    "file_2.py": "# File 2_2 contents",
3187                    "file_3.py": "# File 2_3 contents",
3188                }
3189            }),
3190        )
3191        .await;
3192
3193        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3194        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3195        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3196        let panel = workspace
3197            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3198            .unwrap();
3199
3200        assert_eq!(
3201            visible_entries_as_strings(&panel, 0..20, cx),
3202            &[
3203                "v project_root",
3204                "    > .git",
3205                "    > dir_1",
3206                "    > dir_2",
3207                "      .gitignore",
3208            ]
3209        );
3210
3211        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3212            .expect("dir 1 file is not ignored and should have an entry");
3213        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3214            .expect("dir 2 file is not ignored and should have an entry");
3215        let gitignored_dir_file =
3216            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3217        assert_eq!(
3218            gitignored_dir_file, None,
3219            "File in the gitignored dir should not have an entry before its dir is toggled"
3220        );
3221
3222        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3223        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3224        cx.run_until_parked();
3225        assert_eq!(
3226            visible_entries_as_strings(&panel, 0..20, cx),
3227            &[
3228                "v project_root",
3229                "    > .git",
3230                "    v dir_1",
3231                "        v gitignored_dir  <== selected",
3232                "              file_a.py",
3233                "              file_b.py",
3234                "              file_c.py",
3235                "          file_1.py",
3236                "          file_2.py",
3237                "          file_3.py",
3238                "    > dir_2",
3239                "      .gitignore",
3240            ],
3241            "Should show gitignored dir file list in the project panel"
3242        );
3243        let gitignored_dir_file =
3244            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3245                .expect("after gitignored dir got opened, a file entry should be present");
3246
3247        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3248        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3249        assert_eq!(
3250            visible_entries_as_strings(&panel, 0..20, cx),
3251            &[
3252                "v project_root",
3253                "    > .git",
3254                "    > dir_1  <== selected",
3255                "    > dir_2",
3256                "      .gitignore",
3257            ],
3258            "Should hide all dir contents again and prepare for the explicit reveal test"
3259        );
3260
3261        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3262            panel.update(cx, |panel, cx| {
3263                panel.project.update(cx, |_, cx| {
3264                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3265                })
3266            });
3267            cx.run_until_parked();
3268            assert_eq!(
3269                visible_entries_as_strings(&panel, 0..20, cx),
3270                &[
3271                    "v project_root",
3272                    "    > .git",
3273                    "    > dir_1  <== selected",
3274                    "    > dir_2",
3275                    "      .gitignore",
3276                ],
3277                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3278            );
3279        }
3280
3281        panel.update(cx, |panel, cx| {
3282            panel.project.update(cx, |_, cx| {
3283                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3284            })
3285        });
3286        cx.run_until_parked();
3287        assert_eq!(
3288            visible_entries_as_strings(&panel, 0..20, cx),
3289            &[
3290                "v project_root",
3291                "    > .git",
3292                "    v dir_1",
3293                "        > gitignored_dir",
3294                "          file_1.py  <== selected",
3295                "          file_2.py",
3296                "          file_3.py",
3297                "    > dir_2",
3298                "      .gitignore",
3299            ],
3300            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3301        );
3302
3303        panel.update(cx, |panel, cx| {
3304            panel.project.update(cx, |_, cx| {
3305                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3306            })
3307        });
3308        cx.run_until_parked();
3309        assert_eq!(
3310            visible_entries_as_strings(&panel, 0..20, cx),
3311            &[
3312                "v project_root",
3313                "    > .git",
3314                "    v dir_1",
3315                "        > gitignored_dir",
3316                "          file_1.py",
3317                "          file_2.py",
3318                "          file_3.py",
3319                "    v dir_2",
3320                "          file_1.py  <== selected",
3321                "          file_2.py",
3322                "          file_3.py",
3323                "      .gitignore",
3324            ],
3325            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3326        );
3327
3328        panel.update(cx, |panel, cx| {
3329            panel.project.update(cx, |_, cx| {
3330                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3331            })
3332        });
3333        cx.run_until_parked();
3334        assert_eq!(
3335            visible_entries_as_strings(&panel, 0..20, cx),
3336            &[
3337                "v project_root",
3338                "    > .git",
3339                "    v dir_1",
3340                "        v gitignored_dir",
3341                "              file_a.py  <== selected",
3342                "              file_b.py",
3343                "              file_c.py",
3344                "          file_1.py",
3345                "          file_2.py",
3346                "          file_3.py",
3347                "    v dir_2",
3348                "          file_1.py",
3349                "          file_2.py",
3350                "          file_3.py",
3351                "      .gitignore",
3352            ],
3353            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3354        );
3355    }
3356
3357    fn toggle_expand_dir(
3358        panel: &View<ProjectPanel>,
3359        path: impl AsRef<Path>,
3360        cx: &mut VisualTestContext,
3361    ) {
3362        let path = path.as_ref();
3363        panel.update(cx, |panel, cx| {
3364            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3365                let worktree = worktree.read(cx);
3366                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3367                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3368                    panel.toggle_expanded(entry_id, cx);
3369                    return;
3370                }
3371            }
3372            panic!("no worktree for path {:?}", path);
3373        });
3374    }
3375
3376    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3377        let path = path.as_ref();
3378        panel.update(cx, |panel, cx| {
3379            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3380                let worktree = worktree.read(cx);
3381                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3382                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3383                    panel.selection = Some(crate::Selection {
3384                        worktree_id: worktree.id(),
3385                        entry_id,
3386                    });
3387                    return;
3388                }
3389            }
3390            panic!("no worktree for path {:?}", path);
3391        });
3392    }
3393
3394    fn find_project_entry(
3395        panel: &View<ProjectPanel>,
3396        path: impl AsRef<Path>,
3397        cx: &mut VisualTestContext,
3398    ) -> Option<ProjectEntryId> {
3399        let path = path.as_ref();
3400        panel.update(cx, |panel, cx| {
3401            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3402                let worktree = worktree.read(cx);
3403                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3404                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3405                }
3406            }
3407            panic!("no worktree for path {path:?}");
3408        })
3409    }
3410
3411    fn visible_entries_as_strings(
3412        panel: &View<ProjectPanel>,
3413        range: Range<usize>,
3414        cx: &mut VisualTestContext,
3415    ) -> Vec<String> {
3416        let mut result = Vec::new();
3417        let mut project_entries = HashSet::new();
3418        let mut has_editor = false;
3419
3420        panel.update(cx, |panel, cx| {
3421            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3422                if details.is_editing {
3423                    assert!(!has_editor, "duplicate editor entry");
3424                    has_editor = true;
3425                } else {
3426                    assert!(
3427                        project_entries.insert(project_entry),
3428                        "duplicate project entry {:?} {:?}",
3429                        project_entry,
3430                        details
3431                    );
3432                }
3433
3434                let indent = "    ".repeat(details.depth);
3435                let icon = if details.kind.is_dir() {
3436                    if details.is_expanded {
3437                        "v "
3438                    } else {
3439                        "> "
3440                    }
3441                } else {
3442                    "  "
3443                };
3444                let name = if details.is_editing {
3445                    format!("[EDITOR: '{}']", details.filename)
3446                } else if details.is_processing {
3447                    format!("[PROCESSING: '{}']", details.filename)
3448                } else {
3449                    details.filename.clone()
3450                };
3451                let selected = if details.is_selected {
3452                    "  <== selected"
3453                } else {
3454                    ""
3455                };
3456                result.push(format!("{indent}{icon}{name}{selected}"));
3457            });
3458        });
3459
3460        result
3461    }
3462
3463    fn init_test(cx: &mut TestAppContext) {
3464        cx.update(|cx| {
3465            let settings_store = SettingsStore::test(cx);
3466            cx.set_global(settings_store);
3467            init_settings(cx);
3468            theme::init(theme::LoadThemes::JustBase, cx);
3469            language::init(cx);
3470            editor::init_settings(cx);
3471            crate::init((), cx);
3472            workspace::init_settings(cx);
3473            client::init_settings(cx);
3474            Project::init_settings(cx);
3475
3476            cx.update_global::<SettingsStore, _>(|store, cx| {
3477                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3478                    project_settings.file_scan_exclusions = Some(Vec::new());
3479                });
3480            });
3481        });
3482    }
3483
3484    fn init_test_with_editor(cx: &mut TestAppContext) {
3485        cx.update(|cx| {
3486            let app_state = AppState::test(cx);
3487            theme::init(theme::LoadThemes::JustBase, cx);
3488            init_settings(cx);
3489            language::init(cx);
3490            editor::init(cx);
3491            crate::init((), cx);
3492            workspace::init(app_state.clone(), cx);
3493            Project::init_settings(cx);
3494        });
3495    }
3496
3497    fn ensure_single_file_is_opened(
3498        window: &WindowHandle<Workspace>,
3499        expected_path: &str,
3500        cx: &mut TestAppContext,
3501    ) {
3502        window
3503            .update(cx, |workspace, cx| {
3504                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3505                assert_eq!(worktrees.len(), 1);
3506                let worktree_id = worktrees[0].read(cx).id();
3507
3508                let open_project_paths = workspace
3509                    .panes()
3510                    .iter()
3511                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3512                    .collect::<Vec<_>>();
3513                assert_eq!(
3514                    open_project_paths,
3515                    vec![ProjectPath {
3516                        worktree_id,
3517                        path: Arc::from(Path::new(expected_path))
3518                    }],
3519                    "Should have opened file, selected in project panel"
3520                );
3521            })
3522            .unwrap();
3523    }
3524
3525    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3526        assert!(
3527            !cx.has_pending_prompt(),
3528            "Should have no prompts before the deletion"
3529        );
3530        panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
3531        assert!(
3532            cx.has_pending_prompt(),
3533            "Should have a prompt after the deletion"
3534        );
3535        cx.simulate_prompt_answer(0);
3536        assert!(
3537            !cx.has_pending_prompt(),
3538            "Should have no prompts after prompt was replied to"
3539        );
3540        cx.executor().run_until_parked();
3541    }
3542
3543    fn ensure_no_open_items_and_panes(
3544        workspace: &WindowHandle<Workspace>,
3545        cx: &mut VisualTestContext,
3546    ) {
3547        assert!(
3548            !cx.has_pending_prompt(),
3549            "Should have no prompts after deletion operation closes the file"
3550        );
3551        workspace
3552            .read_with(cx, |workspace, cx| {
3553                let open_project_paths = workspace
3554                    .panes()
3555                    .iter()
3556                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3557                    .collect::<Vec<_>>();
3558                assert!(
3559                    open_project_paths.is_empty(),
3560                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3561                );
3562            })
3563            .unwrap();
3564    }
3565}