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 {
1356                Color::Muted
1357            });
1358
1359        let file_name = details.filename.clone();
1360        let icon = details.icon.clone();
1361        let depth = details.depth;
1362        div()
1363            .id(entry_id.to_proto() as usize)
1364            .on_drag(entry_id, move |entry_id, cx| {
1365                cx.new_view(|_| DraggedProjectEntryView {
1366                    details: details.clone(),
1367                    width,
1368                    entry_id: *entry_id,
1369                })
1370            })
1371            .drag_over::<ProjectEntryId>(|style| {
1372                style.bg(cx.theme().colors().drop_target_background)
1373            })
1374            .on_drop(cx.listener(move |this, dragged_id: &ProjectEntryId, cx| {
1375                this.move_entry(*dragged_id, entry_id, kind.is_file(), cx);
1376            }))
1377            .child(
1378                ListItem::new(entry_id.to_proto() as usize)
1379                    .indent_level(depth)
1380                    .indent_step_size(px(settings.indent_size))
1381                    .selected(is_selected)
1382                    .child(if let Some(icon) = &icon {
1383                        div().child(Icon::from_path(icon.to_string()).color(Color::Muted))
1384                    } else {
1385                        div().size(IconSize::default().rems()).invisible()
1386                    })
1387                    .child(
1388                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1389                            div().h_full().w_full().child(editor.clone())
1390                        } else {
1391                            div().child(Label::new(file_name).color(filename_text_color))
1392                        }
1393                        .ml_1(),
1394                    )
1395                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1396                        if event.down.button == MouseButton::Right {
1397                            return;
1398                        }
1399                        if !show_editor {
1400                            if kind.is_dir() {
1401                                this.toggle_expanded(entry_id, cx);
1402                            } else {
1403                                if event.down.modifiers.command {
1404                                    this.split_entry(entry_id, cx);
1405                                } else {
1406                                    this.open_entry(entry_id, event.up.click_count > 1, cx);
1407                                }
1408                            }
1409                        }
1410                    }))
1411                    .on_secondary_mouse_down(cx.listener(
1412                        move |this, event: &MouseDownEvent, cx| {
1413                            // Stop propagation to prevent the catch-all context menu for the project
1414                            // panel from being deployed.
1415                            cx.stop_propagation();
1416                            this.deploy_context_menu(event.position, entry_id, cx);
1417                        },
1418                    )),
1419            )
1420    }
1421
1422    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1423        let mut dispatch_context = KeyContext::default();
1424        dispatch_context.add("ProjectPanel");
1425        dispatch_context.add("menu");
1426
1427        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1428            "editing"
1429        } else {
1430            "not_editing"
1431        };
1432
1433        dispatch_context.add(identifier);
1434        dispatch_context
1435    }
1436
1437    fn reveal_entry(
1438        &mut self,
1439        project: Model<Project>,
1440        entry_id: ProjectEntryId,
1441        skip_ignored: bool,
1442        cx: &mut ViewContext<'_, ProjectPanel>,
1443    ) {
1444        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1445            let worktree = worktree.read(cx);
1446            if skip_ignored
1447                && worktree
1448                    .entry_for_id(entry_id)
1449                    .map_or(true, |entry| entry.is_ignored)
1450            {
1451                return;
1452            }
1453
1454            let worktree_id = worktree.id();
1455            self.expand_entry(worktree_id, entry_id, cx);
1456            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1457            self.autoscroll(cx);
1458            cx.notify();
1459        }
1460    }
1461    pub fn was_deserialized(&self) -> bool {
1462        self.was_deserialized
1463    }
1464}
1465
1466impl Render for ProjectPanel {
1467    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1468        let has_worktree = self.visible_entries.len() != 0;
1469        let project = self.project.read(cx);
1470
1471        if has_worktree {
1472            div()
1473                .id("project-panel")
1474                .size_full()
1475                .relative()
1476                .key_context(self.dispatch_context(cx))
1477                .on_action(cx.listener(Self::select_next))
1478                .on_action(cx.listener(Self::select_prev))
1479                .on_action(cx.listener(Self::expand_selected_entry))
1480                .on_action(cx.listener(Self::collapse_selected_entry))
1481                .on_action(cx.listener(Self::collapse_all_entries))
1482                .on_action(cx.listener(Self::open_file))
1483                .on_action(cx.listener(Self::confirm))
1484                .on_action(cx.listener(Self::cancel))
1485                .on_action(cx.listener(Self::copy_path))
1486                .on_action(cx.listener(Self::copy_relative_path))
1487                .on_action(cx.listener(Self::new_search_in_directory))
1488                .when(!project.is_read_only(), |el| {
1489                    el.on_action(cx.listener(Self::new_file))
1490                        .on_action(cx.listener(Self::new_directory))
1491                        .on_action(cx.listener(Self::rename))
1492                        .on_action(cx.listener(Self::delete))
1493                        .on_action(cx.listener(Self::cut))
1494                        .on_action(cx.listener(Self::copy))
1495                        .on_action(cx.listener(Self::paste))
1496                })
1497                .when(project.is_local(), |el| {
1498                    el.on_action(cx.listener(Self::reveal_in_finder))
1499                        .on_action(cx.listener(Self::open_in_terminal))
1500                })
1501                .on_mouse_down(
1502                    MouseButton::Right,
1503                    cx.listener(move |this, event: &MouseDownEvent, cx| {
1504                        // When deploying the context menu anywhere below the last project entry,
1505                        // act as if the user clicked the root of the last worktree.
1506                        if let Some(entry_id) = this.last_worktree_root_id {
1507                            this.deploy_context_menu(event.position, entry_id, cx);
1508                        }
1509                    }),
1510                )
1511                .track_focus(&self.focus_handle)
1512                .child(
1513                    uniform_list(
1514                        cx.view().clone(),
1515                        "entries",
1516                        self.visible_entries
1517                            .iter()
1518                            .map(|(_, worktree_entries)| worktree_entries.len())
1519                            .sum(),
1520                        {
1521                            |this, range, cx| {
1522                                let mut items = Vec::new();
1523                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1524                                    items.push(this.render_entry(id, details, cx));
1525                                });
1526                                items
1527                            }
1528                        },
1529                    )
1530                    .size_full()
1531                    .track_scroll(self.list.clone()),
1532                )
1533                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1534                    overlay()
1535                        .position(*position)
1536                        .anchor(gpui::AnchorCorner::TopLeft)
1537                        .child(menu.clone())
1538                }))
1539        } else {
1540            v_flex()
1541                .id("empty-project_panel")
1542                .size_full()
1543                .p_4()
1544                .track_focus(&self.focus_handle)
1545                .child(
1546                    Button::new("open_project", "Open a project")
1547                        .style(ButtonStyle::Filled)
1548                        .full_width()
1549                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1550                        .on_click(cx.listener(|this, _, cx| {
1551                            this.workspace
1552                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1553                                .log_err();
1554                        })),
1555                )
1556        }
1557    }
1558}
1559
1560impl Render for DraggedProjectEntryView {
1561    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1562        let settings = ProjectPanelSettings::get_global(cx);
1563        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1564        h_flex()
1565            .font(ui_font)
1566            .bg(cx.theme().colors().background)
1567            .w(self.width)
1568            .child(
1569                ListItem::new(self.entry_id.to_proto() as usize)
1570                    .indent_level(self.details.depth)
1571                    .indent_step_size(px(settings.indent_size))
1572                    .child(if let Some(icon) = &self.details.icon {
1573                        div().child(Icon::from_path(icon.to_string()))
1574                    } else {
1575                        div()
1576                    })
1577                    .child(Label::new(self.details.filename.clone())),
1578            )
1579    }
1580}
1581
1582impl EventEmitter<Event> for ProjectPanel {}
1583
1584impl EventEmitter<PanelEvent> for ProjectPanel {}
1585
1586impl Panel for ProjectPanel {
1587    fn position(&self, cx: &WindowContext) -> DockPosition {
1588        match ProjectPanelSettings::get_global(cx).dock {
1589            ProjectPanelDockPosition::Left => DockPosition::Left,
1590            ProjectPanelDockPosition::Right => DockPosition::Right,
1591        }
1592    }
1593
1594    fn position_is_valid(&self, position: DockPosition) -> bool {
1595        matches!(position, DockPosition::Left | DockPosition::Right)
1596    }
1597
1598    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1599        settings::update_settings_file::<ProjectPanelSettings>(
1600            self.fs.clone(),
1601            cx,
1602            move |settings| {
1603                let dock = match position {
1604                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1605                    DockPosition::Right => ProjectPanelDockPosition::Right,
1606                };
1607                settings.dock = Some(dock);
1608            },
1609        );
1610    }
1611
1612    fn size(&self, cx: &WindowContext) -> Pixels {
1613        self.width
1614            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1615    }
1616
1617    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1618        self.width = size;
1619        self.serialize(cx);
1620        cx.notify();
1621    }
1622
1623    fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
1624        Some(ui::IconName::FileTree)
1625    }
1626
1627    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1628        Some("Project Panel")
1629    }
1630
1631    fn toggle_action(&self) -> Box<dyn Action> {
1632        Box::new(ToggleFocus)
1633    }
1634
1635    fn persistent_name() -> &'static str {
1636        "Project Panel"
1637    }
1638}
1639
1640impl FocusableView for ProjectPanel {
1641    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1642        self.focus_handle.clone()
1643    }
1644}
1645
1646impl ClipboardEntry {
1647    fn is_cut(&self) -> bool {
1648        matches!(self, Self::Cut { .. })
1649    }
1650
1651    fn entry_id(&self) -> ProjectEntryId {
1652        match self {
1653            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1654                *entry_id
1655            }
1656        }
1657    }
1658
1659    fn worktree_id(&self) -> WorktreeId {
1660        match self {
1661            ClipboardEntry::Copied { worktree_id, .. }
1662            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1663        }
1664    }
1665}
1666
1667#[cfg(test)]
1668mod tests {
1669    use super::*;
1670    use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1671    use pretty_assertions::assert_eq;
1672    use project::{project_settings::ProjectSettings, FakeFs};
1673    use serde_json::json;
1674    use settings::SettingsStore;
1675    use std::{
1676        collections::HashSet,
1677        path::{Path, PathBuf},
1678    };
1679    use workspace::AppState;
1680
1681    #[gpui::test]
1682    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1683        init_test(cx);
1684
1685        let fs = FakeFs::new(cx.executor().clone());
1686        fs.insert_tree(
1687            "/root1",
1688            json!({
1689                ".dockerignore": "",
1690                ".git": {
1691                    "HEAD": "",
1692                },
1693                "a": {
1694                    "0": { "q": "", "r": "", "s": "" },
1695                    "1": { "t": "", "u": "" },
1696                    "2": { "v": "", "w": "", "x": "", "y": "" },
1697                },
1698                "b": {
1699                    "3": { "Q": "" },
1700                    "4": { "R": "", "S": "", "T": "", "U": "" },
1701                },
1702                "C": {
1703                    "5": {},
1704                    "6": { "V": "", "W": "" },
1705                    "7": { "X": "" },
1706                    "8": { "Y": {}, "Z": "" }
1707                }
1708            }),
1709        )
1710        .await;
1711        fs.insert_tree(
1712            "/root2",
1713            json!({
1714                "d": {
1715                    "9": ""
1716                },
1717                "e": {}
1718            }),
1719        )
1720        .await;
1721
1722        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1723        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1724        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1725        let panel = workspace
1726            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1727            .unwrap();
1728        assert_eq!(
1729            visible_entries_as_strings(&panel, 0..50, cx),
1730            &[
1731                "v root1",
1732                "    > .git",
1733                "    > a",
1734                "    > b",
1735                "    > C",
1736                "      .dockerignore",
1737                "v root2",
1738                "    > d",
1739                "    > e",
1740            ]
1741        );
1742
1743        toggle_expand_dir(&panel, "root1/b", cx);
1744        assert_eq!(
1745            visible_entries_as_strings(&panel, 0..50, cx),
1746            &[
1747                "v root1",
1748                "    > .git",
1749                "    > a",
1750                "    v b  <== selected",
1751                "        > 3",
1752                "        > 4",
1753                "    > C",
1754                "      .dockerignore",
1755                "v root2",
1756                "    > d",
1757                "    > e",
1758            ]
1759        );
1760
1761        assert_eq!(
1762            visible_entries_as_strings(&panel, 6..9, cx),
1763            &[
1764                //
1765                "    > C",
1766                "      .dockerignore",
1767                "v root2",
1768            ]
1769        );
1770    }
1771
1772    #[gpui::test]
1773    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1774        init_test(cx);
1775        cx.update(|cx| {
1776            cx.update_global::<SettingsStore, _>(|store, cx| {
1777                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
1778                    project_settings.file_scan_exclusions =
1779                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1780                });
1781            });
1782        });
1783
1784        let fs = FakeFs::new(cx.background_executor.clone());
1785        fs.insert_tree(
1786            "/root1",
1787            json!({
1788                ".dockerignore": "",
1789                ".git": {
1790                    "HEAD": "",
1791                },
1792                "a": {
1793                    "0": { "q": "", "r": "", "s": "" },
1794                    "1": { "t": "", "u": "" },
1795                    "2": { "v": "", "w": "", "x": "", "y": "" },
1796                },
1797                "b": {
1798                    "3": { "Q": "" },
1799                    "4": { "R": "", "S": "", "T": "", "U": "" },
1800                },
1801                "C": {
1802                    "5": {},
1803                    "6": { "V": "", "W": "" },
1804                    "7": { "X": "" },
1805                    "8": { "Y": {}, "Z": "" }
1806                }
1807            }),
1808        )
1809        .await;
1810        fs.insert_tree(
1811            "/root2",
1812            json!({
1813                "d": {
1814                    "4": ""
1815                },
1816                "e": {}
1817            }),
1818        )
1819        .await;
1820
1821        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1822        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1823        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1824        let panel = workspace
1825            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1826            .unwrap();
1827        assert_eq!(
1828            visible_entries_as_strings(&panel, 0..50, cx),
1829            &[
1830                "v root1",
1831                "    > a",
1832                "    > b",
1833                "    > C",
1834                "      .dockerignore",
1835                "v root2",
1836                "    > d",
1837                "    > e",
1838            ]
1839        );
1840
1841        toggle_expand_dir(&panel, "root1/b", cx);
1842        assert_eq!(
1843            visible_entries_as_strings(&panel, 0..50, cx),
1844            &[
1845                "v root1",
1846                "    > a",
1847                "    v b  <== selected",
1848                "        > 3",
1849                "    > C",
1850                "      .dockerignore",
1851                "v root2",
1852                "    > d",
1853                "    > e",
1854            ]
1855        );
1856
1857        toggle_expand_dir(&panel, "root2/d", cx);
1858        assert_eq!(
1859            visible_entries_as_strings(&panel, 0..50, cx),
1860            &[
1861                "v root1",
1862                "    > a",
1863                "    v b",
1864                "        > 3",
1865                "    > C",
1866                "      .dockerignore",
1867                "v root2",
1868                "    v d  <== selected",
1869                "    > e",
1870            ]
1871        );
1872
1873        toggle_expand_dir(&panel, "root2/e", cx);
1874        assert_eq!(
1875            visible_entries_as_strings(&panel, 0..50, cx),
1876            &[
1877                "v root1",
1878                "    > a",
1879                "    v b",
1880                "        > 3",
1881                "    > C",
1882                "      .dockerignore",
1883                "v root2",
1884                "    v d",
1885                "    v e  <== selected",
1886            ]
1887        );
1888    }
1889
1890    #[gpui::test(iterations = 30)]
1891    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1892        init_test(cx);
1893
1894        let fs = FakeFs::new(cx.executor().clone());
1895        fs.insert_tree(
1896            "/root1",
1897            json!({
1898                ".dockerignore": "",
1899                ".git": {
1900                    "HEAD": "",
1901                },
1902                "a": {
1903                    "0": { "q": "", "r": "", "s": "" },
1904                    "1": { "t": "", "u": "" },
1905                    "2": { "v": "", "w": "", "x": "", "y": "" },
1906                },
1907                "b": {
1908                    "3": { "Q": "" },
1909                    "4": { "R": "", "S": "", "T": "", "U": "" },
1910                },
1911                "C": {
1912                    "5": {},
1913                    "6": { "V": "", "W": "" },
1914                    "7": { "X": "" },
1915                    "8": { "Y": {}, "Z": "" }
1916                }
1917            }),
1918        )
1919        .await;
1920        fs.insert_tree(
1921            "/root2",
1922            json!({
1923                "d": {
1924                    "9": ""
1925                },
1926                "e": {}
1927            }),
1928        )
1929        .await;
1930
1931        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1932        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1933        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1934        let panel = workspace
1935            .update(cx, |workspace, cx| {
1936                let panel = ProjectPanel::new(workspace, cx);
1937                workspace.add_panel(panel.clone(), cx);
1938                workspace.toggle_dock(panel.read(cx).position(cx), cx);
1939                panel
1940            })
1941            .unwrap();
1942
1943        select_path(&panel, "root1", cx);
1944        assert_eq!(
1945            visible_entries_as_strings(&panel, 0..10, cx),
1946            &[
1947                "v root1  <== selected",
1948                "    > .git",
1949                "    > a",
1950                "    > b",
1951                "    > C",
1952                "      .dockerignore",
1953                "v root2",
1954                "    > d",
1955                "    > e",
1956            ]
1957        );
1958
1959        // Add a file with the root folder selected. The filename editor is placed
1960        // before the first file in the root folder.
1961        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1962        panel.update(cx, |panel, cx| {
1963            assert!(panel.filename_editor.read(cx).is_focused(cx));
1964        });
1965        assert_eq!(
1966            visible_entries_as_strings(&panel, 0..10, cx),
1967            &[
1968                "v root1",
1969                "    > .git",
1970                "    > a",
1971                "    > b",
1972                "    > C",
1973                "      [EDITOR: '']  <== selected",
1974                "      .dockerignore",
1975                "v root2",
1976                "    > d",
1977                "    > e",
1978            ]
1979        );
1980
1981        let confirm = panel.update(cx, |panel, cx| {
1982            panel
1983                .filename_editor
1984                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1985            panel.confirm_edit(cx).unwrap()
1986        });
1987        assert_eq!(
1988            visible_entries_as_strings(&panel, 0..10, cx),
1989            &[
1990                "v root1",
1991                "    > .git",
1992                "    > a",
1993                "    > b",
1994                "    > C",
1995                "      [PROCESSING: 'the-new-filename']  <== selected",
1996                "      .dockerignore",
1997                "v root2",
1998                "    > d",
1999                "    > e",
2000            ]
2001        );
2002
2003        confirm.await.unwrap();
2004        assert_eq!(
2005            visible_entries_as_strings(&panel, 0..10, cx),
2006            &[
2007                "v root1",
2008                "    > .git",
2009                "    > a",
2010                "    > b",
2011                "    > C",
2012                "      .dockerignore",
2013                "      the-new-filename  <== selected",
2014                "v root2",
2015                "    > d",
2016                "    > e",
2017            ]
2018        );
2019
2020        select_path(&panel, "root1/b", cx);
2021        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2022        assert_eq!(
2023            visible_entries_as_strings(&panel, 0..10, cx),
2024            &[
2025                "v root1",
2026                "    > .git",
2027                "    > a",
2028                "    v b",
2029                "        > 3",
2030                "        > 4",
2031                "          [EDITOR: '']  <== selected",
2032                "    > C",
2033                "      .dockerignore",
2034                "      the-new-filename",
2035            ]
2036        );
2037
2038        panel
2039            .update(cx, |panel, cx| {
2040                panel
2041                    .filename_editor
2042                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2043                panel.confirm_edit(cx).unwrap()
2044            })
2045            .await
2046            .unwrap();
2047        assert_eq!(
2048            visible_entries_as_strings(&panel, 0..10, cx),
2049            &[
2050                "v root1",
2051                "    > .git",
2052                "    > a",
2053                "    v b",
2054                "        > 3",
2055                "        > 4",
2056                "          another-filename.txt  <== selected",
2057                "    > C",
2058                "      .dockerignore",
2059                "      the-new-filename",
2060            ]
2061        );
2062
2063        select_path(&panel, "root1/b/another-filename.txt", cx);
2064        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2065        assert_eq!(
2066            visible_entries_as_strings(&panel, 0..10, cx),
2067            &[
2068                "v root1",
2069                "    > .git",
2070                "    > a",
2071                "    v b",
2072                "        > 3",
2073                "        > 4",
2074                "          [EDITOR: 'another-filename.txt']  <== selected",
2075                "    > C",
2076                "      .dockerignore",
2077                "      the-new-filename",
2078            ]
2079        );
2080
2081        let confirm = panel.update(cx, |panel, cx| {
2082            panel.filename_editor.update(cx, |editor, cx| {
2083                let file_name_selections = editor.selections.all::<usize>(cx);
2084                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2085                let file_name_selection = &file_name_selections[0];
2086                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2087                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2088
2089                editor.set_text("a-different-filename.tar.gz", cx)
2090            });
2091            panel.confirm_edit(cx).unwrap()
2092        });
2093        assert_eq!(
2094            visible_entries_as_strings(&panel, 0..10, cx),
2095            &[
2096                "v root1",
2097                "    > .git",
2098                "    > a",
2099                "    v b",
2100                "        > 3",
2101                "        > 4",
2102                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2103                "    > C",
2104                "      .dockerignore",
2105                "      the-new-filename",
2106            ]
2107        );
2108
2109        confirm.await.unwrap();
2110        assert_eq!(
2111            visible_entries_as_strings(&panel, 0..10, cx),
2112            &[
2113                "v root1",
2114                "    > .git",
2115                "    > a",
2116                "    v b",
2117                "        > 3",
2118                "        > 4",
2119                "          a-different-filename.tar.gz  <== selected",
2120                "    > C",
2121                "      .dockerignore",
2122                "      the-new-filename",
2123            ]
2124        );
2125
2126        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2127        assert_eq!(
2128            visible_entries_as_strings(&panel, 0..10, cx),
2129            &[
2130                "v root1",
2131                "    > .git",
2132                "    > a",
2133                "    v b",
2134                "        > 3",
2135                "        > 4",
2136                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2137                "    > C",
2138                "      .dockerignore",
2139                "      the-new-filename",
2140            ]
2141        );
2142
2143        panel.update(cx, |panel, cx| {
2144            panel.filename_editor.update(cx, |editor, cx| {
2145                let file_name_selections = editor.selections.all::<usize>(cx);
2146                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2147                let file_name_selection = &file_name_selections[0];
2148                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2149                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..");
2150
2151            });
2152            panel.cancel(&Cancel, cx)
2153        });
2154
2155        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2156        assert_eq!(
2157            visible_entries_as_strings(&panel, 0..10, cx),
2158            &[
2159                "v root1",
2160                "    > .git",
2161                "    > a",
2162                "    v b",
2163                "        > [EDITOR: '']  <== selected",
2164                "        > 3",
2165                "        > 4",
2166                "          a-different-filename.tar.gz",
2167                "    > C",
2168                "      .dockerignore",
2169            ]
2170        );
2171
2172        let confirm = panel.update(cx, |panel, cx| {
2173            panel
2174                .filename_editor
2175                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2176            panel.confirm_edit(cx).unwrap()
2177        });
2178        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2179        assert_eq!(
2180            visible_entries_as_strings(&panel, 0..10, cx),
2181            &[
2182                "v root1",
2183                "    > .git",
2184                "    > a",
2185                "    v b",
2186                "        > [PROCESSING: 'new-dir']",
2187                "        > 3  <== selected",
2188                "        > 4",
2189                "          a-different-filename.tar.gz",
2190                "    > C",
2191                "      .dockerignore",
2192            ]
2193        );
2194
2195        confirm.await.unwrap();
2196        assert_eq!(
2197            visible_entries_as_strings(&panel, 0..10, cx),
2198            &[
2199                "v root1",
2200                "    > .git",
2201                "    > a",
2202                "    v b",
2203                "        > 3  <== selected",
2204                "        > 4",
2205                "        > new-dir",
2206                "          a-different-filename.tar.gz",
2207                "    > C",
2208                "      .dockerignore",
2209            ]
2210        );
2211
2212        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2213        assert_eq!(
2214            visible_entries_as_strings(&panel, 0..10, cx),
2215            &[
2216                "v root1",
2217                "    > .git",
2218                "    > a",
2219                "    v b",
2220                "        > [EDITOR: '3']  <== selected",
2221                "        > 4",
2222                "        > new-dir",
2223                "          a-different-filename.tar.gz",
2224                "    > C",
2225                "      .dockerignore",
2226            ]
2227        );
2228
2229        // Dismiss the rename editor when it loses focus.
2230        workspace.update(cx, |_, cx| cx.blur()).unwrap();
2231        assert_eq!(
2232            visible_entries_as_strings(&panel, 0..10, cx),
2233            &[
2234                "v root1",
2235                "    > .git",
2236                "    > a",
2237                "    v b",
2238                "        > 3  <== selected",
2239                "        > 4",
2240                "        > new-dir",
2241                "          a-different-filename.tar.gz",
2242                "    > C",
2243                "      .dockerignore",
2244            ]
2245        );
2246    }
2247
2248    #[gpui::test(iterations = 10)]
2249    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2250        init_test(cx);
2251
2252        let fs = FakeFs::new(cx.executor().clone());
2253        fs.insert_tree(
2254            "/root1",
2255            json!({
2256                ".dockerignore": "",
2257                ".git": {
2258                    "HEAD": "",
2259                },
2260                "a": {
2261                    "0": { "q": "", "r": "", "s": "" },
2262                    "1": { "t": "", "u": "" },
2263                    "2": { "v": "", "w": "", "x": "", "y": "" },
2264                },
2265                "b": {
2266                    "3": { "Q": "" },
2267                    "4": { "R": "", "S": "", "T": "", "U": "" },
2268                },
2269                "C": {
2270                    "5": {},
2271                    "6": { "V": "", "W": "" },
2272                    "7": { "X": "" },
2273                    "8": { "Y": {}, "Z": "" }
2274                }
2275            }),
2276        )
2277        .await;
2278        fs.insert_tree(
2279            "/root2",
2280            json!({
2281                "d": {
2282                    "9": ""
2283                },
2284                "e": {}
2285            }),
2286        )
2287        .await;
2288
2289        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2290        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2291        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2292        let panel = workspace
2293            .update(cx, |workspace, cx| {
2294                let panel = ProjectPanel::new(workspace, cx);
2295                workspace.add_panel(panel.clone(), cx);
2296                workspace.toggle_dock(panel.read(cx).position(cx), cx);
2297                panel
2298            })
2299            .unwrap();
2300
2301        select_path(&panel, "root1", cx);
2302        assert_eq!(
2303            visible_entries_as_strings(&panel, 0..10, cx),
2304            &[
2305                "v root1  <== selected",
2306                "    > .git",
2307                "    > a",
2308                "    > b",
2309                "    > C",
2310                "      .dockerignore",
2311                "v root2",
2312                "    > d",
2313                "    > e",
2314            ]
2315        );
2316
2317        // Add a file with the root folder selected. The filename editor is placed
2318        // before the first file in the root folder.
2319        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2320        panel.update(cx, |panel, cx| {
2321            assert!(panel.filename_editor.read(cx).is_focused(cx));
2322        });
2323        assert_eq!(
2324            visible_entries_as_strings(&panel, 0..10, cx),
2325            &[
2326                "v root1",
2327                "    > .git",
2328                "    > a",
2329                "    > b",
2330                "    > C",
2331                "      [EDITOR: '']  <== selected",
2332                "      .dockerignore",
2333                "v root2",
2334                "    > d",
2335                "    > e",
2336            ]
2337        );
2338
2339        let confirm = panel.update(cx, |panel, cx| {
2340            panel.filename_editor.update(cx, |editor, cx| {
2341                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2342            });
2343            panel.confirm_edit(cx).unwrap()
2344        });
2345
2346        assert_eq!(
2347            visible_entries_as_strings(&panel, 0..10, cx),
2348            &[
2349                "v root1",
2350                "    > .git",
2351                "    > a",
2352                "    > b",
2353                "    > C",
2354                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2355                "      .dockerignore",
2356                "v root2",
2357                "    > d",
2358                "    > e",
2359            ]
2360        );
2361
2362        confirm.await.unwrap();
2363        assert_eq!(
2364            visible_entries_as_strings(&panel, 0..13, cx),
2365            &[
2366                "v root1",
2367                "    > .git",
2368                "    > a",
2369                "    > b",
2370                "    v bdir1",
2371                "        v dir2",
2372                "              the-new-filename  <== selected",
2373                "    > C",
2374                "      .dockerignore",
2375                "v root2",
2376                "    > d",
2377                "    > e",
2378            ]
2379        );
2380    }
2381
2382    #[gpui::test]
2383    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2384        init_test(cx);
2385
2386        let fs = FakeFs::new(cx.executor().clone());
2387        fs.insert_tree(
2388            "/root1",
2389            json!({
2390                "one.two.txt": "",
2391                "one.txt": ""
2392            }),
2393        )
2394        .await;
2395
2396        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2397        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2398        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2399        let panel = workspace
2400            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2401            .unwrap();
2402
2403        panel.update(cx, |panel, cx| {
2404            panel.select_next(&Default::default(), cx);
2405            panel.select_next(&Default::default(), cx);
2406        });
2407
2408        assert_eq!(
2409            visible_entries_as_strings(&panel, 0..50, cx),
2410            &[
2411                //
2412                "v root1",
2413                "      one.two.txt  <== selected",
2414                "      one.txt",
2415            ]
2416        );
2417
2418        // Regression test - file name is created correctly when
2419        // the copied file's name contains multiple dots.
2420        panel.update(cx, |panel, cx| {
2421            panel.copy(&Default::default(), cx);
2422            panel.paste(&Default::default(), cx);
2423        });
2424        cx.executor().run_until_parked();
2425
2426        assert_eq!(
2427            visible_entries_as_strings(&panel, 0..50, cx),
2428            &[
2429                //
2430                "v root1",
2431                "      one.two copy.txt",
2432                "      one.two.txt  <== selected",
2433                "      one.txt",
2434            ]
2435        );
2436
2437        panel.update(cx, |panel, cx| {
2438            panel.paste(&Default::default(), cx);
2439        });
2440        cx.executor().run_until_parked();
2441
2442        assert_eq!(
2443            visible_entries_as_strings(&panel, 0..50, cx),
2444            &[
2445                //
2446                "v root1",
2447                "      one.two copy 1.txt",
2448                "      one.two copy.txt",
2449                "      one.two.txt  <== selected",
2450                "      one.txt",
2451            ]
2452        );
2453    }
2454
2455    #[gpui::test]
2456    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2457        init_test_with_editor(cx);
2458
2459        let fs = FakeFs::new(cx.executor().clone());
2460        fs.insert_tree(
2461            "/src",
2462            json!({
2463                "test": {
2464                    "first.rs": "// First Rust file",
2465                    "second.rs": "// Second Rust file",
2466                    "third.rs": "// Third Rust file",
2467                }
2468            }),
2469        )
2470        .await;
2471
2472        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2473        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2474        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2475        let panel = workspace
2476            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2477            .unwrap();
2478
2479        toggle_expand_dir(&panel, "src/test", cx);
2480        select_path(&panel, "src/test/first.rs", cx);
2481        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2482        cx.executor().run_until_parked();
2483        assert_eq!(
2484            visible_entries_as_strings(&panel, 0..10, cx),
2485            &[
2486                "v src",
2487                "    v test",
2488                "          first.rs  <== selected",
2489                "          second.rs",
2490                "          third.rs"
2491            ]
2492        );
2493        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2494
2495        submit_deletion(&panel, cx);
2496        assert_eq!(
2497            visible_entries_as_strings(&panel, 0..10, cx),
2498            &[
2499                "v src",
2500                "    v test",
2501                "          second.rs",
2502                "          third.rs"
2503            ],
2504            "Project panel should have no deleted file, no other file is selected in it"
2505        );
2506        ensure_no_open_items_and_panes(&workspace, cx);
2507
2508        select_path(&panel, "src/test/second.rs", cx);
2509        panel.update(cx, |panel, cx| panel.open_file(&Open, cx));
2510        cx.executor().run_until_parked();
2511        assert_eq!(
2512            visible_entries_as_strings(&panel, 0..10, cx),
2513            &[
2514                "v src",
2515                "    v test",
2516                "          second.rs  <== selected",
2517                "          third.rs"
2518            ]
2519        );
2520        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2521
2522        workspace
2523            .update(cx, |workspace, cx| {
2524                let active_items = workspace
2525                    .panes()
2526                    .iter()
2527                    .filter_map(|pane| pane.read(cx).active_item())
2528                    .collect::<Vec<_>>();
2529                assert_eq!(active_items.len(), 1);
2530                let open_editor = active_items
2531                    .into_iter()
2532                    .next()
2533                    .unwrap()
2534                    .downcast::<Editor>()
2535                    .expect("Open item should be an editor");
2536                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2537            })
2538            .unwrap();
2539        submit_deletion(&panel, cx);
2540        assert_eq!(
2541            visible_entries_as_strings(&panel, 0..10, cx),
2542            &["v src", "    v test", "          third.rs"],
2543            "Project panel should have no deleted file, with one last file remaining"
2544        );
2545        ensure_no_open_items_and_panes(&workspace, cx);
2546    }
2547
2548    #[gpui::test]
2549    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2550        init_test_with_editor(cx);
2551
2552        let fs = FakeFs::new(cx.executor().clone());
2553        fs.insert_tree(
2554            "/src",
2555            json!({
2556                "test": {
2557                    "first.rs": "// First Rust file",
2558                    "second.rs": "// Second Rust file",
2559                    "third.rs": "// Third Rust file",
2560                }
2561            }),
2562        )
2563        .await;
2564
2565        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2566        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2567        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2568        let panel = workspace
2569            .update(cx, |workspace, cx| {
2570                let panel = ProjectPanel::new(workspace, cx);
2571                workspace.add_panel(panel.clone(), cx);
2572                workspace.toggle_dock(panel.read(cx).position(cx), cx);
2573                panel
2574            })
2575            .unwrap();
2576
2577        select_path(&panel, "src/", cx);
2578        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2579        cx.executor().run_until_parked();
2580        assert_eq!(
2581            visible_entries_as_strings(&panel, 0..10, cx),
2582            &[
2583                //
2584                "v src  <== selected",
2585                "    > test"
2586            ]
2587        );
2588        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2589        panel.update(cx, |panel, cx| {
2590            assert!(panel.filename_editor.read(cx).is_focused(cx));
2591        });
2592        assert_eq!(
2593            visible_entries_as_strings(&panel, 0..10, cx),
2594            &[
2595                //
2596                "v src",
2597                "    > [EDITOR: '']  <== selected",
2598                "    > test"
2599            ]
2600        );
2601        panel.update(cx, |panel, cx| {
2602            panel
2603                .filename_editor
2604                .update(cx, |editor, cx| editor.set_text("test", cx));
2605            assert!(
2606                panel.confirm_edit(cx).is_none(),
2607                "Should not allow to confirm on conflicting new directory name"
2608            )
2609        });
2610        assert_eq!(
2611            visible_entries_as_strings(&panel, 0..10, cx),
2612            &[
2613                //
2614                "v src",
2615                "    > test"
2616            ],
2617            "File list should be unchanged after failed folder create confirmation"
2618        );
2619
2620        select_path(&panel, "src/test/", cx);
2621        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2622        cx.executor().run_until_parked();
2623        assert_eq!(
2624            visible_entries_as_strings(&panel, 0..10, cx),
2625            &[
2626                //
2627                "v src",
2628                "    > test  <== selected"
2629            ]
2630        );
2631        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2632        panel.update(cx, |panel, cx| {
2633            assert!(panel.filename_editor.read(cx).is_focused(cx));
2634        });
2635        assert_eq!(
2636            visible_entries_as_strings(&panel, 0..10, cx),
2637            &[
2638                "v src",
2639                "    v test",
2640                "          [EDITOR: '']  <== selected",
2641                "          first.rs",
2642                "          second.rs",
2643                "          third.rs"
2644            ]
2645        );
2646        panel.update(cx, |panel, cx| {
2647            panel
2648                .filename_editor
2649                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2650            assert!(
2651                panel.confirm_edit(cx).is_none(),
2652                "Should not allow to confirm on conflicting new file name"
2653            )
2654        });
2655        assert_eq!(
2656            visible_entries_as_strings(&panel, 0..10, cx),
2657            &[
2658                "v src",
2659                "    v test",
2660                "          first.rs",
2661                "          second.rs",
2662                "          third.rs"
2663            ],
2664            "File list should be unchanged after failed file create confirmation"
2665        );
2666
2667        select_path(&panel, "src/test/first.rs", cx);
2668        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2669        cx.executor().run_until_parked();
2670        assert_eq!(
2671            visible_entries_as_strings(&panel, 0..10, cx),
2672            &[
2673                "v src",
2674                "    v test",
2675                "          first.rs  <== selected",
2676                "          second.rs",
2677                "          third.rs"
2678            ],
2679        );
2680        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2681        panel.update(cx, |panel, cx| {
2682            assert!(panel.filename_editor.read(cx).is_focused(cx));
2683        });
2684        assert_eq!(
2685            visible_entries_as_strings(&panel, 0..10, cx),
2686            &[
2687                "v src",
2688                "    v test",
2689                "          [EDITOR: 'first.rs']  <== selected",
2690                "          second.rs",
2691                "          third.rs"
2692            ]
2693        );
2694        panel.update(cx, |panel, cx| {
2695            panel
2696                .filename_editor
2697                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2698            assert!(
2699                panel.confirm_edit(cx).is_none(),
2700                "Should not allow to confirm on conflicting file rename"
2701            )
2702        });
2703        assert_eq!(
2704            visible_entries_as_strings(&panel, 0..10, cx),
2705            &[
2706                "v src",
2707                "    v test",
2708                "          first.rs  <== selected",
2709                "          second.rs",
2710                "          third.rs"
2711            ],
2712            "File list should be unchanged after failed rename confirmation"
2713        );
2714    }
2715
2716    #[gpui::test]
2717    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2718        init_test_with_editor(cx);
2719
2720        let fs = FakeFs::new(cx.executor().clone());
2721        fs.insert_tree(
2722            "/project_root",
2723            json!({
2724                "dir_1": {
2725                    "nested_dir": {
2726                        "file_a.py": "# File contents",
2727                        "file_b.py": "# File contents",
2728                        "file_c.py": "# File contents",
2729                    },
2730                    "file_1.py": "# File contents",
2731                    "file_2.py": "# File contents",
2732                    "file_3.py": "# File contents",
2733                },
2734                "dir_2": {
2735                    "file_1.py": "# File contents",
2736                    "file_2.py": "# File contents",
2737                    "file_3.py": "# File contents",
2738                }
2739            }),
2740        )
2741        .await;
2742
2743        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2744        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2745        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2746        let panel = workspace
2747            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2748            .unwrap();
2749
2750        panel.update(cx, |panel, cx| {
2751            panel.collapse_all_entries(&CollapseAllEntries, cx)
2752        });
2753        cx.executor().run_until_parked();
2754        assert_eq!(
2755            visible_entries_as_strings(&panel, 0..10, cx),
2756            &["v project_root", "    > dir_1", "    > dir_2",]
2757        );
2758
2759        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2760        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2761        cx.executor().run_until_parked();
2762        assert_eq!(
2763            visible_entries_as_strings(&panel, 0..10, cx),
2764            &[
2765                "v project_root",
2766                "    v dir_1  <== selected",
2767                "        > nested_dir",
2768                "          file_1.py",
2769                "          file_2.py",
2770                "          file_3.py",
2771                "    > dir_2",
2772            ]
2773        );
2774    }
2775
2776    #[gpui::test]
2777    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2778        init_test(cx);
2779
2780        let fs = FakeFs::new(cx.executor().clone());
2781        fs.as_fake().insert_tree("/root", json!({})).await;
2782        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2783        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2784        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2785        let panel = workspace
2786            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2787            .unwrap();
2788
2789        // Make a new buffer with no backing file
2790        workspace
2791            .update(cx, |workspace, cx| {
2792                Editor::new_file(workspace, &Default::default(), cx)
2793            })
2794            .unwrap();
2795
2796        // "Save as"" the buffer, creating a new backing file for it
2797        let save_task = workspace
2798            .update(cx, |workspace, cx| {
2799                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2800            })
2801            .unwrap();
2802
2803        cx.executor().run_until_parked();
2804        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
2805        save_task.await.unwrap();
2806
2807        // Rename the file
2808        select_path(&panel, "root/new", cx);
2809        assert_eq!(
2810            visible_entries_as_strings(&panel, 0..10, cx),
2811            &["v root", "      new  <== selected"]
2812        );
2813        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2814        panel.update(cx, |panel, cx| {
2815            panel
2816                .filename_editor
2817                .update(cx, |editor, cx| editor.set_text("newer", cx));
2818        });
2819        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2820
2821        cx.executor().run_until_parked();
2822        assert_eq!(
2823            visible_entries_as_strings(&panel, 0..10, cx),
2824            &["v root", "      newer  <== selected"]
2825        );
2826
2827        workspace
2828            .update(cx, |workspace, cx| {
2829                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2830            })
2831            .unwrap()
2832            .await
2833            .unwrap();
2834
2835        cx.executor().run_until_parked();
2836        // assert that saving the file doesn't restore "new"
2837        assert_eq!(
2838            visible_entries_as_strings(&panel, 0..10, cx),
2839            &["v root", "      newer  <== selected"]
2840        );
2841    }
2842
2843    #[gpui::test]
2844    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
2845        init_test_with_editor(cx);
2846        cx.update(|cx| {
2847            cx.update_global::<SettingsStore, _>(|store, cx| {
2848                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
2849                    project_settings.file_scan_exclusions = Some(Vec::new());
2850                });
2851                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2852                    project_panel_settings.auto_reveal_entries = Some(false)
2853                });
2854            })
2855        });
2856
2857        let fs = FakeFs::new(cx.background_executor.clone());
2858        fs.insert_tree(
2859            "/project_root",
2860            json!({
2861                ".git": {},
2862                ".gitignore": "**/gitignored_dir",
2863                "dir_1": {
2864                    "file_1.py": "# File 1_1 contents",
2865                    "file_2.py": "# File 1_2 contents",
2866                    "file_3.py": "# File 1_3 contents",
2867                    "gitignored_dir": {
2868                        "file_a.py": "# File contents",
2869                        "file_b.py": "# File contents",
2870                        "file_c.py": "# File contents",
2871                    },
2872                },
2873                "dir_2": {
2874                    "file_1.py": "# File 2_1 contents",
2875                    "file_2.py": "# File 2_2 contents",
2876                    "file_3.py": "# File 2_3 contents",
2877                }
2878            }),
2879        )
2880        .await;
2881
2882        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2883        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2884        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2885        let panel = workspace
2886            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2887            .unwrap();
2888
2889        assert_eq!(
2890            visible_entries_as_strings(&panel, 0..20, cx),
2891            &[
2892                "v project_root",
2893                "    > .git",
2894                "    > dir_1",
2895                "    > dir_2",
2896                "      .gitignore",
2897            ]
2898        );
2899
2900        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
2901            .expect("dir 1 file is not ignored and should have an entry");
2902        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
2903            .expect("dir 2 file is not ignored and should have an entry");
2904        let gitignored_dir_file =
2905            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
2906        assert_eq!(
2907            gitignored_dir_file, None,
2908            "File in the gitignored dir should not have an entry before its dir is toggled"
2909        );
2910
2911        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2912        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2913        cx.executor().run_until_parked();
2914        assert_eq!(
2915            visible_entries_as_strings(&panel, 0..20, cx),
2916            &[
2917                "v project_root",
2918                "    > .git",
2919                "    v dir_1",
2920                "        v gitignored_dir  <== selected",
2921                "              file_a.py",
2922                "              file_b.py",
2923                "              file_c.py",
2924                "          file_1.py",
2925                "          file_2.py",
2926                "          file_3.py",
2927                "    > dir_2",
2928                "      .gitignore",
2929            ],
2930            "Should show gitignored dir file list in the project panel"
2931        );
2932        let gitignored_dir_file =
2933            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
2934                .expect("after gitignored dir got opened, a file entry should be present");
2935
2936        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
2937        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2938        assert_eq!(
2939            visible_entries_as_strings(&panel, 0..20, cx),
2940            &[
2941                "v project_root",
2942                "    > .git",
2943                "    > dir_1  <== selected",
2944                "    > dir_2",
2945                "      .gitignore",
2946            ],
2947            "Should hide all dir contents again and prepare for the auto reveal test"
2948        );
2949
2950        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
2951            panel.update(cx, |panel, cx| {
2952                panel.project.update(cx, |_, cx| {
2953                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
2954                })
2955            });
2956            cx.run_until_parked();
2957            assert_eq!(
2958                visible_entries_as_strings(&panel, 0..20, cx),
2959                &[
2960                    "v project_root",
2961                    "    > .git",
2962                    "    > dir_1  <== selected",
2963                    "    > dir_2",
2964                    "      .gitignore",
2965                ],
2966                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
2967            );
2968        }
2969
2970        cx.update(|cx| {
2971            cx.update_global::<SettingsStore, _>(|store, cx| {
2972                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
2973                    project_panel_settings.auto_reveal_entries = Some(true)
2974                });
2975            })
2976        });
2977
2978        panel.update(cx, |panel, cx| {
2979            panel.project.update(cx, |_, cx| {
2980                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
2981            })
2982        });
2983        cx.run_until_parked();
2984        assert_eq!(
2985            visible_entries_as_strings(&panel, 0..20, cx),
2986            &[
2987                "v project_root",
2988                "    > .git",
2989                "    v dir_1",
2990                "        > gitignored_dir",
2991                "          file_1.py  <== selected",
2992                "          file_2.py",
2993                "          file_3.py",
2994                "    > dir_2",
2995                "      .gitignore",
2996            ],
2997            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
2998        );
2999
3000        panel.update(cx, |panel, cx| {
3001            panel.project.update(cx, |_, cx| {
3002                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3003            })
3004        });
3005        cx.run_until_parked();
3006        assert_eq!(
3007            visible_entries_as_strings(&panel, 0..20, cx),
3008            &[
3009                "v project_root",
3010                "    > .git",
3011                "    v dir_1",
3012                "        > gitignored_dir",
3013                "          file_1.py",
3014                "          file_2.py",
3015                "          file_3.py",
3016                "    v dir_2",
3017                "          file_1.py  <== selected",
3018                "          file_2.py",
3019                "          file_3.py",
3020                "      .gitignore",
3021            ],
3022            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3023        );
3024
3025        panel.update(cx, |panel, cx| {
3026            panel.project.update(cx, |_, cx| {
3027                cx.emit(project::Event::ActiveEntryChanged(Some(
3028                    gitignored_dir_file,
3029                )))
3030            })
3031        });
3032        cx.run_until_parked();
3033        assert_eq!(
3034            visible_entries_as_strings(&panel, 0..20, cx),
3035            &[
3036                "v project_root",
3037                "    > .git",
3038                "    v dir_1",
3039                "        > gitignored_dir",
3040                "          file_1.py",
3041                "          file_2.py",
3042                "          file_3.py",
3043                "    v dir_2",
3044                "          file_1.py  <== selected",
3045                "          file_2.py",
3046                "          file_3.py",
3047                "      .gitignore",
3048            ],
3049            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3050        );
3051
3052        panel.update(cx, |panel, cx| {
3053            panel.project.update(cx, |_, cx| {
3054                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3055            })
3056        });
3057        cx.run_until_parked();
3058        assert_eq!(
3059            visible_entries_as_strings(&panel, 0..20, cx),
3060            &[
3061                "v project_root",
3062                "    > .git",
3063                "    v dir_1",
3064                "        v gitignored_dir",
3065                "              file_a.py  <== selected",
3066                "              file_b.py",
3067                "              file_c.py",
3068                "          file_1.py",
3069                "          file_2.py",
3070                "          file_3.py",
3071                "    v dir_2",
3072                "          file_1.py",
3073                "          file_2.py",
3074                "          file_3.py",
3075                "      .gitignore",
3076            ],
3077            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3078        );
3079    }
3080
3081    #[gpui::test]
3082    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3083        init_test_with_editor(cx);
3084        cx.update(|cx| {
3085            cx.update_global::<SettingsStore, _>(|store, cx| {
3086                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3087                    project_settings.file_scan_exclusions = Some(Vec::new());
3088                });
3089                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3090                    project_panel_settings.auto_reveal_entries = Some(false)
3091                });
3092            })
3093        });
3094
3095        let fs = FakeFs::new(cx.background_executor.clone());
3096        fs.insert_tree(
3097            "/project_root",
3098            json!({
3099                ".git": {},
3100                ".gitignore": "**/gitignored_dir",
3101                "dir_1": {
3102                    "file_1.py": "# File 1_1 contents",
3103                    "file_2.py": "# File 1_2 contents",
3104                    "file_3.py": "# File 1_3 contents",
3105                    "gitignored_dir": {
3106                        "file_a.py": "# File contents",
3107                        "file_b.py": "# File contents",
3108                        "file_c.py": "# File contents",
3109                    },
3110                },
3111                "dir_2": {
3112                    "file_1.py": "# File 2_1 contents",
3113                    "file_2.py": "# File 2_2 contents",
3114                    "file_3.py": "# File 2_3 contents",
3115                }
3116            }),
3117        )
3118        .await;
3119
3120        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3121        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3122        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3123        let panel = workspace
3124            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3125            .unwrap();
3126
3127        assert_eq!(
3128            visible_entries_as_strings(&panel, 0..20, cx),
3129            &[
3130                "v project_root",
3131                "    > .git",
3132                "    > dir_1",
3133                "    > dir_2",
3134                "      .gitignore",
3135            ]
3136        );
3137
3138        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3139            .expect("dir 1 file is not ignored and should have an entry");
3140        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3141            .expect("dir 2 file is not ignored and should have an entry");
3142        let gitignored_dir_file =
3143            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3144        assert_eq!(
3145            gitignored_dir_file, None,
3146            "File in the gitignored dir should not have an entry before its dir is toggled"
3147        );
3148
3149        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3150        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3151        cx.run_until_parked();
3152        assert_eq!(
3153            visible_entries_as_strings(&panel, 0..20, cx),
3154            &[
3155                "v project_root",
3156                "    > .git",
3157                "    v dir_1",
3158                "        v gitignored_dir  <== selected",
3159                "              file_a.py",
3160                "              file_b.py",
3161                "              file_c.py",
3162                "          file_1.py",
3163                "          file_2.py",
3164                "          file_3.py",
3165                "    > dir_2",
3166                "      .gitignore",
3167            ],
3168            "Should show gitignored dir file list in the project panel"
3169        );
3170        let gitignored_dir_file =
3171            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3172                .expect("after gitignored dir got opened, a file entry should be present");
3173
3174        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3175        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3176        assert_eq!(
3177            visible_entries_as_strings(&panel, 0..20, cx),
3178            &[
3179                "v project_root",
3180                "    > .git",
3181                "    > dir_1  <== selected",
3182                "    > dir_2",
3183                "      .gitignore",
3184            ],
3185            "Should hide all dir contents again and prepare for the explicit reveal test"
3186        );
3187
3188        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3189            panel.update(cx, |panel, cx| {
3190                panel.project.update(cx, |_, cx| {
3191                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3192                })
3193            });
3194            cx.run_until_parked();
3195            assert_eq!(
3196                visible_entries_as_strings(&panel, 0..20, cx),
3197                &[
3198                    "v project_root",
3199                    "    > .git",
3200                    "    > dir_1  <== selected",
3201                    "    > dir_2",
3202                    "      .gitignore",
3203                ],
3204                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3205            );
3206        }
3207
3208        panel.update(cx, |panel, cx| {
3209            panel.project.update(cx, |_, cx| {
3210                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3211            })
3212        });
3213        cx.run_until_parked();
3214        assert_eq!(
3215            visible_entries_as_strings(&panel, 0..20, cx),
3216            &[
3217                "v project_root",
3218                "    > .git",
3219                "    v dir_1",
3220                "        > gitignored_dir",
3221                "          file_1.py  <== selected",
3222                "          file_2.py",
3223                "          file_3.py",
3224                "    > dir_2",
3225                "      .gitignore",
3226            ],
3227            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3228        );
3229
3230        panel.update(cx, |panel, cx| {
3231            panel.project.update(cx, |_, cx| {
3232                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3233            })
3234        });
3235        cx.run_until_parked();
3236        assert_eq!(
3237            visible_entries_as_strings(&panel, 0..20, cx),
3238            &[
3239                "v project_root",
3240                "    > .git",
3241                "    v dir_1",
3242                "        > gitignored_dir",
3243                "          file_1.py",
3244                "          file_2.py",
3245                "          file_3.py",
3246                "    v dir_2",
3247                "          file_1.py  <== selected",
3248                "          file_2.py",
3249                "          file_3.py",
3250                "      .gitignore",
3251            ],
3252            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3253        );
3254
3255        panel.update(cx, |panel, cx| {
3256            panel.project.update(cx, |_, cx| {
3257                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3258            })
3259        });
3260        cx.run_until_parked();
3261        assert_eq!(
3262            visible_entries_as_strings(&panel, 0..20, cx),
3263            &[
3264                "v project_root",
3265                "    > .git",
3266                "    v dir_1",
3267                "        v gitignored_dir",
3268                "              file_a.py  <== selected",
3269                "              file_b.py",
3270                "              file_c.py",
3271                "          file_1.py",
3272                "          file_2.py",
3273                "          file_3.py",
3274                "    v dir_2",
3275                "          file_1.py",
3276                "          file_2.py",
3277                "          file_3.py",
3278                "      .gitignore",
3279            ],
3280            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3281        );
3282    }
3283
3284    fn toggle_expand_dir(
3285        panel: &View<ProjectPanel>,
3286        path: impl AsRef<Path>,
3287        cx: &mut VisualTestContext,
3288    ) {
3289        let path = path.as_ref();
3290        panel.update(cx, |panel, cx| {
3291            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3292                let worktree = worktree.read(cx);
3293                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3294                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3295                    panel.toggle_expanded(entry_id, cx);
3296                    return;
3297                }
3298            }
3299            panic!("no worktree for path {:?}", path);
3300        });
3301    }
3302
3303    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3304        let path = path.as_ref();
3305        panel.update(cx, |panel, cx| {
3306            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3307                let worktree = worktree.read(cx);
3308                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3309                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3310                    panel.selection = Some(crate::Selection {
3311                        worktree_id: worktree.id(),
3312                        entry_id,
3313                    });
3314                    return;
3315                }
3316            }
3317            panic!("no worktree for path {:?}", path);
3318        });
3319    }
3320
3321    fn find_project_entry(
3322        panel: &View<ProjectPanel>,
3323        path: impl AsRef<Path>,
3324        cx: &mut VisualTestContext,
3325    ) -> Option<ProjectEntryId> {
3326        let path = path.as_ref();
3327        panel.update(cx, |panel, cx| {
3328            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3329                let worktree = worktree.read(cx);
3330                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3331                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3332                }
3333            }
3334            panic!("no worktree for path {path:?}");
3335        })
3336    }
3337
3338    fn visible_entries_as_strings(
3339        panel: &View<ProjectPanel>,
3340        range: Range<usize>,
3341        cx: &mut VisualTestContext,
3342    ) -> Vec<String> {
3343        let mut result = Vec::new();
3344        let mut project_entries = HashSet::new();
3345        let mut has_editor = false;
3346
3347        panel.update(cx, |panel, cx| {
3348            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3349                if details.is_editing {
3350                    assert!(!has_editor, "duplicate editor entry");
3351                    has_editor = true;
3352                } else {
3353                    assert!(
3354                        project_entries.insert(project_entry),
3355                        "duplicate project entry {:?} {:?}",
3356                        project_entry,
3357                        details
3358                    );
3359                }
3360
3361                let indent = "    ".repeat(details.depth);
3362                let icon = if details.kind.is_dir() {
3363                    if details.is_expanded {
3364                        "v "
3365                    } else {
3366                        "> "
3367                    }
3368                } else {
3369                    "  "
3370                };
3371                let name = if details.is_editing {
3372                    format!("[EDITOR: '{}']", details.filename)
3373                } else if details.is_processing {
3374                    format!("[PROCESSING: '{}']", details.filename)
3375                } else {
3376                    details.filename.clone()
3377                };
3378                let selected = if details.is_selected {
3379                    "  <== selected"
3380                } else {
3381                    ""
3382                };
3383                result.push(format!("{indent}{icon}{name}{selected}"));
3384            });
3385        });
3386
3387        result
3388    }
3389
3390    fn init_test(cx: &mut TestAppContext) {
3391        cx.update(|cx| {
3392            let settings_store = SettingsStore::test(cx);
3393            cx.set_global(settings_store);
3394            init_settings(cx);
3395            theme::init(theme::LoadThemes::JustBase, cx);
3396            language::init(cx);
3397            editor::init_settings(cx);
3398            crate::init((), cx);
3399            workspace::init_settings(cx);
3400            client::init_settings(cx);
3401            Project::init_settings(cx);
3402
3403            cx.update_global::<SettingsStore, _>(|store, cx| {
3404                store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
3405                    project_settings.file_scan_exclusions = Some(Vec::new());
3406                });
3407            });
3408        });
3409    }
3410
3411    fn init_test_with_editor(cx: &mut TestAppContext) {
3412        cx.update(|cx| {
3413            let app_state = AppState::test(cx);
3414            theme::init(theme::LoadThemes::JustBase, cx);
3415            init_settings(cx);
3416            language::init(cx);
3417            editor::init(cx);
3418            crate::init((), cx);
3419            workspace::init(app_state.clone(), cx);
3420            Project::init_settings(cx);
3421        });
3422    }
3423
3424    fn ensure_single_file_is_opened(
3425        window: &WindowHandle<Workspace>,
3426        expected_path: &str,
3427        cx: &mut TestAppContext,
3428    ) {
3429        window
3430            .update(cx, |workspace, cx| {
3431                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3432                assert_eq!(worktrees.len(), 1);
3433                let worktree_id = worktrees[0].read(cx).id();
3434
3435                let open_project_paths = workspace
3436                    .panes()
3437                    .iter()
3438                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3439                    .collect::<Vec<_>>();
3440                assert_eq!(
3441                    open_project_paths,
3442                    vec![ProjectPath {
3443                        worktree_id,
3444                        path: Arc::from(Path::new(expected_path))
3445                    }],
3446                    "Should have opened file, selected in project panel"
3447                );
3448            })
3449            .unwrap();
3450    }
3451
3452    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3453        assert!(
3454            !cx.has_pending_prompt(),
3455            "Should have no prompts before the deletion"
3456        );
3457        panel.update(cx, |panel, cx| panel.delete(&Delete, cx));
3458        assert!(
3459            cx.has_pending_prompt(),
3460            "Should have a prompt after the deletion"
3461        );
3462        cx.simulate_prompt_answer(0);
3463        assert!(
3464            !cx.has_pending_prompt(),
3465            "Should have no prompts after prompt was replied to"
3466        );
3467        cx.executor().run_until_parked();
3468    }
3469
3470    fn ensure_no_open_items_and_panes(
3471        workspace: &WindowHandle<Workspace>,
3472        cx: &mut VisualTestContext,
3473    ) {
3474        assert!(
3475            !cx.has_pending_prompt(),
3476            "Should have no prompts after deletion operation closes the file"
3477        );
3478        workspace
3479            .read_with(cx, |workspace, cx| {
3480                let open_project_paths = workspace
3481                    .panes()
3482                    .iter()
3483                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3484                    .collect::<Vec<_>>();
3485                assert!(
3486                    open_project_paths.is_empty(),
3487                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3488                );
3489            })
3490            .unwrap();
3491    }
3492}