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