project_panel.rs

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