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            if let Some(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            }
 667            Ok(())
 668        }))
 669    }
 670
 671    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 672        self.edit_state = None;
 673        self.update_visible_entries(None, cx);
 674        cx.focus_self();
 675        cx.notify();
 676    }
 677
 678    fn open_entry(
 679        &mut self,
 680        entry_id: ProjectEntryId,
 681        focus_opened_item: bool,
 682        cx: &mut ViewContext<Self>,
 683    ) {
 684        cx.emit(Event::OpenedEntry {
 685            entry_id,
 686            focus_opened_item,
 687        });
 688    }
 689
 690    fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 691        cx.emit(Event::SplitEntry { entry_id });
 692    }
 693
 694    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 695        self.add_entry(false, cx)
 696    }
 697
 698    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 699        self.add_entry(true, cx)
 700    }
 701
 702    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 703        if let Some(Selection {
 704            worktree_id,
 705            entry_id,
 706        }) = self.selection
 707        {
 708            let directory_id;
 709            if let Some((worktree, expanded_dir_ids)) = self
 710                .project
 711                .read(cx)
 712                .worktree_for_id(worktree_id, cx)
 713                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 714            {
 715                let worktree = worktree.read(cx);
 716                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 717                    loop {
 718                        if entry.is_dir() {
 719                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 720                                expanded_dir_ids.insert(ix, entry.id);
 721                            }
 722                            directory_id = entry.id;
 723                            break;
 724                        } else {
 725                            if let Some(parent_path) = entry.path.parent() {
 726                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 727                                    entry = parent_entry;
 728                                    continue;
 729                                }
 730                            }
 731                            return;
 732                        }
 733                    }
 734                } else {
 735                    return;
 736                };
 737            } else {
 738                return;
 739            };
 740
 741            self.edit_state = Some(EditState {
 742                worktree_id,
 743                entry_id: directory_id,
 744                is_new_entry: true,
 745                is_dir,
 746                processing_filename: None,
 747            });
 748            self.filename_editor
 749                .update(cx, |editor, cx| editor.clear(cx));
 750            cx.focus(&self.filename_editor);
 751            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 752            self.autoscroll(cx);
 753            cx.notify();
 754        }
 755    }
 756
 757    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 758        if let Some(Selection {
 759            worktree_id,
 760            entry_id,
 761        }) = self.selection
 762        {
 763            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 764                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 765                    self.edit_state = Some(EditState {
 766                        worktree_id,
 767                        entry_id,
 768                        is_new_entry: false,
 769                        is_dir: entry.is_dir(),
 770                        processing_filename: None,
 771                    });
 772                    let file_name = entry
 773                        .path
 774                        .file_name()
 775                        .map(|s| s.to_string_lossy())
 776                        .unwrap_or_default()
 777                        .to_string();
 778                    let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
 779                    let selection_end =
 780                        file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
 781                    self.filename_editor.update(cx, |editor, cx| {
 782                        editor.set_text(file_name, cx);
 783                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
 784                            s.select_ranges([0..selection_end])
 785                        })
 786                    });
 787                    cx.focus(&self.filename_editor);
 788                    self.update_visible_entries(None, cx);
 789                    self.autoscroll(cx);
 790                    cx.notify();
 791                }
 792            }
 793
 794            cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
 795                drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
 796            })
 797        }
 798    }
 799
 800    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 801        let Selection { entry_id, .. } = self.selection?;
 802        let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 803        let file_name = path.file_name()?;
 804
 805        let mut answer = cx.prompt(
 806            PromptLevel::Info,
 807            &format!("Delete {file_name:?}?"),
 808            &["Delete", "Cancel"],
 809        );
 810        Some(cx.spawn(|this, mut cx| async move {
 811            if answer.next().await != Some(0) {
 812                return Ok(());
 813            }
 814            this.update(&mut cx, |this, cx| {
 815                this.project
 816                    .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 817                    .ok_or_else(|| anyhow!("no such entry"))
 818            })??
 819            .await
 820        }))
 821    }
 822
 823    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 824        if let Some(selection) = self.selection {
 825            let (mut worktree_ix, mut entry_ix, _) =
 826                self.index_for_selection(selection).unwrap_or_default();
 827            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 828                if entry_ix + 1 < worktree_entries.len() {
 829                    entry_ix += 1;
 830                } else {
 831                    worktree_ix += 1;
 832                    entry_ix = 0;
 833                }
 834            }
 835
 836            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 837                if let Some(entry) = worktree_entries.get(entry_ix) {
 838                    self.selection = Some(Selection {
 839                        worktree_id: *worktree_id,
 840                        entry_id: entry.id,
 841                    });
 842                    self.autoscroll(cx);
 843                    cx.notify();
 844                }
 845            }
 846        } else {
 847            self.select_first(cx);
 848        }
 849    }
 850
 851    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 852        let worktree = self
 853            .visible_entries
 854            .first()
 855            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 856        if let Some(worktree) = worktree {
 857            let worktree = worktree.read(cx);
 858            let worktree_id = worktree.id();
 859            if let Some(root_entry) = worktree.root_entry() {
 860                self.selection = Some(Selection {
 861                    worktree_id,
 862                    entry_id: root_entry.id,
 863                });
 864                self.autoscroll(cx);
 865                cx.notify();
 866            }
 867        }
 868    }
 869
 870    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
 871        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 872            self.list.scroll_to(ScrollTarget::Show(index));
 873            cx.notify();
 874        }
 875    }
 876
 877    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 878        if let Some((worktree, entry)) = self.selected_entry(cx) {
 879            self.clipboard_entry = Some(ClipboardEntry::Cut {
 880                worktree_id: worktree.id(),
 881                entry_id: entry.id,
 882            });
 883            cx.notify();
 884        }
 885    }
 886
 887    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 888        if let Some((worktree, entry)) = self.selected_entry(cx) {
 889            self.clipboard_entry = Some(ClipboardEntry::Copied {
 890                worktree_id: worktree.id(),
 891                entry_id: entry.id,
 892            });
 893            cx.notify();
 894        }
 895    }
 896
 897    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
 898        if let Some((worktree, entry)) = self.selected_entry(cx) {
 899            let clipboard_entry = self.clipboard_entry?;
 900            if clipboard_entry.worktree_id() != worktree.id() {
 901                return None;
 902            }
 903
 904            let clipboard_entry_file_name = self
 905                .project
 906                .read(cx)
 907                .path_for_entry(clipboard_entry.entry_id(), cx)?
 908                .path
 909                .file_name()?
 910                .to_os_string();
 911
 912            let mut new_path = entry.path.to_path_buf();
 913            if entry.is_file() {
 914                new_path.pop();
 915            }
 916
 917            new_path.push(&clipboard_entry_file_name);
 918            let extension = new_path.extension().map(|e| e.to_os_string());
 919            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 920            let mut ix = 0;
 921            while worktree.entry_for_path(&new_path).is_some() {
 922                new_path.pop();
 923
 924                let mut new_file_name = file_name_without_extension.to_os_string();
 925                new_file_name.push(" copy");
 926                if ix > 0 {
 927                    new_file_name.push(format!(" {}", ix));
 928                }
 929                if let Some(extension) = extension.as_ref() {
 930                    new_file_name.push(".");
 931                    new_file_name.push(extension);
 932                }
 933
 934                new_path.push(new_file_name);
 935                ix += 1;
 936            }
 937
 938            if clipboard_entry.is_cut() {
 939                self.project
 940                    .update(cx, |project, cx| {
 941                        project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 942                    })
 943                    .detach_and_log_err(cx)
 944            } else {
 945                self.project
 946                    .update(cx, |project, cx| {
 947                        project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 948                    })
 949                    .detach_and_log_err(cx)
 950            }
 951        }
 952        None
 953    }
 954
 955    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 956        if let Some((worktree, entry)) = self.selected_entry(cx) {
 957            cx.write_to_clipboard(ClipboardItem::new(
 958                worktree
 959                    .abs_path()
 960                    .join(&entry.path)
 961                    .to_string_lossy()
 962                    .to_string(),
 963            ));
 964        }
 965    }
 966
 967    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
 968        if let Some((_, entry)) = self.selected_entry(cx) {
 969            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
 970        }
 971    }
 972
 973    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
 974        if let Some((worktree, entry)) = self.selected_entry(cx) {
 975            cx.reveal_path(&worktree.abs_path().join(&entry.path));
 976        }
 977    }
 978
 979    fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
 980        if let Some((worktree, entry)) = self.selected_entry(cx) {
 981            let window = cx.window();
 982            let view_id = cx.view_id();
 983            let path = worktree.abs_path().join(&entry.path);
 984
 985            cx.app_context()
 986                .spawn(|mut cx| async move {
 987                    window.dispatch_action(
 988                        view_id,
 989                        &workspace::OpenTerminal {
 990                            working_directory: path,
 991                        },
 992                        &mut cx,
 993                    );
 994                })
 995                .detach();
 996        }
 997    }
 998
 999    pub fn new_search_in_directory(
1000        &mut self,
1001        _: &NewSearchInDirectory,
1002        cx: &mut ViewContext<Self>,
1003    ) {
1004        if let Some((_, entry)) = self.selected_entry(cx) {
1005            if entry.is_dir() {
1006                cx.emit(Event::NewSearchInDirectory {
1007                    dir_entry: entry.clone(),
1008                });
1009            }
1010        }
1011    }
1012
1013    fn move_entry(
1014        &mut self,
1015        entry_to_move: ProjectEntryId,
1016        destination: ProjectEntryId,
1017        destination_is_file: bool,
1018        cx: &mut ViewContext<Self>,
1019    ) {
1020        let destination_worktree = self.project.update(cx, |project, cx| {
1021            let entry_path = project.path_for_entry(entry_to_move, cx)?;
1022            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1023
1024            let mut destination_path = destination_entry_path.as_ref();
1025            if destination_is_file {
1026                destination_path = destination_path.parent()?;
1027            }
1028
1029            let mut new_path = destination_path.to_path_buf();
1030            new_path.push(entry_path.path.file_name()?);
1031            if new_path != entry_path.path.as_ref() {
1032                let task = project.rename_entry(entry_to_move, new_path, cx);
1033                cx.foreground().spawn(task).detach_and_log_err(cx);
1034            }
1035
1036            Some(project.worktree_id_for_entry(destination, cx)?)
1037        });
1038
1039        if let Some(destination_worktree) = destination_worktree {
1040            self.expand_entry(destination_worktree, destination, cx);
1041        }
1042    }
1043
1044    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1045        let mut entry_index = 0;
1046        let mut visible_entries_index = 0;
1047        for (worktree_index, (worktree_id, worktree_entries)) in
1048            self.visible_entries.iter().enumerate()
1049        {
1050            if *worktree_id == selection.worktree_id {
1051                for entry in worktree_entries {
1052                    if entry.id == selection.entry_id {
1053                        return Some((worktree_index, entry_index, visible_entries_index));
1054                    } else {
1055                        visible_entries_index += 1;
1056                        entry_index += 1;
1057                    }
1058                }
1059                break;
1060            } else {
1061                visible_entries_index += worktree_entries.len();
1062            }
1063        }
1064        None
1065    }
1066
1067    pub fn selected_entry<'a>(
1068        &self,
1069        cx: &'a AppContext,
1070    ) -> Option<(&'a Worktree, &'a project::Entry)> {
1071        let (worktree, entry) = self.selected_entry_handle(cx)?;
1072        Some((worktree.read(cx), entry))
1073    }
1074
1075    fn selected_entry_handle<'a>(
1076        &self,
1077        cx: &'a AppContext,
1078    ) -> Option<(ModelHandle<Worktree>, &'a project::Entry)> {
1079        let selection = self.selection?;
1080        let project = self.project.read(cx);
1081        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1082        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1083        Some((worktree, entry))
1084    }
1085
1086    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1087        let (worktree, entry) = self.selected_entry(cx)?;
1088        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1089
1090        for path in entry.path.ancestors() {
1091            let Some(entry) = worktree.entry_for_path(path) else {
1092                continue;
1093            };
1094            if entry.is_dir() {
1095                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1096                    expanded_dir_ids.insert(idx, entry.id);
1097                }
1098            }
1099        }
1100
1101        Some(())
1102    }
1103
1104    fn update_visible_entries(
1105        &mut self,
1106        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1107        cx: &mut ViewContext<Self>,
1108    ) {
1109        let project = self.project.read(cx);
1110        self.last_worktree_root_id = project
1111            .visible_worktrees(cx)
1112            .rev()
1113            .next()
1114            .and_then(|worktree| worktree.read(cx).root_entry())
1115            .map(|entry| entry.id);
1116
1117        self.visible_entries.clear();
1118        for worktree in project.visible_worktrees(cx) {
1119            let snapshot = worktree.read(cx).snapshot();
1120            let worktree_id = snapshot.id();
1121
1122            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1123                hash_map::Entry::Occupied(e) => e.into_mut(),
1124                hash_map::Entry::Vacant(e) => {
1125                    // The first time a worktree's root entry becomes available,
1126                    // mark that root entry as expanded.
1127                    if let Some(entry) = snapshot.root_entry() {
1128                        e.insert(vec![entry.id]).as_slice()
1129                    } else {
1130                        &[]
1131                    }
1132                }
1133            };
1134
1135            let mut new_entry_parent_id = None;
1136            let mut new_entry_kind = EntryKind::Dir;
1137            if let Some(edit_state) = &self.edit_state {
1138                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1139                    new_entry_parent_id = Some(edit_state.entry_id);
1140                    new_entry_kind = if edit_state.is_dir {
1141                        EntryKind::Dir
1142                    } else {
1143                        EntryKind::File(Default::default())
1144                    };
1145                }
1146            }
1147
1148            let mut visible_worktree_entries = Vec::new();
1149            let mut entry_iter = snapshot.entries(true);
1150
1151            while let Some(entry) = entry_iter.entry() {
1152                visible_worktree_entries.push(entry.clone());
1153                if Some(entry.id) == new_entry_parent_id {
1154                    visible_worktree_entries.push(Entry {
1155                        id: NEW_ENTRY_ID,
1156                        kind: new_entry_kind,
1157                        path: entry.path.join("\0").into(),
1158                        inode: 0,
1159                        mtime: entry.mtime,
1160                        is_symlink: false,
1161                        is_ignored: false,
1162                        is_external: false,
1163                        git_status: entry.git_status,
1164                    });
1165                }
1166                if expanded_dir_ids.binary_search(&entry.id).is_err()
1167                    && entry_iter.advance_to_sibling()
1168                {
1169                    continue;
1170                }
1171                entry_iter.advance();
1172            }
1173
1174            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1175
1176            visible_worktree_entries.sort_by(|entry_a, entry_b| {
1177                let mut components_a = entry_a.path.components().peekable();
1178                let mut components_b = entry_b.path.components().peekable();
1179                loop {
1180                    match (components_a.next(), components_b.next()) {
1181                        (Some(component_a), Some(component_b)) => {
1182                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1183                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1184                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1185                                let name_a =
1186                                    UniCase::new(component_a.as_os_str().to_string_lossy());
1187                                let name_b =
1188                                    UniCase::new(component_b.as_os_str().to_string_lossy());
1189                                name_a.cmp(&name_b)
1190                            });
1191                            if !ordering.is_eq() {
1192                                return ordering;
1193                            }
1194                        }
1195                        (Some(_), None) => break Ordering::Greater,
1196                        (None, Some(_)) => break Ordering::Less,
1197                        (None, None) => break Ordering::Equal,
1198                    }
1199                }
1200            });
1201            self.visible_entries
1202                .push((worktree_id, visible_worktree_entries));
1203        }
1204
1205        if let Some((worktree_id, entry_id)) = new_selected_entry {
1206            self.selection = Some(Selection {
1207                worktree_id,
1208                entry_id,
1209            });
1210        }
1211    }
1212
1213    fn expand_entry(
1214        &mut self,
1215        worktree_id: WorktreeId,
1216        entry_id: ProjectEntryId,
1217        cx: &mut ViewContext<Self>,
1218    ) {
1219        self.project.update(cx, |project, cx| {
1220            if let Some((worktree, expanded_dir_ids)) = project
1221                .worktree_for_id(worktree_id, cx)
1222                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1223            {
1224                project.expand_entry(worktree_id, entry_id, cx);
1225                let worktree = worktree.read(cx);
1226
1227                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1228                    loop {
1229                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1230                            expanded_dir_ids.insert(ix, entry.id);
1231                        }
1232
1233                        if let Some(parent_entry) =
1234                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1235                        {
1236                            entry = parent_entry;
1237                        } else {
1238                            break;
1239                        }
1240                    }
1241                }
1242            }
1243        });
1244    }
1245
1246    fn for_each_visible_entry(
1247        &self,
1248        range: Range<usize>,
1249        cx: &mut ViewContext<ProjectPanel>,
1250        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1251    ) {
1252        let mut ix = 0;
1253        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1254            if ix >= range.end {
1255                return;
1256            }
1257
1258            if ix + visible_worktree_entries.len() <= range.start {
1259                ix += visible_worktree_entries.len();
1260                continue;
1261            }
1262
1263            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1264            let (git_status_setting, show_file_icons, show_folder_icons) = {
1265                let settings = settings::get::<ProjectPanelSettings>(cx);
1266                (
1267                    settings.git_status,
1268                    settings.file_icons,
1269                    settings.folder_icons,
1270                )
1271            };
1272            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1273                let snapshot = worktree.read(cx).snapshot();
1274                let root_name = OsStr::new(snapshot.root_name());
1275                let expanded_entry_ids = self
1276                    .expanded_dir_ids
1277                    .get(&snapshot.id())
1278                    .map(Vec::as_slice)
1279                    .unwrap_or(&[]);
1280
1281                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1282                for entry in visible_worktree_entries[entry_range].iter() {
1283                    let status = git_status_setting.then(|| entry.git_status).flatten();
1284                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1285                    let icon = match entry.kind {
1286                        EntryKind::File(_) => {
1287                            if show_file_icons {
1288                                Some(FileAssociations::get_icon(&entry.path, cx))
1289                            } else {
1290                                None
1291                            }
1292                        }
1293                        _ => {
1294                            if show_folder_icons {
1295                                Some(FileAssociations::get_folder_icon(is_expanded, cx))
1296                            } else {
1297                                Some(FileAssociations::get_chevron_icon(is_expanded, cx))
1298                            }
1299                        }
1300                    };
1301
1302                    let mut details = EntryDetails {
1303                        filename: entry
1304                            .path
1305                            .file_name()
1306                            .unwrap_or(root_name)
1307                            .to_string_lossy()
1308                            .to_string(),
1309                        icon,
1310                        path: entry.path.clone(),
1311                        depth: entry.path.components().count(),
1312                        kind: entry.kind,
1313                        is_ignored: entry.is_ignored,
1314                        is_expanded,
1315                        is_selected: self.selection.map_or(false, |e| {
1316                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1317                        }),
1318                        is_editing: false,
1319                        is_processing: false,
1320                        is_cut: self
1321                            .clipboard_entry
1322                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1323                        git_status: status,
1324                    };
1325
1326                    if let Some(edit_state) = &self.edit_state {
1327                        let is_edited_entry = if edit_state.is_new_entry {
1328                            entry.id == NEW_ENTRY_ID
1329                        } else {
1330                            entry.id == edit_state.entry_id
1331                        };
1332
1333                        if is_edited_entry {
1334                            if let Some(processing_filename) = &edit_state.processing_filename {
1335                                details.is_processing = true;
1336                                details.filename.clear();
1337                                details.filename.push_str(processing_filename);
1338                            } else {
1339                                if edit_state.is_new_entry {
1340                                    details.filename.clear();
1341                                }
1342                                details.is_editing = true;
1343                            }
1344                        }
1345                    }
1346
1347                    callback(entry.id, details, cx);
1348                }
1349            }
1350            ix = end_ix;
1351        }
1352    }
1353
1354    fn render_entry_visual_element<V: 'static>(
1355        details: &EntryDetails,
1356        editor: Option<&ViewHandle<Editor>>,
1357        padding: f32,
1358        row_container_style: ContainerStyle,
1359        style: &ProjectPanelEntry,
1360        cx: &mut ViewContext<V>,
1361    ) -> AnyElement<V> {
1362        let show_editor = details.is_editing && !details.is_processing;
1363
1364        let mut filename_text_style = style.text.clone();
1365        filename_text_style.color = details
1366            .git_status
1367            .as_ref()
1368            .map(|status| match status {
1369                GitFileStatus::Added => style.status.git.inserted,
1370                GitFileStatus::Modified => style.status.git.modified,
1371                GitFileStatus::Conflict => style.status.git.conflict,
1372            })
1373            .unwrap_or(style.text.color);
1374
1375        Flex::row()
1376            .with_child(if let Some(icon) = &details.icon {
1377                Svg::new(icon.to_string())
1378                    .with_color(style.icon_color)
1379                    .constrained()
1380                    .with_max_width(style.icon_size)
1381                    .with_max_height(style.icon_size)
1382                    .aligned()
1383                    .constrained()
1384                    .with_width(style.icon_size)
1385            } else {
1386                Empty::new()
1387                    .constrained()
1388                    .with_max_width(style.icon_size)
1389                    .with_max_height(style.icon_size)
1390                    .aligned()
1391                    .constrained()
1392                    .with_width(style.icon_size)
1393            })
1394            .with_child(if show_editor && editor.is_some() {
1395                ChildView::new(editor.as_ref().unwrap(), cx)
1396                    .contained()
1397                    .with_margin_left(style.icon_spacing)
1398                    .aligned()
1399                    .left()
1400                    .flex(1.0, true)
1401                    .into_any()
1402            } else {
1403                Label::new(details.filename.clone(), filename_text_style)
1404                    .contained()
1405                    .with_margin_left(style.icon_spacing)
1406                    .aligned()
1407                    .left()
1408                    .into_any()
1409            })
1410            .constrained()
1411            .with_height(style.height)
1412            .contained()
1413            .with_style(row_container_style)
1414            .with_padding_left(padding)
1415            .into_any_named("project panel entry visual element")
1416    }
1417
1418    fn render_entry(
1419        entry_id: ProjectEntryId,
1420        details: EntryDetails,
1421        editor: &ViewHandle<Editor>,
1422        dragged_entry_destination: &mut Option<Arc<Path>>,
1423        theme: &theme::ProjectPanel,
1424        cx: &mut ViewContext<Self>,
1425    ) -> AnyElement<Self> {
1426        let kind = details.kind;
1427        let path = details.path.clone();
1428        let settings = settings::get::<ProjectPanelSettings>(cx);
1429        let padding = theme.container.padding.left + details.depth as f32 * settings.indent_size;
1430
1431        let entry_style = if details.is_cut {
1432            &theme.cut_entry
1433        } else if details.is_ignored {
1434            &theme.ignored_entry
1435        } else {
1436            &theme.entry
1437        };
1438
1439        let show_editor = details.is_editing && !details.is_processing;
1440
1441        MouseEventHandler::new::<Self, _>(entry_id.to_usize(), cx, |state, cx| {
1442            let mut style = entry_style
1443                .in_state(details.is_selected)
1444                .style_for(state)
1445                .clone();
1446
1447            if cx
1448                .global::<DragAndDrop<Workspace>>()
1449                .currently_dragged::<ProjectEntryId>(cx.window())
1450                .is_some()
1451                && dragged_entry_destination
1452                    .as_ref()
1453                    .filter(|destination| details.path.starts_with(destination))
1454                    .is_some()
1455            {
1456                style = entry_style.active_state().default.clone();
1457            }
1458
1459            let row_container_style = if show_editor {
1460                theme.filename_editor.container
1461            } else {
1462                style.container
1463            };
1464
1465            Self::render_entry_visual_element(
1466                &details,
1467                Some(editor),
1468                padding,
1469                row_container_style,
1470                &style,
1471                cx,
1472            )
1473        })
1474        .on_click(MouseButton::Left, move |event, this, cx| {
1475            if !show_editor {
1476                if kind.is_dir() {
1477                    this.toggle_expanded(entry_id, cx);
1478                } else {
1479                    if event.cmd {
1480                        this.split_entry(entry_id, cx);
1481                    } else if !event.cmd {
1482                        this.open_entry(entry_id, event.click_count > 1, cx);
1483                    }
1484                }
1485            }
1486        })
1487        .on_down(MouseButton::Right, move |event, this, cx| {
1488            this.deploy_context_menu(event.position, entry_id, cx);
1489        })
1490        .on_up(MouseButton::Left, move |_, this, cx| {
1491            if let Some((_, dragged_entry)) = cx
1492                .global::<DragAndDrop<Workspace>>()
1493                .currently_dragged::<ProjectEntryId>(cx.window())
1494            {
1495                this.move_entry(
1496                    *dragged_entry,
1497                    entry_id,
1498                    matches!(details.kind, EntryKind::File(_)),
1499                    cx,
1500                );
1501            }
1502        })
1503        .on_move(move |_, this, cx| {
1504            if cx
1505                .global::<DragAndDrop<Workspace>>()
1506                .currently_dragged::<ProjectEntryId>(cx.window())
1507                .is_some()
1508            {
1509                this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1510                    path.parent().map(|parent| Arc::from(parent))
1511                } else {
1512                    Some(path.clone())
1513                };
1514            }
1515        })
1516        .as_draggable(entry_id, {
1517            let row_container_style = theme.dragged_entry.container;
1518
1519            move |_, _, cx: &mut ViewContext<Workspace>| {
1520                let theme = theme::current(cx).clone();
1521                Self::render_entry_visual_element(
1522                    &details,
1523                    None,
1524                    padding,
1525                    row_container_style,
1526                    &theme.project_panel.dragged_entry,
1527                    cx,
1528                )
1529            }
1530        })
1531        .with_cursor_style(CursorStyle::PointingHand)
1532        .into_any_named("project panel entry")
1533    }
1534}
1535
1536impl View for ProjectPanel {
1537    fn ui_name() -> &'static str {
1538        "ProjectPanel"
1539    }
1540
1541    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1542        enum ProjectPanel {}
1543        let theme = &theme::current(cx).project_panel;
1544        let mut container_style = theme.container;
1545        let padding = std::mem::take(&mut container_style.padding);
1546        let last_worktree_root_id = self.last_worktree_root_id;
1547
1548        let has_worktree = self.visible_entries.len() != 0;
1549
1550        if has_worktree {
1551            Stack::new()
1552                .with_child(
1553                    MouseEventHandler::new::<ProjectPanel, _>(0, cx, |_, cx| {
1554                        UniformList::new(
1555                            self.list.clone(),
1556                            self.visible_entries
1557                                .iter()
1558                                .map(|(_, worktree_entries)| worktree_entries.len())
1559                                .sum(),
1560                            cx,
1561                            move |this, range, items, cx| {
1562                                let theme = theme::current(cx).clone();
1563                                let mut dragged_entry_destination =
1564                                    this.dragged_entry_destination.clone();
1565                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1566                                    items.push(Self::render_entry(
1567                                        id,
1568                                        details,
1569                                        &this.filename_editor,
1570                                        &mut dragged_entry_destination,
1571                                        &theme.project_panel,
1572                                        cx,
1573                                    ));
1574                                });
1575                                this.dragged_entry_destination = dragged_entry_destination;
1576                            },
1577                        )
1578                        .with_padding_top(padding.top)
1579                        .with_padding_bottom(padding.bottom)
1580                        .contained()
1581                        .with_style(container_style)
1582                        .expanded()
1583                    })
1584                    .on_down(MouseButton::Right, move |event, this, cx| {
1585                        // When deploying the context menu anywhere below the last project entry,
1586                        // act as if the user clicked the root of the last worktree.
1587                        if let Some(entry_id) = last_worktree_root_id {
1588                            this.deploy_context_menu(event.position, entry_id, cx);
1589                        }
1590                    }),
1591                )
1592                .with_child(ChildView::new(&self.context_menu, cx))
1593                .into_any_named("project panel")
1594        } else {
1595            Flex::column()
1596                .with_child(
1597                    MouseEventHandler::new::<Self, _>(2, cx, {
1598                        let button_style = theme.open_project_button.clone();
1599                        let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1600                        move |state, cx| {
1601                            let button_style = button_style.style_for(state).clone();
1602                            let context_menu_item = context_menu_item_style
1603                                .active_state()
1604                                .style_for(state)
1605                                .clone();
1606
1607                            theme::ui::keystroke_label(
1608                                "Open a project",
1609                                &button_style,
1610                                &context_menu_item.keystroke,
1611                                Box::new(workspace::Open),
1612                                cx,
1613                            )
1614                        }
1615                    })
1616                    .on_click(MouseButton::Left, move |_, this, cx| {
1617                        if let Some(workspace) = this.workspace.upgrade(cx) {
1618                            workspace.update(cx, |workspace, cx| {
1619                                if let Some(task) = workspace.open(&Default::default(), cx) {
1620                                    task.detach_and_log_err(cx);
1621                                }
1622                            })
1623                        }
1624                    })
1625                    .with_cursor_style(CursorStyle::PointingHand),
1626                )
1627                .contained()
1628                .with_style(container_style)
1629                .into_any_named("empty project panel")
1630        }
1631    }
1632
1633    fn update_keymap_context(&self, keymap: &mut KeymapContext, cx: &AppContext) {
1634        Self::reset_to_default_keymap_context(keymap);
1635        keymap.add_identifier("menu");
1636
1637        if let Some(window) = cx.active_window() {
1638            window.read_with(cx, |cx| {
1639                let identifier = if self.filename_editor.is_focused(cx) {
1640                    "editing"
1641                } else {
1642                    "not_editing"
1643                };
1644
1645                keymap.add_identifier(identifier);
1646            });
1647        }
1648    }
1649
1650    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1651        if !self.has_focus {
1652            self.has_focus = true;
1653            cx.emit(Event::Focus);
1654        }
1655    }
1656
1657    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1658        self.has_focus = false;
1659    }
1660}
1661
1662impl Entity for ProjectPanel {
1663    type Event = Event;
1664}
1665
1666impl workspace::dock::Panel for ProjectPanel {
1667    fn position(&self, cx: &WindowContext) -> DockPosition {
1668        match settings::get::<ProjectPanelSettings>(cx).dock {
1669            ProjectPanelDockPosition::Left => DockPosition::Left,
1670            ProjectPanelDockPosition::Right => DockPosition::Right,
1671        }
1672    }
1673
1674    fn position_is_valid(&self, position: DockPosition) -> bool {
1675        matches!(position, DockPosition::Left | DockPosition::Right)
1676    }
1677
1678    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1679        settings::update_settings_file::<ProjectPanelSettings>(
1680            self.fs.clone(),
1681            cx,
1682            move |settings| {
1683                let dock = match position {
1684                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1685                    DockPosition::Right => ProjectPanelDockPosition::Right,
1686                };
1687                settings.dock = Some(dock);
1688            },
1689        );
1690    }
1691
1692    fn size(&self, cx: &WindowContext) -> f32 {
1693        self.width
1694            .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1695    }
1696
1697    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
1698        self.width = size;
1699        self.serialize(cx);
1700        cx.notify();
1701    }
1702
1703    fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
1704        Some("icons/project.svg")
1705    }
1706
1707    fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1708        ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1709    }
1710
1711    fn should_change_position_on_event(event: &Self::Event) -> bool {
1712        matches!(event, Event::DockPositionChanged)
1713    }
1714
1715    fn has_focus(&self, _: &WindowContext) -> bool {
1716        self.has_focus
1717    }
1718
1719    fn is_focus_event(event: &Self::Event) -> bool {
1720        matches!(event, Event::Focus)
1721    }
1722}
1723
1724impl ClipboardEntry {
1725    fn is_cut(&self) -> bool {
1726        matches!(self, Self::Cut { .. })
1727    }
1728
1729    fn entry_id(&self) -> ProjectEntryId {
1730        match self {
1731            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1732                *entry_id
1733            }
1734        }
1735    }
1736
1737    fn worktree_id(&self) -> WorktreeId {
1738        match self {
1739            ClipboardEntry::Copied { worktree_id, .. }
1740            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1741        }
1742    }
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747    use super::*;
1748    use gpui::{AnyWindowHandle, TestAppContext, ViewHandle, WindowHandle};
1749    use pretty_assertions::assert_eq;
1750    use project::{project_settings::ProjectSettings, FakeFs};
1751    use serde_json::json;
1752    use settings::SettingsStore;
1753    use std::{
1754        collections::HashSet,
1755        path::{Path, PathBuf},
1756        sync::atomic::{self, AtomicUsize},
1757    };
1758    use workspace::{pane, AppState};
1759
1760    #[gpui::test]
1761    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1762        init_test(cx);
1763
1764        let fs = FakeFs::new(cx.background());
1765        fs.insert_tree(
1766            "/root1",
1767            json!({
1768                ".dockerignore": "",
1769                ".git": {
1770                    "HEAD": "",
1771                },
1772                "a": {
1773                    "0": { "q": "", "r": "", "s": "" },
1774                    "1": { "t": "", "u": "" },
1775                    "2": { "v": "", "w": "", "x": "", "y": "" },
1776                },
1777                "b": {
1778                    "3": { "Q": "" },
1779                    "4": { "R": "", "S": "", "T": "", "U": "" },
1780                },
1781                "C": {
1782                    "5": {},
1783                    "6": { "V": "", "W": "" },
1784                    "7": { "X": "" },
1785                    "8": { "Y": {}, "Z": "" }
1786                }
1787            }),
1788        )
1789        .await;
1790        fs.insert_tree(
1791            "/root2",
1792            json!({
1793                "d": {
1794                    "9": ""
1795                },
1796                "e": {}
1797            }),
1798        )
1799        .await;
1800
1801        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1802        let workspace = cx
1803            .add_window(|cx| Workspace::test_new(project.clone(), cx))
1804            .root(cx);
1805        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1806        assert_eq!(
1807            visible_entries_as_strings(&panel, 0..50, cx),
1808            &[
1809                "v root1",
1810                "    > .git",
1811                "    > a",
1812                "    > b",
1813                "    > C",
1814                "      .dockerignore",
1815                "v root2",
1816                "    > d",
1817                "    > e",
1818            ]
1819        );
1820
1821        toggle_expand_dir(&panel, "root1/b", cx);
1822        assert_eq!(
1823            visible_entries_as_strings(&panel, 0..50, cx),
1824            &[
1825                "v root1",
1826                "    > .git",
1827                "    > a",
1828                "    v b  <== selected",
1829                "        > 3",
1830                "        > 4",
1831                "    > C",
1832                "      .dockerignore",
1833                "v root2",
1834                "    > d",
1835                "    > e",
1836            ]
1837        );
1838
1839        assert_eq!(
1840            visible_entries_as_strings(&panel, 6..9, cx),
1841            &[
1842                //
1843                "    > C",
1844                "      .dockerignore",
1845                "v root2",
1846            ]
1847        );
1848    }
1849
1850    #[gpui::test]
1851    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1852        init_test(cx);
1853        cx.update(|cx| {
1854            cx.update_global::<SettingsStore, _, _>(|store, cx| {
1855                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1856                    project_settings.file_scan_exclusions =
1857                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1858                });
1859            });
1860        });
1861
1862        let fs = FakeFs::new(cx.background());
1863        fs.insert_tree(
1864            "/root1",
1865            json!({
1866                ".dockerignore": "",
1867                ".git": {
1868                    "HEAD": "",
1869                },
1870                "a": {
1871                    "0": { "q": "", "r": "", "s": "" },
1872                    "1": { "t": "", "u": "" },
1873                    "2": { "v": "", "w": "", "x": "", "y": "" },
1874                },
1875                "b": {
1876                    "3": { "Q": "" },
1877                    "4": { "R": "", "S": "", "T": "", "U": "" },
1878                },
1879                "C": {
1880                    "5": {},
1881                    "6": { "V": "", "W": "" },
1882                    "7": { "X": "" },
1883                    "8": { "Y": {}, "Z": "" }
1884                }
1885            }),
1886        )
1887        .await;
1888        fs.insert_tree(
1889            "/root2",
1890            json!({
1891                "d": {
1892                    "4": ""
1893                },
1894                "e": {}
1895            }),
1896        )
1897        .await;
1898
1899        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1900        let workspace = cx
1901            .add_window(|cx| Workspace::test_new(project.clone(), cx))
1902            .root(cx);
1903        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1904        assert_eq!(
1905            visible_entries_as_strings(&panel, 0..50, cx),
1906            &[
1907                "v root1",
1908                "    > a",
1909                "    > b",
1910                "    > C",
1911                "      .dockerignore",
1912                "v root2",
1913                "    > d",
1914                "    > e",
1915            ]
1916        );
1917
1918        toggle_expand_dir(&panel, "root1/b", cx);
1919        assert_eq!(
1920            visible_entries_as_strings(&panel, 0..50, cx),
1921            &[
1922                "v root1",
1923                "    > a",
1924                "    v b  <== selected",
1925                "        > 3",
1926                "    > C",
1927                "      .dockerignore",
1928                "v root2",
1929                "    > d",
1930                "    > e",
1931            ]
1932        );
1933
1934        toggle_expand_dir(&panel, "root2/d", cx);
1935        assert_eq!(
1936            visible_entries_as_strings(&panel, 0..50, cx),
1937            &[
1938                "v root1",
1939                "    > a",
1940                "    v b",
1941                "        > 3",
1942                "    > C",
1943                "      .dockerignore",
1944                "v root2",
1945                "    v d  <== selected",
1946                "    > e",
1947            ]
1948        );
1949
1950        toggle_expand_dir(&panel, "root2/e", cx);
1951        assert_eq!(
1952            visible_entries_as_strings(&panel, 0..50, cx),
1953            &[
1954                "v root1",
1955                "    > a",
1956                "    v b",
1957                "        > 3",
1958                "    > C",
1959                "      .dockerignore",
1960                "v root2",
1961                "    v d",
1962                "    v e  <== selected",
1963            ]
1964        );
1965    }
1966
1967    #[gpui::test(iterations = 30)]
1968    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1969        init_test(cx);
1970
1971        let fs = FakeFs::new(cx.background());
1972        fs.insert_tree(
1973            "/root1",
1974            json!({
1975                ".dockerignore": "",
1976                ".git": {
1977                    "HEAD": "",
1978                },
1979                "a": {
1980                    "0": { "q": "", "r": "", "s": "" },
1981                    "1": { "t": "", "u": "" },
1982                    "2": { "v": "", "w": "", "x": "", "y": "" },
1983                },
1984                "b": {
1985                    "3": { "Q": "" },
1986                    "4": { "R": "", "S": "", "T": "", "U": "" },
1987                },
1988                "C": {
1989                    "5": {},
1990                    "6": { "V": "", "W": "" },
1991                    "7": { "X": "" },
1992                    "8": { "Y": {}, "Z": "" }
1993                }
1994            }),
1995        )
1996        .await;
1997        fs.insert_tree(
1998            "/root2",
1999            json!({
2000                "d": {
2001                    "9": ""
2002                },
2003                "e": {}
2004            }),
2005        )
2006        .await;
2007
2008        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2009        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2010        let workspace = window.root(cx);
2011        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2012
2013        select_path(&panel, "root1", cx);
2014        assert_eq!(
2015            visible_entries_as_strings(&panel, 0..10, cx),
2016            &[
2017                "v root1  <== selected",
2018                "    > .git",
2019                "    > a",
2020                "    > b",
2021                "    > C",
2022                "      .dockerignore",
2023                "v root2",
2024                "    > d",
2025                "    > e",
2026            ]
2027        );
2028
2029        // Add a file with the root folder selected. The filename editor is placed
2030        // before the first file in the root folder.
2031        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2032        window.read_with(cx, |cx| {
2033            let panel = panel.read(cx);
2034            assert!(panel.filename_editor.is_focused(cx));
2035        });
2036        assert_eq!(
2037            visible_entries_as_strings(&panel, 0..10, cx),
2038            &[
2039                "v root1",
2040                "    > .git",
2041                "    > a",
2042                "    > b",
2043                "    > C",
2044                "      [EDITOR: '']  <== selected",
2045                "      .dockerignore",
2046                "v root2",
2047                "    > d",
2048                "    > e",
2049            ]
2050        );
2051
2052        let confirm = panel.update(cx, |panel, cx| {
2053            panel
2054                .filename_editor
2055                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2056            panel.confirm(&Confirm, cx).unwrap()
2057        });
2058        assert_eq!(
2059            visible_entries_as_strings(&panel, 0..10, cx),
2060            &[
2061                "v root1",
2062                "    > .git",
2063                "    > a",
2064                "    > b",
2065                "    > C",
2066                "      [PROCESSING: 'the-new-filename']  <== selected",
2067                "      .dockerignore",
2068                "v root2",
2069                "    > d",
2070                "    > e",
2071            ]
2072        );
2073
2074        confirm.await.unwrap();
2075        assert_eq!(
2076            visible_entries_as_strings(&panel, 0..10, cx),
2077            &[
2078                "v root1",
2079                "    > .git",
2080                "    > a",
2081                "    > b",
2082                "    > C",
2083                "      .dockerignore",
2084                "      the-new-filename  <== selected",
2085                "v root2",
2086                "    > d",
2087                "    > e",
2088            ]
2089        );
2090
2091        select_path(&panel, "root1/b", cx);
2092        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2093        assert_eq!(
2094            visible_entries_as_strings(&panel, 0..10, cx),
2095            &[
2096                "v root1",
2097                "    > .git",
2098                "    > a",
2099                "    v b",
2100                "        > 3",
2101                "        > 4",
2102                "          [EDITOR: '']  <== selected",
2103                "    > C",
2104                "      .dockerignore",
2105                "      the-new-filename",
2106            ]
2107        );
2108
2109        panel
2110            .update(cx, |panel, cx| {
2111                panel
2112                    .filename_editor
2113                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2114                panel.confirm(&Confirm, cx).unwrap()
2115            })
2116            .await
2117            .unwrap();
2118        assert_eq!(
2119            visible_entries_as_strings(&panel, 0..10, cx),
2120            &[
2121                "v root1",
2122                "    > .git",
2123                "    > a",
2124                "    v b",
2125                "        > 3",
2126                "        > 4",
2127                "          another-filename.txt  <== selected",
2128                "    > C",
2129                "      .dockerignore",
2130                "      the-new-filename",
2131            ]
2132        );
2133
2134        select_path(&panel, "root1/b/another-filename.txt", cx);
2135        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2136        assert_eq!(
2137            visible_entries_as_strings(&panel, 0..10, cx),
2138            &[
2139                "v root1",
2140                "    > .git",
2141                "    > a",
2142                "    v b",
2143                "        > 3",
2144                "        > 4",
2145                "          [EDITOR: 'another-filename.txt']  <== selected",
2146                "    > C",
2147                "      .dockerignore",
2148                "      the-new-filename",
2149            ]
2150        );
2151
2152        let confirm = panel.update(cx, |panel, cx| {
2153            panel.filename_editor.update(cx, |editor, cx| {
2154                let file_name_selections = editor.selections.all::<usize>(cx);
2155                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2156                let file_name_selection = &file_name_selections[0];
2157                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2158                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2159
2160                editor.set_text("a-different-filename.tar.gz", cx)
2161            });
2162            panel.confirm(&Confirm, cx).unwrap()
2163        });
2164        assert_eq!(
2165            visible_entries_as_strings(&panel, 0..10, cx),
2166            &[
2167                "v root1",
2168                "    > .git",
2169                "    > a",
2170                "    v b",
2171                "        > 3",
2172                "        > 4",
2173                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2174                "    > C",
2175                "      .dockerignore",
2176                "      the-new-filename",
2177            ]
2178        );
2179
2180        confirm.await.unwrap();
2181        assert_eq!(
2182            visible_entries_as_strings(&panel, 0..10, cx),
2183            &[
2184                "v root1",
2185                "    > .git",
2186                "    > a",
2187                "    v b",
2188                "        > 3",
2189                "        > 4",
2190                "          a-different-filename.tar.gz  <== selected",
2191                "    > C",
2192                "      .dockerignore",
2193                "      the-new-filename",
2194            ]
2195        );
2196
2197        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2198        assert_eq!(
2199            visible_entries_as_strings(&panel, 0..10, cx),
2200            &[
2201                "v root1",
2202                "    > .git",
2203                "    > a",
2204                "    v b",
2205                "        > 3",
2206                "        > 4",
2207                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2208                "    > C",
2209                "      .dockerignore",
2210                "      the-new-filename",
2211            ]
2212        );
2213
2214        panel.update(cx, |panel, cx| {
2215            panel.filename_editor.update(cx, |editor, cx| {
2216                let file_name_selections = editor.selections.all::<usize>(cx);
2217                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2218                let file_name_selection = &file_name_selections[0];
2219                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2220                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");
2221
2222            });
2223            panel.cancel(&Cancel, cx)
2224        });
2225
2226        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2227        assert_eq!(
2228            visible_entries_as_strings(&panel, 0..10, cx),
2229            &[
2230                "v root1",
2231                "    > .git",
2232                "    > a",
2233                "    v b",
2234                "        > [EDITOR: '']  <== selected",
2235                "        > 3",
2236                "        > 4",
2237                "          a-different-filename.tar.gz",
2238                "    > C",
2239                "      .dockerignore",
2240            ]
2241        );
2242
2243        let confirm = panel.update(cx, |panel, cx| {
2244            panel
2245                .filename_editor
2246                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2247            panel.confirm(&Confirm, cx).unwrap()
2248        });
2249        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2250        assert_eq!(
2251            visible_entries_as_strings(&panel, 0..10, cx),
2252            &[
2253                "v root1",
2254                "    > .git",
2255                "    > a",
2256                "    v b",
2257                "        > [PROCESSING: 'new-dir']",
2258                "        > 3  <== selected",
2259                "        > 4",
2260                "          a-different-filename.tar.gz",
2261                "    > C",
2262                "      .dockerignore",
2263            ]
2264        );
2265
2266        confirm.await.unwrap();
2267        assert_eq!(
2268            visible_entries_as_strings(&panel, 0..10, cx),
2269            &[
2270                "v root1",
2271                "    > .git",
2272                "    > a",
2273                "    v b",
2274                "        > 3  <== selected",
2275                "        > 4",
2276                "        > new-dir",
2277                "          a-different-filename.tar.gz",
2278                "    > C",
2279                "      .dockerignore",
2280            ]
2281        );
2282
2283        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2284        assert_eq!(
2285            visible_entries_as_strings(&panel, 0..10, cx),
2286            &[
2287                "v root1",
2288                "    > .git",
2289                "    > a",
2290                "    v b",
2291                "        > [EDITOR: '3']  <== selected",
2292                "        > 4",
2293                "        > new-dir",
2294                "          a-different-filename.tar.gz",
2295                "    > C",
2296                "      .dockerignore",
2297            ]
2298        );
2299
2300        // Dismiss the rename editor when it loses focus.
2301        workspace.update(cx, |_, cx| cx.focus_self());
2302        assert_eq!(
2303            visible_entries_as_strings(&panel, 0..10, cx),
2304            &[
2305                "v root1",
2306                "    > .git",
2307                "    > a",
2308                "    v b",
2309                "        > 3  <== selected",
2310                "        > 4",
2311                "        > new-dir",
2312                "          a-different-filename.tar.gz",
2313                "    > C",
2314                "      .dockerignore",
2315            ]
2316        );
2317    }
2318
2319    #[gpui::test(iterations = 30)]
2320    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2321        init_test(cx);
2322
2323        let fs = FakeFs::new(cx.background());
2324        fs.insert_tree(
2325            "/root1",
2326            json!({
2327                ".dockerignore": "",
2328                ".git": {
2329                    "HEAD": "",
2330                },
2331                "a": {
2332                    "0": { "q": "", "r": "", "s": "" },
2333                    "1": { "t": "", "u": "" },
2334                    "2": { "v": "", "w": "", "x": "", "y": "" },
2335                },
2336                "b": {
2337                    "3": { "Q": "" },
2338                    "4": { "R": "", "S": "", "T": "", "U": "" },
2339                },
2340                "C": {
2341                    "5": {},
2342                    "6": { "V": "", "W": "" },
2343                    "7": { "X": "" },
2344                    "8": { "Y": {}, "Z": "" }
2345                }
2346            }),
2347        )
2348        .await;
2349        fs.insert_tree(
2350            "/root2",
2351            json!({
2352                "d": {
2353                    "9": ""
2354                },
2355                "e": {}
2356            }),
2357        )
2358        .await;
2359
2360        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2361        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2362        let workspace = window.root(cx);
2363        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2364
2365        select_path(&panel, "root1", cx);
2366        assert_eq!(
2367            visible_entries_as_strings(&panel, 0..10, cx),
2368            &[
2369                "v root1  <== selected",
2370                "    > .git",
2371                "    > a",
2372                "    > b",
2373                "    > C",
2374                "      .dockerignore",
2375                "v root2",
2376                "    > d",
2377                "    > e",
2378            ]
2379        );
2380
2381        // Add a file with the root folder selected. The filename editor is placed
2382        // before the first file in the root folder.
2383        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2384        window.read_with(cx, |cx| {
2385            let panel = panel.read(cx);
2386            assert!(panel.filename_editor.is_focused(cx));
2387        });
2388        assert_eq!(
2389            visible_entries_as_strings(&panel, 0..10, cx),
2390            &[
2391                "v root1",
2392                "    > .git",
2393                "    > a",
2394                "    > b",
2395                "    > C",
2396                "      [EDITOR: '']  <== selected",
2397                "      .dockerignore",
2398                "v root2",
2399                "    > d",
2400                "    > e",
2401            ]
2402        );
2403
2404        let confirm = panel.update(cx, |panel, cx| {
2405            panel.filename_editor.update(cx, |editor, cx| {
2406                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2407            });
2408            panel.confirm(&Confirm, cx).unwrap()
2409        });
2410
2411        assert_eq!(
2412            visible_entries_as_strings(&panel, 0..10, cx),
2413            &[
2414                "v root1",
2415                "    > .git",
2416                "    > a",
2417                "    > b",
2418                "    > C",
2419                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2420                "      .dockerignore",
2421                "v root2",
2422                "    > d",
2423                "    > e",
2424            ]
2425        );
2426
2427        confirm.await.unwrap();
2428        assert_eq!(
2429            visible_entries_as_strings(&panel, 0..13, cx),
2430            &[
2431                "v root1",
2432                "    > .git",
2433                "    > a",
2434                "    > b",
2435                "    v bdir1",
2436                "        v dir2",
2437                "              the-new-filename  <== selected",
2438                "    > C",
2439                "      .dockerignore",
2440                "v root2",
2441                "    > d",
2442                "    > e",
2443            ]
2444        );
2445    }
2446
2447    #[gpui::test]
2448    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2449        init_test(cx);
2450
2451        let fs = FakeFs::new(cx.background());
2452        fs.insert_tree(
2453            "/root1",
2454            json!({
2455                "one.two.txt": "",
2456                "one.txt": ""
2457            }),
2458        )
2459        .await;
2460
2461        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2462        let workspace = cx
2463            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2464            .root(cx);
2465        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2466
2467        panel.update(cx, |panel, cx| {
2468            panel.select_next(&Default::default(), cx);
2469            panel.select_next(&Default::default(), cx);
2470        });
2471
2472        assert_eq!(
2473            visible_entries_as_strings(&panel, 0..50, cx),
2474            &[
2475                //
2476                "v root1",
2477                "      one.two.txt  <== selected",
2478                "      one.txt",
2479            ]
2480        );
2481
2482        // Regression test - file name is created correctly when
2483        // the copied file's name contains multiple dots.
2484        panel.update(cx, |panel, cx| {
2485            panel.copy(&Default::default(), cx);
2486            panel.paste(&Default::default(), cx);
2487        });
2488        cx.foreground().run_until_parked();
2489
2490        assert_eq!(
2491            visible_entries_as_strings(&panel, 0..50, cx),
2492            &[
2493                //
2494                "v root1",
2495                "      one.two copy.txt",
2496                "      one.two.txt  <== selected",
2497                "      one.txt",
2498            ]
2499        );
2500
2501        panel.update(cx, |panel, cx| {
2502            panel.paste(&Default::default(), cx);
2503        });
2504        cx.foreground().run_until_parked();
2505
2506        assert_eq!(
2507            visible_entries_as_strings(&panel, 0..50, cx),
2508            &[
2509                //
2510                "v root1",
2511                "      one.two copy 1.txt",
2512                "      one.two copy.txt",
2513                "      one.two.txt  <== selected",
2514                "      one.txt",
2515            ]
2516        );
2517    }
2518
2519    #[gpui::test]
2520    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2521        init_test_with_editor(cx);
2522
2523        let fs = FakeFs::new(cx.background());
2524        fs.insert_tree(
2525            "/src",
2526            json!({
2527                "test": {
2528                    "first.rs": "// First Rust file",
2529                    "second.rs": "// Second Rust file",
2530                    "third.rs": "// Third Rust file",
2531                }
2532            }),
2533        )
2534        .await;
2535
2536        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2537        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2538        let workspace = window.root(cx);
2539        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2540
2541        toggle_expand_dir(&panel, "src/test", cx);
2542        select_path(&panel, "src/test/first.rs", cx);
2543        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2544        cx.foreground().run_until_parked();
2545        assert_eq!(
2546            visible_entries_as_strings(&panel, 0..10, cx),
2547            &[
2548                "v src",
2549                "    v test",
2550                "          first.rs  <== selected",
2551                "          second.rs",
2552                "          third.rs"
2553            ]
2554        );
2555        ensure_single_file_is_opened(window, "test/first.rs", cx);
2556
2557        submit_deletion(window.into(), &panel, cx);
2558        assert_eq!(
2559            visible_entries_as_strings(&panel, 0..10, cx),
2560            &[
2561                "v src",
2562                "    v test",
2563                "          second.rs",
2564                "          third.rs"
2565            ],
2566            "Project panel should have no deleted file, no other file is selected in it"
2567        );
2568        ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2569
2570        select_path(&panel, "src/test/second.rs", cx);
2571        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2572        cx.foreground().run_until_parked();
2573        assert_eq!(
2574            visible_entries_as_strings(&panel, 0..10, cx),
2575            &[
2576                "v src",
2577                "    v test",
2578                "          second.rs  <== selected",
2579                "          third.rs"
2580            ]
2581        );
2582        ensure_single_file_is_opened(window, "test/second.rs", cx);
2583
2584        window.update(cx, |cx| {
2585            let active_items = workspace
2586                .read(cx)
2587                .panes()
2588                .iter()
2589                .filter_map(|pane| pane.read(cx).active_item())
2590                .collect::<Vec<_>>();
2591            assert_eq!(active_items.len(), 1);
2592            let open_editor = active_items
2593                .into_iter()
2594                .next()
2595                .unwrap()
2596                .downcast::<Editor>()
2597                .expect("Open item should be an editor");
2598            open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2599        });
2600        submit_deletion(window.into(), &panel, cx);
2601        assert_eq!(
2602            visible_entries_as_strings(&panel, 0..10, cx),
2603            &["v src", "    v test", "          third.rs"],
2604            "Project panel should have no deleted file, with one last file remaining"
2605        );
2606        ensure_no_open_items_and_panes(window.into(), &workspace, cx);
2607    }
2608
2609    #[gpui::test]
2610    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2611        init_test_with_editor(cx);
2612
2613        let fs = FakeFs::new(cx.background());
2614        fs.insert_tree(
2615            "/src",
2616            json!({
2617                "test": {
2618                    "first.rs": "// First Rust file",
2619                    "second.rs": "// Second Rust file",
2620                    "third.rs": "// Third Rust file",
2621                }
2622            }),
2623        )
2624        .await;
2625
2626        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2627        let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2628        let workspace = window.root(cx);
2629        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2630
2631        select_path(&panel, "src/", cx);
2632        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2633        cx.foreground().run_until_parked();
2634        assert_eq!(
2635            visible_entries_as_strings(&panel, 0..10, cx),
2636            &["v src  <== selected", "    > test"]
2637        );
2638        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2639        window.read_with(cx, |cx| {
2640            let panel = panel.read(cx);
2641            assert!(panel.filename_editor.is_focused(cx));
2642        });
2643        assert_eq!(
2644            visible_entries_as_strings(&panel, 0..10, cx),
2645            &["v src", "    > [EDITOR: '']  <== selected", "    > test"]
2646        );
2647        panel.update(cx, |panel, cx| {
2648            panel
2649                .filename_editor
2650                .update(cx, |editor, cx| editor.set_text("test", cx));
2651            assert!(
2652                panel.confirm(&Confirm, cx).is_none(),
2653                "Should not allow to confirm on conflicting new directory name"
2654            )
2655        });
2656        assert_eq!(
2657            visible_entries_as_strings(&panel, 0..10, cx),
2658            &["v src", "    > test"],
2659            "File list should be unchanged after failed folder create confirmation"
2660        );
2661
2662        select_path(&panel, "src/test/", cx);
2663        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2664        cx.foreground().run_until_parked();
2665        assert_eq!(
2666            visible_entries_as_strings(&panel, 0..10, cx),
2667            &["v src", "    > test  <== selected"]
2668        );
2669        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2670        window.read_with(cx, |cx| {
2671            let panel = panel.read(cx);
2672            assert!(panel.filename_editor.is_focused(cx));
2673        });
2674        assert_eq!(
2675            visible_entries_as_strings(&panel, 0..10, cx),
2676            &[
2677                "v src",
2678                "    v test",
2679                "          [EDITOR: '']  <== selected",
2680                "          first.rs",
2681                "          second.rs",
2682                "          third.rs"
2683            ]
2684        );
2685        panel.update(cx, |panel, cx| {
2686            panel
2687                .filename_editor
2688                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2689            assert!(
2690                panel.confirm(&Confirm, cx).is_none(),
2691                "Should not allow to confirm on conflicting new file name"
2692            )
2693        });
2694        assert_eq!(
2695            visible_entries_as_strings(&panel, 0..10, cx),
2696            &[
2697                "v src",
2698                "    v test",
2699                "          first.rs",
2700                "          second.rs",
2701                "          third.rs"
2702            ],
2703            "File list should be unchanged after failed file create confirmation"
2704        );
2705
2706        select_path(&panel, "src/test/first.rs", cx);
2707        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2708        cx.foreground().run_until_parked();
2709        assert_eq!(
2710            visible_entries_as_strings(&panel, 0..10, cx),
2711            &[
2712                "v src",
2713                "    v test",
2714                "          first.rs  <== selected",
2715                "          second.rs",
2716                "          third.rs"
2717            ],
2718        );
2719        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2720        window.read_with(cx, |cx| {
2721            let panel = panel.read(cx);
2722            assert!(panel.filename_editor.is_focused(cx));
2723        });
2724        assert_eq!(
2725            visible_entries_as_strings(&panel, 0..10, cx),
2726            &[
2727                "v src",
2728                "    v test",
2729                "          [EDITOR: 'first.rs']  <== selected",
2730                "          second.rs",
2731                "          third.rs"
2732            ]
2733        );
2734        panel.update(cx, |panel, cx| {
2735            panel
2736                .filename_editor
2737                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2738            assert!(
2739                panel.confirm(&Confirm, cx).is_none(),
2740                "Should not allow to confirm on conflicting file rename"
2741            )
2742        });
2743        assert_eq!(
2744            visible_entries_as_strings(&panel, 0..10, cx),
2745            &[
2746                "v src",
2747                "    v test",
2748                "          first.rs  <== selected",
2749                "          second.rs",
2750                "          third.rs"
2751            ],
2752            "File list should be unchanged after failed rename confirmation"
2753        );
2754    }
2755
2756    #[gpui::test]
2757    async fn test_new_search_in_directory_trigger(cx: &mut gpui::TestAppContext) {
2758        init_test_with_editor(cx);
2759
2760        let fs = FakeFs::new(cx.background());
2761        fs.insert_tree(
2762            "/src",
2763            json!({
2764                "test": {
2765                    "first.rs": "// First Rust file",
2766                    "second.rs": "// Second Rust file",
2767                    "third.rs": "// Third Rust file",
2768                }
2769            }),
2770        )
2771        .await;
2772
2773        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2774        let workspace = cx
2775            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2776            .root(cx);
2777        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2778
2779        let new_search_events_count = Arc::new(AtomicUsize::new(0));
2780        let _subscription = panel.update(cx, |_, cx| {
2781            let subcription_count = Arc::clone(&new_search_events_count);
2782            cx.subscribe(&cx.handle(), move |_, _, event, _| {
2783                if matches!(event, Event::NewSearchInDirectory { .. }) {
2784                    subcription_count.fetch_add(1, atomic::Ordering::SeqCst);
2785                }
2786            })
2787        });
2788
2789        toggle_expand_dir(&panel, "src/test", cx);
2790        select_path(&panel, "src/test/first.rs", cx);
2791        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2792        cx.foreground().run_until_parked();
2793        assert_eq!(
2794            visible_entries_as_strings(&panel, 0..10, cx),
2795            &[
2796                "v src",
2797                "    v test",
2798                "          first.rs  <== selected",
2799                "          second.rs",
2800                "          third.rs"
2801            ]
2802        );
2803        panel.update(cx, |panel, cx| {
2804            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2805        });
2806        assert_eq!(
2807            new_search_events_count.load(atomic::Ordering::SeqCst),
2808            0,
2809            "Should not trigger new search in directory when called on a file"
2810        );
2811
2812        select_path(&panel, "src/test", cx);
2813        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2814        cx.foreground().run_until_parked();
2815        assert_eq!(
2816            visible_entries_as_strings(&panel, 0..10, cx),
2817            &[
2818                "v src",
2819                "    v test  <== selected",
2820                "          first.rs",
2821                "          second.rs",
2822                "          third.rs"
2823            ]
2824        );
2825        panel.update(cx, |panel, cx| {
2826            panel.new_search_in_directory(&NewSearchInDirectory, cx)
2827        });
2828        assert_eq!(
2829            new_search_events_count.load(atomic::Ordering::SeqCst),
2830            1,
2831            "Should trigger new search in directory when called on a directory"
2832        );
2833    }
2834
2835    #[gpui::test]
2836    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2837        init_test_with_editor(cx);
2838
2839        let fs = FakeFs::new(cx.background());
2840        fs.insert_tree(
2841            "/project_root",
2842            json!({
2843                "dir_1": {
2844                    "nested_dir": {
2845                        "file_a.py": "# File contents",
2846                        "file_b.py": "# File contents",
2847                        "file_c.py": "# File contents",
2848                    },
2849                    "file_1.py": "# File contents",
2850                    "file_2.py": "# File contents",
2851                    "file_3.py": "# File contents",
2852                },
2853                "dir_2": {
2854                    "file_1.py": "# File contents",
2855                    "file_2.py": "# File contents",
2856                    "file_3.py": "# File contents",
2857                }
2858            }),
2859        )
2860        .await;
2861
2862        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2863        let workspace = cx
2864            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2865            .root(cx);
2866        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2867
2868        panel.update(cx, |panel, cx| {
2869            panel.collapse_all_entries(&CollapseAllEntries, cx)
2870        });
2871        cx.foreground().run_until_parked();
2872        assert_eq!(
2873            visible_entries_as_strings(&panel, 0..10, cx),
2874            &["v project_root", "    > dir_1", "    > dir_2",]
2875        );
2876
2877        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2878        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2879        cx.foreground().run_until_parked();
2880        assert_eq!(
2881            visible_entries_as_strings(&panel, 0..10, cx),
2882            &[
2883                "v project_root",
2884                "    v dir_1  <== selected",
2885                "        > nested_dir",
2886                "          file_1.py",
2887                "          file_2.py",
2888                "          file_3.py",
2889                "    > dir_2",
2890            ]
2891        );
2892    }
2893
2894    #[gpui::test]
2895    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2896        init_test(cx);
2897
2898        let fs = FakeFs::new(cx.background());
2899        fs.as_fake().insert_tree("/root", json!({})).await;
2900        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2901        let workspace = cx
2902            .add_window(|cx| Workspace::test_new(project.clone(), cx))
2903            .root(cx);
2904        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2905
2906        // Make a new buffer with no backing file
2907        workspace.update(cx, |workspace, cx| {
2908            Editor::new_file(workspace, &Default::default(), cx)
2909        });
2910
2911        // "Save as"" the buffer, creating a new backing file for it
2912        let task = workspace.update(cx, |workspace, cx| {
2913            workspace.save_active_item(workspace::SaveIntent::Save, cx)
2914        });
2915
2916        cx.foreground().run_until_parked();
2917        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2918        task.await.unwrap();
2919
2920        // Rename the file
2921        select_path(&panel, "root/new", cx);
2922        assert_eq!(
2923            visible_entries_as_strings(&panel, 0..10, cx),
2924            &["v root", "      new  <== selected"]
2925        );
2926        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2927        panel.update(cx, |panel, cx| {
2928            panel
2929                .filename_editor
2930                .update(cx, |editor, cx| editor.set_text("newer", cx));
2931        });
2932        panel
2933            .update(cx, |panel, cx| panel.confirm(&Confirm, cx))
2934            .unwrap()
2935            .await
2936            .unwrap();
2937
2938        cx.foreground().run_until_parked();
2939        assert_eq!(
2940            visible_entries_as_strings(&panel, 0..10, cx),
2941            &["v root", "      newer  <== selected"]
2942        );
2943
2944        workspace
2945            .update(cx, |workspace, cx| {
2946                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2947            })
2948            .await
2949            .unwrap();
2950
2951        cx.foreground().run_until_parked();
2952        // assert that saving the file doesn't restore "new"
2953        assert_eq!(
2954            visible_entries_as_strings(&panel, 0..10, cx),
2955            &["v root", "      newer  <== selected"]
2956        );
2957    }
2958
2959    fn toggle_expand_dir(
2960        panel: &ViewHandle<ProjectPanel>,
2961        path: impl AsRef<Path>,
2962        cx: &mut TestAppContext,
2963    ) {
2964        let path = path.as_ref();
2965        panel.update(cx, |panel, cx| {
2966            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2967                let worktree = worktree.read(cx);
2968                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2969                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2970                    panel.toggle_expanded(entry_id, cx);
2971                    return;
2972                }
2973            }
2974            panic!("no worktree for path {:?}", path);
2975        });
2976    }
2977
2978    fn select_path(
2979        panel: &ViewHandle<ProjectPanel>,
2980        path: impl AsRef<Path>,
2981        cx: &mut TestAppContext,
2982    ) {
2983        let path = path.as_ref();
2984        panel.update(cx, |panel, cx| {
2985            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2986                let worktree = worktree.read(cx);
2987                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2988                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2989                    panel.selection = Some(Selection {
2990                        worktree_id: worktree.id(),
2991                        entry_id,
2992                    });
2993                    return;
2994                }
2995            }
2996            panic!("no worktree for path {:?}", path);
2997        });
2998    }
2999
3000    fn visible_entries_as_strings(
3001        panel: &ViewHandle<ProjectPanel>,
3002        range: Range<usize>,
3003        cx: &mut TestAppContext,
3004    ) -> Vec<String> {
3005        let mut result = Vec::new();
3006        let mut project_entries = HashSet::new();
3007        let mut has_editor = false;
3008
3009        panel.update(cx, |panel, cx| {
3010            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3011                if details.is_editing {
3012                    assert!(!has_editor, "duplicate editor entry");
3013                    has_editor = true;
3014                } else {
3015                    assert!(
3016                        project_entries.insert(project_entry),
3017                        "duplicate project entry {:?} {:?}",
3018                        project_entry,
3019                        details
3020                    );
3021                }
3022
3023                let indent = "    ".repeat(details.depth);
3024                let icon = if details.kind.is_dir() {
3025                    if details.is_expanded {
3026                        "v "
3027                    } else {
3028                        "> "
3029                    }
3030                } else {
3031                    "  "
3032                };
3033                let name = if details.is_editing {
3034                    format!("[EDITOR: '{}']", details.filename)
3035                } else if details.is_processing {
3036                    format!("[PROCESSING: '{}']", details.filename)
3037                } else {
3038                    details.filename.clone()
3039                };
3040                let selected = if details.is_selected {
3041                    "  <== selected"
3042                } else {
3043                    ""
3044                };
3045                result.push(format!("{indent}{icon}{name}{selected}"));
3046            });
3047        });
3048
3049        result
3050    }
3051
3052    fn init_test(cx: &mut TestAppContext) {
3053        cx.foreground().forbid_parking();
3054        cx.update(|cx| {
3055            cx.set_global(SettingsStore::test(cx));
3056            init_settings(cx);
3057            theme::init((), cx);
3058            language::init(cx);
3059            editor::init_settings(cx);
3060            crate::init((), cx);
3061            workspace::init_settings(cx);
3062            client::init_settings(cx);
3063            Project::init_settings(cx);
3064
3065            cx.update_global::<SettingsStore, _, _>(|store, cx| {
3066                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3067                    project_settings.file_scan_exclusions = Some(Vec::new());
3068                });
3069            });
3070        });
3071    }
3072
3073    fn init_test_with_editor(cx: &mut TestAppContext) {
3074        cx.foreground().forbid_parking();
3075        cx.update(|cx| {
3076            let app_state = AppState::test(cx);
3077            theme::init((), cx);
3078            init_settings(cx);
3079            language::init(cx);
3080            editor::init(cx);
3081            pane::init(cx);
3082            crate::init((), cx);
3083            workspace::init(app_state.clone(), cx);
3084            Project::init_settings(cx);
3085        });
3086    }
3087
3088    fn ensure_single_file_is_opened(
3089        window: WindowHandle<Workspace>,
3090        expected_path: &str,
3091        cx: &mut TestAppContext,
3092    ) {
3093        window.update_root(cx, |workspace, cx| {
3094            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3095            assert_eq!(worktrees.len(), 1);
3096            let worktree_id = WorktreeId::from_usize(worktrees[0].id());
3097
3098            let open_project_paths = workspace
3099                .panes()
3100                .iter()
3101                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3102                .collect::<Vec<_>>();
3103            assert_eq!(
3104                open_project_paths,
3105                vec![ProjectPath {
3106                    worktree_id,
3107                    path: Arc::from(Path::new(expected_path))
3108                }],
3109                "Should have opened file, selected in project panel"
3110            );
3111        });
3112    }
3113
3114    fn submit_deletion(
3115        window: AnyWindowHandle,
3116        panel: &ViewHandle<ProjectPanel>,
3117        cx: &mut TestAppContext,
3118    ) {
3119        assert!(
3120            !window.has_pending_prompt(cx),
3121            "Should have no prompts before the deletion"
3122        );
3123        panel.update(cx, |panel, cx| {
3124            panel
3125                .delete(&Delete, cx)
3126                .expect("Deletion start")
3127                .detach_and_log_err(cx);
3128        });
3129        assert!(
3130            window.has_pending_prompt(cx),
3131            "Should have a prompt after the deletion"
3132        );
3133        window.simulate_prompt_answer(0, cx);
3134        assert!(
3135            !window.has_pending_prompt(cx),
3136            "Should have no prompts after prompt was replied to"
3137        );
3138        cx.foreground().run_until_parked();
3139    }
3140
3141    fn ensure_no_open_items_and_panes(
3142        window: AnyWindowHandle,
3143        workspace: &ViewHandle<Workspace>,
3144        cx: &mut TestAppContext,
3145    ) {
3146        assert!(
3147            !window.has_pending_prompt(cx),
3148            "Should have no prompts after deletion operation closes the file"
3149        );
3150        window.read_with(cx, |cx| {
3151            let open_project_paths = workspace
3152                .read(cx)
3153                .panes()
3154                .iter()
3155                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3156                .collect::<Vec<_>>();
3157            assert!(
3158                open_project_paths.is_empty(),
3159                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3160            );
3161        });
3162    }
3163}
3164// TODO - a workspace command?