project_panel.rs

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