project_panel.rs

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