project_panel.rs

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