project_panel.rs

   1mod project_panel_settings;
   2
   3use context_menu::{ContextMenu, ContextMenuItem};
   4use db::kvp::KEY_VALUE_STORE;
   5use drag_and_drop::{DragAndDrop, Draggable};
   6use editor::{Cancel, Editor};
   7use futures::stream::StreamExt;
   8use gpui::{
   9    actions,
  10    anyhow::{self, anyhow, Result},
  11    elements::{
  12        AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
  13        ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
  14    },
  15    geometry::vector::Vector2F,
  16    keymap_matcher::KeymapContext,
  17    platform::{CursorStyle, MouseButton, PromptLevel},
  18    Action, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, ModelHandle,
  19    Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
  20};
  21use menu::{Confirm, SelectNext, SelectPrev};
  22use project::{
  23    repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
  24    Worktree, WorktreeId,
  25};
  26use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
  27use serde::{Deserialize, Serialize};
  28use settings::SettingsStore;
  29use std::{
  30    cmp::Ordering,
  31    collections::{hash_map, HashMap},
  32    ffi::OsStr,
  33    ops::Range,
  34    path::Path,
  35    sync::Arc,
  36};
  37use theme::ProjectPanelEntry;
  38use unicase::UniCase;
  39use util::{ResultExt, TryFutureExt};
  40use workspace::{
  41    dock::{DockPosition, Panel},
  42    Workspace,
  43};
  44
  45const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
  46const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  47
  48pub struct ProjectPanel {
  49    project: ModelHandle<Project>,
  50    fs: Arc<dyn Fs>,
  51    list: UniformListState,
  52    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  53    last_worktree_root_id: Option<ProjectEntryId>,
  54    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  55    selection: Option<Selection>,
  56    edit_state: Option<EditState>,
  57    filename_editor: ViewHandle<Editor>,
  58    clipboard_entry: Option<ClipboardEntry>,
  59    context_menu: ViewHandle<ContextMenu>,
  60    dragged_entry_destination: Option<Arc<Path>>,
  61    workspace: WeakViewHandle<Workspace>,
  62    has_focus: bool,
  63    width: Option<f32>,
  64    pending_serialization: Task<Option<()>>,
  65}
  66
  67#[derive(Copy, Clone)]
  68struct Selection {
  69    worktree_id: WorktreeId,
  70    entry_id: ProjectEntryId,
  71}
  72
  73#[derive(Clone, Debug)]
  74struct EditState {
  75    worktree_id: WorktreeId,
  76    entry_id: ProjectEntryId,
  77    is_new_entry: bool,
  78    is_dir: bool,
  79    processing_filename: Option<String>,
  80}
  81
  82#[derive(Copy, Clone)]
  83pub enum ClipboardEntry {
  84    Copied {
  85        worktree_id: WorktreeId,
  86        entry_id: ProjectEntryId,
  87    },
  88    Cut {
  89        worktree_id: WorktreeId,
  90        entry_id: ProjectEntryId,
  91    },
  92}
  93
  94#[derive(Debug, PartialEq, Eq)]
  95pub struct EntryDetails {
  96    filename: String,
  97    path: Arc<Path>,
  98    depth: usize,
  99    kind: EntryKind,
 100    is_ignored: bool,
 101    is_expanded: bool,
 102    is_selected: bool,
 103    is_editing: bool,
 104    is_processing: bool,
 105    is_cut: bool,
 106    git_status: Option<GitFileStatus>,
 107}
 108
 109actions!(
 110    project_panel,
 111    [
 112        ExpandSelectedEntry,
 113        CollapseSelectedEntry,
 114        NewDirectory,
 115        NewFile,
 116        Copy,
 117        CopyPath,
 118        CopyRelativePath,
 119        RevealInFinder,
 120        Cut,
 121        Paste,
 122        Delete,
 123        Rename,
 124        ToggleFocus
 125    ]
 126);
 127
 128pub fn init_settings(cx: &mut AppContext) {
 129    settings::register::<ProjectPanelSettings>(cx);
 130}
 131
 132pub fn init(cx: &mut AppContext) {
 133    init_settings(cx);
 134    cx.add_action(ProjectPanel::expand_selected_entry);
 135    cx.add_action(ProjectPanel::collapse_selected_entry);
 136    cx.add_action(ProjectPanel::select_prev);
 137    cx.add_action(ProjectPanel::select_next);
 138    cx.add_action(ProjectPanel::new_file);
 139    cx.add_action(ProjectPanel::new_directory);
 140    cx.add_action(ProjectPanel::rename);
 141    cx.add_async_action(ProjectPanel::delete);
 142    cx.add_async_action(ProjectPanel::confirm);
 143    cx.add_action(ProjectPanel::cancel);
 144    cx.add_action(ProjectPanel::cut);
 145    cx.add_action(ProjectPanel::copy);
 146    cx.add_action(ProjectPanel::copy_path);
 147    cx.add_action(ProjectPanel::copy_relative_path);
 148    cx.add_action(ProjectPanel::reveal_in_finder);
 149    cx.add_action(
 150        |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
 151            this.paste(action, cx);
 152        },
 153    );
 154}
 155
 156pub enum Event {
 157    OpenedEntry {
 158        entry_id: ProjectEntryId,
 159        focus_opened_item: bool,
 160    },
 161    DockPositionChanged,
 162    Focus,
 163}
 164
 165#[derive(Serialize, Deserialize)]
 166struct SerializedProjectPanel {
 167    width: Option<f32>,
 168}
 169
 170impl ProjectPanel {
 171    fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
 172        let project = workspace.project().clone();
 173        let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
 174            cx.observe(&project, |this, _, cx| {
 175                this.update_visible_entries(None, cx);
 176                cx.notify();
 177            })
 178            .detach();
 179            cx.subscribe(&project, |this, project, event, cx| match event {
 180                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 181                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
 182                    {
 183                        this.expand_entry(worktree_id, *entry_id, cx);
 184                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
 185                        this.autoscroll(cx);
 186                        cx.notify();
 187                    }
 188                }
 189                project::Event::WorktreeRemoved(id) => {
 190                    this.expanded_dir_ids.remove(id);
 191                    this.update_visible_entries(None, cx);
 192                    cx.notify();
 193                }
 194                _ => {}
 195            })
 196            .detach();
 197
 198            let filename_editor = cx.add_view(|cx| {
 199                Editor::single_line(
 200                    Some(Arc::new(|theme| {
 201                        let mut style = theme.project_panel.filename_editor.clone();
 202                        style.container.background_color.take();
 203                        style
 204                    })),
 205                    cx,
 206                )
 207            });
 208
 209            cx.subscribe(&filename_editor, |this, _, event, cx| match event {
 210                editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
 211                    this.autoscroll(cx);
 212                }
 213                _ => {}
 214            })
 215            .detach();
 216            cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
 217                if !is_focused
 218                    && this
 219                        .edit_state
 220                        .as_ref()
 221                        .map_or(false, |state| state.processing_filename.is_none())
 222                {
 223                    this.edit_state = None;
 224                    this.update_visible_entries(None, cx);
 225                }
 226            })
 227            .detach();
 228
 229            let view_id = cx.view_id();
 230            let mut this = Self {
 231                project: project.clone(),
 232                fs: workspace.app_state().fs.clone(),
 233                list: Default::default(),
 234                visible_entries: Default::default(),
 235                last_worktree_root_id: Default::default(),
 236                expanded_dir_ids: Default::default(),
 237                selection: None,
 238                edit_state: None,
 239                filename_editor,
 240                clipboard_entry: None,
 241                context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
 242                dragged_entry_destination: None,
 243                workspace: workspace.weak_handle(),
 244                has_focus: false,
 245                width: None,
 246                pending_serialization: Task::ready(None),
 247            };
 248            this.update_visible_entries(None, cx);
 249
 250            // Update the dock position when the setting changes.
 251            let mut old_dock_position = this.position(cx);
 252            cx.observe_global::<SettingsStore, _>(move |this, cx| {
 253                let new_dock_position = this.position(cx);
 254                if new_dock_position != old_dock_position {
 255                    old_dock_position = new_dock_position;
 256                    cx.emit(Event::DockPositionChanged);
 257                }
 258            })
 259            .detach();
 260
 261            this
 262        });
 263
 264        cx.subscribe(&project_panel, {
 265            let project_panel = project_panel.downgrade();
 266            move |workspace, _, event, cx| match event {
 267                &Event::OpenedEntry {
 268                    entry_id,
 269                    focus_opened_item,
 270                } => {
 271                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 272                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 273                            workspace
 274                                .open_path(
 275                                    ProjectPath {
 276                                        worktree_id: worktree.read(cx).id(),
 277                                        path: entry.path.clone(),
 278                                    },
 279                                    None,
 280                                    focus_opened_item,
 281                                    cx,
 282                                )
 283                                .detach_and_log_err(cx);
 284                            if !focus_opened_item {
 285                                if let Some(project_panel) = project_panel.upgrade(cx) {
 286                                    cx.focus(&project_panel);
 287                                }
 288                            }
 289                        }
 290                    }
 291                }
 292                _ => {}
 293            }
 294        })
 295        .detach();
 296
 297        project_panel
 298    }
 299
 300    pub fn load(
 301        workspace: WeakViewHandle<Workspace>,
 302        cx: AsyncAppContext,
 303    ) -> Task<Result<ViewHandle<Self>>> {
 304        cx.spawn(|mut cx| async move {
 305            let serialized_panel = if let Some(panel) = cx
 306                .background()
 307                .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
 308                .await
 309                .log_err()
 310                .flatten()
 311            {
 312                Some(serde_json::from_str::<SerializedProjectPanel>(&panel)?)
 313            } else {
 314                None
 315            };
 316            workspace.update(&mut cx, |workspace, cx| {
 317                let panel = ProjectPanel::new(workspace, cx);
 318                if let Some(serialized_panel) = serialized_panel {
 319                    panel.update(cx, |panel, cx| {
 320                        panel.width = serialized_panel.width;
 321                        cx.notify();
 322                    });
 323                }
 324                panel
 325            })
 326        })
 327    }
 328
 329    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
 330        let width = self.width;
 331        self.pending_serialization = cx.background().spawn(
 332            async move {
 333                KEY_VALUE_STORE
 334                    .write_kvp(
 335                        PROJECT_PANEL_KEY.into(),
 336                        serde_json::to_string(&SerializedProjectPanel { width })?,
 337                    )
 338                    .await?;
 339                anyhow::Ok(())
 340            }
 341            .log_err(),
 342        );
 343    }
 344
 345    fn deploy_context_menu(
 346        &mut self,
 347        position: Vector2F,
 348        entry_id: ProjectEntryId,
 349        cx: &mut ViewContext<Self>,
 350    ) {
 351        let project = self.project.read(cx);
 352
 353        let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 354            id
 355        } else {
 356            return;
 357        };
 358
 359        self.selection = Some(Selection {
 360            worktree_id,
 361            entry_id,
 362        });
 363
 364        let mut menu_entries = Vec::new();
 365        if let Some((worktree, entry)) = self.selected_entry(cx) {
 366            let is_root = Some(entry) == worktree.root_entry();
 367            if !project.is_remote() {
 368                menu_entries.push(ContextMenuItem::action(
 369                    "Add Folder to Project",
 370                    workspace::AddFolderToProject,
 371                ));
 372                if is_root {
 373                    let project = self.project.clone();
 374                    menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
 375                        project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
 376                    }));
 377                }
 378            }
 379            menu_entries.push(ContextMenuItem::action("New File", NewFile));
 380            menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
 381            menu_entries.push(ContextMenuItem::Separator);
 382            menu_entries.push(ContextMenuItem::action("Cut", Cut));
 383            menu_entries.push(ContextMenuItem::action("Copy", Copy));
 384            menu_entries.push(ContextMenuItem::Separator);
 385            menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
 386            menu_entries.push(ContextMenuItem::action(
 387                "Copy Relative Path",
 388                CopyRelativePath,
 389            ));
 390            menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
 391            if let Some(clipboard_entry) = self.clipboard_entry {
 392                if clipboard_entry.worktree_id() == worktree.id() {
 393                    menu_entries.push(ContextMenuItem::action("Paste", Paste));
 394                }
 395            }
 396            menu_entries.push(ContextMenuItem::Separator);
 397            menu_entries.push(ContextMenuItem::action("Rename", Rename));
 398            if !is_root {
 399                menu_entries.push(ContextMenuItem::action("Delete", Delete));
 400            }
 401        }
 402
 403        self.context_menu.update(cx, |menu, cx| {
 404            menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
 405        });
 406
 407        cx.notify();
 408    }
 409
 410    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 411        if let Some((worktree, entry)) = self.selected_entry(cx) {
 412            if entry.is_dir() {
 413                let expanded_dir_ids =
 414                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 415                        expanded_dir_ids
 416                    } else {
 417                        return;
 418                    };
 419
 420                match expanded_dir_ids.binary_search(&entry.id) {
 421                    Ok(_) => self.select_next(&SelectNext, cx),
 422                    Err(ix) => {
 423                        expanded_dir_ids.insert(ix, entry.id);
 424                        self.update_visible_entries(None, cx);
 425                        cx.notify();
 426                    }
 427                }
 428            }
 429        }
 430    }
 431
 432    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 433        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 434            let expanded_dir_ids =
 435                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 436                    expanded_dir_ids
 437                } else {
 438                    return;
 439                };
 440
 441            loop {
 442                match expanded_dir_ids.binary_search(&entry.id) {
 443                    Ok(ix) => {
 444                        expanded_dir_ids.remove(ix);
 445                        self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
 446                        cx.notify();
 447                        break;
 448                    }
 449                    Err(_) => {
 450                        if let Some(parent_entry) =
 451                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 452                        {
 453                            entry = parent_entry;
 454                        } else {
 455                            break;
 456                        }
 457                    }
 458                }
 459            }
 460        }
 461    }
 462
 463    fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
 464        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 465            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 466                match expanded_dir_ids.binary_search(&entry_id) {
 467                    Ok(ix) => {
 468                        expanded_dir_ids.remove(ix);
 469                    }
 470                    Err(ix) => {
 471                        expanded_dir_ids.insert(ix, entry_id);
 472                    }
 473                }
 474                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 475                cx.focus_self();
 476                cx.notify();
 477            }
 478        }
 479    }
 480
 481    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 482        if let Some(selection) = self.selection {
 483            let (mut worktree_ix, mut entry_ix, _) =
 484                self.index_for_selection(selection).unwrap_or_default();
 485            if entry_ix > 0 {
 486                entry_ix -= 1;
 487            } else if worktree_ix > 0 {
 488                worktree_ix -= 1;
 489                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 490            } else {
 491                return;
 492            }
 493
 494            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 495            self.selection = Some(Selection {
 496                worktree_id: *worktree_id,
 497                entry_id: worktree_entries[entry_ix].id,
 498            });
 499            self.autoscroll(cx);
 500            cx.notify();
 501        } else {
 502            self.select_first(cx);
 503        }
 504    }
 505
 506    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 507        if let Some(task) = self.confirm_edit(cx) {
 508            Some(task)
 509        } else if let Some((_, entry)) = self.selected_entry(cx) {
 510            if entry.is_file() {
 511                self.open_entry(entry.id, true, cx);
 512            }
 513            None
 514        } else {
 515            None
 516        }
 517    }
 518
 519    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 520        let edit_state = self.edit_state.as_mut()?;
 521        cx.focus_self();
 522
 523        let worktree_id = edit_state.worktree_id;
 524        let is_new_entry = edit_state.is_new_entry;
 525        let is_dir = edit_state.is_dir;
 526        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 527        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 528        let filename = self.filename_editor.read(cx).text(cx);
 529
 530        let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
 531        let edit_task;
 532        let edited_entry_id;
 533        if is_new_entry {
 534            self.selection = Some(Selection {
 535                worktree_id,
 536                entry_id: NEW_ENTRY_ID,
 537            });
 538            let new_path = entry.path.join(&filename);
 539            if path_already_exists(new_path.as_path()) {
 540                return None;
 541            }
 542
 543            edited_entry_id = NEW_ENTRY_ID;
 544            edit_task = self.project.update(cx, |project, cx| {
 545                project.create_entry((worktree_id, &new_path), is_dir, cx)
 546            })?;
 547        } else {
 548            let new_path = if let Some(parent) = entry.path.clone().parent() {
 549                parent.join(&filename)
 550            } else {
 551                filename.clone().into()
 552            };
 553            if path_already_exists(new_path.as_path()) {
 554                return None;
 555            }
 556
 557            edited_entry_id = entry.id;
 558            edit_task = self.project.update(cx, |project, cx| {
 559                project.rename_entry(entry.id, new_path.as_path(), cx)
 560            })?;
 561        };
 562
 563        edit_state.processing_filename = Some(filename);
 564        cx.notify();
 565
 566        Some(cx.spawn(|this, mut cx| async move {
 567            let new_entry = edit_task.await;
 568            this.update(&mut cx, |this, cx| {
 569                this.edit_state.take();
 570                cx.notify();
 571            })?;
 572
 573            let new_entry = new_entry?;
 574            this.update(&mut cx, |this, cx| {
 575                if let Some(selection) = &mut this.selection {
 576                    if selection.entry_id == edited_entry_id {
 577                        selection.worktree_id = worktree_id;
 578                        selection.entry_id = new_entry.id;
 579                    }
 580                }
 581                this.update_visible_entries(None, cx);
 582                if is_new_entry && !is_dir {
 583                    this.open_entry(new_entry.id, true, cx);
 584                }
 585                cx.notify();
 586            })?;
 587            Ok(())
 588        }))
 589    }
 590
 591    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 592        self.edit_state = None;
 593        self.update_visible_entries(None, cx);
 594        cx.focus_self();
 595        cx.notify();
 596    }
 597
 598    fn open_entry(
 599        &mut self,
 600        entry_id: ProjectEntryId,
 601        focus_opened_item: bool,
 602        cx: &mut ViewContext<Self>,
 603    ) {
 604        cx.emit(Event::OpenedEntry {
 605            entry_id,
 606            focus_opened_item,
 607        });
 608    }
 609
 610    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 611        self.add_entry(false, cx)
 612    }
 613
 614    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 615        self.add_entry(true, cx)
 616    }
 617
 618    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 619        if let Some(Selection {
 620            worktree_id,
 621            entry_id,
 622        }) = self.selection
 623        {
 624            let directory_id;
 625            if let Some((worktree, expanded_dir_ids)) = self
 626                .project
 627                .read(cx)
 628                .worktree_for_id(worktree_id, cx)
 629                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 630            {
 631                let worktree = worktree.read(cx);
 632                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 633                    loop {
 634                        if entry.is_dir() {
 635                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 636                                expanded_dir_ids.insert(ix, entry.id);
 637                            }
 638                            directory_id = entry.id;
 639                            break;
 640                        } else {
 641                            if let Some(parent_path) = entry.path.parent() {
 642                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 643                                    entry = parent_entry;
 644                                    continue;
 645                                }
 646                            }
 647                            return;
 648                        }
 649                    }
 650                } else {
 651                    return;
 652                };
 653            } else {
 654                return;
 655            };
 656
 657            self.edit_state = Some(EditState {
 658                worktree_id,
 659                entry_id: directory_id,
 660                is_new_entry: true,
 661                is_dir,
 662                processing_filename: None,
 663            });
 664            self.filename_editor
 665                .update(cx, |editor, cx| editor.clear(cx));
 666            cx.focus(&self.filename_editor);
 667            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 668            self.autoscroll(cx);
 669            cx.notify();
 670        }
 671    }
 672
 673    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 674        if let Some(Selection {
 675            worktree_id,
 676            entry_id,
 677        }) = self.selection
 678        {
 679            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 680                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 681                    self.edit_state = Some(EditState {
 682                        worktree_id,
 683                        entry_id,
 684                        is_new_entry: false,
 685                        is_dir: entry.is_dir(),
 686                        processing_filename: None,
 687                    });
 688                    let filename = entry
 689                        .path
 690                        .file_name()
 691                        .map_or(String::new(), |s| s.to_string_lossy().to_string());
 692                    self.filename_editor.update(cx, |editor, cx| {
 693                        editor.set_text(filename, cx);
 694                        editor.select_all(&Default::default(), cx);
 695                    });
 696                    cx.focus(&self.filename_editor);
 697                    self.update_visible_entries(None, cx);
 698                    self.autoscroll(cx);
 699                    cx.notify();
 700                }
 701            }
 702
 703            cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
 704                drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
 705            })
 706        }
 707    }
 708
 709    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 710        let Selection { entry_id, .. } = self.selection?;
 711        let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 712        let file_name = path.file_name()?;
 713
 714        let mut answer = cx.prompt(
 715            PromptLevel::Info,
 716            &format!("Delete {file_name:?}?"),
 717            &["Delete", "Cancel"],
 718        );
 719        Some(cx.spawn(|this, mut cx| async move {
 720            if answer.next().await != Some(0) {
 721                return Ok(());
 722            }
 723            this.update(&mut cx, |this, cx| {
 724                this.project
 725                    .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 726                    .ok_or_else(|| anyhow!("no such entry"))
 727            })??
 728            .await
 729        }))
 730    }
 731
 732    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 733        if let Some(selection) = self.selection {
 734            let (mut worktree_ix, mut entry_ix, _) =
 735                self.index_for_selection(selection).unwrap_or_default();
 736            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 737                if entry_ix + 1 < worktree_entries.len() {
 738                    entry_ix += 1;
 739                } else {
 740                    worktree_ix += 1;
 741                    entry_ix = 0;
 742                }
 743            }
 744
 745            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 746                if let Some(entry) = worktree_entries.get(entry_ix) {
 747                    self.selection = Some(Selection {
 748                        worktree_id: *worktree_id,
 749                        entry_id: entry.id,
 750                    });
 751                    self.autoscroll(cx);
 752                    cx.notify();
 753                }
 754            }
 755        } else {
 756            self.select_first(cx);
 757        }
 758    }
 759
 760    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 761        let worktree = self
 762            .visible_entries
 763            .first()
 764            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 765        if let Some(worktree) = worktree {
 766            let worktree = worktree.read(cx);
 767            let worktree_id = worktree.id();
 768            if let Some(root_entry) = worktree.root_entry() {
 769                self.selection = Some(Selection {
 770                    worktree_id,
 771                    entry_id: root_entry.id,
 772                });
 773                self.autoscroll(cx);
 774                cx.notify();
 775            }
 776        }
 777    }
 778
 779    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
 780        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 781            self.list.scroll_to(ScrollTarget::Show(index));
 782            cx.notify();
 783        }
 784    }
 785
 786    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 787        if let Some((worktree, entry)) = self.selected_entry(cx) {
 788            self.clipboard_entry = Some(ClipboardEntry::Cut {
 789                worktree_id: worktree.id(),
 790                entry_id: entry.id,
 791            });
 792            cx.notify();
 793        }
 794    }
 795
 796    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 797        if let Some((worktree, entry)) = self.selected_entry(cx) {
 798            self.clipboard_entry = Some(ClipboardEntry::Copied {
 799                worktree_id: worktree.id(),
 800                entry_id: entry.id,
 801            });
 802            cx.notify();
 803        }
 804    }
 805
 806    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
 807        if let Some((worktree, entry)) = self.selected_entry(cx) {
 808            let clipboard_entry = self.clipboard_entry?;
 809            if clipboard_entry.worktree_id() != worktree.id() {
 810                return None;
 811            }
 812
 813            let clipboard_entry_file_name = self
 814                .project
 815                .read(cx)
 816                .path_for_entry(clipboard_entry.entry_id(), cx)?
 817                .path
 818                .file_name()?
 819                .to_os_string();
 820
 821            let mut new_path = entry.path.to_path_buf();
 822            if entry.is_file() {
 823                new_path.pop();
 824            }
 825
 826            new_path.push(&clipboard_entry_file_name);
 827            let extension = new_path.extension().map(|e| e.to_os_string());
 828            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 829            let mut ix = 0;
 830            while worktree.entry_for_path(&new_path).is_some() {
 831                new_path.pop();
 832
 833                let mut new_file_name = file_name_without_extension.to_os_string();
 834                new_file_name.push(" copy");
 835                if ix > 0 {
 836                    new_file_name.push(format!(" {}", ix));
 837                }
 838                if let Some(extension) = extension.as_ref() {
 839                    new_file_name.push(".");
 840                    new_file_name.push(extension);
 841                }
 842
 843                new_path.push(new_file_name);
 844                ix += 1;
 845            }
 846
 847            if clipboard_entry.is_cut() {
 848                if let Some(task) = self.project.update(cx, |project, cx| {
 849                    project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 850                }) {
 851                    task.detach_and_log_err(cx)
 852                }
 853            } else if let Some(task) = self.project.update(cx, |project, cx| {
 854                project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 855            }) {
 856                task.detach_and_log_err(cx)
 857            }
 858        }
 859        None
 860    }
 861
 862    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 863        if let Some((worktree, entry)) = self.selected_entry(cx) {
 864            cx.write_to_clipboard(ClipboardItem::new(
 865                worktree
 866                    .abs_path()
 867                    .join(&entry.path)
 868                    .to_string_lossy()
 869                    .to_string(),
 870            ));
 871        }
 872    }
 873
 874    fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
 875        if let Some((_, entry)) = self.selected_entry(cx) {
 876            cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
 877        }
 878    }
 879
 880    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
 881        if let Some((worktree, entry)) = self.selected_entry(cx) {
 882            cx.reveal_path(&worktree.abs_path().join(&entry.path));
 883        }
 884    }
 885
 886    fn move_entry(
 887        &mut self,
 888        entry_to_move: ProjectEntryId,
 889        destination: ProjectEntryId,
 890        destination_is_file: bool,
 891        cx: &mut ViewContext<Self>,
 892    ) {
 893        let destination_worktree = self.project.update(cx, |project, cx| {
 894            let entry_path = project.path_for_entry(entry_to_move, cx)?;
 895            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
 896
 897            let mut destination_path = destination_entry_path.as_ref();
 898            if destination_is_file {
 899                destination_path = destination_path.parent()?;
 900            }
 901
 902            let mut new_path = destination_path.to_path_buf();
 903            new_path.push(entry_path.path.file_name()?);
 904            if new_path != entry_path.path.as_ref() {
 905                let task = project.rename_entry(entry_to_move, new_path, cx)?;
 906                cx.foreground().spawn(task).detach_and_log_err(cx);
 907            }
 908
 909            Some(project.worktree_id_for_entry(destination, cx)?)
 910        });
 911
 912        if let Some(destination_worktree) = destination_worktree {
 913            self.expand_entry(destination_worktree, destination, cx);
 914        }
 915    }
 916
 917    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
 918        let mut entry_index = 0;
 919        let mut visible_entries_index = 0;
 920        for (worktree_index, (worktree_id, worktree_entries)) in
 921            self.visible_entries.iter().enumerate()
 922        {
 923            if *worktree_id == selection.worktree_id {
 924                for entry in worktree_entries {
 925                    if entry.id == selection.entry_id {
 926                        return Some((worktree_index, entry_index, visible_entries_index));
 927                    } else {
 928                        visible_entries_index += 1;
 929                        entry_index += 1;
 930                    }
 931                }
 932                break;
 933            } else {
 934                visible_entries_index += worktree_entries.len();
 935            }
 936        }
 937        None
 938    }
 939
 940    fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
 941        let selection = self.selection?;
 942        let project = self.project.read(cx);
 943        let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
 944        Some((worktree, worktree.entry_for_id(selection.entry_id)?))
 945    }
 946
 947    fn update_visible_entries(
 948        &mut self,
 949        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
 950        cx: &mut ViewContext<Self>,
 951    ) {
 952        let project = self.project.read(cx);
 953        self.last_worktree_root_id = project
 954            .visible_worktrees(cx)
 955            .rev()
 956            .next()
 957            .and_then(|worktree| worktree.read(cx).root_entry())
 958            .map(|entry| entry.id);
 959
 960        self.visible_entries.clear();
 961        for worktree in project.visible_worktrees(cx) {
 962            let snapshot = worktree.read(cx).snapshot();
 963            let worktree_id = snapshot.id();
 964
 965            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
 966                hash_map::Entry::Occupied(e) => e.into_mut(),
 967                hash_map::Entry::Vacant(e) => {
 968                    // The first time a worktree's root entry becomes available,
 969                    // mark that root entry as expanded.
 970                    if let Some(entry) = snapshot.root_entry() {
 971                        e.insert(vec![entry.id]).as_slice()
 972                    } else {
 973                        &[]
 974                    }
 975                }
 976            };
 977
 978            let mut new_entry_parent_id = None;
 979            let mut new_entry_kind = EntryKind::Dir;
 980            if let Some(edit_state) = &self.edit_state {
 981                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
 982                    new_entry_parent_id = Some(edit_state.entry_id);
 983                    new_entry_kind = if edit_state.is_dir {
 984                        EntryKind::Dir
 985                    } else {
 986                        EntryKind::File(Default::default())
 987                    };
 988                }
 989            }
 990
 991            let mut visible_worktree_entries = Vec::new();
 992            let mut entry_iter = snapshot.entries(true);
 993
 994            while let Some(entry) = entry_iter.entry() {
 995                visible_worktree_entries.push(entry.clone());
 996                if Some(entry.id) == new_entry_parent_id {
 997                    visible_worktree_entries.push(Entry {
 998                        id: NEW_ENTRY_ID,
 999                        kind: new_entry_kind,
1000                        path: entry.path.join("\0").into(),
1001                        inode: 0,
1002                        mtime: entry.mtime,
1003                        is_symlink: false,
1004                        is_ignored: false,
1005                        git_status: entry.git_status,
1006                    });
1007                }
1008                if expanded_dir_ids.binary_search(&entry.id).is_err()
1009                    && entry_iter.advance_to_sibling()
1010                {
1011                    continue;
1012                }
1013                entry_iter.advance();
1014            }
1015
1016            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1017
1018            visible_worktree_entries.sort_by(|entry_a, entry_b| {
1019                let mut components_a = entry_a.path.components().peekable();
1020                let mut components_b = entry_b.path.components().peekable();
1021                loop {
1022                    match (components_a.next(), components_b.next()) {
1023                        (Some(component_a), Some(component_b)) => {
1024                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1025                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1026                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1027                                let name_a =
1028                                    UniCase::new(component_a.as_os_str().to_string_lossy());
1029                                let name_b =
1030                                    UniCase::new(component_b.as_os_str().to_string_lossy());
1031                                name_a.cmp(&name_b)
1032                            });
1033                            if !ordering.is_eq() {
1034                                return ordering;
1035                            }
1036                        }
1037                        (Some(_), None) => break Ordering::Greater,
1038                        (None, Some(_)) => break Ordering::Less,
1039                        (None, None) => break Ordering::Equal,
1040                    }
1041                }
1042            });
1043            self.visible_entries
1044                .push((worktree_id, visible_worktree_entries));
1045        }
1046
1047        if let Some((worktree_id, entry_id)) = new_selected_entry {
1048            self.selection = Some(Selection {
1049                worktree_id,
1050                entry_id,
1051            });
1052        }
1053    }
1054
1055    fn expand_entry(
1056        &mut self,
1057        worktree_id: WorktreeId,
1058        entry_id: ProjectEntryId,
1059        cx: &mut ViewContext<Self>,
1060    ) {
1061        let project = self.project.read(cx);
1062        if let Some((worktree, expanded_dir_ids)) = project
1063            .worktree_for_id(worktree_id, cx)
1064            .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1065        {
1066            let worktree = worktree.read(cx);
1067
1068            if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1069                loop {
1070                    if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1071                        expanded_dir_ids.insert(ix, entry.id);
1072                    }
1073
1074                    if let Some(parent_entry) =
1075                        entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1076                    {
1077                        entry = parent_entry;
1078                    } else {
1079                        break;
1080                    }
1081                }
1082            }
1083        }
1084    }
1085
1086    fn for_each_visible_entry(
1087        &self,
1088        range: Range<usize>,
1089        cx: &mut ViewContext<ProjectPanel>,
1090        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1091    ) {
1092        let mut ix = 0;
1093        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1094            if ix >= range.end {
1095                return;
1096            }
1097
1098            if ix + visible_worktree_entries.len() <= range.start {
1099                ix += visible_worktree_entries.len();
1100                continue;
1101            }
1102
1103            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1104            let git_status_setting = settings::get::<ProjectPanelSettings>(cx).git_status;
1105            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1106                let snapshot = worktree.read(cx).snapshot();
1107                let root_name = OsStr::new(snapshot.root_name());
1108                let expanded_entry_ids = self
1109                    .expanded_dir_ids
1110                    .get(&snapshot.id())
1111                    .map(Vec::as_slice)
1112                    .unwrap_or(&[]);
1113
1114                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1115                for entry in visible_worktree_entries[entry_range].iter() {
1116                    let status = git_status_setting.then(|| entry.git_status).flatten();
1117
1118                    let mut details = EntryDetails {
1119                        filename: entry
1120                            .path
1121                            .file_name()
1122                            .unwrap_or(root_name)
1123                            .to_string_lossy()
1124                            .to_string(),
1125                        path: entry.path.clone(),
1126                        depth: entry.path.components().count(),
1127                        kind: entry.kind,
1128                        is_ignored: entry.is_ignored,
1129                        is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
1130                        is_selected: self.selection.map_or(false, |e| {
1131                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1132                        }),
1133                        is_editing: false,
1134                        is_processing: false,
1135                        is_cut: self
1136                            .clipboard_entry
1137                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1138                        git_status: status,
1139                    };
1140
1141                    if let Some(edit_state) = &self.edit_state {
1142                        let is_edited_entry = if edit_state.is_new_entry {
1143                            entry.id == NEW_ENTRY_ID
1144                        } else {
1145                            entry.id == edit_state.entry_id
1146                        };
1147
1148                        if is_edited_entry {
1149                            if let Some(processing_filename) = &edit_state.processing_filename {
1150                                details.is_processing = true;
1151                                details.filename.clear();
1152                                details.filename.push_str(processing_filename);
1153                            } else {
1154                                if edit_state.is_new_entry {
1155                                    details.filename.clear();
1156                                }
1157                                details.is_editing = true;
1158                            }
1159                        }
1160                    }
1161
1162                    callback(entry.id, details, cx);
1163                }
1164            }
1165            ix = end_ix;
1166        }
1167    }
1168
1169    fn render_entry_visual_element<V: View>(
1170        details: &EntryDetails,
1171        editor: Option<&ViewHandle<Editor>>,
1172        padding: f32,
1173        row_container_style: ContainerStyle,
1174        style: &ProjectPanelEntry,
1175        cx: &mut ViewContext<V>,
1176    ) -> AnyElement<V> {
1177        let kind = details.kind;
1178        let show_editor = details.is_editing && !details.is_processing;
1179
1180        let mut filename_text_style = style.text.clone();
1181        filename_text_style.color = details
1182            .git_status
1183            .as_ref()
1184            .map(|status| match status {
1185                GitFileStatus::Added => style.status.git.inserted,
1186                GitFileStatus::Modified => style.status.git.modified,
1187                GitFileStatus::Conflict => style.status.git.conflict,
1188            })
1189            .unwrap_or(style.text.color);
1190
1191        Flex::row()
1192            .with_child(
1193                if kind == EntryKind::Dir {
1194                    if details.is_expanded {
1195                        Svg::new("icons/chevron_down_8.svg").with_color(style.icon_color)
1196                    } else {
1197                        Svg::new("icons/chevron_right_8.svg").with_color(style.icon_color)
1198                    }
1199                    .constrained()
1200                } else {
1201                    Empty::new().constrained()
1202                }
1203                .with_max_width(style.icon_size)
1204                .with_max_height(style.icon_size)
1205                .aligned()
1206                .constrained()
1207                .with_width(style.icon_size),
1208            )
1209            .with_child(if show_editor && editor.is_some() {
1210                ChildView::new(editor.as_ref().unwrap(), cx)
1211                    .contained()
1212                    .with_margin_left(style.icon_spacing)
1213                    .aligned()
1214                    .left()
1215                    .flex(1.0, true)
1216                    .into_any()
1217            } else {
1218                Label::new(details.filename.clone(), filename_text_style)
1219                    .contained()
1220                    .with_margin_left(style.icon_spacing)
1221                    .aligned()
1222                    .left()
1223                    .into_any()
1224            })
1225            .constrained()
1226            .with_height(style.height)
1227            .contained()
1228            .with_style(row_container_style)
1229            .with_padding_left(padding)
1230            .into_any_named("project panel entry visual element")
1231    }
1232
1233    fn render_entry(
1234        entry_id: ProjectEntryId,
1235        details: EntryDetails,
1236        editor: &ViewHandle<Editor>,
1237        dragged_entry_destination: &mut Option<Arc<Path>>,
1238        theme: &theme::ProjectPanel,
1239        cx: &mut ViewContext<Self>,
1240    ) -> AnyElement<Self> {
1241        let kind = details.kind;
1242        let path = details.path.clone();
1243        let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
1244
1245        let entry_style = if details.is_cut {
1246            &theme.cut_entry
1247        } else if details.is_ignored {
1248            &theme.ignored_entry
1249        } else {
1250            &theme.entry
1251        };
1252
1253        let show_editor = details.is_editing && !details.is_processing;
1254
1255        MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
1256            let mut style = entry_style.style_for(state, details.is_selected).clone();
1257
1258            if cx
1259                .global::<DragAndDrop<Workspace>>()
1260                .currently_dragged::<ProjectEntryId>(cx.window_id())
1261                .is_some()
1262                && dragged_entry_destination
1263                    .as_ref()
1264                    .filter(|destination| details.path.starts_with(destination))
1265                    .is_some()
1266            {
1267                style = entry_style.active.clone().unwrap();
1268            }
1269
1270            let row_container_style = if show_editor {
1271                theme.filename_editor.container
1272            } else {
1273                style.container
1274            };
1275
1276            Self::render_entry_visual_element(
1277                &details,
1278                Some(editor),
1279                padding,
1280                row_container_style,
1281                &style,
1282                cx,
1283            )
1284        })
1285        .on_click(MouseButton::Left, move |event, this, cx| {
1286            if !show_editor {
1287                if kind == EntryKind::Dir {
1288                    this.toggle_expanded(entry_id, cx);
1289                } else {
1290                    this.open_entry(entry_id, event.click_count > 1, cx);
1291                }
1292            }
1293        })
1294        .on_down(MouseButton::Right, move |event, this, cx| {
1295            this.deploy_context_menu(event.position, entry_id, cx);
1296        })
1297        .on_up(MouseButton::Left, move |_, this, cx| {
1298            if let Some((_, dragged_entry)) = cx
1299                .global::<DragAndDrop<Workspace>>()
1300                .currently_dragged::<ProjectEntryId>(cx.window_id())
1301            {
1302                this.move_entry(
1303                    *dragged_entry,
1304                    entry_id,
1305                    matches!(details.kind, EntryKind::File(_)),
1306                    cx,
1307                );
1308            }
1309        })
1310        .on_move(move |_, this, cx| {
1311            if cx
1312                .global::<DragAndDrop<Workspace>>()
1313                .currently_dragged::<ProjectEntryId>(cx.window_id())
1314                .is_some()
1315            {
1316                this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1317                    path.parent().map(|parent| Arc::from(parent))
1318                } else {
1319                    Some(path.clone())
1320                };
1321            }
1322        })
1323        .as_draggable(entry_id, {
1324            let row_container_style = theme.dragged_entry.container;
1325
1326            move |_, cx: &mut ViewContext<Workspace>| {
1327                let theme = theme::current(cx).clone();
1328                Self::render_entry_visual_element(
1329                    &details,
1330                    None,
1331                    padding,
1332                    row_container_style,
1333                    &theme.project_panel.dragged_entry,
1334                    cx,
1335                )
1336            }
1337        })
1338        .with_cursor_style(CursorStyle::PointingHand)
1339        .into_any_named("project panel entry")
1340    }
1341}
1342
1343impl View for ProjectPanel {
1344    fn ui_name() -> &'static str {
1345        "ProjectPanel"
1346    }
1347
1348    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1349        enum ProjectPanel {}
1350        let theme = &theme::current(cx).project_panel;
1351        let mut container_style = theme.container;
1352        let padding = std::mem::take(&mut container_style.padding);
1353        let last_worktree_root_id = self.last_worktree_root_id;
1354
1355        let has_worktree = self.visible_entries.len() != 0;
1356
1357        if has_worktree {
1358            Stack::new()
1359                .with_child(
1360                    MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1361                        UniformList::new(
1362                            self.list.clone(),
1363                            self.visible_entries
1364                                .iter()
1365                                .map(|(_, worktree_entries)| worktree_entries.len())
1366                                .sum(),
1367                            cx,
1368                            move |this, range, items, cx| {
1369                                let theme = theme::current(cx).clone();
1370                                let mut dragged_entry_destination =
1371                                    this.dragged_entry_destination.clone();
1372                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1373                                    items.push(Self::render_entry(
1374                                        id,
1375                                        details,
1376                                        &this.filename_editor,
1377                                        &mut dragged_entry_destination,
1378                                        &theme.project_panel,
1379                                        cx,
1380                                    ));
1381                                });
1382                                this.dragged_entry_destination = dragged_entry_destination;
1383                            },
1384                        )
1385                        .with_padding_top(padding.top)
1386                        .with_padding_bottom(padding.bottom)
1387                        .contained()
1388                        .with_style(container_style)
1389                        .expanded()
1390                    })
1391                    .on_down(MouseButton::Right, move |event, this, cx| {
1392                        // When deploying the context menu anywhere below the last project entry,
1393                        // act as if the user clicked the root of the last worktree.
1394                        if let Some(entry_id) = last_worktree_root_id {
1395                            this.deploy_context_menu(event.position, entry_id, cx);
1396                        }
1397                    }),
1398                )
1399                .with_child(ChildView::new(&self.context_menu, cx))
1400                .into_any_named("project panel")
1401        } else {
1402            Flex::column()
1403                .with_child(
1404                    MouseEventHandler::<Self, _>::new(2, cx, {
1405                        let button_style = theme.open_project_button.clone();
1406                        let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1407                        move |state, cx| {
1408                            let button_style = button_style.style_for(state, false).clone();
1409                            let context_menu_item =
1410                                context_menu_item_style.style_for(state, true).clone();
1411
1412                            theme::ui::keystroke_label(
1413                                "Open a project",
1414                                &button_style,
1415                                &context_menu_item.keystroke,
1416                                Box::new(workspace::Open),
1417                                cx,
1418                            )
1419                        }
1420                    })
1421                    .on_click(MouseButton::Left, move |_, this, cx| {
1422                        if let Some(workspace) = this.workspace.upgrade(cx) {
1423                            workspace.update(cx, |workspace, cx| {
1424                                if let Some(task) = workspace.open(&Default::default(), cx) {
1425                                    task.detach_and_log_err(cx);
1426                                }
1427                            })
1428                        }
1429                    })
1430                    .with_cursor_style(CursorStyle::PointingHand),
1431                )
1432                .contained()
1433                .with_style(container_style)
1434                .into_any_named("empty project panel")
1435        }
1436    }
1437
1438    fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1439        Self::reset_to_default_keymap_context(keymap);
1440        keymap.add_identifier("menu");
1441    }
1442
1443    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1444        if !self.has_focus {
1445            self.has_focus = true;
1446            cx.emit(Event::Focus);
1447        }
1448    }
1449
1450    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1451        self.has_focus = false;
1452    }
1453}
1454
1455impl Entity for ProjectPanel {
1456    type Event = Event;
1457}
1458
1459impl workspace::dock::Panel for ProjectPanel {
1460    fn position(&self, cx: &WindowContext) -> DockPosition {
1461        match settings::get::<ProjectPanelSettings>(cx).dock {
1462            ProjectPanelDockPosition::Left => DockPosition::Left,
1463            ProjectPanelDockPosition::Right => DockPosition::Right,
1464        }
1465    }
1466
1467    fn position_is_valid(&self, position: DockPosition) -> bool {
1468        matches!(position, DockPosition::Left | DockPosition::Right)
1469    }
1470
1471    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1472        settings::update_settings_file::<ProjectPanelSettings>(
1473            self.fs.clone(),
1474            cx,
1475            move |settings| {
1476                let dock = match position {
1477                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1478                    DockPosition::Right => ProjectPanelDockPosition::Right,
1479                };
1480                settings.dock = Some(dock);
1481            },
1482        );
1483    }
1484
1485    fn size(&self, cx: &WindowContext) -> f32 {
1486        self.width
1487            .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1488    }
1489
1490    fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
1491        self.width = Some(size);
1492        self.serialize(cx);
1493        cx.notify();
1494    }
1495
1496    fn should_zoom_in_on_event(_: &Self::Event) -> bool {
1497        false
1498    }
1499
1500    fn should_zoom_out_on_event(_: &Self::Event) -> bool {
1501        false
1502    }
1503
1504    fn is_zoomed(&self, _: &WindowContext) -> bool {
1505        false
1506    }
1507
1508    fn set_zoomed(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1509
1510    fn set_active(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1511
1512    fn icon_path(&self) -> &'static str {
1513        "icons/folder_tree_16.svg"
1514    }
1515
1516    fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1517        ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1518    }
1519
1520    fn should_change_position_on_event(event: &Self::Event) -> bool {
1521        matches!(event, Event::DockPositionChanged)
1522    }
1523
1524    fn should_activate_on_event(_: &Self::Event) -> bool {
1525        false
1526    }
1527
1528    fn should_close_on_event(_: &Self::Event) -> bool {
1529        false
1530    }
1531
1532    fn has_focus(&self, _: &WindowContext) -> bool {
1533        self.has_focus
1534    }
1535
1536    fn is_focus_event(event: &Self::Event) -> bool {
1537        matches!(event, Event::Focus)
1538    }
1539}
1540
1541impl ClipboardEntry {
1542    fn is_cut(&self) -> bool {
1543        matches!(self, Self::Cut { .. })
1544    }
1545
1546    fn entry_id(&self) -> ProjectEntryId {
1547        match self {
1548            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1549                *entry_id
1550            }
1551        }
1552    }
1553
1554    fn worktree_id(&self) -> WorktreeId {
1555        match self {
1556            ClipboardEntry::Copied { worktree_id, .. }
1557            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1558        }
1559    }
1560}
1561
1562#[cfg(test)]
1563mod tests {
1564    use super::*;
1565    use gpui::{TestAppContext, ViewHandle};
1566    use project::FakeFs;
1567    use serde_json::json;
1568    use settings::SettingsStore;
1569    use std::{collections::HashSet, path::Path};
1570    use workspace::{pane, AppState};
1571
1572    #[gpui::test]
1573    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1574        init_test(cx);
1575
1576        let fs = FakeFs::new(cx.background());
1577        fs.insert_tree(
1578            "/root1",
1579            json!({
1580                ".dockerignore": "",
1581                ".git": {
1582                    "HEAD": "",
1583                },
1584                "a": {
1585                    "0": { "q": "", "r": "", "s": "" },
1586                    "1": { "t": "", "u": "" },
1587                    "2": { "v": "", "w": "", "x": "", "y": "" },
1588                },
1589                "b": {
1590                    "3": { "Q": "" },
1591                    "4": { "R": "", "S": "", "T": "", "U": "" },
1592                },
1593                "C": {
1594                    "5": {},
1595                    "6": { "V": "", "W": "" },
1596                    "7": { "X": "" },
1597                    "8": { "Y": {}, "Z": "" }
1598                }
1599            }),
1600        )
1601        .await;
1602        fs.insert_tree(
1603            "/root2",
1604            json!({
1605                "d": {
1606                    "9": ""
1607                },
1608                "e": {}
1609            }),
1610        )
1611        .await;
1612
1613        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1614        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1615        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1616        assert_eq!(
1617            visible_entries_as_strings(&panel, 0..50, cx),
1618            &[
1619                "v root1",
1620                "    > .git",
1621                "    > a",
1622                "    > b",
1623                "    > C",
1624                "      .dockerignore",
1625                "v root2",
1626                "    > d",
1627                "    > e",
1628            ]
1629        );
1630
1631        toggle_expand_dir(&panel, "root1/b", cx);
1632        assert_eq!(
1633            visible_entries_as_strings(&panel, 0..50, cx),
1634            &[
1635                "v root1",
1636                "    > .git",
1637                "    > a",
1638                "    v b  <== selected",
1639                "        > 3",
1640                "        > 4",
1641                "    > C",
1642                "      .dockerignore",
1643                "v root2",
1644                "    > d",
1645                "    > e",
1646            ]
1647        );
1648
1649        assert_eq!(
1650            visible_entries_as_strings(&panel, 6..9, cx),
1651            &[
1652                //
1653                "    > C",
1654                "      .dockerignore",
1655                "v root2",
1656            ]
1657        );
1658    }
1659
1660    #[gpui::test(iterations = 30)]
1661    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1662        init_test(cx);
1663
1664        let fs = FakeFs::new(cx.background());
1665        fs.insert_tree(
1666            "/root1",
1667            json!({
1668                ".dockerignore": "",
1669                ".git": {
1670                    "HEAD": "",
1671                },
1672                "a": {
1673                    "0": { "q": "", "r": "", "s": "" },
1674                    "1": { "t": "", "u": "" },
1675                    "2": { "v": "", "w": "", "x": "", "y": "" },
1676                },
1677                "b": {
1678                    "3": { "Q": "" },
1679                    "4": { "R": "", "S": "", "T": "", "U": "" },
1680                },
1681                "C": {
1682                    "5": {},
1683                    "6": { "V": "", "W": "" },
1684                    "7": { "X": "" },
1685                    "8": { "Y": {}, "Z": "" }
1686                }
1687            }),
1688        )
1689        .await;
1690        fs.insert_tree(
1691            "/root2",
1692            json!({
1693                "d": {
1694                    "9": ""
1695                },
1696                "e": {}
1697            }),
1698        )
1699        .await;
1700
1701        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1702        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1703        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1704
1705        select_path(&panel, "root1", cx);
1706        assert_eq!(
1707            visible_entries_as_strings(&panel, 0..10, cx),
1708            &[
1709                "v root1  <== selected",
1710                "    > .git",
1711                "    > a",
1712                "    > b",
1713                "    > C",
1714                "      .dockerignore",
1715                "v root2",
1716                "    > d",
1717                "    > e",
1718            ]
1719        );
1720
1721        // Add a file with the root folder selected. The filename editor is placed
1722        // before the first file in the root folder.
1723        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1724        cx.read_window(window_id, |cx| {
1725            let panel = panel.read(cx);
1726            assert!(panel.filename_editor.is_focused(cx));
1727        });
1728        assert_eq!(
1729            visible_entries_as_strings(&panel, 0..10, cx),
1730            &[
1731                "v root1",
1732                "    > .git",
1733                "    > a",
1734                "    > b",
1735                "    > C",
1736                "      [EDITOR: '']  <== selected",
1737                "      .dockerignore",
1738                "v root2",
1739                "    > d",
1740                "    > e",
1741            ]
1742        );
1743
1744        let confirm = panel.update(cx, |panel, cx| {
1745            panel
1746                .filename_editor
1747                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1748            panel.confirm(&Confirm, cx).unwrap()
1749        });
1750        assert_eq!(
1751            visible_entries_as_strings(&panel, 0..10, cx),
1752            &[
1753                "v root1",
1754                "    > .git",
1755                "    > a",
1756                "    > b",
1757                "    > C",
1758                "      [PROCESSING: 'the-new-filename']  <== selected",
1759                "      .dockerignore",
1760                "v root2",
1761                "    > d",
1762                "    > e",
1763            ]
1764        );
1765
1766        confirm.await.unwrap();
1767        assert_eq!(
1768            visible_entries_as_strings(&panel, 0..10, cx),
1769            &[
1770                "v root1",
1771                "    > .git",
1772                "    > a",
1773                "    > b",
1774                "    > C",
1775                "      .dockerignore",
1776                "      the-new-filename  <== selected",
1777                "v root2",
1778                "    > d",
1779                "    > e",
1780            ]
1781        );
1782
1783        select_path(&panel, "root1/b", cx);
1784        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1785        assert_eq!(
1786            visible_entries_as_strings(&panel, 0..10, cx),
1787            &[
1788                "v root1",
1789                "    > .git",
1790                "    > a",
1791                "    v b",
1792                "        > 3",
1793                "        > 4",
1794                "          [EDITOR: '']  <== selected",
1795                "    > C",
1796                "      .dockerignore",
1797                "      the-new-filename",
1798            ]
1799        );
1800
1801        panel
1802            .update(cx, |panel, cx| {
1803                panel
1804                    .filename_editor
1805                    .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1806                panel.confirm(&Confirm, cx).unwrap()
1807            })
1808            .await
1809            .unwrap();
1810        assert_eq!(
1811            visible_entries_as_strings(&panel, 0..10, cx),
1812            &[
1813                "v root1",
1814                "    > .git",
1815                "    > a",
1816                "    v b",
1817                "        > 3",
1818                "        > 4",
1819                "          another-filename  <== selected",
1820                "    > C",
1821                "      .dockerignore",
1822                "      the-new-filename",
1823            ]
1824        );
1825
1826        select_path(&panel, "root1/b/another-filename", cx);
1827        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1828        assert_eq!(
1829            visible_entries_as_strings(&panel, 0..10, cx),
1830            &[
1831                "v root1",
1832                "    > .git",
1833                "    > a",
1834                "    v b",
1835                "        > 3",
1836                "        > 4",
1837                "          [EDITOR: 'another-filename']  <== selected",
1838                "    > C",
1839                "      .dockerignore",
1840                "      the-new-filename",
1841            ]
1842        );
1843
1844        let confirm = panel.update(cx, |panel, cx| {
1845            panel
1846                .filename_editor
1847                .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1848            panel.confirm(&Confirm, cx).unwrap()
1849        });
1850        assert_eq!(
1851            visible_entries_as_strings(&panel, 0..10, cx),
1852            &[
1853                "v root1",
1854                "    > .git",
1855                "    > a",
1856                "    v b",
1857                "        > 3",
1858                "        > 4",
1859                "          [PROCESSING: 'a-different-filename']  <== selected",
1860                "    > C",
1861                "      .dockerignore",
1862                "      the-new-filename",
1863            ]
1864        );
1865
1866        confirm.await.unwrap();
1867        assert_eq!(
1868            visible_entries_as_strings(&panel, 0..10, cx),
1869            &[
1870                "v root1",
1871                "    > .git",
1872                "    > a",
1873                "    v b",
1874                "        > 3",
1875                "        > 4",
1876                "          a-different-filename  <== selected",
1877                "    > C",
1878                "      .dockerignore",
1879                "      the-new-filename",
1880            ]
1881        );
1882
1883        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1884        assert_eq!(
1885            visible_entries_as_strings(&panel, 0..10, cx),
1886            &[
1887                "v root1",
1888                "    > .git",
1889                "    > a",
1890                "    v b",
1891                "        > [EDITOR: '']  <== selected",
1892                "        > 3",
1893                "        > 4",
1894                "          a-different-filename",
1895                "    > C",
1896                "      .dockerignore",
1897            ]
1898        );
1899
1900        let confirm = panel.update(cx, |panel, cx| {
1901            panel
1902                .filename_editor
1903                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1904            panel.confirm(&Confirm, cx).unwrap()
1905        });
1906        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1907        assert_eq!(
1908            visible_entries_as_strings(&panel, 0..10, cx),
1909            &[
1910                "v root1",
1911                "    > .git",
1912                "    > a",
1913                "    v b",
1914                "        > [PROCESSING: 'new-dir']",
1915                "        > 3  <== selected",
1916                "        > 4",
1917                "          a-different-filename",
1918                "    > C",
1919                "      .dockerignore",
1920            ]
1921        );
1922
1923        confirm.await.unwrap();
1924        assert_eq!(
1925            visible_entries_as_strings(&panel, 0..10, cx),
1926            &[
1927                "v root1",
1928                "    > .git",
1929                "    > a",
1930                "    v b",
1931                "        > 3  <== selected",
1932                "        > 4",
1933                "        > new-dir",
1934                "          a-different-filename",
1935                "    > C",
1936                "      .dockerignore",
1937            ]
1938        );
1939
1940        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1941        assert_eq!(
1942            visible_entries_as_strings(&panel, 0..10, cx),
1943            &[
1944                "v root1",
1945                "    > .git",
1946                "    > a",
1947                "    v b",
1948                "        > [EDITOR: '3']  <== selected",
1949                "        > 4",
1950                "        > new-dir",
1951                "          a-different-filename",
1952                "    > C",
1953                "      .dockerignore",
1954            ]
1955        );
1956
1957        // Dismiss the rename editor when it loses focus.
1958        workspace.update(cx, |_, cx| cx.focus_self());
1959        assert_eq!(
1960            visible_entries_as_strings(&panel, 0..10, cx),
1961            &[
1962                "v root1",
1963                "    > .git",
1964                "    > a",
1965                "    v b",
1966                "        > 3  <== selected",
1967                "        > 4",
1968                "        > new-dir",
1969                "          a-different-filename",
1970                "    > C",
1971                "      .dockerignore",
1972            ]
1973        );
1974    }
1975
1976    #[gpui::test]
1977    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1978        init_test(cx);
1979
1980        let fs = FakeFs::new(cx.background());
1981        fs.insert_tree(
1982            "/root1",
1983            json!({
1984                "one.two.txt": "",
1985                "one.txt": ""
1986            }),
1987        )
1988        .await;
1989
1990        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1991        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1992        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1993
1994        panel.update(cx, |panel, cx| {
1995            panel.select_next(&Default::default(), cx);
1996            panel.select_next(&Default::default(), cx);
1997        });
1998
1999        assert_eq!(
2000            visible_entries_as_strings(&panel, 0..50, cx),
2001            &[
2002                //
2003                "v root1",
2004                "      one.two.txt  <== selected",
2005                "      one.txt",
2006            ]
2007        );
2008
2009        // Regression test - file name is created correctly when
2010        // the copied file's name contains multiple dots.
2011        panel.update(cx, |panel, cx| {
2012            panel.copy(&Default::default(), cx);
2013            panel.paste(&Default::default(), cx);
2014        });
2015        cx.foreground().run_until_parked();
2016
2017        assert_eq!(
2018            visible_entries_as_strings(&panel, 0..50, cx),
2019            &[
2020                //
2021                "v root1",
2022                "      one.two copy.txt",
2023                "      one.two.txt  <== selected",
2024                "      one.txt",
2025            ]
2026        );
2027
2028        panel.update(cx, |panel, cx| {
2029            panel.paste(&Default::default(), cx);
2030        });
2031        cx.foreground().run_until_parked();
2032
2033        assert_eq!(
2034            visible_entries_as_strings(&panel, 0..50, cx),
2035            &[
2036                //
2037                "v root1",
2038                "      one.two copy 1.txt",
2039                "      one.two copy.txt",
2040                "      one.two.txt  <== selected",
2041                "      one.txt",
2042            ]
2043        );
2044    }
2045
2046    #[gpui::test]
2047    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2048        init_test_with_editor(cx);
2049
2050        let fs = FakeFs::new(cx.background());
2051        fs.insert_tree(
2052            "/src",
2053            json!({
2054                "test": {
2055                    "first.rs": "// First Rust file",
2056                    "second.rs": "// Second Rust file",
2057                    "third.rs": "// Third Rust file",
2058                }
2059            }),
2060        )
2061        .await;
2062
2063        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2064        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2065        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2066
2067        toggle_expand_dir(&panel, "src/test", cx);
2068        select_path(&panel, "src/test/first.rs", cx);
2069        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2070        cx.foreground().run_until_parked();
2071        assert_eq!(
2072            visible_entries_as_strings(&panel, 0..10, cx),
2073            &[
2074                "v src",
2075                "    v test",
2076                "          first.rs  <== selected",
2077                "          second.rs",
2078                "          third.rs"
2079            ]
2080        );
2081        ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
2082
2083        submit_deletion(window_id, &panel, cx);
2084        assert_eq!(
2085            visible_entries_as_strings(&panel, 0..10, cx),
2086            &[
2087                "v src",
2088                "    v test",
2089                "          second.rs",
2090                "          third.rs"
2091            ],
2092            "Project panel should have no deleted file, no other file is selected in it"
2093        );
2094        ensure_no_open_items_and_panes(window_id, &workspace, cx);
2095
2096        select_path(&panel, "src/test/second.rs", cx);
2097        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2098        cx.foreground().run_until_parked();
2099        assert_eq!(
2100            visible_entries_as_strings(&panel, 0..10, cx),
2101            &[
2102                "v src",
2103                "    v test",
2104                "          second.rs  <== selected",
2105                "          third.rs"
2106            ]
2107        );
2108        ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
2109
2110        cx.update_window(window_id, |cx| {
2111            let active_items = workspace
2112                .read(cx)
2113                .panes()
2114                .iter()
2115                .filter_map(|pane| pane.read(cx).active_item())
2116                .collect::<Vec<_>>();
2117            assert_eq!(active_items.len(), 1);
2118            let open_editor = active_items
2119                .into_iter()
2120                .next()
2121                .unwrap()
2122                .downcast::<Editor>()
2123                .expect("Open item should be an editor");
2124            open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2125        });
2126        submit_deletion(window_id, &panel, cx);
2127        assert_eq!(
2128            visible_entries_as_strings(&panel, 0..10, cx),
2129            &["v src", "    v test", "          third.rs"],
2130            "Project panel should have no deleted file, with one last file remaining"
2131        );
2132        ensure_no_open_items_and_panes(window_id, &workspace, cx);
2133    }
2134
2135    #[gpui::test]
2136    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2137        init_test_with_editor(cx);
2138
2139        let fs = FakeFs::new(cx.background());
2140        fs.insert_tree(
2141            "/src",
2142            json!({
2143                "test": {
2144                    "first.rs": "// First Rust file",
2145                    "second.rs": "// Second Rust file",
2146                    "third.rs": "// Third Rust file",
2147                }
2148            }),
2149        )
2150        .await;
2151
2152        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2153        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2154        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2155
2156        select_path(&panel, "src/", cx);
2157        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2158        cx.foreground().run_until_parked();
2159        assert_eq!(
2160            visible_entries_as_strings(&panel, 0..10, cx),
2161            &["v src  <== selected", "    > test"]
2162        );
2163        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2164        cx.read_window(window_id, |cx| {
2165            let panel = panel.read(cx);
2166            assert!(panel.filename_editor.is_focused(cx));
2167        });
2168        assert_eq!(
2169            visible_entries_as_strings(&panel, 0..10, cx),
2170            &["v src", "    > [EDITOR: '']  <== selected", "    > test"]
2171        );
2172        panel.update(cx, |panel, cx| {
2173            panel
2174                .filename_editor
2175                .update(cx, |editor, cx| editor.set_text("test", cx));
2176            assert!(
2177                panel.confirm(&Confirm, cx).is_none(),
2178                "Should not allow to confirm on conflicting new directory name"
2179            )
2180        });
2181        assert_eq!(
2182            visible_entries_as_strings(&panel, 0..10, cx),
2183            &["v src", "    > test"],
2184            "File list should be unchanged after failed folder create confirmation"
2185        );
2186
2187        select_path(&panel, "src/test/", cx);
2188        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2189        cx.foreground().run_until_parked();
2190        assert_eq!(
2191            visible_entries_as_strings(&panel, 0..10, cx),
2192            &["v src", "    > test  <== selected"]
2193        );
2194        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2195        cx.read_window(window_id, |cx| {
2196            let panel = panel.read(cx);
2197            assert!(panel.filename_editor.is_focused(cx));
2198        });
2199        assert_eq!(
2200            visible_entries_as_strings(&panel, 0..10, cx),
2201            &[
2202                "v src",
2203                "    v test",
2204                "          [EDITOR: '']  <== selected",
2205                "          first.rs",
2206                "          second.rs",
2207                "          third.rs"
2208            ]
2209        );
2210        panel.update(cx, |panel, cx| {
2211            panel
2212                .filename_editor
2213                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2214            assert!(
2215                panel.confirm(&Confirm, cx).is_none(),
2216                "Should not allow to confirm on conflicting new file name"
2217            )
2218        });
2219        assert_eq!(
2220            visible_entries_as_strings(&panel, 0..10, cx),
2221            &[
2222                "v src",
2223                "    v test",
2224                "          first.rs",
2225                "          second.rs",
2226                "          third.rs"
2227            ],
2228            "File list should be unchanged after failed file create confirmation"
2229        );
2230
2231        select_path(&panel, "src/test/first.rs", cx);
2232        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2233        cx.foreground().run_until_parked();
2234        assert_eq!(
2235            visible_entries_as_strings(&panel, 0..10, cx),
2236            &[
2237                "v src",
2238                "    v test",
2239                "          first.rs  <== selected",
2240                "          second.rs",
2241                "          third.rs"
2242            ],
2243        );
2244        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2245        cx.read_window(window_id, |cx| {
2246            let panel = panel.read(cx);
2247            assert!(panel.filename_editor.is_focused(cx));
2248        });
2249        assert_eq!(
2250            visible_entries_as_strings(&panel, 0..10, cx),
2251            &[
2252                "v src",
2253                "    v test",
2254                "          [EDITOR: 'first.rs']  <== selected",
2255                "          second.rs",
2256                "          third.rs"
2257            ]
2258        );
2259        panel.update(cx, |panel, cx| {
2260            panel
2261                .filename_editor
2262                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2263            assert!(
2264                panel.confirm(&Confirm, cx).is_none(),
2265                "Should not allow to confirm on conflicting file rename"
2266            )
2267        });
2268        assert_eq!(
2269            visible_entries_as_strings(&panel, 0..10, cx),
2270            &[
2271                "v src",
2272                "    v test",
2273                "          first.rs  <== selected",
2274                "          second.rs",
2275                "          third.rs"
2276            ],
2277            "File list should be unchanged after failed rename confirmation"
2278        );
2279    }
2280
2281    fn toggle_expand_dir(
2282        panel: &ViewHandle<ProjectPanel>,
2283        path: impl AsRef<Path>,
2284        cx: &mut TestAppContext,
2285    ) {
2286        let path = path.as_ref();
2287        panel.update(cx, |panel, cx| {
2288            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2289                let worktree = worktree.read(cx);
2290                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2291                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2292                    panel.toggle_expanded(entry_id, cx);
2293                    return;
2294                }
2295            }
2296            panic!("no worktree for path {:?}", path);
2297        });
2298    }
2299
2300    fn select_path(
2301        panel: &ViewHandle<ProjectPanel>,
2302        path: impl AsRef<Path>,
2303        cx: &mut TestAppContext,
2304    ) {
2305        let path = path.as_ref();
2306        panel.update(cx, |panel, cx| {
2307            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2308                let worktree = worktree.read(cx);
2309                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2310                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2311                    panel.selection = Some(Selection {
2312                        worktree_id: worktree.id(),
2313                        entry_id,
2314                    });
2315                    return;
2316                }
2317            }
2318            panic!("no worktree for path {:?}", path);
2319        });
2320    }
2321
2322    fn visible_entries_as_strings(
2323        panel: &ViewHandle<ProjectPanel>,
2324        range: Range<usize>,
2325        cx: &mut TestAppContext,
2326    ) -> Vec<String> {
2327        let mut result = Vec::new();
2328        let mut project_entries = HashSet::new();
2329        let mut has_editor = false;
2330
2331        panel.update(cx, |panel, cx| {
2332            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2333                if details.is_editing {
2334                    assert!(!has_editor, "duplicate editor entry");
2335                    has_editor = true;
2336                } else {
2337                    assert!(
2338                        project_entries.insert(project_entry),
2339                        "duplicate project entry {:?} {:?}",
2340                        project_entry,
2341                        details
2342                    );
2343                }
2344
2345                let indent = "    ".repeat(details.depth);
2346                let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
2347                    if details.is_expanded {
2348                        "v "
2349                    } else {
2350                        "> "
2351                    }
2352                } else {
2353                    "  "
2354                };
2355                let name = if details.is_editing {
2356                    format!("[EDITOR: '{}']", details.filename)
2357                } else if details.is_processing {
2358                    format!("[PROCESSING: '{}']", details.filename)
2359                } else {
2360                    details.filename.clone()
2361                };
2362                let selected = if details.is_selected {
2363                    "  <== selected"
2364                } else {
2365                    ""
2366                };
2367                result.push(format!("{indent}{icon}{name}{selected}"));
2368            });
2369        });
2370
2371        result
2372    }
2373
2374    fn init_test(cx: &mut TestAppContext) {
2375        cx.foreground().forbid_parking();
2376        cx.update(|cx| {
2377            cx.set_global(SettingsStore::test(cx));
2378            init_settings(cx);
2379            theme::init((), cx);
2380            language::init(cx);
2381            editor::init_settings(cx);
2382            crate::init(cx);
2383            workspace::init_settings(cx);
2384            Project::init_settings(cx);
2385        });
2386    }
2387
2388    fn init_test_with_editor(cx: &mut TestAppContext) {
2389        cx.foreground().forbid_parking();
2390        cx.update(|cx| {
2391            let app_state = AppState::test(cx);
2392            theme::init((), cx);
2393            init_settings(cx);
2394            language::init(cx);
2395            editor::init(cx);
2396            pane::init(cx);
2397            crate::init(cx);
2398            workspace::init(app_state.clone(), cx);
2399            Project::init_settings(cx);
2400        });
2401    }
2402
2403    fn ensure_single_file_is_opened(
2404        window_id: usize,
2405        workspace: &ViewHandle<Workspace>,
2406        expected_path: &str,
2407        cx: &mut TestAppContext,
2408    ) {
2409        cx.read_window(window_id, |cx| {
2410            let workspace = workspace.read(cx);
2411            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2412            assert_eq!(worktrees.len(), 1);
2413            let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2414
2415            let open_project_paths = workspace
2416                .panes()
2417                .iter()
2418                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2419                .collect::<Vec<_>>();
2420            assert_eq!(
2421                open_project_paths,
2422                vec![ProjectPath {
2423                    worktree_id,
2424                    path: Arc::from(Path::new(expected_path))
2425                }],
2426                "Should have opened file, selected in project panel"
2427            );
2428        });
2429    }
2430
2431    fn submit_deletion(
2432        window_id: usize,
2433        panel: &ViewHandle<ProjectPanel>,
2434        cx: &mut TestAppContext,
2435    ) {
2436        assert!(
2437            !cx.has_pending_prompt(window_id),
2438            "Should have no prompts before the deletion"
2439        );
2440        panel.update(cx, |panel, cx| {
2441            panel
2442                .delete(&Delete, cx)
2443                .expect("Deletion start")
2444                .detach_and_log_err(cx);
2445        });
2446        assert!(
2447            cx.has_pending_prompt(window_id),
2448            "Should have a prompt after the deletion"
2449        );
2450        cx.simulate_prompt_answer(window_id, 0);
2451        assert!(
2452            !cx.has_pending_prompt(window_id),
2453            "Should have no prompts after prompt was replied to"
2454        );
2455        cx.foreground().run_until_parked();
2456    }
2457
2458    fn ensure_no_open_items_and_panes(
2459        window_id: usize,
2460        workspace: &ViewHandle<Workspace>,
2461        cx: &mut TestAppContext,
2462    ) {
2463        assert!(
2464            !cx.has_pending_prompt(window_id),
2465            "Should have no prompts after deletion operation closes the file"
2466        );
2467        cx.read_window(window_id, |cx| {
2468            let open_project_paths = workspace
2469                .read(cx)
2470                .panes()
2471                .iter()
2472                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2473                .collect::<Vec<_>>();
2474            assert!(
2475                open_project_paths.is_empty(),
2476                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2477            );
2478        });
2479    }
2480}