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
1257                .in_state(details.is_selected)
1258                .style_for(state)
1259                .clone();
1260
1261            if cx
1262                .global::<DragAndDrop<Workspace>>()
1263                .currently_dragged::<ProjectEntryId>(cx.window_id())
1264                .is_some()
1265                && dragged_entry_destination
1266                    .as_ref()
1267                    .filter(|destination| details.path.starts_with(destination))
1268                    .is_some()
1269            {
1270                style = entry_style.active_state().default.clone();
1271            }
1272
1273            let row_container_style = if show_editor {
1274                theme.filename_editor.container
1275            } else {
1276                style.container
1277            };
1278
1279            Self::render_entry_visual_element(
1280                &details,
1281                Some(editor),
1282                padding,
1283                row_container_style,
1284                &style,
1285                cx,
1286            )
1287        })
1288        .on_click(MouseButton::Left, move |event, this, cx| {
1289            if !show_editor {
1290                if kind == EntryKind::Dir {
1291                    this.toggle_expanded(entry_id, cx);
1292                } else {
1293                    this.open_entry(entry_id, event.click_count > 1, cx);
1294                }
1295            }
1296        })
1297        .on_down(MouseButton::Right, move |event, this, cx| {
1298            this.deploy_context_menu(event.position, entry_id, cx);
1299        })
1300        .on_up(MouseButton::Left, move |_, this, cx| {
1301            if let Some((_, dragged_entry)) = cx
1302                .global::<DragAndDrop<Workspace>>()
1303                .currently_dragged::<ProjectEntryId>(cx.window_id())
1304            {
1305                this.move_entry(
1306                    *dragged_entry,
1307                    entry_id,
1308                    matches!(details.kind, EntryKind::File(_)),
1309                    cx,
1310                );
1311            }
1312        })
1313        .on_move(move |_, this, cx| {
1314            if cx
1315                .global::<DragAndDrop<Workspace>>()
1316                .currently_dragged::<ProjectEntryId>(cx.window_id())
1317                .is_some()
1318            {
1319                this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1320                    path.parent().map(|parent| Arc::from(parent))
1321                } else {
1322                    Some(path.clone())
1323                };
1324            }
1325        })
1326        .as_draggable(entry_id, {
1327            let row_container_style = theme.dragged_entry.container;
1328
1329            move |_, cx: &mut ViewContext<Workspace>| {
1330                let theme = theme::current(cx).clone();
1331                Self::render_entry_visual_element(
1332                    &details,
1333                    None,
1334                    padding,
1335                    row_container_style,
1336                    &theme.project_panel.dragged_entry,
1337                    cx,
1338                )
1339            }
1340        })
1341        .with_cursor_style(CursorStyle::PointingHand)
1342        .into_any_named("project panel entry")
1343    }
1344}
1345
1346impl View for ProjectPanel {
1347    fn ui_name() -> &'static str {
1348        "ProjectPanel"
1349    }
1350
1351    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1352        enum ProjectPanel {}
1353        let theme = &theme::current(cx).project_panel;
1354        let mut container_style = theme.container;
1355        let padding = std::mem::take(&mut container_style.padding);
1356        let last_worktree_root_id = self.last_worktree_root_id;
1357
1358        let has_worktree = self.visible_entries.len() != 0;
1359
1360        if has_worktree {
1361            Stack::new()
1362                .with_child(
1363                    MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1364                        UniformList::new(
1365                            self.list.clone(),
1366                            self.visible_entries
1367                                .iter()
1368                                .map(|(_, worktree_entries)| worktree_entries.len())
1369                                .sum(),
1370                            cx,
1371                            move |this, range, items, cx| {
1372                                let theme = theme::current(cx).clone();
1373                                let mut dragged_entry_destination =
1374                                    this.dragged_entry_destination.clone();
1375                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1376                                    items.push(Self::render_entry(
1377                                        id,
1378                                        details,
1379                                        &this.filename_editor,
1380                                        &mut dragged_entry_destination,
1381                                        &theme.project_panel,
1382                                        cx,
1383                                    ));
1384                                });
1385                                this.dragged_entry_destination = dragged_entry_destination;
1386                            },
1387                        )
1388                        .with_padding_top(padding.top)
1389                        .with_padding_bottom(padding.bottom)
1390                        .contained()
1391                        .with_style(container_style)
1392                        .expanded()
1393                    })
1394                    .on_down(MouseButton::Right, move |event, this, cx| {
1395                        // When deploying the context menu anywhere below the last project entry,
1396                        // act as if the user clicked the root of the last worktree.
1397                        if let Some(entry_id) = last_worktree_root_id {
1398                            this.deploy_context_menu(event.position, entry_id, cx);
1399                        }
1400                    }),
1401                )
1402                .with_child(ChildView::new(&self.context_menu, cx))
1403                .into_any_named("project panel")
1404        } else {
1405            Flex::column()
1406                .with_child(
1407                    MouseEventHandler::<Self, _>::new(2, cx, {
1408                        let button_style = theme.open_project_button.clone();
1409                        let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1410                        move |state, cx| {
1411                            let button_style = button_style.style_for(state).clone();
1412                            let context_menu_item = context_menu_item_style
1413                                .active_state()
1414                                .style_for(state)
1415                                .clone();
1416
1417                            theme::ui::keystroke_label(
1418                                "Open a project",
1419                                &button_style,
1420                                &context_menu_item.keystroke,
1421                                Box::new(workspace::Open),
1422                                cx,
1423                            )
1424                        }
1425                    })
1426                    .on_click(MouseButton::Left, move |_, this, cx| {
1427                        if let Some(workspace) = this.workspace.upgrade(cx) {
1428                            workspace.update(cx, |workspace, cx| {
1429                                if let Some(task) = workspace.open(&Default::default(), cx) {
1430                                    task.detach_and_log_err(cx);
1431                                }
1432                            })
1433                        }
1434                    })
1435                    .with_cursor_style(CursorStyle::PointingHand),
1436                )
1437                .contained()
1438                .with_style(container_style)
1439                .into_any_named("empty project panel")
1440        }
1441    }
1442
1443    fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1444        Self::reset_to_default_keymap_context(keymap);
1445        keymap.add_identifier("menu");
1446    }
1447
1448    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1449        if !self.has_focus {
1450            self.has_focus = true;
1451            cx.emit(Event::Focus);
1452        }
1453    }
1454
1455    fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1456        self.has_focus = false;
1457    }
1458}
1459
1460impl Entity for ProjectPanel {
1461    type Event = Event;
1462}
1463
1464impl workspace::dock::Panel for ProjectPanel {
1465    fn position(&self, cx: &WindowContext) -> DockPosition {
1466        match settings::get::<ProjectPanelSettings>(cx).dock {
1467            ProjectPanelDockPosition::Left => DockPosition::Left,
1468            ProjectPanelDockPosition::Right => DockPosition::Right,
1469        }
1470    }
1471
1472    fn position_is_valid(&self, position: DockPosition) -> bool {
1473        matches!(position, DockPosition::Left | DockPosition::Right)
1474    }
1475
1476    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1477        settings::update_settings_file::<ProjectPanelSettings>(
1478            self.fs.clone(),
1479            cx,
1480            move |settings| {
1481                let dock = match position {
1482                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1483                    DockPosition::Right => ProjectPanelDockPosition::Right,
1484                };
1485                settings.dock = Some(dock);
1486            },
1487        );
1488    }
1489
1490    fn size(&self, cx: &WindowContext) -> f32 {
1491        self.width
1492            .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1493    }
1494
1495    fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
1496        self.width = Some(size);
1497        self.serialize(cx);
1498        cx.notify();
1499    }
1500
1501    fn should_zoom_in_on_event(_: &Self::Event) -> bool {
1502        false
1503    }
1504
1505    fn should_zoom_out_on_event(_: &Self::Event) -> bool {
1506        false
1507    }
1508
1509    fn is_zoomed(&self, _: &WindowContext) -> bool {
1510        false
1511    }
1512
1513    fn set_zoomed(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1514
1515    fn set_active(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1516
1517    fn icon_path(&self) -> &'static str {
1518        "icons/folder_tree_16.svg"
1519    }
1520
1521    fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1522        ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1523    }
1524
1525    fn should_change_position_on_event(event: &Self::Event) -> bool {
1526        matches!(event, Event::DockPositionChanged)
1527    }
1528
1529    fn should_activate_on_event(_: &Self::Event) -> bool {
1530        false
1531    }
1532
1533    fn should_close_on_event(_: &Self::Event) -> bool {
1534        false
1535    }
1536
1537    fn has_focus(&self, _: &WindowContext) -> bool {
1538        self.has_focus
1539    }
1540
1541    fn is_focus_event(event: &Self::Event) -> bool {
1542        matches!(event, Event::Focus)
1543    }
1544}
1545
1546impl ClipboardEntry {
1547    fn is_cut(&self) -> bool {
1548        matches!(self, Self::Cut { .. })
1549    }
1550
1551    fn entry_id(&self) -> ProjectEntryId {
1552        match self {
1553            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1554                *entry_id
1555            }
1556        }
1557    }
1558
1559    fn worktree_id(&self) -> WorktreeId {
1560        match self {
1561            ClipboardEntry::Copied { worktree_id, .. }
1562            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1563        }
1564    }
1565}
1566
1567#[cfg(test)]
1568mod tests {
1569    use super::*;
1570    use gpui::{TestAppContext, ViewHandle};
1571    use project::FakeFs;
1572    use serde_json::json;
1573    use settings::SettingsStore;
1574    use std::{collections::HashSet, path::Path};
1575    use workspace::{pane, AppState};
1576
1577    #[gpui::test]
1578    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1579        init_test(cx);
1580
1581        let fs = FakeFs::new(cx.background());
1582        fs.insert_tree(
1583            "/root1",
1584            json!({
1585                ".dockerignore": "",
1586                ".git": {
1587                    "HEAD": "",
1588                },
1589                "a": {
1590                    "0": { "q": "", "r": "", "s": "" },
1591                    "1": { "t": "", "u": "" },
1592                    "2": { "v": "", "w": "", "x": "", "y": "" },
1593                },
1594                "b": {
1595                    "3": { "Q": "" },
1596                    "4": { "R": "", "S": "", "T": "", "U": "" },
1597                },
1598                "C": {
1599                    "5": {},
1600                    "6": { "V": "", "W": "" },
1601                    "7": { "X": "" },
1602                    "8": { "Y": {}, "Z": "" }
1603                }
1604            }),
1605        )
1606        .await;
1607        fs.insert_tree(
1608            "/root2",
1609            json!({
1610                "d": {
1611                    "9": ""
1612                },
1613                "e": {}
1614            }),
1615        )
1616        .await;
1617
1618        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1619        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1620        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1621        assert_eq!(
1622            visible_entries_as_strings(&panel, 0..50, cx),
1623            &[
1624                "v root1",
1625                "    > .git",
1626                "    > a",
1627                "    > b",
1628                "    > C",
1629                "      .dockerignore",
1630                "v root2",
1631                "    > d",
1632                "    > e",
1633            ]
1634        );
1635
1636        toggle_expand_dir(&panel, "root1/b", cx);
1637        assert_eq!(
1638            visible_entries_as_strings(&panel, 0..50, cx),
1639            &[
1640                "v root1",
1641                "    > .git",
1642                "    > a",
1643                "    v b  <== selected",
1644                "        > 3",
1645                "        > 4",
1646                "    > C",
1647                "      .dockerignore",
1648                "v root2",
1649                "    > d",
1650                "    > e",
1651            ]
1652        );
1653
1654        assert_eq!(
1655            visible_entries_as_strings(&panel, 6..9, cx),
1656            &[
1657                //
1658                "    > C",
1659                "      .dockerignore",
1660                "v root2",
1661            ]
1662        );
1663    }
1664
1665    #[gpui::test(iterations = 30)]
1666    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1667        init_test(cx);
1668
1669        let fs = FakeFs::new(cx.background());
1670        fs.insert_tree(
1671            "/root1",
1672            json!({
1673                ".dockerignore": "",
1674                ".git": {
1675                    "HEAD": "",
1676                },
1677                "a": {
1678                    "0": { "q": "", "r": "", "s": "" },
1679                    "1": { "t": "", "u": "" },
1680                    "2": { "v": "", "w": "", "x": "", "y": "" },
1681                },
1682                "b": {
1683                    "3": { "Q": "" },
1684                    "4": { "R": "", "S": "", "T": "", "U": "" },
1685                },
1686                "C": {
1687                    "5": {},
1688                    "6": { "V": "", "W": "" },
1689                    "7": { "X": "" },
1690                    "8": { "Y": {}, "Z": "" }
1691                }
1692            }),
1693        )
1694        .await;
1695        fs.insert_tree(
1696            "/root2",
1697            json!({
1698                "d": {
1699                    "9": ""
1700                },
1701                "e": {}
1702            }),
1703        )
1704        .await;
1705
1706        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1707        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1708        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1709
1710        select_path(&panel, "root1", cx);
1711        assert_eq!(
1712            visible_entries_as_strings(&panel, 0..10, cx),
1713            &[
1714                "v root1  <== selected",
1715                "    > .git",
1716                "    > a",
1717                "    > b",
1718                "    > C",
1719                "      .dockerignore",
1720                "v root2",
1721                "    > d",
1722                "    > e",
1723            ]
1724        );
1725
1726        // Add a file with the root folder selected. The filename editor is placed
1727        // before the first file in the root folder.
1728        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1729        cx.read_window(window_id, |cx| {
1730            let panel = panel.read(cx);
1731            assert!(panel.filename_editor.is_focused(cx));
1732        });
1733        assert_eq!(
1734            visible_entries_as_strings(&panel, 0..10, cx),
1735            &[
1736                "v root1",
1737                "    > .git",
1738                "    > a",
1739                "    > b",
1740                "    > C",
1741                "      [EDITOR: '']  <== selected",
1742                "      .dockerignore",
1743                "v root2",
1744                "    > d",
1745                "    > e",
1746            ]
1747        );
1748
1749        let confirm = panel.update(cx, |panel, cx| {
1750            panel
1751                .filename_editor
1752                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1753            panel.confirm(&Confirm, cx).unwrap()
1754        });
1755        assert_eq!(
1756            visible_entries_as_strings(&panel, 0..10, cx),
1757            &[
1758                "v root1",
1759                "    > .git",
1760                "    > a",
1761                "    > b",
1762                "    > C",
1763                "      [PROCESSING: 'the-new-filename']  <== selected",
1764                "      .dockerignore",
1765                "v root2",
1766                "    > d",
1767                "    > e",
1768            ]
1769        );
1770
1771        confirm.await.unwrap();
1772        assert_eq!(
1773            visible_entries_as_strings(&panel, 0..10, cx),
1774            &[
1775                "v root1",
1776                "    > .git",
1777                "    > a",
1778                "    > b",
1779                "    > C",
1780                "      .dockerignore",
1781                "      the-new-filename  <== selected",
1782                "v root2",
1783                "    > d",
1784                "    > e",
1785            ]
1786        );
1787
1788        select_path(&panel, "root1/b", cx);
1789        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1790        assert_eq!(
1791            visible_entries_as_strings(&panel, 0..10, cx),
1792            &[
1793                "v root1",
1794                "    > .git",
1795                "    > a",
1796                "    v b",
1797                "        > 3",
1798                "        > 4",
1799                "          [EDITOR: '']  <== selected",
1800                "    > C",
1801                "      .dockerignore",
1802                "      the-new-filename",
1803            ]
1804        );
1805
1806        panel
1807            .update(cx, |panel, cx| {
1808                panel
1809                    .filename_editor
1810                    .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1811                panel.confirm(&Confirm, cx).unwrap()
1812            })
1813            .await
1814            .unwrap();
1815        assert_eq!(
1816            visible_entries_as_strings(&panel, 0..10, cx),
1817            &[
1818                "v root1",
1819                "    > .git",
1820                "    > a",
1821                "    v b",
1822                "        > 3",
1823                "        > 4",
1824                "          another-filename  <== selected",
1825                "    > C",
1826                "      .dockerignore",
1827                "      the-new-filename",
1828            ]
1829        );
1830
1831        select_path(&panel, "root1/b/another-filename", cx);
1832        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1833        assert_eq!(
1834            visible_entries_as_strings(&panel, 0..10, cx),
1835            &[
1836                "v root1",
1837                "    > .git",
1838                "    > a",
1839                "    v b",
1840                "        > 3",
1841                "        > 4",
1842                "          [EDITOR: 'another-filename']  <== selected",
1843                "    > C",
1844                "      .dockerignore",
1845                "      the-new-filename",
1846            ]
1847        );
1848
1849        let confirm = panel.update(cx, |panel, cx| {
1850            panel
1851                .filename_editor
1852                .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1853            panel.confirm(&Confirm, cx).unwrap()
1854        });
1855        assert_eq!(
1856            visible_entries_as_strings(&panel, 0..10, cx),
1857            &[
1858                "v root1",
1859                "    > .git",
1860                "    > a",
1861                "    v b",
1862                "        > 3",
1863                "        > 4",
1864                "          [PROCESSING: 'a-different-filename']  <== selected",
1865                "    > C",
1866                "      .dockerignore",
1867                "      the-new-filename",
1868            ]
1869        );
1870
1871        confirm.await.unwrap();
1872        assert_eq!(
1873            visible_entries_as_strings(&panel, 0..10, cx),
1874            &[
1875                "v root1",
1876                "    > .git",
1877                "    > a",
1878                "    v b",
1879                "        > 3",
1880                "        > 4",
1881                "          a-different-filename  <== selected",
1882                "    > C",
1883                "      .dockerignore",
1884                "      the-new-filename",
1885            ]
1886        );
1887
1888        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1889        assert_eq!(
1890            visible_entries_as_strings(&panel, 0..10, cx),
1891            &[
1892                "v root1",
1893                "    > .git",
1894                "    > a",
1895                "    v b",
1896                "        > [EDITOR: '']  <== selected",
1897                "        > 3",
1898                "        > 4",
1899                "          a-different-filename",
1900                "    > C",
1901                "      .dockerignore",
1902            ]
1903        );
1904
1905        let confirm = panel.update(cx, |panel, cx| {
1906            panel
1907                .filename_editor
1908                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1909            panel.confirm(&Confirm, cx).unwrap()
1910        });
1911        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1912        assert_eq!(
1913            visible_entries_as_strings(&panel, 0..10, cx),
1914            &[
1915                "v root1",
1916                "    > .git",
1917                "    > a",
1918                "    v b",
1919                "        > [PROCESSING: 'new-dir']",
1920                "        > 3  <== selected",
1921                "        > 4",
1922                "          a-different-filename",
1923                "    > C",
1924                "      .dockerignore",
1925            ]
1926        );
1927
1928        confirm.await.unwrap();
1929        assert_eq!(
1930            visible_entries_as_strings(&panel, 0..10, cx),
1931            &[
1932                "v root1",
1933                "    > .git",
1934                "    > a",
1935                "    v b",
1936                "        > 3  <== selected",
1937                "        > 4",
1938                "        > new-dir",
1939                "          a-different-filename",
1940                "    > C",
1941                "      .dockerignore",
1942            ]
1943        );
1944
1945        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1946        assert_eq!(
1947            visible_entries_as_strings(&panel, 0..10, cx),
1948            &[
1949                "v root1",
1950                "    > .git",
1951                "    > a",
1952                "    v b",
1953                "        > [EDITOR: '3']  <== selected",
1954                "        > 4",
1955                "        > new-dir",
1956                "          a-different-filename",
1957                "    > C",
1958                "      .dockerignore",
1959            ]
1960        );
1961
1962        // Dismiss the rename editor when it loses focus.
1963        workspace.update(cx, |_, cx| cx.focus_self());
1964        assert_eq!(
1965            visible_entries_as_strings(&panel, 0..10, cx),
1966            &[
1967                "v root1",
1968                "    > .git",
1969                "    > a",
1970                "    v b",
1971                "        > 3  <== selected",
1972                "        > 4",
1973                "        > new-dir",
1974                "          a-different-filename",
1975                "    > C",
1976                "      .dockerignore",
1977            ]
1978        );
1979    }
1980
1981    #[gpui::test]
1982    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1983        init_test(cx);
1984
1985        let fs = FakeFs::new(cx.background());
1986        fs.insert_tree(
1987            "/root1",
1988            json!({
1989                "one.two.txt": "",
1990                "one.txt": ""
1991            }),
1992        )
1993        .await;
1994
1995        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1996        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1997        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1998
1999        panel.update(cx, |panel, cx| {
2000            panel.select_next(&Default::default(), cx);
2001            panel.select_next(&Default::default(), cx);
2002        });
2003
2004        assert_eq!(
2005            visible_entries_as_strings(&panel, 0..50, cx),
2006            &[
2007                //
2008                "v root1",
2009                "      one.two.txt  <== selected",
2010                "      one.txt",
2011            ]
2012        );
2013
2014        // Regression test - file name is created correctly when
2015        // the copied file's name contains multiple dots.
2016        panel.update(cx, |panel, cx| {
2017            panel.copy(&Default::default(), cx);
2018            panel.paste(&Default::default(), cx);
2019        });
2020        cx.foreground().run_until_parked();
2021
2022        assert_eq!(
2023            visible_entries_as_strings(&panel, 0..50, cx),
2024            &[
2025                //
2026                "v root1",
2027                "      one.two copy.txt",
2028                "      one.two.txt  <== selected",
2029                "      one.txt",
2030            ]
2031        );
2032
2033        panel.update(cx, |panel, cx| {
2034            panel.paste(&Default::default(), cx);
2035        });
2036        cx.foreground().run_until_parked();
2037
2038        assert_eq!(
2039            visible_entries_as_strings(&panel, 0..50, cx),
2040            &[
2041                //
2042                "v root1",
2043                "      one.two copy 1.txt",
2044                "      one.two copy.txt",
2045                "      one.two.txt  <== selected",
2046                "      one.txt",
2047            ]
2048        );
2049    }
2050
2051    #[gpui::test]
2052    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2053        init_test_with_editor(cx);
2054
2055        let fs = FakeFs::new(cx.background());
2056        fs.insert_tree(
2057            "/src",
2058            json!({
2059                "test": {
2060                    "first.rs": "// First Rust file",
2061                    "second.rs": "// Second Rust file",
2062                    "third.rs": "// Third Rust file",
2063                }
2064            }),
2065        )
2066        .await;
2067
2068        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2069        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2070        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2071
2072        toggle_expand_dir(&panel, "src/test", cx);
2073        select_path(&panel, "src/test/first.rs", cx);
2074        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2075        cx.foreground().run_until_parked();
2076        assert_eq!(
2077            visible_entries_as_strings(&panel, 0..10, cx),
2078            &[
2079                "v src",
2080                "    v test",
2081                "          first.rs  <== selected",
2082                "          second.rs",
2083                "          third.rs"
2084            ]
2085        );
2086        ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
2087
2088        submit_deletion(window_id, &panel, cx);
2089        assert_eq!(
2090            visible_entries_as_strings(&panel, 0..10, cx),
2091            &[
2092                "v src",
2093                "    v test",
2094                "          second.rs",
2095                "          third.rs"
2096            ],
2097            "Project panel should have no deleted file, no other file is selected in it"
2098        );
2099        ensure_no_open_items_and_panes(window_id, &workspace, cx);
2100
2101        select_path(&panel, "src/test/second.rs", cx);
2102        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2103        cx.foreground().run_until_parked();
2104        assert_eq!(
2105            visible_entries_as_strings(&panel, 0..10, cx),
2106            &[
2107                "v src",
2108                "    v test",
2109                "          second.rs  <== selected",
2110                "          third.rs"
2111            ]
2112        );
2113        ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
2114
2115        cx.update_window(window_id, |cx| {
2116            let active_items = workspace
2117                .read(cx)
2118                .panes()
2119                .iter()
2120                .filter_map(|pane| pane.read(cx).active_item())
2121                .collect::<Vec<_>>();
2122            assert_eq!(active_items.len(), 1);
2123            let open_editor = active_items
2124                .into_iter()
2125                .next()
2126                .unwrap()
2127                .downcast::<Editor>()
2128                .expect("Open item should be an editor");
2129            open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2130        });
2131        submit_deletion(window_id, &panel, cx);
2132        assert_eq!(
2133            visible_entries_as_strings(&panel, 0..10, cx),
2134            &["v src", "    v test", "          third.rs"],
2135            "Project panel should have no deleted file, with one last file remaining"
2136        );
2137        ensure_no_open_items_and_panes(window_id, &workspace, cx);
2138    }
2139
2140    #[gpui::test]
2141    async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2142        init_test_with_editor(cx);
2143
2144        let fs = FakeFs::new(cx.background());
2145        fs.insert_tree(
2146            "/src",
2147            json!({
2148                "test": {
2149                    "first.rs": "// First Rust file",
2150                    "second.rs": "// Second Rust file",
2151                    "third.rs": "// Third Rust file",
2152                }
2153            }),
2154        )
2155        .await;
2156
2157        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2158        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2159        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2160
2161        select_path(&panel, "src/", cx);
2162        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2163        cx.foreground().run_until_parked();
2164        assert_eq!(
2165            visible_entries_as_strings(&panel, 0..10, cx),
2166            &["v src  <== selected", "    > test"]
2167        );
2168        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2169        cx.read_window(window_id, |cx| {
2170            let panel = panel.read(cx);
2171            assert!(panel.filename_editor.is_focused(cx));
2172        });
2173        assert_eq!(
2174            visible_entries_as_strings(&panel, 0..10, cx),
2175            &["v src", "    > [EDITOR: '']  <== selected", "    > test"]
2176        );
2177        panel.update(cx, |panel, cx| {
2178            panel
2179                .filename_editor
2180                .update(cx, |editor, cx| editor.set_text("test", cx));
2181            assert!(
2182                panel.confirm(&Confirm, cx).is_none(),
2183                "Should not allow to confirm on conflicting new directory name"
2184            )
2185        });
2186        assert_eq!(
2187            visible_entries_as_strings(&panel, 0..10, cx),
2188            &["v src", "    > test"],
2189            "File list should be unchanged after failed folder create confirmation"
2190        );
2191
2192        select_path(&panel, "src/test/", cx);
2193        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2194        cx.foreground().run_until_parked();
2195        assert_eq!(
2196            visible_entries_as_strings(&panel, 0..10, cx),
2197            &["v src", "    > test  <== selected"]
2198        );
2199        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2200        cx.read_window(window_id, |cx| {
2201            let panel = panel.read(cx);
2202            assert!(panel.filename_editor.is_focused(cx));
2203        });
2204        assert_eq!(
2205            visible_entries_as_strings(&panel, 0..10, cx),
2206            &[
2207                "v src",
2208                "    v test",
2209                "          [EDITOR: '']  <== selected",
2210                "          first.rs",
2211                "          second.rs",
2212                "          third.rs"
2213            ]
2214        );
2215        panel.update(cx, |panel, cx| {
2216            panel
2217                .filename_editor
2218                .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2219            assert!(
2220                panel.confirm(&Confirm, cx).is_none(),
2221                "Should not allow to confirm on conflicting new file name"
2222            )
2223        });
2224        assert_eq!(
2225            visible_entries_as_strings(&panel, 0..10, cx),
2226            &[
2227                "v src",
2228                "    v test",
2229                "          first.rs",
2230                "          second.rs",
2231                "          third.rs"
2232            ],
2233            "File list should be unchanged after failed file create confirmation"
2234        );
2235
2236        select_path(&panel, "src/test/first.rs", cx);
2237        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2238        cx.foreground().run_until_parked();
2239        assert_eq!(
2240            visible_entries_as_strings(&panel, 0..10, cx),
2241            &[
2242                "v src",
2243                "    v test",
2244                "          first.rs  <== selected",
2245                "          second.rs",
2246                "          third.rs"
2247            ],
2248        );
2249        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2250        cx.read_window(window_id, |cx| {
2251            let panel = panel.read(cx);
2252            assert!(panel.filename_editor.is_focused(cx));
2253        });
2254        assert_eq!(
2255            visible_entries_as_strings(&panel, 0..10, cx),
2256            &[
2257                "v src",
2258                "    v test",
2259                "          [EDITOR: 'first.rs']  <== selected",
2260                "          second.rs",
2261                "          third.rs"
2262            ]
2263        );
2264        panel.update(cx, |panel, cx| {
2265            panel
2266                .filename_editor
2267                .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2268            assert!(
2269                panel.confirm(&Confirm, cx).is_none(),
2270                "Should not allow to confirm on conflicting file rename"
2271            )
2272        });
2273        assert_eq!(
2274            visible_entries_as_strings(&panel, 0..10, cx),
2275            &[
2276                "v src",
2277                "    v test",
2278                "          first.rs  <== selected",
2279                "          second.rs",
2280                "          third.rs"
2281            ],
2282            "File list should be unchanged after failed rename confirmation"
2283        );
2284    }
2285
2286    fn toggle_expand_dir(
2287        panel: &ViewHandle<ProjectPanel>,
2288        path: impl AsRef<Path>,
2289        cx: &mut TestAppContext,
2290    ) {
2291        let path = path.as_ref();
2292        panel.update(cx, |panel, cx| {
2293            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2294                let worktree = worktree.read(cx);
2295                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2296                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2297                    panel.toggle_expanded(entry_id, cx);
2298                    return;
2299                }
2300            }
2301            panic!("no worktree for path {:?}", path);
2302        });
2303    }
2304
2305    fn select_path(
2306        panel: &ViewHandle<ProjectPanel>,
2307        path: impl AsRef<Path>,
2308        cx: &mut TestAppContext,
2309    ) {
2310        let path = path.as_ref();
2311        panel.update(cx, |panel, cx| {
2312            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2313                let worktree = worktree.read(cx);
2314                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2315                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2316                    panel.selection = Some(Selection {
2317                        worktree_id: worktree.id(),
2318                        entry_id,
2319                    });
2320                    return;
2321                }
2322            }
2323            panic!("no worktree for path {:?}", path);
2324        });
2325    }
2326
2327    fn visible_entries_as_strings(
2328        panel: &ViewHandle<ProjectPanel>,
2329        range: Range<usize>,
2330        cx: &mut TestAppContext,
2331    ) -> Vec<String> {
2332        let mut result = Vec::new();
2333        let mut project_entries = HashSet::new();
2334        let mut has_editor = false;
2335
2336        panel.update(cx, |panel, cx| {
2337            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2338                if details.is_editing {
2339                    assert!(!has_editor, "duplicate editor entry");
2340                    has_editor = true;
2341                } else {
2342                    assert!(
2343                        project_entries.insert(project_entry),
2344                        "duplicate project entry {:?} {:?}",
2345                        project_entry,
2346                        details
2347                    );
2348                }
2349
2350                let indent = "    ".repeat(details.depth);
2351                let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
2352                    if details.is_expanded {
2353                        "v "
2354                    } else {
2355                        "> "
2356                    }
2357                } else {
2358                    "  "
2359                };
2360                let name = if details.is_editing {
2361                    format!("[EDITOR: '{}']", details.filename)
2362                } else if details.is_processing {
2363                    format!("[PROCESSING: '{}']", details.filename)
2364                } else {
2365                    details.filename.clone()
2366                };
2367                let selected = if details.is_selected {
2368                    "  <== selected"
2369                } else {
2370                    ""
2371                };
2372                result.push(format!("{indent}{icon}{name}{selected}"));
2373            });
2374        });
2375
2376        result
2377    }
2378
2379    fn init_test(cx: &mut TestAppContext) {
2380        cx.foreground().forbid_parking();
2381        cx.update(|cx| {
2382            cx.set_global(SettingsStore::test(cx));
2383            init_settings(cx);
2384            theme::init((), cx);
2385            language::init(cx);
2386            editor::init_settings(cx);
2387            crate::init(cx);
2388            workspace::init_settings(cx);
2389            Project::init_settings(cx);
2390        });
2391    }
2392
2393    fn init_test_with_editor(cx: &mut TestAppContext) {
2394        cx.foreground().forbid_parking();
2395        cx.update(|cx| {
2396            let app_state = AppState::test(cx);
2397            theme::init((), cx);
2398            init_settings(cx);
2399            language::init(cx);
2400            editor::init(cx);
2401            pane::init(cx);
2402            crate::init(cx);
2403            workspace::init(app_state.clone(), cx);
2404            Project::init_settings(cx);
2405        });
2406    }
2407
2408    fn ensure_single_file_is_opened(
2409        window_id: usize,
2410        workspace: &ViewHandle<Workspace>,
2411        expected_path: &str,
2412        cx: &mut TestAppContext,
2413    ) {
2414        cx.read_window(window_id, |cx| {
2415            let workspace = workspace.read(cx);
2416            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2417            assert_eq!(worktrees.len(), 1);
2418            let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2419
2420            let open_project_paths = workspace
2421                .panes()
2422                .iter()
2423                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2424                .collect::<Vec<_>>();
2425            assert_eq!(
2426                open_project_paths,
2427                vec![ProjectPath {
2428                    worktree_id,
2429                    path: Arc::from(Path::new(expected_path))
2430                }],
2431                "Should have opened file, selected in project panel"
2432            );
2433        });
2434    }
2435
2436    fn submit_deletion(
2437        window_id: usize,
2438        panel: &ViewHandle<ProjectPanel>,
2439        cx: &mut TestAppContext,
2440    ) {
2441        assert!(
2442            !cx.has_pending_prompt(window_id),
2443            "Should have no prompts before the deletion"
2444        );
2445        panel.update(cx, |panel, cx| {
2446            panel
2447                .delete(&Delete, cx)
2448                .expect("Deletion start")
2449                .detach_and_log_err(cx);
2450        });
2451        assert!(
2452            cx.has_pending_prompt(window_id),
2453            "Should have a prompt after the deletion"
2454        );
2455        cx.simulate_prompt_answer(window_id, 0);
2456        assert!(
2457            !cx.has_pending_prompt(window_id),
2458            "Should have no prompts after prompt was replied to"
2459        );
2460        cx.foreground().run_until_parked();
2461    }
2462
2463    fn ensure_no_open_items_and_panes(
2464        window_id: usize,
2465        workspace: &ViewHandle<Workspace>,
2466        cx: &mut TestAppContext,
2467    ) {
2468        assert!(
2469            !cx.has_pending_prompt(window_id),
2470            "Should have no prompts after deletion operation closes the file"
2471        );
2472        cx.read_window(window_id, |cx| {
2473            let open_project_paths = workspace
2474                .read(cx)
2475                .panes()
2476                .iter()
2477                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2478                .collect::<Vec<_>>();
2479            assert!(
2480                open_project_paths.is_empty(),
2481                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2482            );
2483        });
2484    }
2485}