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