project_panel.rs

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