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