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                &["Delete", "Cancel"],
 785            );
 786
 787            cx.spawn(|this, mut cx| async move {
 788                if answer.await != Ok(0) {
 789                    return Ok(());
 790                }
 791                this.update(&mut cx, |this, cx| {
 792                    this.project
 793                        .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 794                        .ok_or_else(|| anyhow!("no such entry"))
 795                })??
 796                .await
 797            })
 798            .detach_and_log_err(cx);
 799            Some(())
 800        });
 801    }
 802
 803    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 804        if let Some(selection) = self.selection {
 805            let (mut worktree_ix, mut entry_ix, _) =
 806                self.index_for_selection(selection).unwrap_or_default();
 807            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 808                if entry_ix + 1 < worktree_entries.len() {
 809                    entry_ix += 1;
 810                } else {
 811                    worktree_ix += 1;
 812                    entry_ix = 0;
 813                }
 814            }
 815
 816            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 817                if let Some(entry) = worktree_entries.get(entry_ix) {
 818                    self.selection = Some(Selection {
 819                        worktree_id: *worktree_id,
 820                        entry_id: entry.id,
 821                    });
 822                    self.autoscroll(cx);
 823                    cx.notify();
 824                }
 825            }
 826        } else {
 827            self.select_first(cx);
 828        }
 829    }
 830
 831    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 832        let worktree = self
 833            .visible_entries
 834            .first()
 835            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 836        if let Some(worktree) = worktree {
 837            let worktree = worktree.read(cx);
 838            let worktree_id = worktree.id();
 839            if let Some(root_entry) = worktree.root_entry() {
 840                self.selection = Some(Selection {
 841                    worktree_id,
 842                    entry_id: root_entry.id,
 843                });
 844                self.autoscroll(cx);
 845                cx.notify();
 846            }
 847        }
 848    }
 849
 850    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
 851        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 852            self.list.scroll_to_item(index);
 853            cx.notify();
 854        }
 855    }
 856
 857    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 858        if let Some((worktree, entry)) = self.selected_entry(cx) {
 859            self.clipboard_entry = Some(ClipboardEntry::Cut {
 860                worktree_id: worktree.id(),
 861                entry_id: entry.id,
 862            });
 863            cx.notify();
 864        }
 865    }
 866
 867    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 868        if let Some((worktree, entry)) = self.selected_entry(cx) {
 869            self.clipboard_entry = Some(ClipboardEntry::Copied {
 870                worktree_id: worktree.id(),
 871                entry_id: entry.id,
 872            });
 873            cx.notify();
 874        }
 875    }
 876
 877    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
 878        maybe!({
 879            let (worktree, entry) = self.selected_entry(cx)?;
 880            let clipboard_entry = self.clipboard_entry?;
 881            if clipboard_entry.worktree_id() != worktree.id() {
 882                return None;
 883            }
 884
 885            let clipboard_entry_file_name = self
 886                .project
 887                .read(cx)
 888                .path_for_entry(clipboard_entry.entry_id(), cx)?
 889                .path
 890                .file_name()?
 891                .to_os_string();
 892
 893            let mut new_path = entry.path.to_path_buf();
 894            if entry.is_file() {
 895                new_path.pop();
 896            }
 897
 898            new_path.push(&clipboard_entry_file_name);
 899            let extension = new_path.extension().map(|e| e.to_os_string());
 900            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 901            let mut ix = 0;
 902            while worktree.entry_for_path(&new_path).is_some() {
 903                new_path.pop();
 904
 905                let mut new_file_name = file_name_without_extension.to_os_string();
 906                new_file_name.push(" copy");
 907                if ix > 0 {
 908                    new_file_name.push(format!(" {}", ix));
 909                }
 910                if let Some(extension) = extension.as_ref() {
 911                    new_file_name.push(".");
 912                    new_file_name.push(extension);
 913                }
 914
 915                new_path.push(new_file_name);
 916                ix += 1;
 917            }
 918
 919            if clipboard_entry.is_cut() {
 920                self.project
 921                    .update(cx, |project, cx| {
 922                        project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 923                    })
 924                    .detach_and_log_err(cx)
 925            } else {
 926                self.project
 927                    .update(cx, |project, cx| {
 928                        project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 929                    })
 930                    .detach_and_log_err(cx)
 931            }
 932
 933            Some(())
 934        });
 935    }
 936
 937    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 938        if let Some((worktree, entry)) = self.selected_entry(cx) {
 939            cx.write_to_clipboard(ClipboardItem::new(
 940                worktree
 941                    .abs_path()
 942                    .join(&entry.path)
 943                    .to_string_lossy()
 944                    .to_string(),
 945            ));
 946        }
 947    }
 948
 949    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
 950        if let Some((_, entry)) = self.selected_entry(cx) {
 951            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
 952        }
 953    }
 954
 955    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
 956        if let Some((worktree, entry)) = self.selected_entry(cx) {
 957            cx.reveal_path(&worktree.abs_path().join(&entry.path));
 958        }
 959    }
 960
 961    fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
 962        if let Some((worktree, entry)) = self.selected_entry(cx) {
 963            let path = worktree.abs_path().join(&entry.path);
 964            cx.dispatch_action(
 965                workspace::OpenTerminal {
 966                    working_directory: path,
 967                }
 968                .boxed_clone(),
 969            )
 970        }
 971    }
 972
 973    pub fn new_search_in_directory(
 974        &mut self,
 975        _: &NewSearchInDirectory,
 976        cx: &mut ViewContext<Self>,
 977    ) {
 978        if let Some((_, entry)) = self.selected_entry(cx) {
 979            if entry.is_dir() {
 980                let entry = entry.clone();
 981                self.workspace
 982                    .update(cx, |workspace, cx| {
 983                        search::ProjectSearchView::new_search_in_directory(workspace, &entry, cx);
 984                    })
 985                    .ok();
 986            }
 987        }
 988    }
 989
 990    fn move_entry(
 991        &mut self,
 992        entry_to_move: ProjectEntryId,
 993        destination: ProjectEntryId,
 994        destination_is_file: bool,
 995        cx: &mut ViewContext<Self>,
 996    ) {
 997        let destination_worktree = self.project.update(cx, |project, cx| {
 998            let entry_path = project.path_for_entry(entry_to_move, cx)?;
 999            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1000
1001            let mut destination_path = destination_entry_path.as_ref();
1002            if destination_is_file {
1003                destination_path = destination_path.parent()?;
1004            }
1005
1006            let mut new_path = destination_path.to_path_buf();
1007            new_path.push(entry_path.path.file_name()?);
1008            if new_path != entry_path.path.as_ref() {
1009                let task = project.rename_entry(entry_to_move, new_path, cx);
1010                cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1011            }
1012
1013            Some(project.worktree_id_for_entry(destination, cx)?)
1014        });
1015
1016        if let Some(destination_worktree) = destination_worktree {
1017            self.expand_entry(destination_worktree, destination, cx);
1018        }
1019    }
1020
1021    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
1022        let mut entry_index = 0;
1023        let mut visible_entries_index = 0;
1024        for (worktree_index, (worktree_id, worktree_entries)) in
1025            self.visible_entries.iter().enumerate()
1026        {
1027            if *worktree_id == selection.worktree_id {
1028                for entry in worktree_entries {
1029                    if entry.id == selection.entry_id {
1030                        return Some((worktree_index, entry_index, visible_entries_index));
1031                    } else {
1032                        visible_entries_index += 1;
1033                        entry_index += 1;
1034                    }
1035                }
1036                break;
1037            } else {
1038                visible_entries_index += worktree_entries.len();
1039            }
1040        }
1041        None
1042    }
1043
1044    pub fn selected_entry<'a>(
1045        &self,
1046        cx: &'a AppContext,
1047    ) -> Option<(&'a Worktree, &'a project::Entry)> {
1048        let (worktree, entry) = self.selected_entry_handle(cx)?;
1049        Some((worktree.read(cx), entry))
1050    }
1051
1052    fn selected_entry_handle<'a>(
1053        &self,
1054        cx: &'a AppContext,
1055    ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1056        let selection = self.selection?;
1057        let project = self.project.read(cx);
1058        let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1059        let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1060        Some((worktree, entry))
1061    }
1062
1063    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1064        let (worktree, entry) = self.selected_entry(cx)?;
1065        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1066
1067        for path in entry.path.ancestors() {
1068            let Some(entry) = worktree.entry_for_path(path) else {
1069                continue;
1070            };
1071            if entry.is_dir() {
1072                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1073                    expanded_dir_ids.insert(idx, entry.id);
1074                }
1075            }
1076        }
1077
1078        Some(())
1079    }
1080
1081    fn update_visible_entries(
1082        &mut self,
1083        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1084        cx: &mut ViewContext<Self>,
1085    ) {
1086        let project = self.project.read(cx);
1087        self.last_worktree_root_id = project
1088            .visible_worktrees(cx)
1089            .rev()
1090            .next()
1091            .and_then(|worktree| worktree.read(cx).root_entry())
1092            .map(|entry| entry.id);
1093
1094        self.visible_entries.clear();
1095        for worktree in project.visible_worktrees(cx) {
1096            let snapshot = worktree.read(cx).snapshot();
1097            let worktree_id = snapshot.id();
1098
1099            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1100                hash_map::Entry::Occupied(e) => e.into_mut(),
1101                hash_map::Entry::Vacant(e) => {
1102                    // The first time a worktree's root entry becomes available,
1103                    // mark that root entry as expanded.
1104                    if let Some(entry) = snapshot.root_entry() {
1105                        e.insert(vec![entry.id]).as_slice()
1106                    } else {
1107                        &[]
1108                    }
1109                }
1110            };
1111
1112            let mut new_entry_parent_id = None;
1113            let mut new_entry_kind = EntryKind::Dir;
1114            if let Some(edit_state) = &self.edit_state {
1115                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1116                    new_entry_parent_id = Some(edit_state.entry_id);
1117                    new_entry_kind = if edit_state.is_dir {
1118                        EntryKind::Dir
1119                    } else {
1120                        EntryKind::File(Default::default())
1121                    };
1122                }
1123            }
1124
1125            let mut visible_worktree_entries = Vec::new();
1126            let mut entry_iter = snapshot.entries(true);
1127
1128            while let Some(entry) = entry_iter.entry() {
1129                visible_worktree_entries.push(entry.clone());
1130                if Some(entry.id) == new_entry_parent_id {
1131                    visible_worktree_entries.push(Entry {
1132                        id: NEW_ENTRY_ID,
1133                        kind: new_entry_kind,
1134                        path: entry.path.join("\0").into(),
1135                        inode: 0,
1136                        mtime: entry.mtime,
1137                        is_symlink: false,
1138                        is_ignored: false,
1139                        is_external: false,
1140                        git_status: entry.git_status,
1141                    });
1142                }
1143                if expanded_dir_ids.binary_search(&entry.id).is_err()
1144                    && entry_iter.advance_to_sibling()
1145                {
1146                    continue;
1147                }
1148                entry_iter.advance();
1149            }
1150
1151            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1152
1153            visible_worktree_entries.sort_by(|entry_a, entry_b| {
1154                let mut components_a = entry_a.path.components().peekable();
1155                let mut components_b = entry_b.path.components().peekable();
1156                loop {
1157                    match (components_a.next(), components_b.next()) {
1158                        (Some(component_a), Some(component_b)) => {
1159                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1160                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1161                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1162                                let name_a =
1163                                    UniCase::new(component_a.as_os_str().to_string_lossy());
1164                                let name_b =
1165                                    UniCase::new(component_b.as_os_str().to_string_lossy());
1166                                name_a.cmp(&name_b)
1167                            });
1168                            if !ordering.is_eq() {
1169                                return ordering;
1170                            }
1171                        }
1172                        (Some(_), None) => break Ordering::Greater,
1173                        (None, Some(_)) => break Ordering::Less,
1174                        (None, None) => break Ordering::Equal,
1175                    }
1176                }
1177            });
1178            self.visible_entries
1179                .push((worktree_id, visible_worktree_entries));
1180        }
1181
1182        if let Some((worktree_id, entry_id)) = new_selected_entry {
1183            self.selection = Some(Selection {
1184                worktree_id,
1185                entry_id,
1186            });
1187        }
1188    }
1189
1190    fn expand_entry(
1191        &mut self,
1192        worktree_id: WorktreeId,
1193        entry_id: ProjectEntryId,
1194        cx: &mut ViewContext<Self>,
1195    ) {
1196        self.project.update(cx, |project, cx| {
1197            if let Some((worktree, expanded_dir_ids)) = project
1198                .worktree_for_id(worktree_id, cx)
1199                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1200            {
1201                project.expand_entry(worktree_id, entry_id, cx);
1202                let worktree = worktree.read(cx);
1203
1204                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1205                    loop {
1206                        if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1207                            expanded_dir_ids.insert(ix, entry.id);
1208                        }
1209
1210                        if let Some(parent_entry) =
1211                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1212                        {
1213                            entry = parent_entry;
1214                        } else {
1215                            break;
1216                        }
1217                    }
1218                }
1219            }
1220        });
1221    }
1222
1223    fn for_each_visible_entry(
1224        &self,
1225        range: Range<usize>,
1226        cx: &mut ViewContext<ProjectPanel>,
1227        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1228    ) {
1229        let mut ix = 0;
1230        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1231            if ix >= range.end {
1232                return;
1233            }
1234
1235            if ix + visible_worktree_entries.len() <= range.start {
1236                ix += visible_worktree_entries.len();
1237                continue;
1238            }
1239
1240            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1241            let (git_status_setting, show_file_icons, show_folder_icons) = {
1242                let settings = ProjectPanelSettings::get_global(cx);
1243                (
1244                    settings.git_status,
1245                    settings.file_icons,
1246                    settings.folder_icons,
1247                )
1248            };
1249            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1250                let snapshot = worktree.read(cx).snapshot();
1251                let root_name = OsStr::new(snapshot.root_name());
1252                let expanded_entry_ids = self
1253                    .expanded_dir_ids
1254                    .get(&snapshot.id())
1255                    .map(Vec::as_slice)
1256                    .unwrap_or(&[]);
1257
1258                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1259                for entry in visible_worktree_entries[entry_range].iter() {
1260                    let status = git_status_setting.then(|| entry.git_status).flatten();
1261                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1262                    let icon = match entry.kind {
1263                        EntryKind::File(_) => {
1264                            if show_file_icons {
1265                                FileAssociations::get_icon(&entry.path, cx)
1266                            } else {
1267                                None
1268                            }
1269                        }
1270                        _ => {
1271                            if show_folder_icons {
1272                                FileAssociations::get_folder_icon(is_expanded, cx)
1273                            } else {
1274                                FileAssociations::get_chevron_icon(is_expanded, cx)
1275                            }
1276                        }
1277                    };
1278
1279                    let mut details = EntryDetails {
1280                        filename: entry
1281                            .path
1282                            .file_name()
1283                            .unwrap_or(root_name)
1284                            .to_string_lossy()
1285                            .to_string(),
1286                        icon,
1287                        path: entry.path.clone(),
1288                        depth: entry.path.components().count(),
1289                        kind: entry.kind,
1290                        is_ignored: entry.is_ignored,
1291                        is_expanded,
1292                        is_selected: self.selection.map_or(false, |e| {
1293                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1294                        }),
1295                        is_editing: false,
1296                        is_processing: false,
1297                        is_cut: self
1298                            .clipboard_entry
1299                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1300                        git_status: status,
1301                    };
1302
1303                    if let Some(edit_state) = &self.edit_state {
1304                        let is_edited_entry = if edit_state.is_new_entry {
1305                            entry.id == NEW_ENTRY_ID
1306                        } else {
1307                            entry.id == edit_state.entry_id
1308                        };
1309
1310                        if is_edited_entry {
1311                            if let Some(processing_filename) = &edit_state.processing_filename {
1312                                details.is_processing = true;
1313                                details.filename.clear();
1314                                details.filename.push_str(processing_filename);
1315                            } else {
1316                                if edit_state.is_new_entry {
1317                                    details.filename.clear();
1318                                }
1319                                details.is_editing = true;
1320                            }
1321                        }
1322                    }
1323
1324                    callback(entry.id, details, cx);
1325                }
1326            }
1327            ix = end_ix;
1328        }
1329    }
1330
1331    fn render_entry(
1332        &self,
1333        entry_id: ProjectEntryId,
1334        details: EntryDetails,
1335        cx: &mut ViewContext<Self>,
1336    ) -> Stateful<Div> {
1337        let kind = details.kind;
1338        let settings = ProjectPanelSettings::get_global(cx);
1339        let show_editor = details.is_editing && !details.is_processing;
1340        let is_selected = self
1341            .selection
1342            .map_or(false, |selection| selection.entry_id == entry_id);
1343        let width = self.width.unwrap_or(px(0.));
1344
1345        let filename_text_color = details
1346            .git_status
1347            .as_ref()
1348            .map(|status| match status {
1349                GitFileStatus::Added => Color::Created,
1350                GitFileStatus::Modified => Color::Modified,
1351                GitFileStatus::Conflict => Color::Conflict,
1352            })
1353            .unwrap_or(if is_selected {
1354                Color::Default
1355            } else if details.is_ignored {
1356                Color::Disabled
1357            } else {
1358                Color::Muted
1359            });
1360
1361        let file_name = details.filename.clone();
1362        let icon = details.icon.clone();
1363        let depth = details.depth;
1364        div()
1365            .id(entry_id.to_proto() as usize)
1366            .on_drag(entry_id, move |entry_id, cx| {
1367                cx.new_view(|_| DraggedProjectEntryView {
1368                    details: details.clone(),
1369                    width,
1370                    entry_id: *entry_id,
1371                })
1372            })
1373            .drag_over::<ProjectEntryId>(|style| {
1374                style.bg(cx.theme().colors().drop_target_background)
1375            })
1376            .on_drop(cx.listener(move |this, dragged_id: &ProjectEntryId, cx| {
1377                this.move_entry(*dragged_id, entry_id, kind.is_file(), cx);
1378            }))
1379            .child(
1380                ListItem::new(entry_id.to_proto() as usize)
1381                    .indent_level(depth)
1382                    .indent_step_size(px(settings.indent_size))
1383                    .selected(is_selected)
1384                    .child(if let Some(icon) = &icon {
1385                        div().child(Icon::from_path(icon.to_string()).color(Color::Muted))
1386                    } else {
1387                        div().size(IconSize::default().rems()).invisible()
1388                    })
1389                    .child(
1390                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1391                            div().h_full().w_full().child(editor.clone())
1392                        } else {
1393                            div().child(Label::new(file_name).color(filename_text_color))
1394                        }
1395                        .ml_1(),
1396                    )
1397                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1398                        if event.down.button == MouseButton::Right {
1399                            return;
1400                        }
1401                        if !show_editor {
1402                            if kind.is_dir() {
1403                                this.toggle_expanded(entry_id, cx);
1404                            } else {
1405                                if event.down.modifiers.command {
1406                                    this.split_entry(entry_id, cx);
1407                                } else {
1408                                    this.open_entry(entry_id, event.up.click_count > 1, cx);
1409                                }
1410                            }
1411                        }
1412                    }))
1413                    .on_secondary_mouse_down(cx.listener(
1414                        move |this, event: &MouseDownEvent, cx| {
1415                            // Stop propagation to prevent the catch-all context menu for the project
1416                            // panel from being deployed.
1417                            cx.stop_propagation();
1418                            this.deploy_context_menu(event.position, entry_id, cx);
1419                        },
1420                    )),
1421            )
1422    }
1423
1424    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1425        let mut dispatch_context = KeyContext::default();
1426        dispatch_context.add("ProjectPanel");
1427        dispatch_context.add("menu");
1428
1429        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1430            "editing"
1431        } else {
1432            "not_editing"
1433        };
1434
1435        dispatch_context.add(identifier);
1436        dispatch_context
1437    }
1438
1439    fn reveal_entry(
1440        &mut self,
1441        project: Model<Project>,
1442        entry_id: ProjectEntryId,
1443        skip_ignored: bool,
1444        cx: &mut ViewContext<'_, ProjectPanel>,
1445    ) {
1446        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1447            let worktree = worktree.read(cx);
1448            if skip_ignored
1449                && worktree
1450                    .entry_for_id(entry_id)
1451                    .map_or(true, |entry| entry.is_ignored)
1452            {
1453                return;
1454            }
1455
1456            let worktree_id = worktree.id();
1457            self.expand_entry(worktree_id, entry_id, cx);
1458            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1459            self.autoscroll(cx);
1460            cx.notify();
1461        }
1462    }
1463    pub fn was_deserialized(&self) -> bool {
1464        self.was_deserialized
1465    }
1466}
1467
1468impl Render for ProjectPanel {
1469    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1470        let has_worktree = self.visible_entries.len() != 0;
1471        let project = self.project.read(cx);
1472
1473        if has_worktree {
1474            div()
1475                .id("project-panel")
1476                .size_full()
1477                .relative()
1478                .key_context(self.dispatch_context(cx))
1479                .on_action(cx.listener(Self::select_next))
1480                .on_action(cx.listener(Self::select_prev))
1481                .on_action(cx.listener(Self::expand_selected_entry))
1482                .on_action(cx.listener(Self::collapse_selected_entry))
1483                .on_action(cx.listener(Self::collapse_all_entries))
1484                .on_action(cx.listener(Self::open_file))
1485                .on_action(cx.listener(Self::confirm))
1486                .on_action(cx.listener(Self::cancel))
1487                .on_action(cx.listener(Self::copy_path))
1488                .on_action(cx.listener(Self::copy_relative_path))
1489                .on_action(cx.listener(Self::new_search_in_directory))
1490                .when(!project.is_read_only(), |el| {
1491                    el.on_action(cx.listener(Self::new_file))
1492                        .on_action(cx.listener(Self::new_directory))
1493                        .on_action(cx.listener(Self::rename))
1494                        .on_action(cx.listener(Self::delete))
1495                        .on_action(cx.listener(Self::cut))
1496                        .on_action(cx.listener(Self::copy))
1497                        .on_action(cx.listener(Self::paste))
1498                })
1499                .when(project.is_local(), |el| {
1500                    el.on_action(cx.listener(Self::reveal_in_finder))
1501                        .on_action(cx.listener(Self::open_in_terminal))
1502                })
1503                .on_mouse_down(
1504                    MouseButton::Right,
1505                    cx.listener(move |this, event: &MouseDownEvent, cx| {
1506                        // When deploying the context menu anywhere below the last project entry,
1507                        // act as if the user clicked the root of the last worktree.
1508                        if let Some(entry_id) = this.last_worktree_root_id {
1509                            this.deploy_context_menu(event.position, entry_id, cx);
1510                        }
1511                    }),
1512                )
1513                .track_focus(&self.focus_handle)
1514                .child(
1515                    uniform_list(
1516                        cx.view().clone(),
1517                        "entries",
1518                        self.visible_entries
1519                            .iter()
1520                            .map(|(_, worktree_entries)| worktree_entries.len())
1521                            .sum(),
1522                        {
1523                            |this, range, cx| {
1524                                let mut items = Vec::new();
1525                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1526                                    items.push(this.render_entry(id, details, cx));
1527                                });
1528                                items
1529                            }
1530                        },
1531                    )
1532                    .size_full()
1533                    .track_scroll(self.list.clone()),
1534                )
1535                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1536                    overlay()
1537                        .position(*position)
1538                        .anchor(gpui::AnchorCorner::TopLeft)
1539                        .child(menu.clone())
1540                }))
1541        } else {
1542            v_flex()
1543                .id("empty-project_panel")
1544                .size_full()
1545                .p_4()
1546                .track_focus(&self.focus_handle)
1547                .child(
1548                    Button::new("open_project", "Open a project")
1549                        .style(ButtonStyle::Filled)
1550                        .full_width()
1551                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1552                        .on_click(cx.listener(|this, _, cx| {
1553                            this.workspace
1554                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1555                                .log_err();
1556                        })),
1557                )
1558        }
1559    }
1560}
1561
1562impl Render for DraggedProjectEntryView {
1563    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1564        let settings = ProjectPanelSettings::get_global(cx);
1565        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1566        h_flex()
1567            .font(ui_font)
1568            .bg(cx.theme().colors().background)
1569            .w(self.width)
1570            .child(
1571                ListItem::new(self.entry_id.to_proto() as usize)
1572                    .indent_level(self.details.depth)
1573                    .indent_step_size(px(settings.indent_size))
1574                    .child(if let Some(icon) = &self.details.icon {
1575                        div().child(Icon::from_path(icon.to_string()))
1576                    } else {
1577                        div()
1578                    })
1579                    .child(Label::new(self.details.filename.clone())),
1580            )
1581    }
1582}
1583
1584impl EventEmitter<Event> for ProjectPanel {}
1585
1586impl EventEmitter<PanelEvent> for ProjectPanel {}
1587
1588impl Panel for ProjectPanel {
1589    fn position(&self, cx: &WindowContext) -> DockPosition {
1590        match ProjectPanelSettings::get_global(cx).dock {
1591            ProjectPanelDockPosition::Left => DockPosition::Left,
1592            ProjectPanelDockPosition::Right => DockPosition::Right,
1593        }
1594    }
1595
1596    fn position_is_valid(&self, position: DockPosition) -> bool {
1597        matches!(position, DockPosition::Left | DockPosition::Right)
1598    }
1599
1600    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1601        settings::update_settings_file::<ProjectPanelSettings>(
1602            self.fs.clone(),
1603            cx,
1604            move |settings| {
1605                let dock = match position {
1606                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1607                    DockPosition::Right => ProjectPanelDockPosition::Right,
1608                };
1609                settings.dock = Some(dock);
1610            },
1611        );
1612    }
1613
1614    fn size(&self, cx: &WindowContext) -> Pixels {
1615        self.width
1616            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1617    }
1618
1619    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1620        self.width = size;
1621        self.serialize(cx);
1622        cx.notify();
1623    }
1624
1625    fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
1626        Some(ui::IconName::FileTree)
1627    }
1628
1629    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1630        Some("Project Panel")
1631    }
1632
1633    fn toggle_action(&self) -> Box<dyn Action> {
1634        Box::new(ToggleFocus)
1635    }
1636
1637    fn persistent_name() -> &'static str {
1638        "Project Panel"
1639    }
1640}
1641
1642impl FocusableView for ProjectPanel {
1643    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1644        self.focus_handle.clone()
1645    }
1646}
1647
1648impl ClipboardEntry {
1649    fn is_cut(&self) -> bool {
1650        matches!(self, Self::Cut { .. })
1651    }
1652
1653    fn entry_id(&self) -> ProjectEntryId {
1654        match self {
1655            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1656                *entry_id
1657            }
1658        }
1659    }
1660
1661    fn worktree_id(&self) -> WorktreeId {
1662        match self {
1663            ClipboardEntry::Copied { worktree_id, .. }
1664            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1665        }
1666    }
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671    use super::*;
1672    use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1673    use pretty_assertions::assert_eq;
1674    use project::{project_settings::ProjectSettings, FakeFs};
1675    use serde_json::json;
1676    use settings::SettingsStore;
1677    use std::{
1678        collections::HashSet,
1679        path::{Path, PathBuf},
1680    };
1681    use workspace::AppState;
1682
1683    #[gpui::test]
1684    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1685        init_test(cx);
1686
1687        let fs = FakeFs::new(cx.executor().clone());
1688        fs.insert_tree(
1689            "/root1",
1690            json!({
1691                ".dockerignore": "",
1692                ".git": {
1693                    "HEAD": "",
1694                },
1695                "a": {
1696                    "0": { "q": "", "r": "", "s": "" },
1697                    "1": { "t": "", "u": "" },
1698                    "2": { "v": "", "w": "", "x": "", "y": "" },
1699                },
1700                "b": {
1701                    "3": { "Q": "" },
1702                    "4": { "R": "", "S": "", "T": "", "U": "" },
1703                },
1704                "C": {
1705                    "5": {},
1706                    "6": { "V": "", "W": "" },
1707                    "7": { "X": "" },
1708                    "8": { "Y": {}, "Z": "" }
1709                }
1710            }),
1711        )
1712        .await;
1713        fs.insert_tree(
1714            "/root2",
1715            json!({
1716                "d": {
1717                    "9": ""
1718                },
1719                "e": {}
1720            }),
1721        )
1722        .await;
1723
1724        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1725        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1726        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1727        let panel = workspace
1728            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1729            .unwrap();
1730        assert_eq!(
1731            visible_entries_as_strings(&panel, 0..50, cx),
1732            &[
1733                "v root1",
1734                "    > .git",
1735                "    > a",
1736                "    > b",
1737                "    > C",
1738                "      .dockerignore",
1739                "v root2",
1740                "    > d",
1741                "    > e",
1742            ]
1743        );
1744
1745        toggle_expand_dir(&panel, "root1/b", cx);
1746        assert_eq!(
1747            visible_entries_as_strings(&panel, 0..50, cx),
1748            &[
1749                "v root1",
1750                "    > .git",
1751                "    > a",
1752                "    v b  <== selected",
1753                "        > 3",
1754                "        > 4",
1755                "    > C",
1756                "      .dockerignore",
1757                "v root2",
1758                "    > d",
1759                "    > e",
1760            ]
1761        );
1762
1763        assert_eq!(
1764            visible_entries_as_strings(&panel, 6..9, cx),
1765            &[
1766                //
1767                "    > C",
1768                "      .dockerignore",
1769                "v root2",
1770            ]
1771        );
1772    }
1773
1774    #[gpui::test]
1775    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1776        init_test(cx);
1777        cx.update(|cx| {
1778            cx.update_global::<SettingsStore, _>(|store, cx| {
1779                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1780                    project_settings.file_scan_exclusions =
1781                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1782                });
1783            });
1784        });
1785
1786        let fs = FakeFs::new(cx.background_executor.clone());
1787        fs.insert_tree(
1788            "/root1",
1789            json!({
1790                ".dockerignore": "",
1791                ".git": {
1792                    "HEAD": "",
1793                },
1794                "a": {
1795                    "0": { "q": "", "r": "", "s": "" },
1796                    "1": { "t": "", "u": "" },
1797                    "2": { "v": "", "w": "", "x": "", "y": "" },
1798                },
1799                "b": {
1800                    "3": { "Q": "" },
1801                    "4": { "R": "", "S": "", "T": "", "U": "" },
1802                },
1803                "C": {
1804                    "5": {},
1805                    "6": { "V": "", "W": "" },
1806                    "7": { "X": "" },
1807                    "8": { "Y": {}, "Z": "" }
1808                }
1809            }),
1810        )
1811        .await;
1812        fs.insert_tree(
1813            "/root2",
1814            json!({
1815                "d": {
1816                    "4": ""
1817                },
1818                "e": {}
1819            }),
1820        )
1821        .await;
1822
1823        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1824        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1825        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1826        let panel = workspace
1827            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1828            .unwrap();
1829        assert_eq!(
1830            visible_entries_as_strings(&panel, 0..50, cx),
1831            &[
1832                "v root1",
1833                "    > a",
1834                "    > b",
1835                "    > C",
1836                "      .dockerignore",
1837                "v root2",
1838                "    > d",
1839                "    > e",
1840            ]
1841        );
1842
1843        toggle_expand_dir(&panel, "root1/b", cx);
1844        assert_eq!(
1845            visible_entries_as_strings(&panel, 0..50, cx),
1846            &[
1847                "v root1",
1848                "    > a",
1849                "    v b  <== selected",
1850                "        > 3",
1851                "    > C",
1852                "      .dockerignore",
1853                "v root2",
1854                "    > d",
1855                "    > e",
1856            ]
1857        );
1858
1859        toggle_expand_dir(&panel, "root2/d", cx);
1860        assert_eq!(
1861            visible_entries_as_strings(&panel, 0..50, cx),
1862            &[
1863                "v root1",
1864                "    > a",
1865                "    v b",
1866                "        > 3",
1867                "    > C",
1868                "      .dockerignore",
1869                "v root2",
1870                "    v d  <== selected",
1871                "    > e",
1872            ]
1873        );
1874
1875        toggle_expand_dir(&panel, "root2/e", cx);
1876        assert_eq!(
1877            visible_entries_as_strings(&panel, 0..50, cx),
1878            &[
1879                "v root1",
1880                "    > a",
1881                "    v b",
1882                "        > 3",
1883                "    > C",
1884                "      .dockerignore",
1885                "v root2",
1886                "    v d",
1887                "    v e  <== selected",
1888            ]
1889        );
1890    }
1891
1892    #[gpui::test(iterations = 30)]
1893    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1894        init_test(cx);
1895
1896        let fs = FakeFs::new(cx.executor().clone());
1897        fs.insert_tree(
1898            "/root1",
1899            json!({
1900                ".dockerignore": "",
1901                ".git": {
1902                    "HEAD": "",
1903                },
1904                "a": {
1905                    "0": { "q": "", "r": "", "s": "" },
1906                    "1": { "t": "", "u": "" },
1907                    "2": { "v": "", "w": "", "x": "", "y": "" },
1908                },
1909                "b": {
1910                    "3": { "Q": "" },
1911                    "4": { "R": "", "S": "", "T": "", "U": "" },
1912                },
1913                "C": {
1914                    "5": {},
1915                    "6": { "V": "", "W": "" },
1916                    "7": { "X": "" },
1917                    "8": { "Y": {}, "Z": "" }
1918                }
1919            }),
1920        )
1921        .await;
1922        fs.insert_tree(
1923            "/root2",
1924            json!({
1925                "d": {
1926                    "9": ""
1927                },
1928                "e": {}
1929            }),
1930        )
1931        .await;
1932
1933        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1934        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1935        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1936        let panel = workspace
1937            .update(cx, |workspace, cx| {
1938                let panel = ProjectPanel::new(workspace, cx);
1939                workspace.add_panel(panel.clone(), cx);
1940                workspace.toggle_dock(panel.read(cx).position(cx), cx);
1941                panel
1942            })
1943            .unwrap();
1944
1945        select_path(&panel, "root1", cx);
1946        assert_eq!(
1947            visible_entries_as_strings(&panel, 0..10, cx),
1948            &[
1949                "v root1  <== selected",
1950                "    > .git",
1951                "    > a",
1952                "    > b",
1953                "    > C",
1954                "      .dockerignore",
1955                "v root2",
1956                "    > d",
1957                "    > e",
1958            ]
1959        );
1960
1961        // Add a file with the root folder selected. The filename editor is placed
1962        // before the first file in the root folder.
1963        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1964        panel.update(cx, |panel, cx| {
1965            assert!(panel.filename_editor.read(cx).is_focused(cx));
1966        });
1967        assert_eq!(
1968            visible_entries_as_strings(&panel, 0..10, cx),
1969            &[
1970                "v root1",
1971                "    > .git",
1972                "    > a",
1973                "    > b",
1974                "    > C",
1975                "      [EDITOR: '']  <== selected",
1976                "      .dockerignore",
1977                "v root2",
1978                "    > d",
1979                "    > e",
1980            ]
1981        );
1982
1983        let confirm = panel.update(cx, |panel, cx| {
1984            panel
1985                .filename_editor
1986                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1987            panel.confirm_edit(cx).unwrap()
1988        });
1989        assert_eq!(
1990            visible_entries_as_strings(&panel, 0..10, cx),
1991            &[
1992                "v root1",
1993                "    > .git",
1994                "    > a",
1995                "    > b",
1996                "    > C",
1997                "      [PROCESSING: 'the-new-filename']  <== selected",
1998                "      .dockerignore",
1999                "v root2",
2000                "    > d",
2001                "    > e",
2002            ]
2003        );
2004
2005        confirm.await.unwrap();
2006        assert_eq!(
2007            visible_entries_as_strings(&panel, 0..10, cx),
2008            &[
2009                "v root1",
2010                "    > .git",
2011                "    > a",
2012                "    > b",
2013                "    > C",
2014                "      .dockerignore",
2015                "      the-new-filename  <== selected",
2016                "v root2",
2017                "    > d",
2018                "    > e",
2019            ]
2020        );
2021
2022        select_path(&panel, "root1/b", cx);
2023        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2024        assert_eq!(
2025            visible_entries_as_strings(&panel, 0..10, cx),
2026            &[
2027                "v root1",
2028                "    > .git",
2029                "    > a",
2030                "    v b",
2031                "        > 3",
2032                "        > 4",
2033                "          [EDITOR: '']  <== selected",
2034                "    > C",
2035                "      .dockerignore",
2036                "      the-new-filename",
2037            ]
2038        );
2039
2040        panel
2041            .update(cx, |panel, cx| {
2042                panel
2043                    .filename_editor
2044                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2045                panel.confirm_edit(cx).unwrap()
2046            })
2047            .await
2048            .unwrap();
2049        assert_eq!(
2050            visible_entries_as_strings(&panel, 0..10, cx),
2051            &[
2052                "v root1",
2053                "    > .git",
2054                "    > a",
2055                "    v b",
2056                "        > 3",
2057                "        > 4",
2058                "          another-filename.txt  <== selected",
2059                "    > C",
2060                "      .dockerignore",
2061                "      the-new-filename",
2062            ]
2063        );
2064
2065        select_path(&panel, "root1/b/another-filename.txt", cx);
2066        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2067        assert_eq!(
2068            visible_entries_as_strings(&panel, 0..10, cx),
2069            &[
2070                "v root1",
2071                "    > .git",
2072                "    > a",
2073                "    v b",
2074                "        > 3",
2075                "        > 4",
2076                "          [EDITOR: 'another-filename.txt']  <== selected",
2077                "    > C",
2078                "      .dockerignore",
2079                "      the-new-filename",
2080            ]
2081        );
2082
2083        let confirm = panel.update(cx, |panel, cx| {
2084            panel.filename_editor.update(cx, |editor, cx| {
2085                let file_name_selections = editor.selections.all::<usize>(cx);
2086                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2087                let file_name_selection = &file_name_selections[0];
2088                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2089                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2090
2091                editor.set_text("a-different-filename.tar.gz", cx)
2092            });
2093            panel.confirm_edit(cx).unwrap()
2094        });
2095        assert_eq!(
2096            visible_entries_as_strings(&panel, 0..10, cx),
2097            &[
2098                "v root1",
2099                "    > .git",
2100                "    > a",
2101                "    v b",
2102                "        > 3",
2103                "        > 4",
2104                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2105                "    > C",
2106                "      .dockerignore",
2107                "      the-new-filename",
2108            ]
2109        );
2110
2111        confirm.await.unwrap();
2112        assert_eq!(
2113            visible_entries_as_strings(&panel, 0..10, cx),
2114            &[
2115                "v root1",
2116                "    > .git",
2117                "    > a",
2118                "    v b",
2119                "        > 3",
2120                "        > 4",
2121                "          a-different-filename.tar.gz  <== selected",
2122                "    > C",
2123                "      .dockerignore",
2124                "      the-new-filename",
2125            ]
2126        );
2127
2128        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2129        assert_eq!(
2130            visible_entries_as_strings(&panel, 0..10, cx),
2131            &[
2132                "v root1",
2133                "    > .git",
2134                "    > a",
2135                "    v b",
2136                "        > 3",
2137                "        > 4",
2138                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2139                "    > C",
2140                "      .dockerignore",
2141                "      the-new-filename",
2142            ]
2143        );
2144
2145        panel.update(cx, |panel, cx| {
2146            panel.filename_editor.update(cx, |editor, cx| {
2147                let file_name_selections = editor.selections.all::<usize>(cx);
2148                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2149                let file_name_selection = &file_name_selections[0];
2150                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2151                assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
2152
2153            });
2154            panel.cancel(&Cancel, cx)
2155        });
2156
2157        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2158        assert_eq!(
2159            visible_entries_as_strings(&panel, 0..10, cx),
2160            &[
2161                "v root1",
2162                "    > .git",
2163                "    > a",
2164                "    v b",
2165                "        > [EDITOR: '']  <== selected",
2166                "        > 3",
2167                "        > 4",
2168                "          a-different-filename.tar.gz",
2169                "    > C",
2170                "      .dockerignore",
2171            ]
2172        );
2173
2174        let confirm = panel.update(cx, |panel, cx| {
2175            panel
2176                .filename_editor
2177                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2178            panel.confirm_edit(cx).unwrap()
2179        });
2180        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2181        assert_eq!(
2182            visible_entries_as_strings(&panel, 0..10, cx),
2183            &[
2184                "v root1",
2185                "    > .git",
2186                "    > a",
2187                "    v b",
2188                "        > [PROCESSING: 'new-dir']",
2189                "        > 3  <== selected",
2190                "        > 4",
2191                "          a-different-filename.tar.gz",
2192                "    > C",
2193                "      .dockerignore",
2194            ]
2195        );
2196
2197        confirm.await.unwrap();
2198        assert_eq!(
2199            visible_entries_as_strings(&panel, 0..10, cx),
2200            &[
2201                "v root1",
2202                "    > .git",
2203                "    > a",
2204                "    v b",
2205                "        > 3  <== selected",
2206                "        > 4",
2207                "        > new-dir",
2208                "          a-different-filename.tar.gz",
2209                "    > C",
2210                "      .dockerignore",
2211            ]
2212        );
2213
2214        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2215        assert_eq!(
2216            visible_entries_as_strings(&panel, 0..10, cx),
2217            &[
2218                "v root1",
2219                "    > .git",
2220                "    > a",
2221                "    v b",
2222                "        > [EDITOR: '3']  <== selected",
2223                "        > 4",
2224                "        > new-dir",
2225                "          a-different-filename.tar.gz",
2226                "    > C",
2227                "      .dockerignore",
2228            ]
2229        );
2230
2231        // Dismiss the rename editor when it loses focus.
2232        workspace.update(cx, |_, cx| cx.blur()).unwrap();
2233        assert_eq!(
2234            visible_entries_as_strings(&panel, 0..10, cx),
2235            &[
2236                "v root1",
2237                "    > .git",
2238                "    > a",
2239                "    v b",
2240                "        > 3  <== selected",
2241                "        > 4",
2242                "        > new-dir",
2243                "          a-different-filename.tar.gz",
2244                "    > C",
2245                "      .dockerignore",
2246            ]
2247        );
2248    }
2249
2250    #[gpui::test(iterations = 10)]
2251    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2252        init_test(cx);
2253
2254        let fs = FakeFs::new(cx.executor().clone());
2255        fs.insert_tree(
2256            "/root1",
2257            json!({
2258                ".dockerignore": "",
2259                ".git": {
2260                    "HEAD": "",
2261                },
2262                "a": {
2263                    "0": { "q": "", "r": "", "s": "" },
2264                    "1": { "t": "", "u": "" },
2265                    "2": { "v": "", "w": "", "x": "", "y": "" },
2266                },
2267                "b": {
2268                    "3": { "Q": "" },
2269                    "4": { "R": "", "S": "", "T": "", "U": "" },
2270                },
2271                "C": {
2272                    "5": {},
2273                    "6": { "V": "", "W": "" },
2274                    "7": { "X": "" },
2275                    "8": { "Y": {}, "Z": "" }
2276                }
2277            }),
2278        )
2279        .await;
2280        fs.insert_tree(
2281            "/root2",
2282            json!({
2283                "d": {
2284                    "9": ""
2285                },
2286                "e": {}
2287            }),
2288        )
2289        .await;
2290
2291        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2292        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2293        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2294        let panel = workspace
2295            .update(cx, |workspace, cx| {
2296                let panel = ProjectPanel::new(workspace, cx);
2297                workspace.add_panel(panel.clone(), cx);
2298                workspace.toggle_dock(panel.read(cx).position(cx), cx);
2299                panel
2300            })
2301            .unwrap();
2302
2303        select_path(&panel, "root1", cx);
2304        assert_eq!(
2305            visible_entries_as_strings(&panel, 0..10, cx),
2306            &[
2307                "v root1  <== selected",
2308                "    > .git",
2309                "    > a",
2310                "    > b",
2311                "    > C",
2312                "      .dockerignore",
2313                "v root2",
2314                "    > d",
2315                "    > e",
2316            ]
2317        );
2318
2319        // Add a file with the root folder selected. The filename editor is placed
2320        // before the first file in the root folder.
2321        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2322        panel.update(cx, |panel, cx| {
2323            assert!(panel.filename_editor.read(cx).is_focused(cx));
2324        });
2325        assert_eq!(
2326            visible_entries_as_strings(&panel, 0..10, cx),
2327            &[
2328                "v root1",
2329                "    > .git",
2330                "    > a",
2331                "    > b",
2332                "    > C",
2333                "      [EDITOR: '']  <== selected",
2334                "      .dockerignore",
2335                "v root2",
2336                "    > d",
2337                "    > e",
2338            ]
2339        );
2340
2341        let confirm = panel.update(cx, |panel, cx| {
2342            panel.filename_editor.update(cx, |editor, cx| {
2343                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2344            });
2345            panel.confirm_edit(cx).unwrap()
2346        });
2347
2348        assert_eq!(
2349            visible_entries_as_strings(&panel, 0..10, cx),
2350            &[
2351                "v root1",
2352                "    > .git",
2353                "    > a",
2354                "    > b",
2355                "    > C",
2356                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2357                "      .dockerignore",
2358                "v root2",
2359                "    > d",
2360                "    > e",
2361            ]
2362        );
2363
2364        confirm.await.unwrap();
2365        assert_eq!(
2366            visible_entries_as_strings(&panel, 0..13, cx),
2367            &[
2368                "v root1",
2369                "    > .git",
2370                "    > a",
2371                "    > b",
2372                "    v bdir1",
2373                "        v dir2",
2374                "              the-new-filename  <== selected",
2375                "    > C",
2376                "      .dockerignore",
2377                "v root2",
2378                "    > d",
2379                "    > e",
2380            ]
2381        );
2382    }
2383
2384    #[gpui::test]
2385    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2386        init_test(cx);
2387
2388        let fs = FakeFs::new(cx.executor().clone());
2389        fs.insert_tree(
2390            "/root1",
2391            json!({
2392                "one.two.txt": "",
2393                "one.txt": ""
2394            }),
2395        )
2396        .await;
2397
2398        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2399        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2400        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2401        let panel = workspace
2402            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2403            .unwrap();
2404
2405        panel.update(cx, |panel, cx| {
2406            panel.select_next(&Default::default(), cx);
2407            panel.select_next(&Default::default(), cx);
2408        });
2409
2410        assert_eq!(
2411            visible_entries_as_strings(&panel, 0..50, cx),
2412            &[
2413                //
2414                "v root1",
2415                "      one.two.txt  <== selected",
2416                "      one.txt",
2417            ]
2418        );
2419
2420        // Regression test - file name is created correctly when
2421        // the copied file's name contains multiple dots.
2422        panel.update(cx, |panel, cx| {
2423            panel.copy(&Default::default(), cx);
2424            panel.paste(&Default::default(), cx);
2425        });
2426        cx.executor().run_until_parked();
2427
2428        assert_eq!(
2429            visible_entries_as_strings(&panel, 0..50, cx),
2430            &[
2431                //
2432                "v root1",
2433                "      one.two copy.txt",
2434                "      one.two.txt  <== selected",
2435                "      one.txt",
2436            ]
2437        );
2438
2439        panel.update(cx, |panel, cx| {
2440            panel.paste(&Default::default(), cx);
2441        });
2442        cx.executor().run_until_parked();
2443
2444        assert_eq!(
2445            visible_entries_as_strings(&panel, 0..50, cx),
2446            &[
2447                //
2448                "v root1",
2449                "      one.two copy 1.txt",
2450                "      one.two copy.txt",
2451                "      one.two.txt  <== selected",
2452                "      one.txt",
2453            ]
2454        );
2455    }
2456
2457    #[gpui::test]
2458    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2459        init_test_with_editor(cx);
2460
2461        let fs = FakeFs::new(cx.executor().clone());
2462        fs.insert_tree(
2463            "/src",
2464            json!({
2465                "test": {
2466                    "first.rs": "// First Rust file",
2467                    "second.rs": "// Second Rust file",
2468                    "third.rs": "// Third Rust file",
2469                }
2470            }),
2471        )
2472        .await;
2473
2474        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2475        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2476        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2477        let panel = workspace
2478            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2479            .unwrap();
2480
2481        toggle_expand_dir(&panel, "src/test", cx);
2482        select_path(&panel, "src/test/first.rs", cx);
2483        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2484        cx.executor().run_until_parked();
2485        assert_eq!(
2486            visible_entries_as_strings(&panel, 0..10, cx),
2487            &[
2488                "v src",
2489                "    v test",
2490                "          first.rs  <== selected",
2491                "          second.rs",
2492                "          third.rs"
2493            ]
2494        );
2495        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2496
2497        submit_deletion(&panel, cx);
2498        assert_eq!(
2499            visible_entries_as_strings(&panel, 0..10, cx),
2500            &[
2501                "v src",
2502                "    v test",
2503                "          second.rs",
2504                "          third.rs"
2505            ],
2506            "Project panel should have no deleted file, no other file is selected in it"
2507        );
2508        ensure_no_open_items_and_panes(&workspace, cx);
2509
2510        select_path(&panel, "src/test/second.rs", cx);
2511        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2512        cx.executor().run_until_parked();
2513        assert_eq!(
2514            visible_entries_as_strings(&panel, 0..10, cx),
2515            &[
2516                "v src",
2517                "    v test",
2518                "          second.rs  <== selected",
2519                "          third.rs"
2520            ]
2521        );
2522        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2523
2524        workspace
2525            .update(cx, |workspace, cx| {
2526                let active_items = workspace
2527                    .panes()
2528                    .iter()
2529                    .filter_map(|pane| pane.read(cx).active_item())
2530                    .collect::<Vec<_>>();
2531                assert_eq!(active_items.len(), 1);
2532                let open_editor = active_items
2533                    .into_iter()
2534                    .next()
2535                    .unwrap()
2536                    .downcast::<Editor>()
2537                    .expect("Open item should be an editor");
2538                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2539            })
2540            .unwrap();
2541        submit_deletion(&panel, cx);
2542        assert_eq!(
2543            visible_entries_as_strings(&panel, 0..10, cx),
2544            &["v src", "    v test", "          third.rs"],
2545            "Project panel should have no deleted file, with one last file remaining"
2546        );
2547        ensure_no_open_items_and_panes(&workspace, cx);
2548    }
2549
2550    #[gpui::test]
2551    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2552        init_test_with_editor(cx);
2553
2554        let fs = FakeFs::new(cx.executor().clone());
2555        fs.insert_tree(
2556            "/src",
2557            json!({
2558                "test": {
2559                    "first.rs": "// First Rust file",
2560                    "second.rs": "// Second Rust file",
2561                    "third.rs": "// Third Rust file",
2562                }
2563            }),
2564        )
2565        .await;
2566
2567        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2568        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2569        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2570        let panel = workspace
2571            .update(cx, |workspace, cx| {
2572                let panel = ProjectPanel::new(workspace, cx);
2573                workspace.add_panel(panel.clone(), cx);
2574                workspace.toggle_dock(panel.read(cx).position(cx), cx);
2575                panel
2576            })
2577            .unwrap();
2578
2579        select_path(&panel, "src/", cx);
2580        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2581        cx.executor().run_until_parked();
2582        assert_eq!(
2583            visible_entries_as_strings(&panel, 0..10, cx),
2584            &[
2585                //
2586                "v src  <== selected",
2587                "    > test"
2588            ]
2589        );
2590        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2591        panel.update(cx, |panel, cx| {
2592            assert!(panel.filename_editor.read(cx).is_focused(cx));
2593        });
2594        assert_eq!(
2595            visible_entries_as_strings(&panel, 0..10, cx),
2596            &[
2597                //
2598                "v src",
2599                "    > [EDITOR: '']  <== selected",
2600                "    > test"
2601            ]
2602        );
2603        panel.update(cx, |panel, cx| {
2604            panel
2605                .filename_editor
2606                .update(cx, |editor, cx| editor.set_text("test", cx));
2607            assert!(
2608                panel.confirm_edit(cx).is_none(),
2609                "Should not allow to confirm on conflicting new directory name"
2610            )
2611        });
2612        assert_eq!(
2613            visible_entries_as_strings(&panel, 0..10, cx),
2614            &[
2615                //
2616                "v src",
2617                "    > test"
2618            ],
2619            "File list should be unchanged after failed folder create confirmation"
2620        );
2621
2622        select_path(&panel, "src/test/", cx);
2623        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2624        cx.executor().run_until_parked();
2625        assert_eq!(
2626            visible_entries_as_strings(&panel, 0..10, cx),
2627            &[
2628                //
2629                "v src",
2630                "    > test  <== selected"
2631            ]
2632        );
2633        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2634        panel.update(cx, |panel, cx| {
2635            assert!(panel.filename_editor.read(cx).is_focused(cx));
2636        });
2637        assert_eq!(
2638            visible_entries_as_strings(&panel, 0..10, cx),
2639            &[
2640                "v src",
2641                "    v test",
2642                "          [EDITOR: '']  <== selected",
2643                "          first.rs",
2644                "          second.rs",
2645                "          third.rs"
2646            ]
2647        );
2648        panel.update(cx, |panel, cx| {
2649            panel
2650                .filename_editor
2651                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2652            assert!(
2653                panel.confirm_edit(cx).is_none(),
2654                "Should not allow to confirm on conflicting new file name"
2655            )
2656        });
2657        assert_eq!(
2658            visible_entries_as_strings(&panel, 0..10, cx),
2659            &[
2660                "v src",
2661                "    v test",
2662                "          first.rs",
2663                "          second.rs",
2664                "          third.rs"
2665            ],
2666            "File list should be unchanged after failed file create confirmation"
2667        );
2668
2669        select_path(&panel, "src/test/first.rs", cx);
2670        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2671        cx.executor().run_until_parked();
2672        assert_eq!(
2673            visible_entries_as_strings(&panel, 0..10, cx),
2674            &[
2675                "v src",
2676                "    v test",
2677                "          first.rs  <== selected",
2678                "          second.rs",
2679                "          third.rs"
2680            ],
2681        );
2682        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2683        panel.update(cx, |panel, cx| {
2684            assert!(panel.filename_editor.read(cx).is_focused(cx));
2685        });
2686        assert_eq!(
2687            visible_entries_as_strings(&panel, 0..10, cx),
2688            &[
2689                "v src",
2690                "    v test",
2691                "          [EDITOR: 'first.rs']  <== selected",
2692                "          second.rs",
2693                "          third.rs"
2694            ]
2695        );
2696        panel.update(cx, |panel, cx| {
2697            panel
2698                .filename_editor
2699                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2700            assert!(
2701                panel.confirm_edit(cx).is_none(),
2702                "Should not allow to confirm on conflicting file rename"
2703            )
2704        });
2705        assert_eq!(
2706            visible_entries_as_strings(&panel, 0..10, cx),
2707            &[
2708                "v src",
2709                "    v test",
2710                "          first.rs  <== selected",
2711                "          second.rs",
2712                "          third.rs"
2713            ],
2714            "File list should be unchanged after failed rename confirmation"
2715        );
2716    }
2717
2718    #[gpui::test]
2719    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2720        init_test_with_editor(cx);
2721
2722        let fs = FakeFs::new(cx.executor().clone());
2723        fs.insert_tree(
2724            "/project_root",
2725            json!({
2726                "dir_1": {
2727                    "nested_dir": {
2728                        "file_a.py": "# File contents",
2729                        "file_b.py": "# File contents",
2730                        "file_c.py": "# File contents",
2731                    },
2732                    "file_1.py": "# File contents",
2733                    "file_2.py": "# File contents",
2734                    "file_3.py": "# File contents",
2735                },
2736                "dir_2": {
2737                    "file_1.py": "# File contents",
2738                    "file_2.py": "# File contents",
2739                    "file_3.py": "# File contents",
2740                }
2741            }),
2742        )
2743        .await;
2744
2745        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2746        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2747        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2748        let panel = workspace
2749            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2750            .unwrap();
2751
2752        panel.update(cx, |panel, cx| {
2753            panel.collapse_all_entries(&CollapseAllEntries, cx)
2754        });
2755        cx.executor().run_until_parked();
2756        assert_eq!(
2757            visible_entries_as_strings(&panel, 0..10, cx),
2758            &["v project_root", "    > dir_1", "    > dir_2",]
2759        );
2760
2761        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2762        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2763        cx.executor().run_until_parked();
2764        assert_eq!(
2765            visible_entries_as_strings(&panel, 0..10, cx),
2766            &[
2767                "v project_root",
2768                "    v dir_1  <== selected",
2769                "        > nested_dir",
2770                "          file_1.py",
2771                "          file_2.py",
2772                "          file_3.py",
2773                "    > dir_2",
2774            ]
2775        );
2776    }
2777
2778    #[gpui::test]
2779    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2780        init_test(cx);
2781
2782        let fs = FakeFs::new(cx.executor().clone());
2783        fs.as_fake().insert_tree("/root", json!({})).await;
2784        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2785        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2786        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2787        let panel = workspace
2788            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2789            .unwrap();
2790
2791        // Make a new buffer with no backing file
2792        workspace
2793            .update(cx, |workspace, cx| {
2794                Editor::new_file(workspace, &Default::default(), cx)
2795            })
2796            .unwrap();
2797
2798        // "Save as"" the buffer, creating a new backing file for it
2799        let save_task = workspace
2800            .update(cx, |workspace, cx| {
2801                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2802            })
2803            .unwrap();
2804
2805        cx.executor().run_until_parked();
2806        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2807        save_task.await.unwrap();
2808
2809        // Rename the file
2810        select_path(&panel, "root/new", cx);
2811        assert_eq!(
2812            visible_entries_as_strings(&panel, 0..10, cx),
2813            &["v root", "      new  <== selected"]
2814        );
2815        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2816        panel.update(cx, |panel, cx| {
2817            panel
2818                .filename_editor
2819                .update(cx, |editor, cx| editor.set_text("newer", cx));
2820        });
2821        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2822
2823        cx.executor().run_until_parked();
2824        assert_eq!(
2825            visible_entries_as_strings(&panel, 0..10, cx),
2826            &["v root", "      newer  <== selected"]
2827        );
2828
2829        workspace
2830            .update(cx, |workspace, cx| {
2831                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2832            })
2833            .unwrap()
2834            .await
2835            .unwrap();
2836
2837        cx.executor().run_until_parked();
2838        // assert that saving the file doesn't restore "new"
2839        assert_eq!(
2840            visible_entries_as_strings(&panel, 0..10, cx),
2841            &["v root", "      newer  <== selected"]
2842        );
2843    }
2844
2845    #[gpui::test]
2846    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
2847        init_test_with_editor(cx);
2848        cx.update(|cx| {
2849            cx.update_global::<SettingsStore, _>(|store, cx| {
2850                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2851                    project_settings.file_scan_exclusions = Some(Vec::new());
2852                });
2853                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2854                    project_panel_settings.auto_reveal_entries = Some(false)
2855                });
2856            })
2857        });
2858
2859        let fs = FakeFs::new(cx.background_executor.clone());
2860        fs.insert_tree(
2861            "/project_root",
2862            json!({
2863                ".git": {},
2864                ".gitignore": "**/gitignored_dir",
2865                "dir_1": {
2866                    "file_1.py": "# File 1_1 contents",
2867                    "file_2.py": "# File 1_2 contents",
2868                    "file_3.py": "# File 1_3 contents",
2869                    "gitignored_dir": {
2870                        "file_a.py": "# File contents",
2871                        "file_b.py": "# File contents",
2872                        "file_c.py": "# File contents",
2873                    },
2874                },
2875                "dir_2": {
2876                    "file_1.py": "# File 2_1 contents",
2877                    "file_2.py": "# File 2_2 contents",
2878                    "file_3.py": "# File 2_3 contents",
2879                }
2880            }),
2881        )
2882        .await;
2883
2884        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2885        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2886        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2887        let panel = workspace
2888            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2889            .unwrap();
2890
2891        assert_eq!(
2892            visible_entries_as_strings(&panel, 0..20, cx),
2893            &[
2894                "v project_root",
2895                "    > .git",
2896                "    > dir_1",
2897                "    > dir_2",
2898                "      .gitignore",
2899            ]
2900        );
2901
2902        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
2903            .expect("dir 1 file is not ignored and should have an entry");
2904        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
2905            .expect("dir 2 file is not ignored and should have an entry");
2906        let gitignored_dir_file =
2907            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
2908        assert_eq!(
2909            gitignored_dir_file, None,
2910            "File in the gitignored dir should not have an entry before its dir is toggled"
2911        );
2912
2913        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2914        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2915        cx.executor().run_until_parked();
2916        assert_eq!(
2917            visible_entries_as_strings(&panel, 0..20, cx),
2918            &[
2919                "v project_root",
2920                "    > .git",
2921                "    v dir_1",
2922                "        v gitignored_dir  <== selected",
2923                "              file_a.py",
2924                "              file_b.py",
2925                "              file_c.py",
2926                "          file_1.py",
2927                "          file_2.py",
2928                "          file_3.py",
2929                "    > dir_2",
2930                "      .gitignore",
2931            ],
2932            "Should show gitignored dir file list in the project panel"
2933        );
2934        let gitignored_dir_file =
2935            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
2936                .expect("after gitignored dir got opened, a file entry should be present");
2937
2938        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2939        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2940        assert_eq!(
2941            visible_entries_as_strings(&panel, 0..20, cx),
2942            &[
2943                "v project_root",
2944                "    > .git",
2945                "    > dir_1  <== selected",
2946                "    > dir_2",
2947                "      .gitignore",
2948            ],
2949            "Should hide all dir contents again and prepare for the auto reveal test"
2950        );
2951
2952        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
2953            panel.update(cx, |panel, cx| {
2954                panel.project.update(cx, |_, cx| {
2955                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
2956                })
2957            });
2958            cx.run_until_parked();
2959            assert_eq!(
2960                visible_entries_as_strings(&panel, 0..20, cx),
2961                &[
2962                    "v project_root",
2963                    "    > .git",
2964                    "    > dir_1  <== selected",
2965                    "    > dir_2",
2966                    "      .gitignore",
2967                ],
2968                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
2969            );
2970        }
2971
2972        cx.update(|cx| {
2973            cx.update_global::<SettingsStore, _>(|store, cx| {
2974                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2975                    project_panel_settings.auto_reveal_entries = Some(true)
2976                });
2977            })
2978        });
2979
2980        panel.update(cx, |panel, cx| {
2981            panel.project.update(cx, |_, cx| {
2982                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
2983            })
2984        });
2985        cx.run_until_parked();
2986        assert_eq!(
2987            visible_entries_as_strings(&panel, 0..20, cx),
2988            &[
2989                "v project_root",
2990                "    > .git",
2991                "    v dir_1",
2992                "        > gitignored_dir",
2993                "          file_1.py  <== selected",
2994                "          file_2.py",
2995                "          file_3.py",
2996                "    > dir_2",
2997                "      .gitignore",
2998            ],
2999            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3000        );
3001
3002        panel.update(cx, |panel, cx| {
3003            panel.project.update(cx, |_, cx| {
3004                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3005            })
3006        });
3007        cx.run_until_parked();
3008        assert_eq!(
3009            visible_entries_as_strings(&panel, 0..20, cx),
3010            &[
3011                "v project_root",
3012                "    > .git",
3013                "    v dir_1",
3014                "        > gitignored_dir",
3015                "          file_1.py",
3016                "          file_2.py",
3017                "          file_3.py",
3018                "    v dir_2",
3019                "          file_1.py  <== selected",
3020                "          file_2.py",
3021                "          file_3.py",
3022                "      .gitignore",
3023            ],
3024            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3025        );
3026
3027        panel.update(cx, |panel, cx| {
3028            panel.project.update(cx, |_, cx| {
3029                cx.emit(project::Event::ActiveEntryChanged(Some(
3030                    gitignored_dir_file,
3031                )))
3032            })
3033        });
3034        cx.run_until_parked();
3035        assert_eq!(
3036            visible_entries_as_strings(&panel, 0..20, cx),
3037            &[
3038                "v project_root",
3039                "    > .git",
3040                "    v dir_1",
3041                "        > gitignored_dir",
3042                "          file_1.py",
3043                "          file_2.py",
3044                "          file_3.py",
3045                "    v dir_2",
3046                "          file_1.py  <== selected",
3047                "          file_2.py",
3048                "          file_3.py",
3049                "      .gitignore",
3050            ],
3051            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3052        );
3053
3054        panel.update(cx, |panel, cx| {
3055            panel.project.update(cx, |_, cx| {
3056                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3057            })
3058        });
3059        cx.run_until_parked();
3060        assert_eq!(
3061            visible_entries_as_strings(&panel, 0..20, cx),
3062            &[
3063                "v project_root",
3064                "    > .git",
3065                "    v dir_1",
3066                "        v gitignored_dir",
3067                "              file_a.py  <== selected",
3068                "              file_b.py",
3069                "              file_c.py",
3070                "          file_1.py",
3071                "          file_2.py",
3072                "          file_3.py",
3073                "    v dir_2",
3074                "          file_1.py",
3075                "          file_2.py",
3076                "          file_3.py",
3077                "      .gitignore",
3078            ],
3079            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3080        );
3081    }
3082
3083    #[gpui::test]
3084    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3085        init_test_with_editor(cx);
3086        cx.update(|cx| {
3087            cx.update_global::<SettingsStore, _>(|store, cx| {
3088                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3089                    project_settings.file_scan_exclusions = Some(Vec::new());
3090                });
3091                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3092                    project_panel_settings.auto_reveal_entries = Some(false)
3093                });
3094            })
3095        });
3096
3097        let fs = FakeFs::new(cx.background_executor.clone());
3098        fs.insert_tree(
3099            "/project_root",
3100            json!({
3101                ".git": {},
3102                ".gitignore": "**/gitignored_dir",
3103                "dir_1": {
3104                    "file_1.py": "# File 1_1 contents",
3105                    "file_2.py": "# File 1_2 contents",
3106                    "file_3.py": "# File 1_3 contents",
3107                    "gitignored_dir": {
3108                        "file_a.py": "# File contents",
3109                        "file_b.py": "# File contents",
3110                        "file_c.py": "# File contents",
3111                    },
3112                },
3113                "dir_2": {
3114                    "file_1.py": "# File 2_1 contents",
3115                    "file_2.py": "# File 2_2 contents",
3116                    "file_3.py": "# File 2_3 contents",
3117                }
3118            }),
3119        )
3120        .await;
3121
3122        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3123        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3124        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3125        let panel = workspace
3126            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3127            .unwrap();
3128
3129        assert_eq!(
3130            visible_entries_as_strings(&panel, 0..20, cx),
3131            &[
3132                "v project_root",
3133                "    > .git",
3134                "    > dir_1",
3135                "    > dir_2",
3136                "      .gitignore",
3137            ]
3138        );
3139
3140        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3141            .expect("dir 1 file is not ignored and should have an entry");
3142        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3143            .expect("dir 2 file is not ignored and should have an entry");
3144        let gitignored_dir_file =
3145            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3146        assert_eq!(
3147            gitignored_dir_file, None,
3148            "File in the gitignored dir should not have an entry before its dir is toggled"
3149        );
3150
3151        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3152        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3153        cx.run_until_parked();
3154        assert_eq!(
3155            visible_entries_as_strings(&panel, 0..20, cx),
3156            &[
3157                "v project_root",
3158                "    > .git",
3159                "    v dir_1",
3160                "        v gitignored_dir  <== selected",
3161                "              file_a.py",
3162                "              file_b.py",
3163                "              file_c.py",
3164                "          file_1.py",
3165                "          file_2.py",
3166                "          file_3.py",
3167                "    > dir_2",
3168                "      .gitignore",
3169            ],
3170            "Should show gitignored dir file list in the project panel"
3171        );
3172        let gitignored_dir_file =
3173            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3174                .expect("after gitignored dir got opened, a file entry should be present");
3175
3176        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3177        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3178        assert_eq!(
3179            visible_entries_as_strings(&panel, 0..20, cx),
3180            &[
3181                "v project_root",
3182                "    > .git",
3183                "    > dir_1  <== selected",
3184                "    > dir_2",
3185                "      .gitignore",
3186            ],
3187            "Should hide all dir contents again and prepare for the explicit reveal test"
3188        );
3189
3190        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3191            panel.update(cx, |panel, cx| {
3192                panel.project.update(cx, |_, cx| {
3193                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3194                })
3195            });
3196            cx.run_until_parked();
3197            assert_eq!(
3198                visible_entries_as_strings(&panel, 0..20, cx),
3199                &[
3200                    "v project_root",
3201                    "    > .git",
3202                    "    > dir_1  <== selected",
3203                    "    > dir_2",
3204                    "      .gitignore",
3205                ],
3206                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3207            );
3208        }
3209
3210        panel.update(cx, |panel, cx| {
3211            panel.project.update(cx, |_, cx| {
3212                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3213            })
3214        });
3215        cx.run_until_parked();
3216        assert_eq!(
3217            visible_entries_as_strings(&panel, 0..20, cx),
3218            &[
3219                "v project_root",
3220                "    > .git",
3221                "    v dir_1",
3222                "        > gitignored_dir",
3223                "          file_1.py  <== selected",
3224                "          file_2.py",
3225                "          file_3.py",
3226                "    > dir_2",
3227                "      .gitignore",
3228            ],
3229            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3230        );
3231
3232        panel.update(cx, |panel, cx| {
3233            panel.project.update(cx, |_, cx| {
3234                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3235            })
3236        });
3237        cx.run_until_parked();
3238        assert_eq!(
3239            visible_entries_as_strings(&panel, 0..20, cx),
3240            &[
3241                "v project_root",
3242                "    > .git",
3243                "    v dir_1",
3244                "        > gitignored_dir",
3245                "          file_1.py",
3246                "          file_2.py",
3247                "          file_3.py",
3248                "    v dir_2",
3249                "          file_1.py  <== selected",
3250                "          file_2.py",
3251                "          file_3.py",
3252                "      .gitignore",
3253            ],
3254            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3255        );
3256
3257        panel.update(cx, |panel, cx| {
3258            panel.project.update(cx, |_, cx| {
3259                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3260            })
3261        });
3262        cx.run_until_parked();
3263        assert_eq!(
3264            visible_entries_as_strings(&panel, 0..20, cx),
3265            &[
3266                "v project_root",
3267                "    > .git",
3268                "    v dir_1",
3269                "        v gitignored_dir",
3270                "              file_a.py  <== selected",
3271                "              file_b.py",
3272                "              file_c.py",
3273                "          file_1.py",
3274                "          file_2.py",
3275                "          file_3.py",
3276                "    v dir_2",
3277                "          file_1.py",
3278                "          file_2.py",
3279                "          file_3.py",
3280                "      .gitignore",
3281            ],
3282            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3283        );
3284    }
3285
3286    fn toggle_expand_dir(
3287        panel: &View<ProjectPanel>,
3288        path: impl AsRef<Path>,
3289        cx: &mut VisualTestContext,
3290    ) {
3291        let path = path.as_ref();
3292        panel.update(cx, |panel, cx| {
3293            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3294                let worktree = worktree.read(cx);
3295                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3296                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3297                    panel.toggle_expanded(entry_id, cx);
3298                    return;
3299                }
3300            }
3301            panic!("no worktree for path {:?}", path);
3302        });
3303    }
3304
3305    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3306        let path = path.as_ref();
3307        panel.update(cx, |panel, cx| {
3308            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3309                let worktree = worktree.read(cx);
3310                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3311                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3312                    panel.selection = Some(crate::Selection {
3313                        worktree_id: worktree.id(),
3314                        entry_id,
3315                    });
3316                    return;
3317                }
3318            }
3319            panic!("no worktree for path {:?}", path);
3320        });
3321    }
3322
3323    fn find_project_entry(
3324        panel: &View<ProjectPanel>,
3325        path: impl AsRef<Path>,
3326        cx: &mut VisualTestContext,
3327    ) -> Option<ProjectEntryId> {
3328        let path = path.as_ref();
3329        panel.update(cx, |panel, cx| {
3330            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3331                let worktree = worktree.read(cx);
3332                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3333                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3334                }
3335            }
3336            panic!("no worktree for path {path:?}");
3337        })
3338    }
3339
3340    fn visible_entries_as_strings(
3341        panel: &View<ProjectPanel>,
3342        range: Range<usize>,
3343        cx: &mut VisualTestContext,
3344    ) -> Vec<String> {
3345        let mut result = Vec::new();
3346        let mut project_entries = HashSet::new();
3347        let mut has_editor = false;
3348
3349        panel.update(cx, |panel, cx| {
3350            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3351                if details.is_editing {
3352                    assert!(!has_editor, "duplicate editor entry");
3353                    has_editor = true;
3354                } else {
3355                    assert!(
3356                        project_entries.insert(project_entry),
3357                        "duplicate project entry {:?} {:?}",
3358                        project_entry,
3359                        details
3360                    );
3361                }
3362
3363                let indent = "    ".repeat(details.depth);
3364                let icon = if details.kind.is_dir() {
3365                    if details.is_expanded {
3366                        "v "
3367                    } else {
3368                        "> "
3369                    }
3370                } else {
3371                    "  "
3372                };
3373                let name = if details.is_editing {
3374                    format!("[EDITOR: '{}']", details.filename)
3375                } else if details.is_processing {
3376                    format!("[PROCESSING: '{}']", details.filename)
3377                } else {
3378                    details.filename.clone()
3379                };
3380                let selected = if details.is_selected {
3381                    "  <== selected"
3382                } else {
3383                    ""
3384                };
3385                result.push(format!("{indent}{icon}{name}{selected}"));
3386            });
3387        });
3388
3389        result
3390    }
3391
3392    fn init_test(cx: &mut TestAppContext) {
3393        cx.update(|cx| {
3394            let settings_store = SettingsStore::test(cx);
3395            cx.set_global(settings_store);
3396            init_settings(cx);
3397            theme::init(theme::LoadThemes::JustBase, cx);
3398            language::init(cx);
3399            editor::init_settings(cx);
3400            crate::init((), cx);
3401            workspace::init_settings(cx);
3402            client::init_settings(cx);
3403            Project::init_settings(cx);
3404
3405            cx.update_global::<SettingsStore, _>(|store, cx| {
3406                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3407                    project_settings.file_scan_exclusions = Some(Vec::new());
3408                });
3409            });
3410        });
3411    }
3412
3413    fn init_test_with_editor(cx: &mut TestAppContext) {
3414        cx.update(|cx| {
3415            let app_state = AppState::test(cx);
3416            theme::init(theme::LoadThemes::JustBase, cx);
3417            init_settings(cx);
3418            language::init(cx);
3419            editor::init(cx);
3420            crate::init((), cx);
3421            workspace::init(app_state.clone(), cx);
3422            Project::init_settings(cx);
3423        });
3424    }
3425
3426    fn ensure_single_file_is_opened(
3427        window: &WindowHandle<Workspace>,
3428        expected_path: &str,
3429        cx: &mut TestAppContext,
3430    ) {
3431        window
3432            .update(cx, |workspace, cx| {
3433                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3434                assert_eq!(worktrees.len(), 1);
3435                let worktree_id = worktrees[0].read(cx).id();
3436
3437                let open_project_paths = workspace
3438                    .panes()
3439                    .iter()
3440                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3441                    .collect::<Vec<_>>();
3442                assert_eq!(
3443                    open_project_paths,
3444                    vec![ProjectPath {
3445                        worktree_id,
3446                        path: Arc::from(Path::new(expected_path))
3447                    }],
3448                    "Should have opened file, selected in project panel"
3449                );
3450            })
3451            .unwrap();
3452    }
3453
3454    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3455        assert!(
3456            !cx.has_pending_prompt(),
3457            "Should have no prompts before the deletion"
3458        );
3459        panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
3460        assert!(
3461            cx.has_pending_prompt(),
3462            "Should have a prompt after the deletion"
3463        );
3464        cx.simulate_prompt_answer(0);
3465        assert!(
3466            !cx.has_pending_prompt(),
3467            "Should have no prompts after prompt was replied to"
3468        );
3469        cx.executor().run_until_parked();
3470    }
3471
3472    fn ensure_no_open_items_and_panes(
3473        workspace: &WindowHandle<Workspace>,
3474        cx: &mut VisualTestContext,
3475    ) {
3476        assert!(
3477            !cx.has_pending_prompt(),
3478            "Should have no prompts after deletion operation closes the file"
3479        );
3480        workspace
3481            .read_with(cx, |workspace, cx| {
3482                let open_project_paths = workspace
3483                    .panes()
3484                    .iter()
3485                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3486                    .collect::<Vec<_>>();
3487                assert!(
3488                    open_project_paths.is_empty(),
3489                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3490                );
3491            })
3492            .unwrap();
3493    }
3494}