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