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                let identifier = if self.filename_editor.is_focused(cx) {
1637                    "editing"
1638                } else {
1639                    "not_editing"
1640                };
1641
1642                keymap.add_identifier(identifier);
1643            });
1644        }
1645    }
1646
1647    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1648        if !self.has_focus {
1649            self.has_focus = true;
1650            cx.emit(Event::Focus);
1651        }
1652    }
1653
1654    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1655        self.has_focus = false;
1656    }
1657}
1658
1659impl Entity for ProjectPanel {
1660    type Event = Event;
1661}
1662
1663impl workspace::dock::Panel for ProjectPanel {
1664    fn position(&self, cx: &WindowContext) -> DockPosition {
1665        match settings::get::<ProjectPanelSettings>(cx).dock {
1666            ProjectPanelDockPosition::Left => DockPosition::Left,
1667            ProjectPanelDockPosition::Right => DockPosition::Right,
1668        }
1669    }
1670
1671    fn position_is_valid(&self, position: DockPosition) -> bool {
1672        matches!(position, DockPosition::Left | DockPosition::Right)
1673    }
1674
1675    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1676        settings::update_settings_file::<ProjectPanelSettings>(
1677            self.fs.clone(),
1678            cx,
1679            move |settings| {
1680                let dock = match position {
1681                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1682                    DockPosition::Right => ProjectPanelDockPosition::Right,
1683                };
1684                settings.dock = Some(dock);
1685            },
1686        );
1687    }
1688
1689    fn size(&self, cx: &WindowContext) -> f32 {
1690        self.width
1691            .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1692    }
1693
1694    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1695        self.width = size;
1696        self.serialize(cx);
1697        cx.notify();
1698    }
1699
1700    fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
1701        Some("icons/project.svg")
1702    }
1703
1704    fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1705        ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1706    }
1707
1708    fn should_change_position_on_event(event: &Self::Event) -> bool {
1709        matches!(event, Event::DockPositionChanged)
1710    }
1711
1712    fn has_focus(&self, _: &WindowContext) -> bool {
1713        self.has_focus
1714    }
1715
1716    fn is_focus_event(event: &Self::Event) -> bool {
1717        matches!(event, Event::Focus)
1718    }
1719}
1720
1721impl ClipboardEntry {
1722    fn is_cut(&self) -> bool {
1723        matches!(self, Self::Cut { .. })
1724    }
1725
1726    fn entry_id(&self) -> ProjectEntryId {
1727        match self {
1728            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1729                *entry_id
1730            }
1731        }
1732    }
1733
1734    fn worktree_id(&self) -> WorktreeId {
1735        match self {
1736            ClipboardEntry::Copied { worktree_id, .. }
1737            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1738        }
1739    }
1740}
1741
1742#[cfg(test)]
1743mod tests {
1744    use super::*;
1745    use gpui::{AnyWindowHandle, TestAppContext, ViewHandle, WindowHandle};
1746    use pretty_assertions::assert_eq;
1747    use project::{project_settings::ProjectSettings, FakeFs};
1748    use serde_json::json;
1749    use settings::SettingsStore;
1750    use std::{
1751        collections::HashSet,
1752        path::{Path, PathBuf},
1753        sync::atomic::{self, AtomicUsize},
1754    };
1755    use workspace::{pane, AppState};
1756
1757    #[gpui::test]
1758    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1759        init_test(cx);
1760
1761        let fs = FakeFs::new(cx.background());
1762        fs.insert_tree(
1763            "/root1",
1764            json!({
1765                ".dockerignore": "",
1766                ".git": {
1767                    "HEAD": "",
1768                },
1769                "a": {
1770                    "0": { "q": "", "r": "", "s": "" },
1771                    "1": { "t": "", "u": "" },
1772                    "2": { "v": "", "w": "", "x": "", "y": "" },
1773                },
1774                "b": {
1775                    "3": { "Q": "" },
1776                    "4": { "R": "", "S": "", "T": "", "U": "" },
1777                },
1778                "C": {
1779                    "5": {},
1780                    "6": { "V": "", "W": "" },
1781                    "7": { "X": "" },
1782                    "8": { "Y": {}, "Z": "" }
1783                }
1784            }),
1785        )
1786        .await;
1787        fs.insert_tree(
1788            "/root2",
1789            json!({
1790                "d": {
1791                    "9": ""
1792                },
1793                "e": {}
1794            }),
1795        )
1796        .await;
1797
1798        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1799        let workspace = cx
1800            .add_window(|cx| Workspace::test_new(project.clone(), cx))
1801            .root(cx);
1802        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1803        assert_eq!(
1804            visible_entries_as_strings(&panel, 0..50, cx),
1805            &[
1806                "v root1",
1807                "    > .git",
1808                "    > a",
1809                "    > b",
1810                "    > C",
1811                "      .dockerignore",
1812                "v root2",
1813                "    > d",
1814                "    > e",
1815            ]
1816        );
1817
1818        toggle_expand_dir(&panel, "root1/b", cx);
1819        assert_eq!(
1820            visible_entries_as_strings(&panel, 0..50, cx),
1821            &[
1822                "v root1",
1823                "    > .git",
1824                "    > a",
1825                "    v b  <== selected",
1826                "        > 3",
1827                "        > 4",
1828                "    > C",
1829                "      .dockerignore",
1830                "v root2",
1831                "    > d",
1832                "    > e",
1833            ]
1834        );
1835
1836        assert_eq!(
1837            visible_entries_as_strings(&panel, 6..9, cx),
1838            &[
1839                //
1840                "    > C",
1841                "      .dockerignore",
1842                "v root2",
1843            ]
1844        );
1845    }
1846
1847    #[gpui::test]
1848    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1849        init_test(cx);
1850        cx.update(|cx| {
1851            cx.update_global::<SettingsStore, _, _>(|store, cx| {
1852                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1853                    project_settings.file_scan_exclusions =
1854                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1855                });
1856            });
1857        });
1858
1859        let fs = FakeFs::new(cx.background());
1860        fs.insert_tree(
1861            "/root1",
1862            json!({
1863                ".dockerignore": "",
1864                ".git": {
1865                    "HEAD": "",
1866                },
1867                "a": {
1868                    "0": { "q": "", "r": "", "s": "" },
1869                    "1": { "t": "", "u": "" },
1870                    "2": { "v": "", "w": "", "x": "", "y": "" },
1871                },
1872                "b": {
1873                    "3": { "Q": "" },
1874                    "4": { "R": "", "S": "", "T": "", "U": "" },
1875                },
1876                "C": {
1877                    "5": {},
1878                    "6": { "V": "", "W": "" },
1879                    "7": { "X": "" },
1880                    "8": { "Y": {}, "Z": "" }
1881                }
1882            }),
1883        )
1884        .await;
1885        fs.insert_tree(
1886            "/root2",
1887            json!({
1888                "d": {
1889                    "4": ""
1890                },
1891                "e": {}
1892            }),
1893        )
1894        .await;
1895
1896        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1897        let workspace = cx
1898            .add_window(|cx| Workspace::test_new(project.clone(), cx))
1899            .root(cx);
1900        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1901        assert_eq!(
1902            visible_entries_as_strings(&panel, 0..50, cx),
1903            &[
1904                "v root1",
1905                "    > a",
1906                "    > b",
1907                "    > C",
1908                "      .dockerignore",
1909                "v root2",
1910                "    > d",
1911                "    > e",
1912            ]
1913        );
1914
1915        toggle_expand_dir(&panel, "root1/b", cx);
1916        assert_eq!(
1917            visible_entries_as_strings(&panel, 0..50, cx),
1918            &[
1919                "v root1",
1920                "    > a",
1921                "    v b  <== selected",
1922                "        > 3",
1923                "    > C",
1924                "      .dockerignore",
1925                "v root2",
1926                "    > d",
1927                "    > e",
1928            ]
1929        );
1930
1931        toggle_expand_dir(&panel, "root2/d", cx);
1932        assert_eq!(
1933            visible_entries_as_strings(&panel, 0..50, cx),
1934            &[
1935                "v root1",
1936                "    > a",
1937                "    v b",
1938                "        > 3",
1939                "    > C",
1940                "      .dockerignore",
1941                "v root2",
1942                "    v d  <== selected",
1943                "    > e",
1944            ]
1945        );
1946
1947        toggle_expand_dir(&panel, "root2/e", cx);
1948        assert_eq!(
1949            visible_entries_as_strings(&panel, 0..50, cx),
1950            &[
1951                "v root1",
1952                "    > a",
1953                "    v b",
1954                "        > 3",
1955                "    > C",
1956                "      .dockerignore",
1957                "v root2",
1958                "    v d",
1959                "    v e  <== selected",
1960            ]
1961        );
1962    }
1963
1964    #[gpui::test(iterations = 30)]
1965    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1966        init_test(cx);
1967
1968        let fs = FakeFs::new(cx.background());
1969        fs.insert_tree(
1970            "/root1",
1971            json!({
1972                ".dockerignore": "",
1973                ".git": {
1974                    "HEAD": "",
1975                },
1976                "a": {
1977                    "0": { "q": "", "r": "", "s": "" },
1978                    "1": { "t": "", "u": "" },
1979                    "2": { "v": "", "w": "", "x": "", "y": "" },
1980                },
1981                "b": {
1982                    "3": { "Q": "" },
1983                    "4": { "R": "", "S": "", "T": "", "U": "" },
1984                },
1985                "C": {
1986                    "5": {},
1987                    "6": { "V": "", "W": "" },
1988                    "7": { "X": "" },
1989                    "8": { "Y": {}, "Z": "" }
1990                }
1991            }),
1992        )
1993        .await;
1994        fs.insert_tree(
1995            "/root2",
1996            json!({
1997                "d": {
1998                    "9": ""
1999                },
2000                "e": {}
2001            }),
2002        )
2003        .await;
2004
2005        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2006        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2007        let workspace = window.root(cx);
2008        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2009
2010        select_path(&panel, "root1", cx);
2011        assert_eq!(
2012            visible_entries_as_strings(&panel, 0..10, cx),
2013            &[
2014                "v root1  <== selected",
2015                "    > .git",
2016                "    > a",
2017                "    > b",
2018                "    > C",
2019                "      .dockerignore",
2020                "v root2",
2021                "    > d",
2022                "    > e",
2023            ]
2024        );
2025
2026        // Add a file with the root folder selected. The filename editor is placed
2027        // before the first file in the root folder.
2028        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2029        window.read_with(cx, |cx| {
2030            let panel = panel.read(cx);
2031            assert!(panel.filename_editor.is_focused(cx));
2032        });
2033        assert_eq!(
2034            visible_entries_as_strings(&panel, 0..10, cx),
2035            &[
2036                "v root1",
2037                "    > .git",
2038                "    > a",
2039                "    > b",
2040                "    > C",
2041                "      [EDITOR: '']  <== selected",
2042                "      .dockerignore",
2043                "v root2",
2044                "    > d",
2045                "    > e",
2046            ]
2047        );
2048
2049        let confirm = panel.update(cx, |panel, cx| {
2050            panel
2051                .filename_editor
2052                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2053            panel.confirm(&Confirm, cx).unwrap()
2054        });
2055        assert_eq!(
2056            visible_entries_as_strings(&panel, 0..10, cx),
2057            &[
2058                "v root1",
2059                "    > .git",
2060                "    > a",
2061                "    > b",
2062                "    > C",
2063                "      [PROCESSING: 'the-new-filename']  <== selected",
2064                "      .dockerignore",
2065                "v root2",
2066                "    > d",
2067                "    > e",
2068            ]
2069        );
2070
2071        confirm.await.unwrap();
2072        assert_eq!(
2073            visible_entries_as_strings(&panel, 0..10, cx),
2074            &[
2075                "v root1",
2076                "    > .git",
2077                "    > a",
2078                "    > b",
2079                "    > C",
2080                "      .dockerignore",
2081                "      the-new-filename  <== selected",
2082                "v root2",
2083                "    > d",
2084                "    > e",
2085            ]
2086        );
2087
2088        select_path(&panel, "root1/b", cx);
2089        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2090        assert_eq!(
2091            visible_entries_as_strings(&panel, 0..10, cx),
2092            &[
2093                "v root1",
2094                "    > .git",
2095                "    > a",
2096                "    v b",
2097                "        > 3",
2098                "        > 4",
2099                "          [EDITOR: '']  <== selected",
2100                "    > C",
2101                "      .dockerignore",
2102                "      the-new-filename",
2103            ]
2104        );
2105
2106        panel
2107            .update(cx, |panel, cx| {
2108                panel
2109                    .filename_editor
2110                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2111                panel.confirm(&Confirm, cx).unwrap()
2112            })
2113            .await
2114            .unwrap();
2115        assert_eq!(
2116            visible_entries_as_strings(&panel, 0..10, cx),
2117            &[
2118                "v root1",
2119                "    > .git",
2120                "    > a",
2121                "    v b",
2122                "        > 3",
2123                "        > 4",
2124                "          another-filename.txt  <== selected",
2125                "    > C",
2126                "      .dockerignore",
2127                "      the-new-filename",
2128            ]
2129        );
2130
2131        select_path(&panel, "root1/b/another-filename.txt", cx);
2132        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2133        assert_eq!(
2134            visible_entries_as_strings(&panel, 0..10, cx),
2135            &[
2136                "v root1",
2137                "    > .git",
2138                "    > a",
2139                "    v b",
2140                "        > 3",
2141                "        > 4",
2142                "          [EDITOR: 'another-filename.txt']  <== selected",
2143                "    > C",
2144                "      .dockerignore",
2145                "      the-new-filename",
2146            ]
2147        );
2148
2149        let confirm = panel.update(cx, |panel, cx| {
2150            panel.filename_editor.update(cx, |editor, cx| {
2151                let file_name_selections = editor.selections.all::<usize>(cx);
2152                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2153                let file_name_selection = &file_name_selections[0];
2154                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2155                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2156
2157                editor.set_text("a-different-filename.tar.gz", cx)
2158            });
2159            panel.confirm(&Confirm, cx).unwrap()
2160        });
2161        assert_eq!(
2162            visible_entries_as_strings(&panel, 0..10, cx),
2163            &[
2164                "v root1",
2165                "    > .git",
2166                "    > a",
2167                "    v b",
2168                "        > 3",
2169                "        > 4",
2170                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2171                "    > C",
2172                "      .dockerignore",
2173                "      the-new-filename",
2174            ]
2175        );
2176
2177        confirm.await.unwrap();
2178        assert_eq!(
2179            visible_entries_as_strings(&panel, 0..10, cx),
2180            &[
2181                "v root1",
2182                "    > .git",
2183                "    > a",
2184                "    v b",
2185                "        > 3",
2186                "        > 4",
2187                "          a-different-filename.tar.gz  <== selected",
2188                "    > C",
2189                "      .dockerignore",
2190                "      the-new-filename",
2191            ]
2192        );
2193
2194        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2195        assert_eq!(
2196            visible_entries_as_strings(&panel, 0..10, cx),
2197            &[
2198                "v root1",
2199                "    > .git",
2200                "    > a",
2201                "    v b",
2202                "        > 3",
2203                "        > 4",
2204                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2205                "    > C",
2206                "      .dockerignore",
2207                "      the-new-filename",
2208            ]
2209        );
2210
2211        panel.update(cx, |panel, cx| {
2212            panel.filename_editor.update(cx, |editor, cx| {
2213                let file_name_selections = editor.selections.all::<usize>(cx);
2214                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2215                let file_name_selection = &file_name_selections[0];
2216                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2217                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");
2218
2219            });
2220            panel.cancel(&Cancel, cx)
2221        });
2222
2223        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2224        assert_eq!(
2225            visible_entries_as_strings(&panel, 0..10, cx),
2226            &[
2227                "v root1",
2228                "    > .git",
2229                "    > a",
2230                "    v b",
2231                "        > [EDITOR: '']  <== selected",
2232                "        > 3",
2233                "        > 4",
2234                "          a-different-filename.tar.gz",
2235                "    > C",
2236                "      .dockerignore",
2237            ]
2238        );
2239
2240        let confirm = panel.update(cx, |panel, cx| {
2241            panel
2242                .filename_editor
2243                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2244            panel.confirm(&Confirm, cx).unwrap()
2245        });
2246        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2247        assert_eq!(
2248            visible_entries_as_strings(&panel, 0..10, cx),
2249            &[
2250                "v root1",
2251                "    > .git",
2252                "    > a",
2253                "    v b",
2254                "        > [PROCESSING: 'new-dir']",
2255                "        > 3  <== selected",
2256                "        > 4",
2257                "          a-different-filename.tar.gz",
2258                "    > C",
2259                "      .dockerignore",
2260            ]
2261        );
2262
2263        confirm.await.unwrap();
2264        assert_eq!(
2265            visible_entries_as_strings(&panel, 0..10, cx),
2266            &[
2267                "v root1",
2268                "    > .git",
2269                "    > a",
2270                "    v b",
2271                "        > 3  <== selected",
2272                "        > 4",
2273                "        > new-dir",
2274                "          a-different-filename.tar.gz",
2275                "    > C",
2276                "      .dockerignore",
2277            ]
2278        );
2279
2280        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2281        assert_eq!(
2282            visible_entries_as_strings(&panel, 0..10, cx),
2283            &[
2284                "v root1",
2285                "    > .git",
2286                "    > a",
2287                "    v b",
2288                "        > [EDITOR: '3']  <== selected",
2289                "        > 4",
2290                "        > new-dir",
2291                "          a-different-filename.tar.gz",
2292                "    > C",
2293                "      .dockerignore",
2294            ]
2295        );
2296
2297        // Dismiss the rename editor when it loses focus.
2298        workspace.update(cx, |_, cx| cx.focus_self());
2299        assert_eq!(
2300            visible_entries_as_strings(&panel, 0..10, cx),
2301            &[
2302                "v root1",
2303                "    > .git",
2304                "    > a",
2305                "    v b",
2306                "        > 3  <== selected",
2307                "        > 4",
2308                "        > new-dir",
2309                "          a-different-filename.tar.gz",
2310                "    > C",
2311                "      .dockerignore",
2312            ]
2313        );
2314    }
2315
2316    #[gpui::test(iterations = 30)]
2317    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2318        init_test(cx);
2319
2320        let fs = FakeFs::new(cx.background());
2321        fs.insert_tree(
2322            "/root1",
2323            json!({
2324                ".dockerignore": "",
2325                ".git": {
2326                    "HEAD": "",
2327                },
2328                "a": {
2329                    "0": { "q": "", "r": "", "s": "" },
2330                    "1": { "t": "", "u": "" },
2331                    "2": { "v": "", "w": "", "x": "", "y": "" },
2332                },
2333                "b": {
2334                    "3": { "Q": "" },
2335                    "4": { "R": "", "S": "", "T": "", "U": "" },
2336                },
2337                "C": {
2338                    "5": {},
2339                    "6": { "V": "", "W": "" },
2340                    "7": { "X": "" },
2341                    "8": { "Y": {}, "Z": "" }
2342                }
2343            }),
2344        )
2345        .await;
2346        fs.insert_tree(
2347            "/root2",
2348            json!({
2349                "d": {
2350                    "9": ""
2351                },
2352                "e": {}
2353            }),
2354        )
2355        .await;
2356
2357        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2358        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2359        let workspace = window.root(cx);
2360        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2361
2362        select_path(&panel, "root1", cx);
2363        assert_eq!(
2364            visible_entries_as_strings(&panel, 0..10, cx),
2365            &[
2366                "v root1  <== selected",
2367                "    > .git",
2368                "    > a",
2369                "    > b",
2370                "    > C",
2371                "      .dockerignore",
2372                "v root2",
2373                "    > d",
2374                "    > e",
2375            ]
2376        );
2377
2378        // Add a file with the root folder selected. The filename editor is placed
2379        // before the first file in the root folder.
2380        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2381        window.read_with(cx, |cx| {
2382            let panel = panel.read(cx);
2383            assert!(panel.filename_editor.is_focused(cx));
2384        });
2385        assert_eq!(
2386            visible_entries_as_strings(&panel, 0..10, cx),
2387            &[
2388                "v root1",
2389                "    > .git",
2390                "    > a",
2391                "    > b",
2392                "    > C",
2393                "      [EDITOR: '']  <== selected",
2394                "      .dockerignore",
2395                "v root2",
2396                "    > d",
2397                "    > e",
2398            ]
2399        );
2400
2401        let confirm = panel.update(cx, |panel, cx| {
2402            panel.filename_editor.update(cx, |editor, cx| {
2403                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2404            });
2405            panel.confirm(&Confirm, cx).unwrap()
2406        });
2407
2408        assert_eq!(
2409            visible_entries_as_strings(&panel, 0..10, cx),
2410            &[
2411                "v root1",
2412                "    > .git",
2413                "    > a",
2414                "    > b",
2415                "    > C",
2416                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2417                "      .dockerignore",
2418                "v root2",
2419                "    > d",
2420                "    > e",
2421            ]
2422        );
2423
2424        confirm.await.unwrap();
2425        assert_eq!(
2426            visible_entries_as_strings(&panel, 0..13, cx),
2427            &[
2428                "v root1",
2429                "    > .git",
2430                "    > a",
2431                "    > b",
2432                "    v bdir1",
2433                "        v dir2",
2434                "              the-new-filename  <== selected",
2435                "    > C",
2436                "      .dockerignore",
2437                "v root2",
2438                "    > d",
2439                "    > e",
2440            ]
2441        );
2442    }
2443
2444    #[gpui::test]
2445    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2446        init_test(cx);
2447
2448        let fs = FakeFs::new(cx.background());
2449        fs.insert_tree(
2450            "/root1",
2451            json!({
2452                "one.two.txt": "",
2453                "one.txt": ""
2454            }),
2455        )
2456        .await;
2457
2458        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2459        let workspace = cx
2460            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2461            .root(cx);
2462        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2463
2464        panel.update(cx, |panel, cx| {
2465            panel.select_next(&Default::default(), cx);
2466            panel.select_next(&Default::default(), cx);
2467        });
2468
2469        assert_eq!(
2470            visible_entries_as_strings(&panel, 0..50, cx),
2471            &[
2472                //
2473                "v root1",
2474                "      one.two.txt  <== selected",
2475                "      one.txt",
2476            ]
2477        );
2478
2479        // Regression test - file name is created correctly when
2480        // the copied file's name contains multiple dots.
2481        panel.update(cx, |panel, cx| {
2482            panel.copy(&Default::default(), cx);
2483            panel.paste(&Default::default(), cx);
2484        });
2485        cx.foreground().run_until_parked();
2486
2487        assert_eq!(
2488            visible_entries_as_strings(&panel, 0..50, cx),
2489            &[
2490                //
2491                "v root1",
2492                "      one.two copy.txt",
2493                "      one.two.txt  <== selected",
2494                "      one.txt",
2495            ]
2496        );
2497
2498        panel.update(cx, |panel, cx| {
2499            panel.paste(&Default::default(), cx);
2500        });
2501        cx.foreground().run_until_parked();
2502
2503        assert_eq!(
2504            visible_entries_as_strings(&panel, 0..50, cx),
2505            &[
2506                //
2507                "v root1",
2508                "      one.two copy 1.txt",
2509                "      one.two copy.txt",
2510                "      one.two.txt  <== selected",
2511                "      one.txt",
2512            ]
2513        );
2514    }
2515
2516    #[gpui::test]
2517    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2518        init_test_with_editor(cx);
2519
2520        let fs = FakeFs::new(cx.background());
2521        fs.insert_tree(
2522            "/src",
2523            json!({
2524                "test": {
2525                    "first.rs": "// First Rust file",
2526                    "second.rs": "// Second Rust file",
2527                    "third.rs": "// Third Rust file",
2528                }
2529            }),
2530        )
2531        .await;
2532
2533        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2534        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2535        let workspace = window.root(cx);
2536        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2537
2538        toggle_expand_dir(&panel, "src/test", cx);
2539        select_path(&panel, "src/test/first.rs", cx);
2540        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2541        cx.foreground().run_until_parked();
2542        assert_eq!(
2543            visible_entries_as_strings(&panel, 0..10, cx),
2544            &[
2545                "v src",
2546                "    v test",
2547                "          first.rs  <== selected",
2548                "          second.rs",
2549                "          third.rs"
2550            ]
2551        );
2552        ensure_single_file_is_opened(window, "test/first.rs", cx);
2553
2554        submit_deletion(window.into(), &panel, cx);
2555        assert_eq!(
2556            visible_entries_as_strings(&panel, 0..10, cx),
2557            &[
2558                "v src",
2559                "    v test",
2560                "          second.rs",
2561                "          third.rs"
2562            ],
2563            "Project panel should have no deleted file, no other file is selected in it"
2564        );
2565        ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2566
2567        select_path(&panel, "src/test/second.rs", cx);
2568        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2569        cx.foreground().run_until_parked();
2570        assert_eq!(
2571            visible_entries_as_strings(&panel, 0..10, cx),
2572            &[
2573                "v src",
2574                "    v test",
2575                "          second.rs  <== selected",
2576                "          third.rs"
2577            ]
2578        );
2579        ensure_single_file_is_opened(window, "test/second.rs", cx);
2580
2581        window.update(cx, |cx| {
2582            let active_items = workspace
2583                .read(cx)
2584                .panes()
2585                .iter()
2586                .filter_map(|pane| pane.read(cx).active_item())
2587                .collect::<Vec<_>>();
2588            assert_eq!(active_items.len(), 1);
2589            let open_editor = active_items
2590                .into_iter()
2591                .next()
2592                .unwrap()
2593                .downcast::<Editor>()
2594                .expect("Open item should be an editor");
2595            open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2596        });
2597        submit_deletion(window.into(), &panel, cx);
2598        assert_eq!(
2599            visible_entries_as_strings(&panel, 0..10, cx),
2600            &["v src", "    v test", "          third.rs"],
2601            "Project panel should have no deleted file, with one last file remaining"
2602        );
2603        ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2604    }
2605
2606    #[gpui::test]
2607    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2608        init_test_with_editor(cx);
2609
2610        let fs = FakeFs::new(cx.background());
2611        fs.insert_tree(
2612            "/src",
2613            json!({
2614                "test": {
2615                    "first.rs": "// First Rust file",
2616                    "second.rs": "// Second Rust file",
2617                    "third.rs": "// Third Rust file",
2618                }
2619            }),
2620        )
2621        .await;
2622
2623        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2624        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2625        let workspace = window.root(cx);
2626        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2627
2628        select_path(&panel, "src/", cx);
2629        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2630        cx.foreground().run_until_parked();
2631        assert_eq!(
2632            visible_entries_as_strings(&panel, 0..10, cx),
2633            &["v src  <== selected", "    > test"]
2634        );
2635        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2636        window.read_with(cx, |cx| {
2637            let panel = panel.read(cx);
2638            assert!(panel.filename_editor.is_focused(cx));
2639        });
2640        assert_eq!(
2641            visible_entries_as_strings(&panel, 0..10, cx),
2642            &["v src", "    > [EDITOR: '']  <== selected", "    > test"]
2643        );
2644        panel.update(cx, |panel, cx| {
2645            panel
2646                .filename_editor
2647                .update(cx, |editor, cx| editor.set_text("test", cx));
2648            assert!(
2649                panel.confirm(&Confirm, cx).is_none(),
2650                "Should not allow to confirm on conflicting new directory name"
2651            )
2652        });
2653        assert_eq!(
2654            visible_entries_as_strings(&panel, 0..10, cx),
2655            &["v src", "    > test"],
2656            "File list should be unchanged after failed folder create confirmation"
2657        );
2658
2659        select_path(&panel, "src/test/", cx);
2660        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2661        cx.foreground().run_until_parked();
2662        assert_eq!(
2663            visible_entries_as_strings(&panel, 0..10, cx),
2664            &["v src", "    > test  <== selected"]
2665        );
2666        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2667        window.read_with(cx, |cx| {
2668            let panel = panel.read(cx);
2669            assert!(panel.filename_editor.is_focused(cx));
2670        });
2671        assert_eq!(
2672            visible_entries_as_strings(&panel, 0..10, cx),
2673            &[
2674                "v src",
2675                "    v test",
2676                "          [EDITOR: '']  <== selected",
2677                "          first.rs",
2678                "          second.rs",
2679                "          third.rs"
2680            ]
2681        );
2682        panel.update(cx, |panel, cx| {
2683            panel
2684                .filename_editor
2685                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2686            assert!(
2687                panel.confirm(&Confirm, cx).is_none(),
2688                "Should not allow to confirm on conflicting new file name"
2689            )
2690        });
2691        assert_eq!(
2692            visible_entries_as_strings(&panel, 0..10, cx),
2693            &[
2694                "v src",
2695                "    v test",
2696                "          first.rs",
2697                "          second.rs",
2698                "          third.rs"
2699            ],
2700            "File list should be unchanged after failed file create confirmation"
2701        );
2702
2703        select_path(&panel, "src/test/first.rs", cx);
2704        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2705        cx.foreground().run_until_parked();
2706        assert_eq!(
2707            visible_entries_as_strings(&panel, 0..10, cx),
2708            &[
2709                "v src",
2710                "    v test",
2711                "          first.rs  <== selected",
2712                "          second.rs",
2713                "          third.rs"
2714            ],
2715        );
2716        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2717        window.read_with(cx, |cx| {
2718            let panel = panel.read(cx);
2719            assert!(panel.filename_editor.is_focused(cx));
2720        });
2721        assert_eq!(
2722            visible_entries_as_strings(&panel, 0..10, cx),
2723            &[
2724                "v src",
2725                "    v test",
2726                "          [EDITOR: 'first.rs']  <== selected",
2727                "          second.rs",
2728                "          third.rs"
2729            ]
2730        );
2731        panel.update(cx, |panel, cx| {
2732            panel
2733                .filename_editor
2734                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2735            assert!(
2736                panel.confirm(&Confirm, cx).is_none(),
2737                "Should not allow to confirm on conflicting file rename"
2738            )
2739        });
2740        assert_eq!(
2741            visible_entries_as_strings(&panel, 0..10, cx),
2742            &[
2743                "v src",
2744                "    v test",
2745                "          first.rs  <== selected",
2746                "          second.rs",
2747                "          third.rs"
2748            ],
2749            "File list should be unchanged after failed rename confirmation"
2750        );
2751    }
2752
2753    #[gpui::test]
2754    async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2755        init_test_with_editor(cx);
2756
2757        let fs = FakeFs::new(cx.background());
2758        fs.insert_tree(
2759            "/src",
2760            json!({
2761                "test": {
2762                    "first.rs": "// First Rust file",
2763                    "second.rs": "// Second Rust file",
2764                    "third.rs": "// Third Rust file",
2765                }
2766            }),
2767        )
2768        .await;
2769
2770        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2771        let workspace = cx
2772            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2773            .root(cx);
2774        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2775
2776        let new_search_events_count = Arc::new(AtomicUsize::new(0));
2777        let _subscription = panel.update(cx, |_, cx| {
2778            let subcription_count = Arc::clone(&new_search_events_count);
2779            cx.subscribe(&cx.handle(), move |_, _, event, _| {
2780                if matches!(event, Event::NewSearchInDirectory { .. }) {
2781                    subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2782                }
2783            })
2784        });
2785
2786        toggle_expand_dir(&panel, "src/test", cx);
2787        select_path(&panel, "src/test/first.rs", cx);
2788        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2789        cx.foreground().run_until_parked();
2790        assert_eq!(
2791            visible_entries_as_strings(&panel, 0..10, cx),
2792            &[
2793                "v src",
2794                "    v test",
2795                "          first.rs  <== selected",
2796                "          second.rs",
2797                "          third.rs"
2798            ]
2799        );
2800        panel.update(cx, |panel, cx| {
2801            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2802        });
2803        assert_eq!(
2804            new_search_events_count.load(atomic::Ordering::SeqCst),
2805            0,
2806            "Should not trigger new search in directory when called on a file"
2807        );
2808
2809        select_path(&panel, "src/test", cx);
2810        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2811        cx.foreground().run_until_parked();
2812        assert_eq!(
2813            visible_entries_as_strings(&panel, 0..10, cx),
2814            &[
2815                "v src",
2816                "    v test  <== selected",
2817                "          first.rs",
2818                "          second.rs",
2819                "          third.rs"
2820            ]
2821        );
2822        panel.update(cx, |panel, cx| {
2823            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2824        });
2825        assert_eq!(
2826            new_search_events_count.load(atomic::Ordering::SeqCst),
2827            1,
2828            "Should trigger new search in directory when called on a directory"
2829        );
2830    }
2831
2832    #[gpui::test]
2833    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2834        init_test_with_editor(cx);
2835
2836        let fs = FakeFs::new(cx.background());
2837        fs.insert_tree(
2838            "/project_root",
2839            json!({
2840                "dir_1": {
2841                    "nested_dir": {
2842                        "file_a.py": "# File contents",
2843                        "file_b.py": "# File contents",
2844                        "file_c.py": "# File contents",
2845                    },
2846                    "file_1.py": "# File contents",
2847                    "file_2.py": "# File contents",
2848                    "file_3.py": "# File contents",
2849                },
2850                "dir_2": {
2851                    "file_1.py": "# File contents",
2852                    "file_2.py": "# File contents",
2853                    "file_3.py": "# File contents",
2854                }
2855            }),
2856        )
2857        .await;
2858
2859        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2860        let workspace = cx
2861            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2862            .root(cx);
2863        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2864
2865        panel.update(cx, |panel, cx| {
2866            panel.collapse_all_entries(&CollapseAllEntries, cx)
2867        });
2868        cx.foreground().run_until_parked();
2869        assert_eq!(
2870            visible_entries_as_strings(&panel, 0..10, cx),
2871            &["v project_root", "    > dir_1", "    > dir_2",]
2872        );
2873
2874        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2875        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2876        cx.foreground().run_until_parked();
2877        assert_eq!(
2878            visible_entries_as_strings(&panel, 0..10, cx),
2879            &[
2880                "v project_root",
2881                "    v dir_1  <== selected",
2882                "        > nested_dir",
2883                "          file_1.py",
2884                "          file_2.py",
2885                "          file_3.py",
2886                "    > dir_2",
2887            ]
2888        );
2889    }
2890
2891    #[gpui::test]
2892    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2893        init_test(cx);
2894
2895        let fs = FakeFs::new(cx.background());
2896        fs.as_fake().insert_tree("/root", json!({})).await;
2897        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2898        let workspace = cx
2899            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2900            .root(cx);
2901        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2902
2903        // Make a new buffer with no backing file
2904        workspace.update(cx, |workspace, cx| {
2905            Editor::new_file(workspace, &Default::default(), cx)
2906        });
2907
2908        // "Save as"" the buffer, creating a new backing file for it
2909        let task = workspace.update(cx, |workspace, cx| {
2910            workspace.save_active_item(workspace::SaveIntent::Save, cx)
2911        });
2912
2913        cx.foreground().run_until_parked();
2914        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2915        task.await.unwrap();
2916
2917        // Rename the file
2918        select_path(&panel, "root/new", cx);
2919        assert_eq!(
2920            visible_entries_as_strings(&panel, 0..10, cx),
2921            &["v root", "      new  <== selected"]
2922        );
2923        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2924        panel.update(cx, |panel, cx| {
2925            panel
2926                .filename_editor
2927                .update(cx, |editor, cx| editor.set_text("newer", cx));
2928        });
2929        panel
2930            .update(cx, |panel, cx| panel.confirm(&Confirm, cx))
2931            .unwrap()
2932            .await
2933            .unwrap();
2934
2935        cx.foreground().run_until_parked();
2936        assert_eq!(
2937            visible_entries_as_strings(&panel, 0..10, cx),
2938            &["v root", "      newer  <== selected"]
2939        );
2940
2941        workspace
2942            .update(cx, |workspace, cx| {
2943                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2944            })
2945            .await
2946            .unwrap();
2947
2948        cx.foreground().run_until_parked();
2949        // assert that saving the file doesn't restore "new"
2950        assert_eq!(
2951            visible_entries_as_strings(&panel, 0..10, cx),
2952            &["v root", "      newer  <== selected"]
2953        );
2954    }
2955
2956    fn toggle_expand_dir(
2957        panel: &ViewHandle<ProjectPanel>,
2958        path: impl AsRef<Path>,
2959        cx: &mut TestAppContext,
2960    ) {
2961        let path = path.as_ref();
2962        panel.update(cx, |panel, cx| {
2963            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2964                let worktree = worktree.read(cx);
2965                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2966                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2967                    panel.toggle_expanded(entry_id, cx);
2968                    return;
2969                }
2970            }
2971            panic!("no worktree for path {:?}", path);
2972        });
2973    }
2974
2975    fn select_path(
2976        panel: &ViewHandle<ProjectPanel>,
2977        path: impl AsRef<Path>,
2978        cx: &mut TestAppContext,
2979    ) {
2980        let path = path.as_ref();
2981        panel.update(cx, |panel, cx| {
2982            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2983                let worktree = worktree.read(cx);
2984                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2985                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2986                    panel.selection = Some(Selection {
2987                        worktree_id: worktree.id(),
2988                        entry_id,
2989                    });
2990                    return;
2991                }
2992            }
2993            panic!("no worktree for path {:?}", path);
2994        });
2995    }
2996
2997    fn visible_entries_as_strings(
2998        panel: &ViewHandle<ProjectPanel>,
2999        range: Range<usize>,
3000        cx: &mut TestAppContext,
3001    ) -> Vec<String> {
3002        let mut result = Vec::new();
3003        let mut project_entries = HashSet::new();
3004        let mut has_editor = false;
3005
3006        panel.update(cx, |panel, cx| {
3007            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3008                if details.is_editing {
3009                    assert!(!has_editor, "duplicate editor entry");
3010                    has_editor = true;
3011                } else {
3012                    assert!(
3013                        project_entries.insert(project_entry),
3014                        "duplicate project entry {:?} {:?}",
3015                        project_entry,
3016                        details
3017                    );
3018                }
3019
3020                let indent = "    ".repeat(details.depth);
3021                let icon = if details.kind.is_dir() {
3022                    if details.is_expanded {
3023                        "v "
3024                    } else {
3025                        "> "
3026                    }
3027                } else {
3028                    "  "
3029                };
3030                let name = if details.is_editing {
3031                    format!("[EDITOR: '{}']", details.filename)
3032                } else if details.is_processing {
3033                    format!("[PROCESSING: '{}']", details.filename)
3034                } else {
3035                    details.filename.clone()
3036                };
3037                let selected = if details.is_selected {
3038                    "  <== selected"
3039                } else {
3040                    ""
3041                };
3042                result.push(format!("{indent}{icon}{name}{selected}"));
3043            });
3044        });
3045
3046        result
3047    }
3048
3049    fn init_test(cx: &mut TestAppContext) {
3050        cx.foreground().forbid_parking();
3051        cx.update(|cx| {
3052            cx.set_global(SettingsStore::test(cx));
3053            init_settings(cx);
3054            theme::init((), cx);
3055            language::init(cx);
3056            editor::init_settings(cx);
3057            crate::init((), cx);
3058            workspace::init_settings(cx);
3059            client::init_settings(cx);
3060            Project::init_settings(cx);
3061
3062            cx.update_global::<SettingsStore, _, _>(|store, cx| {
3063                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3064                    project_settings.file_scan_exclusions = Some(Vec::new());
3065                });
3066            });
3067        });
3068    }
3069
3070    fn init_test_with_editor(cx: &mut TestAppContext) {
3071        cx.foreground().forbid_parking();
3072        cx.update(|cx| {
3073            let app_state = AppState::test(cx);
3074            theme::init((), cx);
3075            init_settings(cx);
3076            language::init(cx);
3077            editor::init(cx);
3078            pane::init(cx);
3079            crate::init((), cx);
3080            workspace::init(app_state.clone(), cx);
3081            Project::init_settings(cx);
3082        });
3083    }
3084
3085    fn ensure_single_file_is_opened(
3086        window: WindowHandle<Workspace>,
3087        expected_path: &str,
3088        cx: &mut TestAppContext,
3089    ) {
3090        window.update_root(cx, |workspace, cx| {
3091            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3092            assert_eq!(worktrees.len(), 1);
3093            let worktree_id = WorktreeId::from_usize(worktrees[0].id());
3094
3095            let open_project_paths = workspace
3096                .panes()
3097                .iter()
3098                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3099                .collect::<Vec<_>>();
3100            assert_eq!(
3101                open_project_paths,
3102                vec![ProjectPath {
3103                    worktree_id,
3104                    path: Arc::from(Path::new(expected_path))
3105                }],
3106                "Should have opened file, selected in project panel"
3107            );
3108        });
3109    }
3110
3111    fn submit_deletion(
3112        window: AnyWindowHandle,
3113        panel: &ViewHandle<ProjectPanel>,
3114        cx: &mut TestAppContext,
3115    ) {
3116        assert!(
3117            !window.has_pending_prompt(cx),
3118            "Should have no prompts before the deletion"
3119        );
3120        panel.update(cx, |panel, cx| {
3121            panel
3122                .delete(&Delete, cx)
3123                .expect("Deletion start")
3124                .detach_and_log_err(cx);
3125        });
3126        assert!(
3127            window.has_pending_prompt(cx),
3128            "Should have a prompt after the deletion"
3129        );
3130        window.simulate_prompt_answer(0, cx);
3131        assert!(
3132            !window.has_pending_prompt(cx),
3133            "Should have no prompts after prompt was replied to"
3134        );
3135        cx.foreground().run_until_parked();
3136    }
3137
3138    fn ensure_no_open_items_and_panes(
3139        window: AnyWindowHandle,
3140        workspace: &ViewHandle<Workspace>,
3141        cx: &mut TestAppContext,
3142    ) {
3143        assert!(
3144            !window.has_pending_prompt(cx),
3145            "Should have no prompts after deletion operation closes the file"
3146        );
3147        window.read_with(cx, |cx| {
3148            let open_project_paths = workspace
3149                .read(cx)
3150                .panes()
3151                .iter()
3152                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3153                .collect::<Vec<_>>();
3154            assert!(
3155                open_project_paths.is_empty(),
3156                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3157            );
3158        });
3159    }
3160}
3161// TODO - a workspace command?