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, items::entry_git_aware_label_color, 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: entry.is_ignored,
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        let filename_text_color =
1412            entry_git_aware_label_color(details.git_status, details.is_ignored, is_selected);
1413        let file_name = details.filename.clone();
1414        let icon = details.icon.clone();
1415        let depth = details.depth;
1416        div()
1417            .id(entry_id.to_proto() as usize)
1418            .on_drag(entry_id, move |entry_id, cx| {
1419                cx.new_view(|_| DraggedProjectEntryView {
1420                    details: details.clone(),
1421                    width,
1422                    entry_id: *entry_id,
1423                })
1424            })
1425            .drag_over::<ProjectEntryId>(|style, _, cx| {
1426                style.bg(cx.theme().colors().drop_target_background)
1427            })
1428            .on_drop(cx.listener(move |this, dragged_id: &ProjectEntryId, cx| {
1429                this.move_entry(*dragged_id, entry_id, kind.is_file(), cx);
1430            }))
1431            .child(
1432                ListItem::new(entry_id.to_proto() as usize)
1433                    .indent_level(depth)
1434                    .indent_step_size(px(settings.indent_size))
1435                    .selected(is_selected)
1436                    .child(if let Some(icon) = &icon {
1437                        div().child(Icon::from_path(icon.to_string()).color(filename_text_color))
1438                    } else {
1439                        div().size(IconSize::default().rems()).invisible()
1440                    })
1441                    .child(
1442                        if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1443                            h_flex().h_6().w_full().child(editor.clone())
1444                        } else {
1445                            div()
1446                                .h_6()
1447                                .child(Label::new(file_name).color(filename_text_color))
1448                        }
1449                        .ml_1(),
1450                    )
1451                    .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1452                        if event.down.button == MouseButton::Right {
1453                            return;
1454                        }
1455                        if !show_editor {
1456                            if kind.is_dir() {
1457                                this.toggle_expanded(entry_id, cx);
1458                            } else {
1459                                if event.down.modifiers.command {
1460                                    this.split_entry(entry_id, cx);
1461                                } else {
1462                                    this.open_entry(entry_id, event.up.click_count > 1, cx);
1463                                }
1464                            }
1465                        }
1466                    }))
1467                    .on_secondary_mouse_down(cx.listener(
1468                        move |this, event: &MouseDownEvent, cx| {
1469                            // Stop propagation to prevent the catch-all context menu for the project
1470                            // panel from being deployed.
1471                            cx.stop_propagation();
1472                            this.deploy_context_menu(event.position, entry_id, cx);
1473                        },
1474                    )),
1475            )
1476    }
1477
1478    fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1479        let mut dispatch_context = KeyContext::default();
1480        dispatch_context.add("ProjectPanel");
1481        dispatch_context.add("menu");
1482
1483        let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1484            "editing"
1485        } else {
1486            "not_editing"
1487        };
1488
1489        dispatch_context.add(identifier);
1490        dispatch_context
1491    }
1492
1493    fn reveal_entry(
1494        &mut self,
1495        project: Model<Project>,
1496        entry_id: ProjectEntryId,
1497        skip_ignored: bool,
1498        cx: &mut ViewContext<'_, ProjectPanel>,
1499    ) {
1500        if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1501            let worktree = worktree.read(cx);
1502            if skip_ignored
1503                && worktree
1504                    .entry_for_id(entry_id)
1505                    .map_or(true, |entry| entry.is_ignored)
1506            {
1507                return;
1508            }
1509
1510            let worktree_id = worktree.id();
1511            self.expand_entry(worktree_id, entry_id, cx);
1512            self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1513            self.autoscroll(cx);
1514            cx.notify();
1515        }
1516    }
1517}
1518
1519impl Render for ProjectPanel {
1520    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1521        let has_worktree = self.visible_entries.len() != 0;
1522        let project = self.project.read(cx);
1523
1524        if has_worktree {
1525            div()
1526                .id("project-panel")
1527                .size_full()
1528                .relative()
1529                .key_context(self.dispatch_context(cx))
1530                .on_action(cx.listener(Self::select_next))
1531                .on_action(cx.listener(Self::select_prev))
1532                .on_action(cx.listener(Self::expand_selected_entry))
1533                .on_action(cx.listener(Self::collapse_selected_entry))
1534                .on_action(cx.listener(Self::collapse_all_entries))
1535                .on_action(cx.listener(Self::open))
1536                .on_action(cx.listener(Self::confirm))
1537                .on_action(cx.listener(Self::cancel))
1538                .on_action(cx.listener(Self::copy_path))
1539                .on_action(cx.listener(Self::copy_relative_path))
1540                .on_action(cx.listener(Self::new_search_in_directory))
1541                .when(!project.is_read_only(), |el| {
1542                    el.on_action(cx.listener(Self::new_file))
1543                        .on_action(cx.listener(Self::new_directory))
1544                        .on_action(cx.listener(Self::rename))
1545                        .on_action(cx.listener(Self::delete))
1546                        .on_action(cx.listener(Self::cut))
1547                        .on_action(cx.listener(Self::copy))
1548                        .on_action(cx.listener(Self::paste))
1549                })
1550                .when(project.is_local(), |el| {
1551                    el.on_action(cx.listener(Self::reveal_in_finder))
1552                        .on_action(cx.listener(Self::open_in_terminal))
1553                })
1554                .on_mouse_down(
1555                    MouseButton::Right,
1556                    cx.listener(move |this, event: &MouseDownEvent, cx| {
1557                        // When deploying the context menu anywhere below the last project entry,
1558                        // act as if the user clicked the root of the last worktree.
1559                        if let Some(entry_id) = this.last_worktree_root_id {
1560                            this.deploy_context_menu(event.position, entry_id, cx);
1561                        }
1562                    }),
1563                )
1564                .track_focus(&self.focus_handle)
1565                .child(
1566                    uniform_list(
1567                        cx.view().clone(),
1568                        "entries",
1569                        self.visible_entries
1570                            .iter()
1571                            .map(|(_, worktree_entries)| worktree_entries.len())
1572                            .sum(),
1573                        {
1574                            |this, range, cx| {
1575                                let mut items = Vec::new();
1576                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1577                                    items.push(this.render_entry(id, details, cx));
1578                                });
1579                                items
1580                            }
1581                        },
1582                    )
1583                    .size_full()
1584                    .track_scroll(self.scroll_handle.clone()),
1585                )
1586                .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1587                    overlay()
1588                        .position(*position)
1589                        .anchor(gpui::AnchorCorner::TopLeft)
1590                        .child(menu.clone())
1591                }))
1592        } else {
1593            v_flex()
1594                .id("empty-project_panel")
1595                .size_full()
1596                .p_4()
1597                .track_focus(&self.focus_handle)
1598                .child(
1599                    Button::new("open_project", "Open a project")
1600                        .style(ButtonStyle::Filled)
1601                        .full_width()
1602                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1603                        .on_click(cx.listener(|this, _, cx| {
1604                            this.workspace
1605                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1606                                .log_err();
1607                        })),
1608                )
1609        }
1610    }
1611}
1612
1613impl Render for DraggedProjectEntryView {
1614    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1615        let settings = ProjectPanelSettings::get_global(cx);
1616        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1617        h_flex()
1618            .font(ui_font)
1619            .bg(cx.theme().colors().background)
1620            .w(self.width)
1621            .child(
1622                ListItem::new(self.entry_id.to_proto() as usize)
1623                    .indent_level(self.details.depth)
1624                    .indent_step_size(px(settings.indent_size))
1625                    .child(if let Some(icon) = &self.details.icon {
1626                        div().child(Icon::from_path(icon.to_string()))
1627                    } else {
1628                        div()
1629                    })
1630                    .child(Label::new(self.details.filename.clone())),
1631            )
1632    }
1633}
1634
1635impl EventEmitter<Event> for ProjectPanel {}
1636
1637impl EventEmitter<PanelEvent> for ProjectPanel {}
1638
1639impl Panel for ProjectPanel {
1640    fn position(&self, cx: &WindowContext) -> DockPosition {
1641        match ProjectPanelSettings::get_global(cx).dock {
1642            ProjectPanelDockPosition::Left => DockPosition::Left,
1643            ProjectPanelDockPosition::Right => DockPosition::Right,
1644        }
1645    }
1646
1647    fn position_is_valid(&self, position: DockPosition) -> bool {
1648        matches!(position, DockPosition::Left | DockPosition::Right)
1649    }
1650
1651    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1652        settings::update_settings_file::<ProjectPanelSettings>(
1653            self.fs.clone(),
1654            cx,
1655            move |settings| {
1656                let dock = match position {
1657                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1658                    DockPosition::Right => ProjectPanelDockPosition::Right,
1659                };
1660                settings.dock = Some(dock);
1661            },
1662        );
1663    }
1664
1665    fn size(&self, cx: &WindowContext) -> Pixels {
1666        self.width
1667            .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1668    }
1669
1670    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1671        self.width = size;
1672        self.serialize(cx);
1673        cx.notify();
1674    }
1675
1676    fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
1677        Some(ui::IconName::FileTree)
1678    }
1679
1680    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1681        Some("Project Panel")
1682    }
1683
1684    fn toggle_action(&self) -> Box<dyn Action> {
1685        Box::new(ToggleFocus)
1686    }
1687
1688    fn persistent_name() -> &'static str {
1689        "Project Panel"
1690    }
1691
1692    fn starts_open(&self, cx: &WindowContext) -> bool {
1693        self.project.read(cx).visible_worktrees(cx).any(|tree| {
1694            tree.read(cx)
1695                .root_entry()
1696                .map_or(false, |entry| entry.is_dir())
1697        })
1698    }
1699}
1700
1701impl FocusableView for ProjectPanel {
1702    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1703        self.focus_handle.clone()
1704    }
1705}
1706
1707impl ClipboardEntry {
1708    fn is_cut(&self) -> bool {
1709        matches!(self, Self::Cut { .. })
1710    }
1711
1712    fn entry_id(&self) -> ProjectEntryId {
1713        match self {
1714            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1715                *entry_id
1716            }
1717        }
1718    }
1719
1720    fn worktree_id(&self) -> WorktreeId {
1721        match self {
1722            ClipboardEntry::Copied { worktree_id, .. }
1723            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1724        }
1725    }
1726}
1727
1728#[cfg(test)]
1729mod tests {
1730    use super::*;
1731    use collections::HashSet;
1732    use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1733    use pretty_assertions::assert_eq;
1734    use project::{FakeFs, WorktreeSettings};
1735    use serde_json::json;
1736    use settings::SettingsStore;
1737    use std::path::{Path, PathBuf};
1738    use workspace::AppState;
1739
1740    #[gpui::test]
1741    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1742        init_test(cx);
1743
1744        let fs = FakeFs::new(cx.executor().clone());
1745        fs.insert_tree(
1746            "/root1",
1747            json!({
1748                ".dockerignore": "",
1749                ".git": {
1750                    "HEAD": "",
1751                },
1752                "a": {
1753                    "0": { "q": "", "r": "", "s": "" },
1754                    "1": { "t": "", "u": "" },
1755                    "2": { "v": "", "w": "", "x": "", "y": "" },
1756                },
1757                "b": {
1758                    "3": { "Q": "" },
1759                    "4": { "R": "", "S": "", "T": "", "U": "" },
1760                },
1761                "C": {
1762                    "5": {},
1763                    "6": { "V": "", "W": "" },
1764                    "7": { "X": "" },
1765                    "8": { "Y": {}, "Z": "" }
1766                }
1767            }),
1768        )
1769        .await;
1770        fs.insert_tree(
1771            "/root2",
1772            json!({
1773                "d": {
1774                    "9": ""
1775                },
1776                "e": {}
1777            }),
1778        )
1779        .await;
1780
1781        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1782        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1783        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1784        let panel = workspace
1785            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1786            .unwrap();
1787        assert_eq!(
1788            visible_entries_as_strings(&panel, 0..50, cx),
1789            &[
1790                "v root1",
1791                "    > .git",
1792                "    > a",
1793                "    > b",
1794                "    > C",
1795                "      .dockerignore",
1796                "v root2",
1797                "    > d",
1798                "    > e",
1799            ]
1800        );
1801
1802        toggle_expand_dir(&panel, "root1/b", cx);
1803        assert_eq!(
1804            visible_entries_as_strings(&panel, 0..50, cx),
1805            &[
1806                "v root1",
1807                "    > .git",
1808                "    > a",
1809                "    v b  <== selected",
1810                "        > 3",
1811                "        > 4",
1812                "    > C",
1813                "      .dockerignore",
1814                "v root2",
1815                "    > d",
1816                "    > e",
1817            ]
1818        );
1819
1820        assert_eq!(
1821            visible_entries_as_strings(&panel, 6..9, cx),
1822            &[
1823                //
1824                "    > C",
1825                "      .dockerignore",
1826                "v root2",
1827            ]
1828        );
1829    }
1830
1831    #[gpui::test]
1832    async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
1833        init_test(cx);
1834        cx.update(|cx| {
1835            cx.update_global::<SettingsStore, _>(|store, cx| {
1836                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
1837                    worktree_settings.file_scan_exclusions =
1838                        Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
1839                });
1840            });
1841        });
1842
1843        let fs = FakeFs::new(cx.background_executor.clone());
1844        fs.insert_tree(
1845            "/root1",
1846            json!({
1847                ".dockerignore": "",
1848                ".git": {
1849                    "HEAD": "",
1850                },
1851                "a": {
1852                    "0": { "q": "", "r": "", "s": "" },
1853                    "1": { "t": "", "u": "" },
1854                    "2": { "v": "", "w": "", "x": "", "y": "" },
1855                },
1856                "b": {
1857                    "3": { "Q": "" },
1858                    "4": { "R": "", "S": "", "T": "", "U": "" },
1859                },
1860                "C": {
1861                    "5": {},
1862                    "6": { "V": "", "W": "" },
1863                    "7": { "X": "" },
1864                    "8": { "Y": {}, "Z": "" }
1865                }
1866            }),
1867        )
1868        .await;
1869        fs.insert_tree(
1870            "/root2",
1871            json!({
1872                "d": {
1873                    "4": ""
1874                },
1875                "e": {}
1876            }),
1877        )
1878        .await;
1879
1880        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1881        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1882        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1883        let panel = workspace
1884            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
1885            .unwrap();
1886        assert_eq!(
1887            visible_entries_as_strings(&panel, 0..50, cx),
1888            &[
1889                "v root1",
1890                "    > a",
1891                "    > b",
1892                "    > C",
1893                "      .dockerignore",
1894                "v root2",
1895                "    > d",
1896                "    > e",
1897            ]
1898        );
1899
1900        toggle_expand_dir(&panel, "root1/b", cx);
1901        assert_eq!(
1902            visible_entries_as_strings(&panel, 0..50, cx),
1903            &[
1904                "v root1",
1905                "    > a",
1906                "    v b  <== selected",
1907                "        > 3",
1908                "    > C",
1909                "      .dockerignore",
1910                "v root2",
1911                "    > d",
1912                "    > e",
1913            ]
1914        );
1915
1916        toggle_expand_dir(&panel, "root2/d", cx);
1917        assert_eq!(
1918            visible_entries_as_strings(&panel, 0..50, cx),
1919            &[
1920                "v root1",
1921                "    > a",
1922                "    v b",
1923                "        > 3",
1924                "    > C",
1925                "      .dockerignore",
1926                "v root2",
1927                "    v d  <== selected",
1928                "    > e",
1929            ]
1930        );
1931
1932        toggle_expand_dir(&panel, "root2/e", cx);
1933        assert_eq!(
1934            visible_entries_as_strings(&panel, 0..50, cx),
1935            &[
1936                "v root1",
1937                "    > a",
1938                "    v b",
1939                "        > 3",
1940                "    > C",
1941                "      .dockerignore",
1942                "v root2",
1943                "    v d",
1944                "    v e  <== selected",
1945            ]
1946        );
1947    }
1948
1949    #[gpui::test(iterations = 30)]
1950    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1951        init_test(cx);
1952
1953        let fs = FakeFs::new(cx.executor().clone());
1954        fs.insert_tree(
1955            "/root1",
1956            json!({
1957                ".dockerignore": "",
1958                ".git": {
1959                    "HEAD": "",
1960                },
1961                "a": {
1962                    "0": { "q": "", "r": "", "s": "" },
1963                    "1": { "t": "", "u": "" },
1964                    "2": { "v": "", "w": "", "x": "", "y": "" },
1965                },
1966                "b": {
1967                    "3": { "Q": "" },
1968                    "4": { "R": "", "S": "", "T": "", "U": "" },
1969                },
1970                "C": {
1971                    "5": {},
1972                    "6": { "V": "", "W": "" },
1973                    "7": { "X": "" },
1974                    "8": { "Y": {}, "Z": "" }
1975                }
1976            }),
1977        )
1978        .await;
1979        fs.insert_tree(
1980            "/root2",
1981            json!({
1982                "d": {
1983                    "9": ""
1984                },
1985                "e": {}
1986            }),
1987        )
1988        .await;
1989
1990        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1991        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1992        let cx = &mut VisualTestContext::from_window(*workspace, cx);
1993        let panel = workspace
1994            .update(cx, |workspace, cx| {
1995                let panel = ProjectPanel::new(workspace, cx);
1996                workspace.add_panel(panel.clone(), cx);
1997                panel
1998            })
1999            .unwrap();
2000
2001        select_path(&panel, "root1", cx);
2002        assert_eq!(
2003            visible_entries_as_strings(&panel, 0..10, cx),
2004            &[
2005                "v root1  <== selected",
2006                "    > .git",
2007                "    > a",
2008                "    > b",
2009                "    > C",
2010                "      .dockerignore",
2011                "v root2",
2012                "    > d",
2013                "    > e",
2014            ]
2015        );
2016
2017        // Add a file with the root folder selected. The filename editor is placed
2018        // before the first file in the root folder.
2019        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2020        panel.update(cx, |panel, cx| {
2021            assert!(panel.filename_editor.read(cx).is_focused(cx));
2022        });
2023        assert_eq!(
2024            visible_entries_as_strings(&panel, 0..10, cx),
2025            &[
2026                "v root1",
2027                "    > .git",
2028                "    > a",
2029                "    > b",
2030                "    > C",
2031                "      [EDITOR: '']  <== selected",
2032                "      .dockerignore",
2033                "v root2",
2034                "    > d",
2035                "    > e",
2036            ]
2037        );
2038
2039        let confirm = panel.update(cx, |panel, cx| {
2040            panel
2041                .filename_editor
2042                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2043            panel.confirm_edit(cx).unwrap()
2044        });
2045        assert_eq!(
2046            visible_entries_as_strings(&panel, 0..10, cx),
2047            &[
2048                "v root1",
2049                "    > .git",
2050                "    > a",
2051                "    > b",
2052                "    > C",
2053                "      [PROCESSING: 'the-new-filename']  <== selected",
2054                "      .dockerignore",
2055                "v root2",
2056                "    > d",
2057                "    > e",
2058            ]
2059        );
2060
2061        confirm.await.unwrap();
2062        assert_eq!(
2063            visible_entries_as_strings(&panel, 0..10, cx),
2064            &[
2065                "v root1",
2066                "    > .git",
2067                "    > a",
2068                "    > b",
2069                "    > C",
2070                "      .dockerignore",
2071                "      the-new-filename  <== selected",
2072                "v root2",
2073                "    > d",
2074                "    > e",
2075            ]
2076        );
2077
2078        select_path(&panel, "root1/b", cx);
2079        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2080        assert_eq!(
2081            visible_entries_as_strings(&panel, 0..10, cx),
2082            &[
2083                "v root1",
2084                "    > .git",
2085                "    > a",
2086                "    v b",
2087                "        > 3",
2088                "        > 4",
2089                "          [EDITOR: '']  <== selected",
2090                "    > C",
2091                "      .dockerignore",
2092                "      the-new-filename",
2093            ]
2094        );
2095
2096        panel
2097            .update(cx, |panel, cx| {
2098                panel
2099                    .filename_editor
2100                    .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2101                panel.confirm_edit(cx).unwrap()
2102            })
2103            .await
2104            .unwrap();
2105        assert_eq!(
2106            visible_entries_as_strings(&panel, 0..10, cx),
2107            &[
2108                "v root1",
2109                "    > .git",
2110                "    > a",
2111                "    v b",
2112                "        > 3",
2113                "        > 4",
2114                "          another-filename.txt  <== selected",
2115                "    > C",
2116                "      .dockerignore",
2117                "      the-new-filename",
2118            ]
2119        );
2120
2121        select_path(&panel, "root1/b/another-filename.txt", cx);
2122        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2123        assert_eq!(
2124            visible_entries_as_strings(&panel, 0..10, cx),
2125            &[
2126                "v root1",
2127                "    > .git",
2128                "    > a",
2129                "    v b",
2130                "        > 3",
2131                "        > 4",
2132                "          [EDITOR: 'another-filename.txt']  <== selected",
2133                "    > C",
2134                "      .dockerignore",
2135                "      the-new-filename",
2136            ]
2137        );
2138
2139        let confirm = panel.update(cx, |panel, cx| {
2140            panel.filename_editor.update(cx, |editor, cx| {
2141                let file_name_selections = editor.selections.all::<usize>(cx);
2142                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2143                let file_name_selection = &file_name_selections[0];
2144                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2145                assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2146
2147                editor.set_text("a-different-filename.tar.gz", cx)
2148            });
2149            panel.confirm_edit(cx).unwrap()
2150        });
2151        assert_eq!(
2152            visible_entries_as_strings(&panel, 0..10, cx),
2153            &[
2154                "v root1",
2155                "    > .git",
2156                "    > a",
2157                "    v b",
2158                "        > 3",
2159                "        > 4",
2160                "          [PROCESSING: 'a-different-filename.tar.gz']  <== selected",
2161                "    > C",
2162                "      .dockerignore",
2163                "      the-new-filename",
2164            ]
2165        );
2166
2167        confirm.await.unwrap();
2168        assert_eq!(
2169            visible_entries_as_strings(&panel, 0..10, cx),
2170            &[
2171                "v root1",
2172                "    > .git",
2173                "    > a",
2174                "    v b",
2175                "        > 3",
2176                "        > 4",
2177                "          a-different-filename.tar.gz  <== selected",
2178                "    > C",
2179                "      .dockerignore",
2180                "      the-new-filename",
2181            ]
2182        );
2183
2184        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2185        assert_eq!(
2186            visible_entries_as_strings(&panel, 0..10, cx),
2187            &[
2188                "v root1",
2189                "    > .git",
2190                "    > a",
2191                "    v b",
2192                "        > 3",
2193                "        > 4",
2194                "          [EDITOR: 'a-different-filename.tar.gz']  <== selected",
2195                "    > C",
2196                "      .dockerignore",
2197                "      the-new-filename",
2198            ]
2199        );
2200
2201        panel.update(cx, |panel, cx| {
2202            panel.filename_editor.update(cx, |editor, cx| {
2203                let file_name_selections = editor.selections.all::<usize>(cx);
2204                assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2205                let file_name_selection = &file_name_selections[0];
2206                assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2207                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..");
2208
2209            });
2210            panel.cancel(&Cancel, cx)
2211        });
2212
2213        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2214        assert_eq!(
2215            visible_entries_as_strings(&panel, 0..10, cx),
2216            &[
2217                "v root1",
2218                "    > .git",
2219                "    > a",
2220                "    v b",
2221                "        > [EDITOR: '']  <== selected",
2222                "        > 3",
2223                "        > 4",
2224                "          a-different-filename.tar.gz",
2225                "    > C",
2226                "      .dockerignore",
2227            ]
2228        );
2229
2230        let confirm = panel.update(cx, |panel, cx| {
2231            panel
2232                .filename_editor
2233                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2234            panel.confirm_edit(cx).unwrap()
2235        });
2236        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2237        assert_eq!(
2238            visible_entries_as_strings(&panel, 0..10, cx),
2239            &[
2240                "v root1",
2241                "    > .git",
2242                "    > a",
2243                "    v b",
2244                "        > [PROCESSING: 'new-dir']",
2245                "        > 3  <== selected",
2246                "        > 4",
2247                "          a-different-filename.tar.gz",
2248                "    > C",
2249                "      .dockerignore",
2250            ]
2251        );
2252
2253        confirm.await.unwrap();
2254        assert_eq!(
2255            visible_entries_as_strings(&panel, 0..10, cx),
2256            &[
2257                "v root1",
2258                "    > .git",
2259                "    > a",
2260                "    v b",
2261                "        > 3  <== selected",
2262                "        > 4",
2263                "        > new-dir",
2264                "          a-different-filename.tar.gz",
2265                "    > C",
2266                "      .dockerignore",
2267            ]
2268        );
2269
2270        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2271        assert_eq!(
2272            visible_entries_as_strings(&panel, 0..10, cx),
2273            &[
2274                "v root1",
2275                "    > .git",
2276                "    > a",
2277                "    v b",
2278                "        > [EDITOR: '3']  <== selected",
2279                "        > 4",
2280                "        > new-dir",
2281                "          a-different-filename.tar.gz",
2282                "    > C",
2283                "      .dockerignore",
2284            ]
2285        );
2286
2287        // Dismiss the rename editor when it loses focus.
2288        workspace.update(cx, |_, cx| cx.blur()).unwrap();
2289        assert_eq!(
2290            visible_entries_as_strings(&panel, 0..10, cx),
2291            &[
2292                "v root1",
2293                "    > .git",
2294                "    > a",
2295                "    v b",
2296                "        > 3  <== selected",
2297                "        > 4",
2298                "        > new-dir",
2299                "          a-different-filename.tar.gz",
2300                "    > C",
2301                "      .dockerignore",
2302            ]
2303        );
2304    }
2305
2306    #[gpui::test(iterations = 10)]
2307    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2308        init_test(cx);
2309
2310        let fs = FakeFs::new(cx.executor().clone());
2311        fs.insert_tree(
2312            "/root1",
2313            json!({
2314                ".dockerignore": "",
2315                ".git": {
2316                    "HEAD": "",
2317                },
2318                "a": {
2319                    "0": { "q": "", "r": "", "s": "" },
2320                    "1": { "t": "", "u": "" },
2321                    "2": { "v": "", "w": "", "x": "", "y": "" },
2322                },
2323                "b": {
2324                    "3": { "Q": "" },
2325                    "4": { "R": "", "S": "", "T": "", "U": "" },
2326                },
2327                "C": {
2328                    "5": {},
2329                    "6": { "V": "", "W": "" },
2330                    "7": { "X": "" },
2331                    "8": { "Y": {}, "Z": "" }
2332                }
2333            }),
2334        )
2335        .await;
2336        fs.insert_tree(
2337            "/root2",
2338            json!({
2339                "d": {
2340                    "9": ""
2341                },
2342                "e": {}
2343            }),
2344        )
2345        .await;
2346
2347        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2348        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2349        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2350        let panel = workspace
2351            .update(cx, |workspace, cx| {
2352                let panel = ProjectPanel::new(workspace, cx);
2353                workspace.add_panel(panel.clone(), cx);
2354                panel
2355            })
2356            .unwrap();
2357
2358        select_path(&panel, "root1", cx);
2359        assert_eq!(
2360            visible_entries_as_strings(&panel, 0..10, cx),
2361            &[
2362                "v root1  <== selected",
2363                "    > .git",
2364                "    > a",
2365                "    > b",
2366                "    > C",
2367                "      .dockerignore",
2368                "v root2",
2369                "    > d",
2370                "    > e",
2371            ]
2372        );
2373
2374        // Add a file with the root folder selected. The filename editor is placed
2375        // before the first file in the root folder.
2376        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2377        panel.update(cx, |panel, cx| {
2378            assert!(panel.filename_editor.read(cx).is_focused(cx));
2379        });
2380        assert_eq!(
2381            visible_entries_as_strings(&panel, 0..10, cx),
2382            &[
2383                "v root1",
2384                "    > .git",
2385                "    > a",
2386                "    > b",
2387                "    > C",
2388                "      [EDITOR: '']  <== selected",
2389                "      .dockerignore",
2390                "v root2",
2391                "    > d",
2392                "    > e",
2393            ]
2394        );
2395
2396        let confirm = panel.update(cx, |panel, cx| {
2397            panel.filename_editor.update(cx, |editor, cx| {
2398                editor.set_text("/bdir1/dir2/the-new-filename", cx)
2399            });
2400            panel.confirm_edit(cx).unwrap()
2401        });
2402
2403        assert_eq!(
2404            visible_entries_as_strings(&panel, 0..10, cx),
2405            &[
2406                "v root1",
2407                "    > .git",
2408                "    > a",
2409                "    > b",
2410                "    > C",
2411                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
2412                "      .dockerignore",
2413                "v root2",
2414                "    > d",
2415                "    > e",
2416            ]
2417        );
2418
2419        confirm.await.unwrap();
2420        assert_eq!(
2421            visible_entries_as_strings(&panel, 0..13, cx),
2422            &[
2423                "v root1",
2424                "    > .git",
2425                "    > a",
2426                "    > b",
2427                "    v bdir1",
2428                "        v dir2",
2429                "              the-new-filename  <== selected",
2430                "    > C",
2431                "      .dockerignore",
2432                "v root2",
2433                "    > d",
2434                "    > e",
2435            ]
2436        );
2437    }
2438
2439    #[gpui::test]
2440    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2441        init_test(cx);
2442
2443        let fs = FakeFs::new(cx.executor().clone());
2444        fs.insert_tree(
2445            "/root1",
2446            json!({
2447                "one.two.txt": "",
2448                "one.txt": ""
2449            }),
2450        )
2451        .await;
2452
2453        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2454        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2455        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2456        let panel = workspace
2457            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2458            .unwrap();
2459
2460        panel.update(cx, |panel, cx| {
2461            panel.select_next(&Default::default(), cx);
2462            panel.select_next(&Default::default(), cx);
2463        });
2464
2465        assert_eq!(
2466            visible_entries_as_strings(&panel, 0..50, cx),
2467            &[
2468                //
2469                "v root1",
2470                "      one.two.txt  <== selected",
2471                "      one.txt",
2472            ]
2473        );
2474
2475        // Regression test - file name is created correctly when
2476        // the copied file's name contains multiple dots.
2477        panel.update(cx, |panel, cx| {
2478            panel.copy(&Default::default(), cx);
2479            panel.paste(&Default::default(), cx);
2480        });
2481        cx.executor().run_until_parked();
2482
2483        assert_eq!(
2484            visible_entries_as_strings(&panel, 0..50, cx),
2485            &[
2486                //
2487                "v root1",
2488                "      one.two copy.txt",
2489                "      one.two.txt  <== selected",
2490                "      one.txt",
2491            ]
2492        );
2493
2494        panel.update(cx, |panel, cx| {
2495            panel.paste(&Default::default(), cx);
2496        });
2497        cx.executor().run_until_parked();
2498
2499        assert_eq!(
2500            visible_entries_as_strings(&panel, 0..50, cx),
2501            &[
2502                //
2503                "v root1",
2504                "      one.two copy 1.txt",
2505                "      one.two copy.txt",
2506                "      one.two.txt  <== selected",
2507                "      one.txt",
2508            ]
2509        );
2510    }
2511
2512    #[gpui::test]
2513    async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
2514        init_test(cx);
2515
2516        let fs = FakeFs::new(cx.executor().clone());
2517        fs.insert_tree(
2518            "/root",
2519            json!({
2520                "a": {
2521                    "one.txt": "",
2522                    "two.txt": "",
2523                    "inner_dir": {
2524                        "three.txt": "",
2525                        "four.txt": "",
2526                    }
2527                },
2528                "b": {}
2529            }),
2530        )
2531        .await;
2532
2533        let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
2534        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2535        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2536        let panel = workspace
2537            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2538            .unwrap();
2539
2540        select_path(&panel, "root/a", cx);
2541        panel.update(cx, |panel, cx| {
2542            panel.copy(&Default::default(), cx);
2543            panel.select_next(&Default::default(), cx);
2544            panel.paste(&Default::default(), cx);
2545        });
2546        cx.executor().run_until_parked();
2547
2548        let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
2549        assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
2550
2551        let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
2552        assert_ne!(
2553            pasted_dir_file, None,
2554            "Pasted directory file should have an entry"
2555        );
2556
2557        let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
2558        assert_ne!(
2559            pasted_dir_inner_dir, None,
2560            "Directories inside pasted directory should have an entry"
2561        );
2562
2563        toggle_expand_dir(&panel, "root/b", cx);
2564        toggle_expand_dir(&panel, "root/b/a", cx);
2565        toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
2566
2567        assert_eq!(
2568            visible_entries_as_strings(&panel, 0..50, cx),
2569            &[
2570                //
2571                "v root",
2572                "    > a",
2573                "    v b",
2574                "        v a",
2575                "            v inner_dir  <== selected",
2576                "                  four.txt",
2577                "                  three.txt",
2578                "              one.txt",
2579                "              two.txt",
2580            ]
2581        );
2582
2583        select_path(&panel, "root", cx);
2584        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2585        cx.executor().run_until_parked();
2586        panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2587        cx.executor().run_until_parked();
2588        assert_eq!(
2589            visible_entries_as_strings(&panel, 0..50, cx),
2590            &[
2591                //
2592                "v root  <== selected",
2593                "    > a",
2594                "    > a copy",
2595                "    > a copy 1",
2596                "    v b",
2597                "        v a",
2598                "            v inner_dir",
2599                "                  four.txt",
2600                "                  three.txt",
2601                "              one.txt",
2602                "              two.txt"
2603            ]
2604        );
2605    }
2606
2607    #[gpui::test]
2608    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2609        init_test_with_editor(cx);
2610
2611        let fs = FakeFs::new(cx.executor().clone());
2612        fs.insert_tree(
2613            "/src",
2614            json!({
2615                "test": {
2616                    "first.rs": "// First Rust file",
2617                    "second.rs": "// Second Rust file",
2618                    "third.rs": "// Third Rust file",
2619                }
2620            }),
2621        )
2622        .await;
2623
2624        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2625        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2626        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2627        let panel = workspace
2628            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2629            .unwrap();
2630
2631        toggle_expand_dir(&panel, "src/test", cx);
2632        select_path(&panel, "src/test/first.rs", cx);
2633        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2634        cx.executor().run_until_parked();
2635        assert_eq!(
2636            visible_entries_as_strings(&panel, 0..10, cx),
2637            &[
2638                "v src",
2639                "    v test",
2640                "          first.rs  <== selected",
2641                "          second.rs",
2642                "          third.rs"
2643            ]
2644        );
2645        ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2646
2647        submit_deletion(&panel, cx);
2648        assert_eq!(
2649            visible_entries_as_strings(&panel, 0..10, cx),
2650            &[
2651                "v src",
2652                "    v test",
2653                "          second.rs",
2654                "          third.rs"
2655            ],
2656            "Project panel should have no deleted file, no other file is selected in it"
2657        );
2658        ensure_no_open_items_and_panes(&workspace, cx);
2659
2660        select_path(&panel, "src/test/second.rs", cx);
2661        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2662        cx.executor().run_until_parked();
2663        assert_eq!(
2664            visible_entries_as_strings(&panel, 0..10, cx),
2665            &[
2666                "v src",
2667                "    v test",
2668                "          second.rs  <== selected",
2669                "          third.rs"
2670            ]
2671        );
2672        ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
2673
2674        workspace
2675            .update(cx, |workspace, cx| {
2676                let active_items = workspace
2677                    .panes()
2678                    .iter()
2679                    .filter_map(|pane| pane.read(cx).active_item())
2680                    .collect::<Vec<_>>();
2681                assert_eq!(active_items.len(), 1);
2682                let open_editor = active_items
2683                    .into_iter()
2684                    .next()
2685                    .unwrap()
2686                    .downcast::<Editor>()
2687                    .expect("Open item should be an editor");
2688                open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2689            })
2690            .unwrap();
2691        submit_deletion_skipping_prompt(&panel, cx);
2692        assert_eq!(
2693            visible_entries_as_strings(&panel, 0..10, cx),
2694            &["v src", "    v test", "          third.rs"],
2695            "Project panel should have no deleted file, with one last file remaining"
2696        );
2697        ensure_no_open_items_and_panes(&workspace, cx);
2698    }
2699
2700    #[gpui::test]
2701    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2702        init_test_with_editor(cx);
2703
2704        let fs = FakeFs::new(cx.executor().clone());
2705        fs.insert_tree(
2706            "/src",
2707            json!({
2708                "test": {
2709                    "first.rs": "// First Rust file",
2710                    "second.rs": "// Second Rust file",
2711                    "third.rs": "// Third Rust file",
2712                }
2713            }),
2714        )
2715        .await;
2716
2717        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2718        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2719        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2720        let panel = workspace
2721            .update(cx, |workspace, cx| {
2722                let panel = ProjectPanel::new(workspace, cx);
2723                workspace.add_panel(panel.clone(), cx);
2724                panel
2725            })
2726            .unwrap();
2727
2728        select_path(&panel, "src/", cx);
2729        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2730        cx.executor().run_until_parked();
2731        assert_eq!(
2732            visible_entries_as_strings(&panel, 0..10, cx),
2733            &[
2734                //
2735                "v src  <== selected",
2736                "    > test"
2737            ]
2738        );
2739        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2740        panel.update(cx, |panel, cx| {
2741            assert!(panel.filename_editor.read(cx).is_focused(cx));
2742        });
2743        assert_eq!(
2744            visible_entries_as_strings(&panel, 0..10, cx),
2745            &[
2746                //
2747                "v src",
2748                "    > [EDITOR: '']  <== selected",
2749                "    > test"
2750            ]
2751        );
2752        panel.update(cx, |panel, cx| {
2753            panel
2754                .filename_editor
2755                .update(cx, |editor, cx| editor.set_text("test", cx));
2756            assert!(
2757                panel.confirm_edit(cx).is_none(),
2758                "Should not allow to confirm on conflicting new directory name"
2759            )
2760        });
2761        assert_eq!(
2762            visible_entries_as_strings(&panel, 0..10, cx),
2763            &[
2764                //
2765                "v src",
2766                "    > test"
2767            ],
2768            "File list should be unchanged after failed folder create confirmation"
2769        );
2770
2771        select_path(&panel, "src/test/", cx);
2772        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2773        cx.executor().run_until_parked();
2774        assert_eq!(
2775            visible_entries_as_strings(&panel, 0..10, cx),
2776            &[
2777                //
2778                "v src",
2779                "    > test  <== selected"
2780            ]
2781        );
2782        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2783        panel.update(cx, |panel, cx| {
2784            assert!(panel.filename_editor.read(cx).is_focused(cx));
2785        });
2786        assert_eq!(
2787            visible_entries_as_strings(&panel, 0..10, cx),
2788            &[
2789                "v src",
2790                "    v test",
2791                "          [EDITOR: '']  <== selected",
2792                "          first.rs",
2793                "          second.rs",
2794                "          third.rs"
2795            ]
2796        );
2797        panel.update(cx, |panel, cx| {
2798            panel
2799                .filename_editor
2800                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2801            assert!(
2802                panel.confirm_edit(cx).is_none(),
2803                "Should not allow to confirm on conflicting new file name"
2804            )
2805        });
2806        assert_eq!(
2807            visible_entries_as_strings(&panel, 0..10, cx),
2808            &[
2809                "v src",
2810                "    v test",
2811                "          first.rs",
2812                "          second.rs",
2813                "          third.rs"
2814            ],
2815            "File list should be unchanged after failed file create confirmation"
2816        );
2817
2818        select_path(&panel, "src/test/first.rs", cx);
2819        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2820        cx.executor().run_until_parked();
2821        assert_eq!(
2822            visible_entries_as_strings(&panel, 0..10, cx),
2823            &[
2824                "v src",
2825                "    v test",
2826                "          first.rs  <== selected",
2827                "          second.rs",
2828                "          third.rs"
2829            ],
2830        );
2831        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2832        panel.update(cx, |panel, cx| {
2833            assert!(panel.filename_editor.read(cx).is_focused(cx));
2834        });
2835        assert_eq!(
2836            visible_entries_as_strings(&panel, 0..10, cx),
2837            &[
2838                "v src",
2839                "    v test",
2840                "          [EDITOR: 'first.rs']  <== selected",
2841                "          second.rs",
2842                "          third.rs"
2843            ]
2844        );
2845        panel.update(cx, |panel, cx| {
2846            panel
2847                .filename_editor
2848                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2849            assert!(
2850                panel.confirm_edit(cx).is_none(),
2851                "Should not allow to confirm on conflicting file rename"
2852            )
2853        });
2854        assert_eq!(
2855            visible_entries_as_strings(&panel, 0..10, cx),
2856            &[
2857                "v src",
2858                "    v test",
2859                "          first.rs  <== selected",
2860                "          second.rs",
2861                "          third.rs"
2862            ],
2863            "File list should be unchanged after failed rename confirmation"
2864        );
2865    }
2866
2867    #[gpui::test]
2868    async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
2869        init_test_with_editor(cx);
2870
2871        let fs = FakeFs::new(cx.executor().clone());
2872        fs.insert_tree(
2873            "/project_root",
2874            json!({
2875                "dir_1": {
2876                    "nested_dir": {
2877                        "file_a.py": "# File contents",
2878                    }
2879                },
2880                "file_1.py": "# File contents",
2881            }),
2882        )
2883        .await;
2884
2885        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2886        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2887        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2888        let panel = workspace
2889            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2890            .unwrap();
2891
2892        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2893        cx.executor().run_until_parked();
2894        select_path(&panel, "project_root/dir_1", cx);
2895        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2896        select_path(&panel, "project_root/dir_1/nested_dir", cx);
2897        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2898        panel.update(cx, |panel, cx| panel.open(&Open, cx));
2899        cx.executor().run_until_parked();
2900        assert_eq!(
2901            visible_entries_as_strings(&panel, 0..10, cx),
2902            &[
2903                "v project_root",
2904                "    v dir_1",
2905                "        > nested_dir  <== selected",
2906                "      file_1.py",
2907            ]
2908        );
2909    }
2910
2911    #[gpui::test]
2912    async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
2913        init_test_with_editor(cx);
2914
2915        let fs = FakeFs::new(cx.executor().clone());
2916        fs.insert_tree(
2917            "/project_root",
2918            json!({
2919                "dir_1": {
2920                    "nested_dir": {
2921                        "file_a.py": "# File contents",
2922                        "file_b.py": "# File contents",
2923                        "file_c.py": "# File contents",
2924                    },
2925                    "file_1.py": "# File contents",
2926                    "file_2.py": "# File contents",
2927                    "file_3.py": "# File contents",
2928                },
2929                "dir_2": {
2930                    "file_1.py": "# File contents",
2931                    "file_2.py": "# File contents",
2932                    "file_3.py": "# File contents",
2933                }
2934            }),
2935        )
2936        .await;
2937
2938        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
2939        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2940        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2941        let panel = workspace
2942            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2943            .unwrap();
2944
2945        panel.update(cx, |panel, cx| {
2946            panel.collapse_all_entries(&CollapseAllEntries, cx)
2947        });
2948        cx.executor().run_until_parked();
2949        assert_eq!(
2950            visible_entries_as_strings(&panel, 0..10, cx),
2951            &["v project_root", "    > dir_1", "    > dir_2",]
2952        );
2953
2954        // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
2955        toggle_expand_dir(&panel, "project_root/dir_1", cx);
2956        cx.executor().run_until_parked();
2957        assert_eq!(
2958            visible_entries_as_strings(&panel, 0..10, cx),
2959            &[
2960                "v project_root",
2961                "    v dir_1  <== selected",
2962                "        > nested_dir",
2963                "          file_1.py",
2964                "          file_2.py",
2965                "          file_3.py",
2966                "    > dir_2",
2967            ]
2968        );
2969    }
2970
2971    #[gpui::test]
2972    async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
2973        init_test(cx);
2974
2975        let fs = FakeFs::new(cx.executor().clone());
2976        fs.as_fake().insert_tree("/root", json!({})).await;
2977        let project = Project::test(fs, ["/root".as_ref()], cx).await;
2978        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2979        let cx = &mut VisualTestContext::from_window(*workspace, cx);
2980        let panel = workspace
2981            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2982            .unwrap();
2983
2984        // Make a new buffer with no backing file
2985        workspace
2986            .update(cx, |workspace, cx| {
2987                Editor::new_file(workspace, &Default::default(), cx)
2988            })
2989            .unwrap();
2990
2991        // "Save as"" the buffer, creating a new backing file for it
2992        let save_task = workspace
2993            .update(cx, |workspace, cx| {
2994                workspace.save_active_item(workspace::SaveIntent::Save, cx)
2995            })
2996            .unwrap();
2997
2998        cx.executor().run_until_parked();
2999        cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3000        save_task.await.unwrap();
3001
3002        // Rename the file
3003        select_path(&panel, "root/new", cx);
3004        assert_eq!(
3005            visible_entries_as_strings(&panel, 0..10, cx),
3006            &["v root", "      new  <== selected"]
3007        );
3008        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3009        panel.update(cx, |panel, cx| {
3010            panel
3011                .filename_editor
3012                .update(cx, |editor, cx| editor.set_text("newer", cx));
3013        });
3014        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3015
3016        cx.executor().run_until_parked();
3017        assert_eq!(
3018            visible_entries_as_strings(&panel, 0..10, cx),
3019            &["v root", "      newer  <== selected"]
3020        );
3021
3022        workspace
3023            .update(cx, |workspace, cx| {
3024                workspace.save_active_item(workspace::SaveIntent::Save, cx)
3025            })
3026            .unwrap()
3027            .await
3028            .unwrap();
3029
3030        cx.executor().run_until_parked();
3031        // assert that saving the file doesn't restore "new"
3032        assert_eq!(
3033            visible_entries_as_strings(&panel, 0..10, cx),
3034            &["v root", "      newer  <== selected"]
3035        );
3036    }
3037
3038    #[gpui::test]
3039    async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3040        init_test_with_editor(cx);
3041        cx.update(|cx| {
3042            cx.update_global::<SettingsStore, _>(|store, cx| {
3043                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3044                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3045                });
3046                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3047                    project_panel_settings.auto_reveal_entries = Some(false)
3048                });
3049            })
3050        });
3051
3052        let fs = FakeFs::new(cx.background_executor.clone());
3053        fs.insert_tree(
3054            "/project_root",
3055            json!({
3056                ".git": {},
3057                ".gitignore": "**/gitignored_dir",
3058                "dir_1": {
3059                    "file_1.py": "# File 1_1 contents",
3060                    "file_2.py": "# File 1_2 contents",
3061                    "file_3.py": "# File 1_3 contents",
3062                    "gitignored_dir": {
3063                        "file_a.py": "# File contents",
3064                        "file_b.py": "# File contents",
3065                        "file_c.py": "# File contents",
3066                    },
3067                },
3068                "dir_2": {
3069                    "file_1.py": "# File 2_1 contents",
3070                    "file_2.py": "# File 2_2 contents",
3071                    "file_3.py": "# File 2_3 contents",
3072                }
3073            }),
3074        )
3075        .await;
3076
3077        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3078        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3079        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3080        let panel = workspace
3081            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3082            .unwrap();
3083
3084        assert_eq!(
3085            visible_entries_as_strings(&panel, 0..20, cx),
3086            &[
3087                "v project_root",
3088                "    > .git",
3089                "    > dir_1",
3090                "    > dir_2",
3091                "      .gitignore",
3092            ]
3093        );
3094
3095        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3096            .expect("dir 1 file is not ignored and should have an entry");
3097        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3098            .expect("dir 2 file is not ignored and should have an entry");
3099        let gitignored_dir_file =
3100            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3101        assert_eq!(
3102            gitignored_dir_file, None,
3103            "File in the gitignored dir should not have an entry before its dir is toggled"
3104        );
3105
3106        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3107        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3108        cx.executor().run_until_parked();
3109        assert_eq!(
3110            visible_entries_as_strings(&panel, 0..20, cx),
3111            &[
3112                "v project_root",
3113                "    > .git",
3114                "    v dir_1",
3115                "        v gitignored_dir  <== selected",
3116                "              file_a.py",
3117                "              file_b.py",
3118                "              file_c.py",
3119                "          file_1.py",
3120                "          file_2.py",
3121                "          file_3.py",
3122                "    > dir_2",
3123                "      .gitignore",
3124            ],
3125            "Should show gitignored dir file list in the project panel"
3126        );
3127        let gitignored_dir_file =
3128            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3129                .expect("after gitignored dir got opened, a file entry should be present");
3130
3131        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3132        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3133        assert_eq!(
3134            visible_entries_as_strings(&panel, 0..20, cx),
3135            &[
3136                "v project_root",
3137                "    > .git",
3138                "    > dir_1  <== selected",
3139                "    > dir_2",
3140                "      .gitignore",
3141            ],
3142            "Should hide all dir contents again and prepare for the auto reveal test"
3143        );
3144
3145        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3146            panel.update(cx, |panel, cx| {
3147                panel.project.update(cx, |_, cx| {
3148                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3149                })
3150            });
3151            cx.run_until_parked();
3152            assert_eq!(
3153                visible_entries_as_strings(&panel, 0..20, cx),
3154                &[
3155                    "v project_root",
3156                    "    > .git",
3157                    "    > dir_1  <== selected",
3158                    "    > dir_2",
3159                    "      .gitignore",
3160                ],
3161                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3162            );
3163        }
3164
3165        cx.update(|cx| {
3166            cx.update_global::<SettingsStore, _>(|store, cx| {
3167                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3168                    project_panel_settings.auto_reveal_entries = Some(true)
3169                });
3170            })
3171        });
3172
3173        panel.update(cx, |panel, cx| {
3174            panel.project.update(cx, |_, cx| {
3175                cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3176            })
3177        });
3178        cx.run_until_parked();
3179        assert_eq!(
3180            visible_entries_as_strings(&panel, 0..20, cx),
3181            &[
3182                "v project_root",
3183                "    > .git",
3184                "    v dir_1",
3185                "        > gitignored_dir",
3186                "          file_1.py  <== selected",
3187                "          file_2.py",
3188                "          file_3.py",
3189                "    > dir_2",
3190                "      .gitignore",
3191            ],
3192            "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3193        );
3194
3195        panel.update(cx, |panel, cx| {
3196            panel.project.update(cx, |_, cx| {
3197                cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3198            })
3199        });
3200        cx.run_until_parked();
3201        assert_eq!(
3202            visible_entries_as_strings(&panel, 0..20, cx),
3203            &[
3204                "v project_root",
3205                "    > .git",
3206                "    v dir_1",
3207                "        > gitignored_dir",
3208                "          file_1.py",
3209                "          file_2.py",
3210                "          file_3.py",
3211                "    v dir_2",
3212                "          file_1.py  <== selected",
3213                "          file_2.py",
3214                "          file_3.py",
3215                "      .gitignore",
3216            ],
3217            "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3218        );
3219
3220        panel.update(cx, |panel, cx| {
3221            panel.project.update(cx, |_, cx| {
3222                cx.emit(project::Event::ActiveEntryChanged(Some(
3223                    gitignored_dir_file,
3224                )))
3225            })
3226        });
3227        cx.run_until_parked();
3228        assert_eq!(
3229            visible_entries_as_strings(&panel, 0..20, cx),
3230            &[
3231                "v project_root",
3232                "    > .git",
3233                "    v dir_1",
3234                "        > gitignored_dir",
3235                "          file_1.py",
3236                "          file_2.py",
3237                "          file_3.py",
3238                "    v dir_2",
3239                "          file_1.py  <== selected",
3240                "          file_2.py",
3241                "          file_3.py",
3242                "      .gitignore",
3243            ],
3244            "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3245        );
3246
3247        panel.update(cx, |panel, cx| {
3248            panel.project.update(cx, |_, cx| {
3249                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3250            })
3251        });
3252        cx.run_until_parked();
3253        assert_eq!(
3254            visible_entries_as_strings(&panel, 0..20, cx),
3255            &[
3256                "v project_root",
3257                "    > .git",
3258                "    v dir_1",
3259                "        v gitignored_dir",
3260                "              file_a.py  <== selected",
3261                "              file_b.py",
3262                "              file_c.py",
3263                "          file_1.py",
3264                "          file_2.py",
3265                "          file_3.py",
3266                "    v dir_2",
3267                "          file_1.py",
3268                "          file_2.py",
3269                "          file_3.py",
3270                "      .gitignore",
3271            ],
3272            "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3273        );
3274    }
3275
3276    #[gpui::test]
3277    async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3278        init_test_with_editor(cx);
3279        cx.update(|cx| {
3280            cx.update_global::<SettingsStore, _>(|store, cx| {
3281                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3282                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3283                });
3284                store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3285                    project_panel_settings.auto_reveal_entries = Some(false)
3286                });
3287            })
3288        });
3289
3290        let fs = FakeFs::new(cx.background_executor.clone());
3291        fs.insert_tree(
3292            "/project_root",
3293            json!({
3294                ".git": {},
3295                ".gitignore": "**/gitignored_dir",
3296                "dir_1": {
3297                    "file_1.py": "# File 1_1 contents",
3298                    "file_2.py": "# File 1_2 contents",
3299                    "file_3.py": "# File 1_3 contents",
3300                    "gitignored_dir": {
3301                        "file_a.py": "# File contents",
3302                        "file_b.py": "# File contents",
3303                        "file_c.py": "# File contents",
3304                    },
3305                },
3306                "dir_2": {
3307                    "file_1.py": "# File 2_1 contents",
3308                    "file_2.py": "# File 2_2 contents",
3309                    "file_3.py": "# File 2_3 contents",
3310                }
3311            }),
3312        )
3313        .await;
3314
3315        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3316        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3317        let cx = &mut VisualTestContext::from_window(*workspace, cx);
3318        let panel = workspace
3319            .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3320            .unwrap();
3321
3322        assert_eq!(
3323            visible_entries_as_strings(&panel, 0..20, cx),
3324            &[
3325                "v project_root",
3326                "    > .git",
3327                "    > dir_1",
3328                "    > dir_2",
3329                "      .gitignore",
3330            ]
3331        );
3332
3333        let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3334            .expect("dir 1 file is not ignored and should have an entry");
3335        let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3336            .expect("dir 2 file is not ignored and should have an entry");
3337        let gitignored_dir_file =
3338            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3339        assert_eq!(
3340            gitignored_dir_file, None,
3341            "File in the gitignored dir should not have an entry before its dir is toggled"
3342        );
3343
3344        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3345        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3346        cx.run_until_parked();
3347        assert_eq!(
3348            visible_entries_as_strings(&panel, 0..20, cx),
3349            &[
3350                "v project_root",
3351                "    > .git",
3352                "    v dir_1",
3353                "        v gitignored_dir  <== selected",
3354                "              file_a.py",
3355                "              file_b.py",
3356                "              file_c.py",
3357                "          file_1.py",
3358                "          file_2.py",
3359                "          file_3.py",
3360                "    > dir_2",
3361                "      .gitignore",
3362            ],
3363            "Should show gitignored dir file list in the project panel"
3364        );
3365        let gitignored_dir_file =
3366            find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3367                .expect("after gitignored dir got opened, a file entry should be present");
3368
3369        toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3370        toggle_expand_dir(&panel, "project_root/dir_1", cx);
3371        assert_eq!(
3372            visible_entries_as_strings(&panel, 0..20, cx),
3373            &[
3374                "v project_root",
3375                "    > .git",
3376                "    > dir_1  <== selected",
3377                "    > dir_2",
3378                "      .gitignore",
3379            ],
3380            "Should hide all dir contents again and prepare for the explicit reveal test"
3381        );
3382
3383        for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3384            panel.update(cx, |panel, cx| {
3385                panel.project.update(cx, |_, cx| {
3386                    cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3387                })
3388            });
3389            cx.run_until_parked();
3390            assert_eq!(
3391                visible_entries_as_strings(&panel, 0..20, cx),
3392                &[
3393                    "v project_root",
3394                    "    > .git",
3395                    "    > dir_1  <== selected",
3396                    "    > dir_2",
3397                    "      .gitignore",
3398                ],
3399                "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3400            );
3401        }
3402
3403        panel.update(cx, |panel, cx| {
3404            panel.project.update(cx, |_, cx| {
3405                cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3406            })
3407        });
3408        cx.run_until_parked();
3409        assert_eq!(
3410            visible_entries_as_strings(&panel, 0..20, cx),
3411            &[
3412                "v project_root",
3413                "    > .git",
3414                "    v dir_1",
3415                "        > gitignored_dir",
3416                "          file_1.py  <== selected",
3417                "          file_2.py",
3418                "          file_3.py",
3419                "    > dir_2",
3420                "      .gitignore",
3421            ],
3422            "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3423        );
3424
3425        panel.update(cx, |panel, cx| {
3426            panel.project.update(cx, |_, cx| {
3427                cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3428            })
3429        });
3430        cx.run_until_parked();
3431        assert_eq!(
3432            visible_entries_as_strings(&panel, 0..20, cx),
3433            &[
3434                "v project_root",
3435                "    > .git",
3436                "    v dir_1",
3437                "        > gitignored_dir",
3438                "          file_1.py",
3439                "          file_2.py",
3440                "          file_3.py",
3441                "    v dir_2",
3442                "          file_1.py  <== selected",
3443                "          file_2.py",
3444                "          file_3.py",
3445                "      .gitignore",
3446            ],
3447            "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3448        );
3449
3450        panel.update(cx, |panel, cx| {
3451            panel.project.update(cx, |_, cx| {
3452                cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3453            })
3454        });
3455        cx.run_until_parked();
3456        assert_eq!(
3457            visible_entries_as_strings(&panel, 0..20, cx),
3458            &[
3459                "v project_root",
3460                "    > .git",
3461                "    v dir_1",
3462                "        v gitignored_dir",
3463                "              file_a.py  <== selected",
3464                "              file_b.py",
3465                "              file_c.py",
3466                "          file_1.py",
3467                "          file_2.py",
3468                "          file_3.py",
3469                "    v dir_2",
3470                "          file_1.py",
3471                "          file_2.py",
3472                "          file_3.py",
3473                "      .gitignore",
3474            ],
3475            "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3476        );
3477    }
3478
3479    fn toggle_expand_dir(
3480        panel: &View<ProjectPanel>,
3481        path: impl AsRef<Path>,
3482        cx: &mut VisualTestContext,
3483    ) {
3484        let path = path.as_ref();
3485        panel.update(cx, |panel, cx| {
3486            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3487                let worktree = worktree.read(cx);
3488                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3489                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3490                    panel.toggle_expanded(entry_id, cx);
3491                    return;
3492                }
3493            }
3494            panic!("no worktree for path {:?}", path);
3495        });
3496    }
3497
3498    fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
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.selection = Some(crate::Selection {
3506                        worktree_id: worktree.id(),
3507                        entry_id,
3508                    });
3509                    return;
3510                }
3511            }
3512            panic!("no worktree for path {:?}", path);
3513        });
3514    }
3515
3516    fn find_project_entry(
3517        panel: &View<ProjectPanel>,
3518        path: impl AsRef<Path>,
3519        cx: &mut VisualTestContext,
3520    ) -> Option<ProjectEntryId> {
3521        let path = path.as_ref();
3522        panel.update(cx, |panel, cx| {
3523            for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3524                let worktree = worktree.read(cx);
3525                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3526                    return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3527                }
3528            }
3529            panic!("no worktree for path {path:?}");
3530        })
3531    }
3532
3533    fn visible_entries_as_strings(
3534        panel: &View<ProjectPanel>,
3535        range: Range<usize>,
3536        cx: &mut VisualTestContext,
3537    ) -> Vec<String> {
3538        let mut result = Vec::new();
3539        let mut project_entries = HashSet::default();
3540        let mut has_editor = false;
3541
3542        panel.update(cx, |panel, cx| {
3543            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3544                if details.is_editing {
3545                    assert!(!has_editor, "duplicate editor entry");
3546                    has_editor = true;
3547                } else {
3548                    assert!(
3549                        project_entries.insert(project_entry),
3550                        "duplicate project entry {:?} {:?}",
3551                        project_entry,
3552                        details
3553                    );
3554                }
3555
3556                let indent = "    ".repeat(details.depth);
3557                let icon = if details.kind.is_dir() {
3558                    if details.is_expanded {
3559                        "v "
3560                    } else {
3561                        "> "
3562                    }
3563                } else {
3564                    "  "
3565                };
3566                let name = if details.is_editing {
3567                    format!("[EDITOR: '{}']", details.filename)
3568                } else if details.is_processing {
3569                    format!("[PROCESSING: '{}']", details.filename)
3570                } else {
3571                    details.filename.clone()
3572                };
3573                let selected = if details.is_selected {
3574                    "  <== selected"
3575                } else {
3576                    ""
3577                };
3578                result.push(format!("{indent}{icon}{name}{selected}"));
3579            });
3580        });
3581
3582        result
3583    }
3584
3585    fn init_test(cx: &mut TestAppContext) {
3586        cx.update(|cx| {
3587            let settings_store = SettingsStore::test(cx);
3588            cx.set_global(settings_store);
3589            init_settings(cx);
3590            theme::init(theme::LoadThemes::JustBase, cx);
3591            language::init(cx);
3592            editor::init_settings(cx);
3593            crate::init((), cx);
3594            workspace::init_settings(cx);
3595            client::init_settings(cx);
3596            Project::init_settings(cx);
3597
3598            cx.update_global::<SettingsStore, _>(|store, cx| {
3599                store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3600                    worktree_settings.file_scan_exclusions = Some(Vec::new());
3601                });
3602            });
3603        });
3604    }
3605
3606    fn init_test_with_editor(cx: &mut TestAppContext) {
3607        cx.update(|cx| {
3608            let app_state = AppState::test(cx);
3609            theme::init(theme::LoadThemes::JustBase, cx);
3610            init_settings(cx);
3611            language::init(cx);
3612            editor::init(cx);
3613            crate::init((), cx);
3614            workspace::init(app_state.clone(), cx);
3615            Project::init_settings(cx);
3616        });
3617    }
3618
3619    fn ensure_single_file_is_opened(
3620        window: &WindowHandle<Workspace>,
3621        expected_path: &str,
3622        cx: &mut TestAppContext,
3623    ) {
3624        window
3625            .update(cx, |workspace, cx| {
3626                let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3627                assert_eq!(worktrees.len(), 1);
3628                let worktree_id = worktrees[0].read(cx).id();
3629
3630                let open_project_paths = workspace
3631                    .panes()
3632                    .iter()
3633                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3634                    .collect::<Vec<_>>();
3635                assert_eq!(
3636                    open_project_paths,
3637                    vec![ProjectPath {
3638                        worktree_id,
3639                        path: Arc::from(Path::new(expected_path))
3640                    }],
3641                    "Should have opened file, selected in project panel"
3642                );
3643            })
3644            .unwrap();
3645    }
3646
3647    fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3648        assert!(
3649            !cx.has_pending_prompt(),
3650            "Should have no prompts before the deletion"
3651        );
3652        panel.update(cx, |panel, cx| {
3653            panel.delete(&Delete { skip_prompt: false }, cx)
3654        });
3655        assert!(
3656            cx.has_pending_prompt(),
3657            "Should have a prompt after the deletion"
3658        );
3659        cx.simulate_prompt_answer(0);
3660        assert!(
3661            !cx.has_pending_prompt(),
3662            "Should have no prompts after prompt was replied to"
3663        );
3664        cx.executor().run_until_parked();
3665    }
3666
3667    fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3668        assert!(
3669            !cx.has_pending_prompt(),
3670            "Should have no prompts before the deletion"
3671        );
3672        panel.update(cx, |panel, cx| {
3673            panel.delete(&Delete { skip_prompt: true }, cx)
3674        });
3675        assert!(!cx.has_pending_prompt(), "Should have received no prompts");
3676        cx.executor().run_until_parked();
3677    }
3678
3679    fn ensure_no_open_items_and_panes(
3680        workspace: &WindowHandle<Workspace>,
3681        cx: &mut VisualTestContext,
3682    ) {
3683        assert!(
3684            !cx.has_pending_prompt(),
3685            "Should have no prompts after deletion operation closes the file"
3686        );
3687        workspace
3688            .read_with(cx, |workspace, cx| {
3689                let open_project_paths = workspace
3690                    .panes()
3691                    .iter()
3692                    .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3693                    .collect::<Vec<_>>();
3694                assert!(
3695                    open_project_paths.is_empty(),
3696                    "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
3697                );
3698            })
3699            .unwrap();
3700    }
3701}