project_panel.rs

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