project_panel.rs

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