project_panel.rs

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