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