project_panel.rs

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