project_panel.rs

   1use context_menu::{ContextMenu, ContextMenuItem};
   2use drag_and_drop::{DragAndDrop, Draggable};
   3use editor::{Cancel, Editor};
   4use futures::stream::StreamExt;
   5use gpui::{
   6    actions,
   7    anyhow::{anyhow, Result},
   8    elements::{
   9        AnchorCorner, ChildView, ConstrainedBox, ContainerStyle, Empty, Flex, Label,
  10        MouseEventHandler, ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
  11    },
  12    geometry::vector::Vector2F,
  13    impl_internal_actions,
  14    keymap_matcher::KeymapContext,
  15    platform::CursorStyle,
  16    AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle, MouseButton,
  17    MutableAppContext, PromptLevel, RenderContext, Task, View, ViewContext, ViewHandle,
  18};
  19use menu::{Confirm, SelectNext, SelectPrev};
  20use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
  21use settings::Settings;
  22use std::{
  23    cmp::Ordering,
  24    collections::{hash_map, HashMap},
  25    ffi::OsStr,
  26    ops::Range,
  27    path::{Path, PathBuf},
  28    sync::Arc,
  29};
  30use theme::ProjectPanelEntry;
  31use unicase::UniCase;
  32use workspace::Workspace;
  33
  34const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
  35
  36pub struct ProjectPanel {
  37    project: ModelHandle<Project>,
  38    list: UniformListState,
  39    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  40    last_worktree_root_id: Option<ProjectEntryId>,
  41    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  42    selection: Option<Selection>,
  43    edit_state: Option<EditState>,
  44    filename_editor: ViewHandle<Editor>,
  45    clipboard_entry: Option<ClipboardEntry>,
  46    context_menu: ViewHandle<ContextMenu>,
  47    dragged_entry_destination: Option<Arc<Path>>,
  48}
  49
  50#[derive(Copy, Clone)]
  51struct Selection {
  52    worktree_id: WorktreeId,
  53    entry_id: ProjectEntryId,
  54}
  55
  56#[derive(Clone, Debug)]
  57struct EditState {
  58    worktree_id: WorktreeId,
  59    entry_id: ProjectEntryId,
  60    is_new_entry: bool,
  61    is_dir: bool,
  62    processing_filename: Option<String>,
  63}
  64
  65#[derive(Copy, Clone)]
  66pub enum ClipboardEntry {
  67    Copied {
  68        worktree_id: WorktreeId,
  69        entry_id: ProjectEntryId,
  70    },
  71    Cut {
  72        worktree_id: WorktreeId,
  73        entry_id: ProjectEntryId,
  74    },
  75}
  76
  77#[derive(Debug, PartialEq, Eq)]
  78pub struct EntryDetails {
  79    filename: String,
  80    path: Arc<Path>,
  81    depth: usize,
  82    kind: EntryKind,
  83    is_ignored: bool,
  84    is_expanded: bool,
  85    is_selected: bool,
  86    is_editing: bool,
  87    is_processing: bool,
  88    is_cut: bool,
  89}
  90
  91#[derive(Clone, PartialEq)]
  92pub struct ToggleExpanded(pub ProjectEntryId);
  93
  94#[derive(Clone, PartialEq)]
  95pub struct Open {
  96    pub entry_id: ProjectEntryId,
  97    pub change_focus: bool,
  98}
  99
 100#[derive(Clone, PartialEq)]
 101pub struct MoveProjectEntry {
 102    pub entry_to_move: ProjectEntryId,
 103    pub destination: ProjectEntryId,
 104    pub destination_is_file: bool,
 105}
 106
 107#[derive(Clone, PartialEq)]
 108pub struct DeployContextMenu {
 109    pub position: Vector2F,
 110    pub entry_id: ProjectEntryId,
 111}
 112
 113actions!(
 114    project_panel,
 115    [
 116        ExpandSelectedEntry,
 117        CollapseSelectedEntry,
 118        NewDirectory,
 119        NewFile,
 120        Copy,
 121        CopyPath,
 122        RevealInFinder,
 123        Cut,
 124        Paste,
 125        Delete,
 126        Rename,
 127        ToggleFocus
 128    ]
 129);
 130impl_internal_actions!(
 131    project_panel,
 132    [Open, ToggleExpanded, DeployContextMenu, MoveProjectEntry]
 133);
 134
 135pub fn init(cx: &mut MutableAppContext) {
 136    cx.add_action(ProjectPanel::deploy_context_menu);
 137    cx.add_action(ProjectPanel::expand_selected_entry);
 138    cx.add_action(ProjectPanel::collapse_selected_entry);
 139    cx.add_action(ProjectPanel::toggle_expanded);
 140    cx.add_action(ProjectPanel::select_prev);
 141    cx.add_action(ProjectPanel::select_next);
 142    cx.add_action(ProjectPanel::open_entry);
 143    cx.add_action(ProjectPanel::new_file);
 144    cx.add_action(ProjectPanel::new_directory);
 145    cx.add_action(ProjectPanel::rename);
 146    cx.add_async_action(ProjectPanel::delete);
 147    cx.add_async_action(ProjectPanel::confirm);
 148    cx.add_action(ProjectPanel::cancel);
 149    cx.add_action(ProjectPanel::copy);
 150    cx.add_action(ProjectPanel::copy_path);
 151    cx.add_action(ProjectPanel::reveal_in_finder);
 152    cx.add_action(ProjectPanel::cut);
 153    cx.add_action(
 154        |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
 155            this.paste(action, cx);
 156        },
 157    );
 158    cx.add_action(ProjectPanel::move_entry);
 159}
 160
 161pub enum Event {
 162    OpenedEntry {
 163        entry_id: ProjectEntryId,
 164        focus_opened_item: bool,
 165    },
 166}
 167
 168impl ProjectPanel {
 169    pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
 170        let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
 171            cx.observe(&project, |this, _, cx| {
 172                this.update_visible_entries(None, cx);
 173                cx.notify();
 174            })
 175            .detach();
 176            cx.subscribe(&project, |this, project, event, cx| match event {
 177                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 178                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
 179                    {
 180                        this.expand_entry(worktree_id, *entry_id, cx);
 181                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
 182                        this.autoscroll(cx);
 183                        cx.notify();
 184                    }
 185                }
 186                project::Event::WorktreeRemoved(id) => {
 187                    this.expanded_dir_ids.remove(id);
 188                    this.update_visible_entries(None, cx);
 189                    cx.notify();
 190                }
 191                _ => {}
 192            })
 193            .detach();
 194
 195            let filename_editor = cx.add_view(|cx| {
 196                Editor::single_line(
 197                    Some(Arc::new(|theme| {
 198                        let mut style = theme.project_panel.filename_editor.clone();
 199                        style.container.background_color.take();
 200                        style
 201                    })),
 202                    cx,
 203                )
 204            });
 205
 206            cx.subscribe(&filename_editor, |this, _, event, cx| match event {
 207                editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
 208                    this.autoscroll(cx);
 209                }
 210                _ => {}
 211            })
 212            .detach();
 213            cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
 214                if !is_focused
 215                    && this
 216                        .edit_state
 217                        .as_ref()
 218                        .map_or(false, |state| state.processing_filename.is_none())
 219                {
 220                    this.edit_state = None;
 221                    this.update_visible_entries(None, cx);
 222                }
 223            })
 224            .detach();
 225
 226            let mut this = Self {
 227                project: project.clone(),
 228                list: Default::default(),
 229                visible_entries: Default::default(),
 230                last_worktree_root_id: Default::default(),
 231                expanded_dir_ids: Default::default(),
 232                selection: None,
 233                edit_state: None,
 234                filename_editor,
 235                clipboard_entry: None,
 236                context_menu: cx.add_view(ContextMenu::new),
 237                dragged_entry_destination: None,
 238            };
 239            this.update_visible_entries(None, cx);
 240            this
 241        });
 242
 243        cx.subscribe(&project_panel, {
 244            let project_panel = project_panel.downgrade();
 245            move |workspace, _, event, cx| match event {
 246                &Event::OpenedEntry {
 247                    entry_id,
 248                    focus_opened_item,
 249                } => {
 250                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 251                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 252                            workspace
 253                                .open_path(
 254                                    ProjectPath {
 255                                        worktree_id: worktree.read(cx).id(),
 256                                        path: entry.path.clone(),
 257                                    },
 258                                    None,
 259                                    focus_opened_item,
 260                                    cx,
 261                                )
 262                                .detach_and_log_err(cx);
 263                            if !focus_opened_item {
 264                                if let Some(project_panel) = project_panel.upgrade(cx) {
 265                                    cx.focus(&project_panel);
 266                                }
 267                            }
 268                        }
 269                    }
 270                }
 271            }
 272        })
 273        .detach();
 274
 275        project_panel
 276    }
 277
 278    fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
 279        let project = self.project.read(cx);
 280
 281        let entry_id = action.entry_id;
 282        let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
 283            id
 284        } else {
 285            return;
 286        };
 287
 288        self.selection = Some(Selection {
 289            worktree_id,
 290            entry_id,
 291        });
 292
 293        let mut menu_entries = Vec::new();
 294        if let Some((worktree, entry)) = self.selected_entry(cx) {
 295            let is_root = Some(entry) == worktree.root_entry();
 296            if !project.is_remote() {
 297                menu_entries.push(ContextMenuItem::item(
 298                    "Add Folder to Project",
 299                    workspace::AddFolderToProject,
 300                ));
 301                if is_root {
 302                    menu_entries.push(ContextMenuItem::item(
 303                        "Remove from Project",
 304                        workspace::RemoveWorktreeFromProject(worktree_id),
 305                    ));
 306                }
 307            }
 308            menu_entries.push(ContextMenuItem::item("New File", NewFile));
 309            menu_entries.push(ContextMenuItem::item("New Folder", NewDirectory));
 310            menu_entries.push(ContextMenuItem::item("Reveal in Finder", RevealInFinder));
 311            menu_entries.push(ContextMenuItem::Separator);
 312            menu_entries.push(ContextMenuItem::item("Copy", Copy));
 313            menu_entries.push(ContextMenuItem::item("Copy Path", CopyPath));
 314            menu_entries.push(ContextMenuItem::item("Cut", Cut));
 315            if let Some(clipboard_entry) = self.clipboard_entry {
 316                if clipboard_entry.worktree_id() == worktree.id() {
 317                    menu_entries.push(ContextMenuItem::item("Paste", Paste));
 318                }
 319            }
 320            menu_entries.push(ContextMenuItem::Separator);
 321            menu_entries.push(ContextMenuItem::item("Rename", Rename));
 322            if !is_root {
 323                menu_entries.push(ContextMenuItem::item("Delete", Delete));
 324            }
 325        }
 326
 327        self.context_menu.update(cx, |menu, cx| {
 328            menu.show(action.position, AnchorCorner::TopLeft, menu_entries, cx);
 329        });
 330
 331        cx.notify();
 332    }
 333
 334    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 335        if let Some((worktree, entry)) = self.selected_entry(cx) {
 336            if entry.is_dir() {
 337                let expanded_dir_ids =
 338                    if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 339                        expanded_dir_ids
 340                    } else {
 341                        return;
 342                    };
 343
 344                match expanded_dir_ids.binary_search(&entry.id) {
 345                    Ok(_) => self.select_next(&SelectNext, cx),
 346                    Err(ix) => {
 347                        expanded_dir_ids.insert(ix, entry.id);
 348                        self.update_visible_entries(None, cx);
 349                        cx.notify();
 350                    }
 351                }
 352            }
 353        }
 354    }
 355
 356    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 357        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 358            let expanded_dir_ids =
 359                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 360                    expanded_dir_ids
 361                } else {
 362                    return;
 363                };
 364
 365            loop {
 366                match expanded_dir_ids.binary_search(&entry.id) {
 367                    Ok(ix) => {
 368                        expanded_dir_ids.remove(ix);
 369                        self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
 370                        cx.notify();
 371                        break;
 372                    }
 373                    Err(_) => {
 374                        if let Some(parent_entry) =
 375                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 376                        {
 377                            entry = parent_entry;
 378                        } else {
 379                            break;
 380                        }
 381                    }
 382                }
 383            }
 384        }
 385    }
 386
 387    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
 388        let entry_id = action.0;
 389        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 390            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 391                match expanded_dir_ids.binary_search(&entry_id) {
 392                    Ok(ix) => {
 393                        expanded_dir_ids.remove(ix);
 394                    }
 395                    Err(ix) => {
 396                        expanded_dir_ids.insert(ix, entry_id);
 397                    }
 398                }
 399                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 400                cx.focus_self();
 401                cx.notify();
 402            }
 403        }
 404    }
 405
 406    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 407        if let Some(selection) = self.selection {
 408            let (mut worktree_ix, mut entry_ix, _) =
 409                self.index_for_selection(selection).unwrap_or_default();
 410            if entry_ix > 0 {
 411                entry_ix -= 1;
 412            } else if worktree_ix > 0 {
 413                worktree_ix -= 1;
 414                entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 415            } else {
 416                return;
 417            }
 418
 419            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 420            self.selection = Some(Selection {
 421                worktree_id: *worktree_id,
 422                entry_id: worktree_entries[entry_ix].id,
 423            });
 424            self.autoscroll(cx);
 425            cx.notify();
 426        } else {
 427            self.select_first(cx);
 428        }
 429    }
 430
 431    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 432        if let Some(task) = self.confirm_edit(cx) {
 433            Some(task)
 434        } else if let Some((_, entry)) = self.selected_entry(cx) {
 435            if entry.is_file() {
 436                self.open_entry(
 437                    &Open {
 438                        entry_id: entry.id,
 439                        change_focus: true,
 440                    },
 441                    cx,
 442                );
 443            }
 444            None
 445        } else {
 446            None
 447        }
 448    }
 449
 450    fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 451        let edit_state = self.edit_state.as_mut()?;
 452        cx.focus_self();
 453
 454        let worktree_id = edit_state.worktree_id;
 455        let is_new_entry = edit_state.is_new_entry;
 456        let is_dir = edit_state.is_dir;
 457        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
 458        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
 459        let filename = self.filename_editor.read(cx).text(cx);
 460
 461        let edit_task;
 462        let edited_entry_id;
 463
 464        if is_new_entry {
 465            self.selection = Some(Selection {
 466                worktree_id,
 467                entry_id: NEW_ENTRY_ID,
 468            });
 469            let new_path = entry.path.join(&filename);
 470            edited_entry_id = NEW_ENTRY_ID;
 471            edit_task = self.project.update(cx, |project, cx| {
 472                project.create_entry((worktree_id, new_path), is_dir, cx)
 473            })?;
 474        } else {
 475            let new_path = if let Some(parent) = entry.path.clone().parent() {
 476                parent.join(&filename)
 477            } else {
 478                filename.clone().into()
 479            };
 480            edited_entry_id = entry.id;
 481            edit_task = self.project.update(cx, |project, cx| {
 482                project.rename_entry(entry.id, new_path, cx)
 483            })?;
 484        };
 485
 486        edit_state.processing_filename = Some(filename);
 487        cx.notify();
 488
 489        Some(cx.spawn(|this, mut cx| async move {
 490            let new_entry = edit_task.await;
 491            this.update(&mut cx, |this, cx| {
 492                this.edit_state.take();
 493                cx.notify();
 494            });
 495
 496            let new_entry = new_entry?;
 497            this.update(&mut cx, |this, cx| {
 498                if let Some(selection) = &mut this.selection {
 499                    if selection.entry_id == edited_entry_id {
 500                        selection.worktree_id = worktree_id;
 501                        selection.entry_id = new_entry.id;
 502                    }
 503                }
 504                this.update_visible_entries(None, cx);
 505                if is_new_entry && !is_dir {
 506                    this.open_entry(
 507                        &Open {
 508                            entry_id: new_entry.id,
 509                            change_focus: true,
 510                        },
 511                        cx,
 512                    );
 513                }
 514                cx.notify();
 515            });
 516            Ok(())
 517        }))
 518    }
 519
 520    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 521        self.edit_state = None;
 522        self.update_visible_entries(None, cx);
 523        cx.focus_self();
 524        cx.notify();
 525    }
 526
 527    fn open_entry(&mut self, action: &Open, cx: &mut ViewContext<Self>) {
 528        cx.emit(Event::OpenedEntry {
 529            entry_id: action.entry_id,
 530            focus_opened_item: action.change_focus,
 531        });
 532    }
 533
 534    fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
 535        self.add_entry(false, cx)
 536    }
 537
 538    fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
 539        self.add_entry(true, cx)
 540    }
 541
 542    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
 543        if let Some(Selection {
 544            worktree_id,
 545            entry_id,
 546        }) = self.selection
 547        {
 548            let directory_id;
 549            if let Some((worktree, expanded_dir_ids)) = self
 550                .project
 551                .read(cx)
 552                .worktree_for_id(worktree_id, cx)
 553                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 554            {
 555                let worktree = worktree.read(cx);
 556                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 557                    loop {
 558                        if entry.is_dir() {
 559                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 560                                expanded_dir_ids.insert(ix, entry.id);
 561                            }
 562                            directory_id = entry.id;
 563                            break;
 564                        } else {
 565                            if let Some(parent_path) = entry.path.parent() {
 566                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 567                                    entry = parent_entry;
 568                                    continue;
 569                                }
 570                            }
 571                            return;
 572                        }
 573                    }
 574                } else {
 575                    return;
 576                };
 577            } else {
 578                return;
 579            };
 580
 581            self.edit_state = Some(EditState {
 582                worktree_id,
 583                entry_id: directory_id,
 584                is_new_entry: true,
 585                is_dir,
 586                processing_filename: None,
 587            });
 588            self.filename_editor
 589                .update(cx, |editor, cx| editor.clear(cx));
 590            cx.focus(&self.filename_editor);
 591            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
 592            self.autoscroll(cx);
 593            cx.notify();
 594        }
 595    }
 596
 597    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 598        if let Some(Selection {
 599            worktree_id,
 600            entry_id,
 601        }) = self.selection
 602        {
 603            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 604                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 605                    self.edit_state = Some(EditState {
 606                        worktree_id,
 607                        entry_id,
 608                        is_new_entry: false,
 609                        is_dir: entry.is_dir(),
 610                        processing_filename: None,
 611                    });
 612                    let filename = entry
 613                        .path
 614                        .file_name()
 615                        .map_or(String::new(), |s| s.to_string_lossy().to_string());
 616                    self.filename_editor.update(cx, |editor, cx| {
 617                        editor.set_text(filename, cx);
 618                        editor.select_all(&Default::default(), cx);
 619                    });
 620                    cx.focus(&self.filename_editor);
 621                    self.update_visible_entries(None, cx);
 622                    self.autoscroll(cx);
 623                    cx.notify();
 624                }
 625            }
 626
 627            cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
 628                drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
 629            })
 630        }
 631    }
 632
 633    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 634        let Selection { entry_id, .. } = self.selection?;
 635        let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
 636        let file_name = path.file_name()?;
 637
 638        let mut answer = cx.prompt(
 639            PromptLevel::Info,
 640            &format!("Delete {file_name:?}?"),
 641            &["Delete", "Cancel"],
 642        );
 643        Some(cx.spawn(|this, mut cx| async move {
 644            if answer.next().await != Some(0) {
 645                return Ok(());
 646            }
 647            this.update(&mut cx, |this, cx| {
 648                this.project
 649                    .update(cx, |project, cx| project.delete_entry(entry_id, cx))
 650                    .ok_or_else(|| anyhow!("no such entry"))
 651            })?
 652            .await
 653        }))
 654    }
 655
 656    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 657        if let Some(selection) = self.selection {
 658            let (mut worktree_ix, mut entry_ix, _) =
 659                self.index_for_selection(selection).unwrap_or_default();
 660            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 661                if entry_ix + 1 < worktree_entries.len() {
 662                    entry_ix += 1;
 663                } else {
 664                    worktree_ix += 1;
 665                    entry_ix = 0;
 666                }
 667            }
 668
 669            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 670                if let Some(entry) = worktree_entries.get(entry_ix) {
 671                    self.selection = Some(Selection {
 672                        worktree_id: *worktree_id,
 673                        entry_id: entry.id,
 674                    });
 675                    self.autoscroll(cx);
 676                    cx.notify();
 677                }
 678            }
 679        } else {
 680            self.select_first(cx);
 681        }
 682    }
 683
 684    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 685        let worktree = self
 686            .visible_entries
 687            .first()
 688            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 689        if let Some(worktree) = worktree {
 690            let worktree = worktree.read(cx);
 691            let worktree_id = worktree.id();
 692            if let Some(root_entry) = worktree.root_entry() {
 693                self.selection = Some(Selection {
 694                    worktree_id,
 695                    entry_id: root_entry.id,
 696                });
 697                self.autoscroll(cx);
 698                cx.notify();
 699            }
 700        }
 701    }
 702
 703    fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
 704        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 705            self.list.scroll_to(ScrollTarget::Show(index));
 706            cx.notify();
 707        }
 708    }
 709
 710    fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
 711        if let Some((worktree, entry)) = self.selected_entry(cx) {
 712            self.clipboard_entry = Some(ClipboardEntry::Cut {
 713                worktree_id: worktree.id(),
 714                entry_id: entry.id,
 715            });
 716            cx.notify();
 717        }
 718    }
 719
 720    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
 721        if let Some((worktree, entry)) = self.selected_entry(cx) {
 722            self.clipboard_entry = Some(ClipboardEntry::Copied {
 723                worktree_id: worktree.id(),
 724                entry_id: entry.id,
 725            });
 726            cx.notify();
 727        }
 728    }
 729
 730    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
 731        if let Some((worktree, entry)) = self.selected_entry(cx) {
 732            let clipboard_entry = self.clipboard_entry?;
 733            if clipboard_entry.worktree_id() != worktree.id() {
 734                return None;
 735            }
 736
 737            let clipboard_entry_file_name = self
 738                .project
 739                .read(cx)
 740                .path_for_entry(clipboard_entry.entry_id(), cx)?
 741                .path
 742                .file_name()?
 743                .to_os_string();
 744
 745            let mut new_path = entry.path.to_path_buf();
 746            if entry.is_file() {
 747                new_path.pop();
 748            }
 749
 750            new_path.push(&clipboard_entry_file_name);
 751            let extension = new_path.extension().map(|e| e.to_os_string());
 752            let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
 753            let mut ix = 0;
 754            while worktree.entry_for_path(&new_path).is_some() {
 755                new_path.pop();
 756
 757                let mut new_file_name = file_name_without_extension.to_os_string();
 758                new_file_name.push(" copy");
 759                if ix > 0 {
 760                    new_file_name.push(format!(" {}", ix));
 761                }
 762                if let Some(extension) = extension.as_ref() {
 763                    new_file_name.push(".");
 764                    new_file_name.push(extension);
 765                }
 766
 767                new_path.push(new_file_name);
 768                ix += 1;
 769            }
 770
 771            if clipboard_entry.is_cut() {
 772                if let Some(task) = self.project.update(cx, |project, cx| {
 773                    project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
 774                }) {
 775                    task.detach_and_log_err(cx)
 776                }
 777            } else if let Some(task) = self.project.update(cx, |project, cx| {
 778                project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
 779            }) {
 780                task.detach_and_log_err(cx)
 781            }
 782        }
 783        None
 784    }
 785
 786    fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
 787        if let Some((worktree, entry)) = self.selected_entry(cx) {
 788            let mut path = PathBuf::new();
 789            path.push(worktree.root_name());
 790            path.push(&entry.path);
 791            cx.write_to_clipboard(ClipboardItem::new(path.to_string_lossy().to_string()));
 792        }
 793    }
 794
 795    fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
 796        if let Some((worktree, entry)) = self.selected_entry(cx) {
 797            cx.reveal_path(&worktree.abs_path().join(&entry.path));
 798        }
 799    }
 800
 801    fn move_entry(
 802        &mut self,
 803        &MoveProjectEntry {
 804            entry_to_move,
 805            destination,
 806            destination_is_file,
 807        }: &MoveProjectEntry,
 808        cx: &mut ViewContext<Self>,
 809    ) {
 810        let destination_worktree = self.project.update(cx, |project, cx| {
 811            let entry_path = project.path_for_entry(entry_to_move, cx)?;
 812            let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
 813
 814            let mut destination_path = destination_entry_path.as_ref();
 815            if destination_is_file {
 816                destination_path = destination_path.parent()?;
 817            }
 818
 819            let mut new_path = destination_path.to_path_buf();
 820            new_path.push(entry_path.path.file_name()?);
 821            if new_path != entry_path.path.as_ref() {
 822                let task = project.rename_entry(entry_to_move, new_path, cx)?;
 823                cx.foreground().spawn(task).detach_and_log_err(cx);
 824            }
 825
 826            Some(project.worktree_id_for_entry(destination, cx)?)
 827        });
 828
 829        if let Some(destination_worktree) = destination_worktree {
 830            self.expand_entry(destination_worktree, destination, cx);
 831        }
 832    }
 833
 834    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
 835        let mut entry_index = 0;
 836        let mut visible_entries_index = 0;
 837        for (worktree_index, (worktree_id, worktree_entries)) in
 838            self.visible_entries.iter().enumerate()
 839        {
 840            if *worktree_id == selection.worktree_id {
 841                for entry in worktree_entries {
 842                    if entry.id == selection.entry_id {
 843                        return Some((worktree_index, entry_index, visible_entries_index));
 844                    } else {
 845                        visible_entries_index += 1;
 846                        entry_index += 1;
 847                    }
 848                }
 849                break;
 850            } else {
 851                visible_entries_index += worktree_entries.len();
 852            }
 853        }
 854        None
 855    }
 856
 857    fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
 858        let selection = self.selection?;
 859        let project = self.project.read(cx);
 860        let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
 861        Some((worktree, worktree.entry_for_id(selection.entry_id)?))
 862    }
 863
 864    fn update_visible_entries(
 865        &mut self,
 866        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
 867        cx: &mut ViewContext<Self>,
 868    ) {
 869        let project = self.project.read(cx);
 870        self.last_worktree_root_id = project
 871            .visible_worktrees(cx)
 872            .rev()
 873            .next()
 874            .and_then(|worktree| worktree.read(cx).root_entry())
 875            .map(|entry| entry.id);
 876
 877        self.visible_entries.clear();
 878        for worktree in project.visible_worktrees(cx) {
 879            let snapshot = worktree.read(cx).snapshot();
 880            let worktree_id = snapshot.id();
 881
 882            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
 883                hash_map::Entry::Occupied(e) => e.into_mut(),
 884                hash_map::Entry::Vacant(e) => {
 885                    // The first time a worktree's root entry becomes available,
 886                    // mark that root entry as expanded.
 887                    if let Some(entry) = snapshot.root_entry() {
 888                        e.insert(vec![entry.id]).as_slice()
 889                    } else {
 890                        &[]
 891                    }
 892                }
 893            };
 894
 895            let mut new_entry_parent_id = None;
 896            let mut new_entry_kind = EntryKind::Dir;
 897            if let Some(edit_state) = &self.edit_state {
 898                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
 899                    new_entry_parent_id = Some(edit_state.entry_id);
 900                    new_entry_kind = if edit_state.is_dir {
 901                        EntryKind::Dir
 902                    } else {
 903                        EntryKind::File(Default::default())
 904                    };
 905                }
 906            }
 907
 908            let mut visible_worktree_entries = Vec::new();
 909            let mut entry_iter = snapshot.entries(true);
 910
 911            while let Some(entry) = entry_iter.entry() {
 912                visible_worktree_entries.push(entry.clone());
 913                if Some(entry.id) == new_entry_parent_id {
 914                    visible_worktree_entries.push(Entry {
 915                        id: NEW_ENTRY_ID,
 916                        kind: new_entry_kind,
 917                        path: entry.path.join("\0").into(),
 918                        inode: 0,
 919                        mtime: entry.mtime,
 920                        is_symlink: false,
 921                        is_ignored: false,
 922                    });
 923                }
 924                if expanded_dir_ids.binary_search(&entry.id).is_err()
 925                    && entry_iter.advance_to_sibling()
 926                {
 927                    continue;
 928                }
 929                entry_iter.advance();
 930            }
 931            visible_worktree_entries.sort_by(|entry_a, entry_b| {
 932                let mut components_a = entry_a.path.components().peekable();
 933                let mut components_b = entry_b.path.components().peekable();
 934                loop {
 935                    match (components_a.next(), components_b.next()) {
 936                        (Some(component_a), Some(component_b)) => {
 937                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
 938                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
 939                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
 940                                let name_a =
 941                                    UniCase::new(component_a.as_os_str().to_string_lossy());
 942                                let name_b =
 943                                    UniCase::new(component_b.as_os_str().to_string_lossy());
 944                                name_a.cmp(&name_b)
 945                            });
 946                            if !ordering.is_eq() {
 947                                return ordering;
 948                            }
 949                        }
 950                        (Some(_), None) => break Ordering::Greater,
 951                        (None, Some(_)) => break Ordering::Less,
 952                        (None, None) => break Ordering::Equal,
 953                    }
 954                }
 955            });
 956            self.visible_entries
 957                .push((worktree_id, visible_worktree_entries));
 958        }
 959
 960        if let Some((worktree_id, entry_id)) = new_selected_entry {
 961            self.selection = Some(Selection {
 962                worktree_id,
 963                entry_id,
 964            });
 965        }
 966    }
 967
 968    fn expand_entry(
 969        &mut self,
 970        worktree_id: WorktreeId,
 971        entry_id: ProjectEntryId,
 972        cx: &mut ViewContext<Self>,
 973    ) {
 974        let project = self.project.read(cx);
 975        if let Some((worktree, expanded_dir_ids)) = project
 976            .worktree_for_id(worktree_id, cx)
 977            .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 978        {
 979            let worktree = worktree.read(cx);
 980
 981            if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 982                loop {
 983                    if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 984                        expanded_dir_ids.insert(ix, entry.id);
 985                    }
 986
 987                    if let Some(parent_entry) =
 988                        entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 989                    {
 990                        entry = parent_entry;
 991                    } else {
 992                        break;
 993                    }
 994                }
 995            }
 996        }
 997    }
 998
 999    fn for_each_visible_entry(
1000        &self,
1001        range: Range<usize>,
1002        cx: &mut RenderContext<ProjectPanel>,
1003        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut RenderContext<ProjectPanel>),
1004    ) {
1005        let mut ix = 0;
1006        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1007            if ix >= range.end {
1008                return;
1009            }
1010
1011            if ix + visible_worktree_entries.len() <= range.start {
1012                ix += visible_worktree_entries.len();
1013                continue;
1014            }
1015
1016            let end_ix = range.end.min(ix + visible_worktree_entries.len());
1017            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1018                let snapshot = worktree.read(cx).snapshot();
1019                let root_name = OsStr::new(snapshot.root_name());
1020                let expanded_entry_ids = self
1021                    .expanded_dir_ids
1022                    .get(&snapshot.id())
1023                    .map(Vec::as_slice)
1024                    .unwrap_or(&[]);
1025
1026                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1027                for entry in &visible_worktree_entries[entry_range] {
1028                    let mut details = EntryDetails {
1029                        filename: entry
1030                            .path
1031                            .file_name()
1032                            .unwrap_or(root_name)
1033                            .to_string_lossy()
1034                            .to_string(),
1035                        path: entry.path.clone(),
1036                        depth: entry.path.components().count(),
1037                        kind: entry.kind,
1038                        is_ignored: entry.is_ignored,
1039                        is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
1040                        is_selected: self.selection.map_or(false, |e| {
1041                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
1042                        }),
1043                        is_editing: false,
1044                        is_processing: false,
1045                        is_cut: self
1046                            .clipboard_entry
1047                            .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1048                    };
1049
1050                    if let Some(edit_state) = &self.edit_state {
1051                        let is_edited_entry = if edit_state.is_new_entry {
1052                            entry.id == NEW_ENTRY_ID
1053                        } else {
1054                            entry.id == edit_state.entry_id
1055                        };
1056
1057                        if is_edited_entry {
1058                            if let Some(processing_filename) = &edit_state.processing_filename {
1059                                details.is_processing = true;
1060                                details.filename.clear();
1061                                details.filename.push_str(processing_filename);
1062                            } else {
1063                                if edit_state.is_new_entry {
1064                                    details.filename.clear();
1065                                }
1066                                details.is_editing = true;
1067                            }
1068                        }
1069                    }
1070
1071                    callback(entry.id, details, cx);
1072                }
1073            }
1074            ix = end_ix;
1075        }
1076    }
1077
1078    fn render_entry_visual_element<V: View>(
1079        details: &EntryDetails,
1080        editor: Option<&ViewHandle<Editor>>,
1081        padding: f32,
1082        row_container_style: ContainerStyle,
1083        style: &ProjectPanelEntry,
1084        cx: &mut RenderContext<V>,
1085    ) -> ElementBox {
1086        let kind = details.kind;
1087        let show_editor = details.is_editing && !details.is_processing;
1088
1089        Flex::row()
1090            .with_child(
1091                ConstrainedBox::new(if kind == EntryKind::Dir {
1092                    if details.is_expanded {
1093                        Svg::new("icons/chevron_down_8.svg")
1094                            .with_color(style.icon_color)
1095                            .boxed()
1096                    } else {
1097                        Svg::new("icons/chevron_right_8.svg")
1098                            .with_color(style.icon_color)
1099                            .boxed()
1100                    }
1101                } else {
1102                    Empty::new().boxed()
1103                })
1104                .with_max_width(style.icon_size)
1105                .with_max_height(style.icon_size)
1106                .aligned()
1107                .constrained()
1108                .with_width(style.icon_size)
1109                .boxed(),
1110            )
1111            .with_child(if show_editor && editor.is_some() {
1112                ChildView::new(editor.as_ref().unwrap(), cx)
1113                    .contained()
1114                    .with_margin_left(style.icon_spacing)
1115                    .aligned()
1116                    .left()
1117                    .flex(1.0, true)
1118                    .boxed()
1119            } else {
1120                Label::new(details.filename.clone(), style.text.clone())
1121                    .contained()
1122                    .with_margin_left(style.icon_spacing)
1123                    .aligned()
1124                    .left()
1125                    .boxed()
1126            })
1127            .constrained()
1128            .with_height(style.height)
1129            .contained()
1130            .with_style(row_container_style)
1131            .with_padding_left(padding)
1132            .boxed()
1133    }
1134
1135    fn render_entry(
1136        entry_id: ProjectEntryId,
1137        details: EntryDetails,
1138        editor: &ViewHandle<Editor>,
1139        dragged_entry_destination: &mut Option<Arc<Path>>,
1140        theme: &theme::ProjectPanel,
1141        cx: &mut RenderContext<Self>,
1142    ) -> ElementBox {
1143        let this = cx.handle();
1144        let kind = details.kind;
1145        let path = details.path.clone();
1146        let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
1147
1148        let entry_style = if details.is_cut {
1149            &theme.cut_entry
1150        } else if details.is_ignored {
1151            &theme.ignored_entry
1152        } else {
1153            &theme.entry
1154        };
1155
1156        let show_editor = details.is_editing && !details.is_processing;
1157
1158        MouseEventHandler::<Self>::new(entry_id.to_usize(), cx, |state, cx| {
1159            let mut style = entry_style.style_for(state, details.is_selected).clone();
1160
1161            if cx
1162                .global::<DragAndDrop<Workspace>>()
1163                .currently_dragged::<ProjectEntryId>(cx.window_id())
1164                .is_some()
1165                && dragged_entry_destination
1166                    .as_ref()
1167                    .filter(|destination| details.path.starts_with(destination))
1168                    .is_some()
1169            {
1170                style = entry_style.active.clone().unwrap();
1171            }
1172
1173            let row_container_style = if show_editor {
1174                theme.filename_editor.container
1175            } else {
1176                style.container
1177            };
1178
1179            Self::render_entry_visual_element(
1180                &details,
1181                Some(editor),
1182                padding,
1183                row_container_style,
1184                &style,
1185                cx,
1186            )
1187        })
1188        .on_click(MouseButton::Left, move |e, cx| {
1189            if !show_editor {
1190                if kind == EntryKind::Dir {
1191                    cx.dispatch_action(ToggleExpanded(entry_id))
1192                } else {
1193                    cx.dispatch_action(Open {
1194                        entry_id,
1195                        change_focus: e.click_count > 1,
1196                    })
1197                }
1198            }
1199        })
1200        .on_down(MouseButton::Right, move |e, cx| {
1201            cx.dispatch_action(DeployContextMenu {
1202                entry_id,
1203                position: e.position,
1204            })
1205        })
1206        .on_up(MouseButton::Left, move |_, cx| {
1207            if let Some((_, dragged_entry)) = cx
1208                .global::<DragAndDrop<Workspace>>()
1209                .currently_dragged::<ProjectEntryId>(cx.window_id())
1210            {
1211                cx.dispatch_action(MoveProjectEntry {
1212                    entry_to_move: *dragged_entry,
1213                    destination: entry_id,
1214                    destination_is_file: matches!(details.kind, EntryKind::File(_)),
1215                });
1216            }
1217        })
1218        .on_move(move |_, cx| {
1219            if cx
1220                .global::<DragAndDrop<Workspace>>()
1221                .currently_dragged::<ProjectEntryId>(cx.window_id())
1222                .is_some()
1223            {
1224                if let Some(this) = this.upgrade(cx.app) {
1225                    this.update(cx.app, |this, _| {
1226                        this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1227                            path.parent().map(|parent| Arc::from(parent))
1228                        } else {
1229                            Some(path.clone())
1230                        };
1231                    })
1232                }
1233            }
1234        })
1235        .as_draggable(entry_id, {
1236            let row_container_style = theme.dragged_entry.container;
1237
1238            move |_, cx: &mut RenderContext<Workspace>| {
1239                let theme = cx.global::<Settings>().theme.clone();
1240                Self::render_entry_visual_element(
1241                    &details,
1242                    None,
1243                    padding,
1244                    row_container_style,
1245                    &theme.project_panel.dragged_entry,
1246                    cx,
1247                )
1248            }
1249        })
1250        .with_cursor_style(CursorStyle::PointingHand)
1251        .boxed()
1252    }
1253}
1254
1255impl View for ProjectPanel {
1256    fn ui_name() -> &'static str {
1257        "ProjectPanel"
1258    }
1259
1260    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
1261        enum ProjectPanel {}
1262        let theme = &cx.global::<Settings>().theme.project_panel;
1263        let mut container_style = theme.container;
1264        let padding = std::mem::take(&mut container_style.padding);
1265        let last_worktree_root_id = self.last_worktree_root_id;
1266
1267        let has_worktree = self.visible_entries.len() != 0;
1268
1269        if has_worktree {
1270            Stack::new()
1271                .with_child(
1272                    MouseEventHandler::<ProjectPanel>::new(0, cx, |_, cx| {
1273                        UniformList::new(
1274                            self.list.clone(),
1275                            self.visible_entries
1276                                .iter()
1277                                .map(|(_, worktree_entries)| worktree_entries.len())
1278                                .sum(),
1279                            cx,
1280                            move |this, range, items, cx| {
1281                                let theme = cx.global::<Settings>().theme.clone();
1282                                let mut dragged_entry_destination =
1283                                    this.dragged_entry_destination.clone();
1284                                this.for_each_visible_entry(range, cx, |id, details, cx| {
1285                                    items.push(Self::render_entry(
1286                                        id,
1287                                        details,
1288                                        &this.filename_editor,
1289                                        &mut dragged_entry_destination,
1290                                        &theme.project_panel,
1291                                        cx,
1292                                    ));
1293                                });
1294                                this.dragged_entry_destination = dragged_entry_destination;
1295                            },
1296                        )
1297                        .with_padding_top(padding.top)
1298                        .with_padding_bottom(padding.bottom)
1299                        .contained()
1300                        .with_style(container_style)
1301                        .expanded()
1302                        .boxed()
1303                    })
1304                    .on_down(MouseButton::Right, move |e, cx| {
1305                        // When deploying the context menu anywhere below the last project entry,
1306                        // act as if the user clicked the root of the last worktree.
1307                        if let Some(entry_id) = last_worktree_root_id {
1308                            cx.dispatch_action(DeployContextMenu {
1309                                entry_id,
1310                                position: e.position,
1311                            })
1312                        }
1313                    })
1314                    .boxed(),
1315                )
1316                .with_child(ChildView::new(&self.context_menu, cx).boxed())
1317                .boxed()
1318        } else {
1319            Flex::column()
1320                .with_child(
1321                    MouseEventHandler::<Self>::new(2, cx, {
1322                        let button_style = theme.open_project_button.clone();
1323                        let context_menu_item_style =
1324                            cx.global::<Settings>().theme.context_menu.item.clone();
1325                        move |state, cx| {
1326                            let button_style = button_style.style_for(state, false).clone();
1327                            let context_menu_item =
1328                                context_menu_item_style.style_for(state, true).clone();
1329
1330                            theme::ui::keystroke_label(
1331                                "Open a project",
1332                                &button_style,
1333                                &context_menu_item.keystroke,
1334                                Box::new(workspace::Open),
1335                                cx,
1336                            )
1337                            .boxed()
1338                        }
1339                    })
1340                    .on_click(MouseButton::Left, move |_, cx| {
1341                        cx.dispatch_action(workspace::Open)
1342                    })
1343                    .with_cursor_style(CursorStyle::PointingHand)
1344                    .boxed(),
1345                )
1346                .contained()
1347                .with_style(container_style)
1348                .boxed()
1349        }
1350    }
1351
1352    fn keymap_context(&self, _: &AppContext) -> KeymapContext {
1353        let mut cx = Self::default_keymap_context();
1354        cx.add_identifier("menu");
1355        cx
1356    }
1357}
1358
1359impl Entity for ProjectPanel {
1360    type Event = Event;
1361}
1362
1363impl workspace::sidebar::SidebarItem for ProjectPanel {
1364    fn should_show_badge(&self, _: &AppContext) -> bool {
1365        false
1366    }
1367}
1368
1369impl ClipboardEntry {
1370    fn is_cut(&self) -> bool {
1371        matches!(self, Self::Cut { .. })
1372    }
1373
1374    fn entry_id(&self) -> ProjectEntryId {
1375        match self {
1376            ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1377                *entry_id
1378            }
1379        }
1380    }
1381
1382    fn worktree_id(&self) -> WorktreeId {
1383        match self {
1384            ClipboardEntry::Copied { worktree_id, .. }
1385            | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1386        }
1387    }
1388}
1389
1390#[cfg(test)]
1391mod tests {
1392    use super::*;
1393    use gpui::{TestAppContext, ViewHandle};
1394    use project::FakeFs;
1395    use serde_json::json;
1396    use std::{collections::HashSet, path::Path};
1397
1398    #[gpui::test]
1399    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1400        cx.foreground().forbid_parking();
1401        cx.update(|cx| {
1402            let settings = Settings::test(cx);
1403            cx.set_global(settings);
1404        });
1405
1406        let fs = FakeFs::new(cx.background());
1407        fs.insert_tree(
1408            "/root1",
1409            json!({
1410                ".dockerignore": "",
1411                ".git": {
1412                    "HEAD": "",
1413                },
1414                "a": {
1415                    "0": { "q": "", "r": "", "s": "" },
1416                    "1": { "t": "", "u": "" },
1417                    "2": { "v": "", "w": "", "x": "", "y": "" },
1418                },
1419                "b": {
1420                    "3": { "Q": "" },
1421                    "4": { "R": "", "S": "", "T": "", "U": "" },
1422                },
1423                "C": {
1424                    "5": {},
1425                    "6": { "V": "", "W": "" },
1426                    "7": { "X": "" },
1427                    "8": { "Y": {}, "Z": "" }
1428                }
1429            }),
1430        )
1431        .await;
1432        fs.insert_tree(
1433            "/root2",
1434            json!({
1435                "d": {
1436                    "9": ""
1437                },
1438                "e": {}
1439            }),
1440        )
1441        .await;
1442
1443        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1444        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1445        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1446        assert_eq!(
1447            visible_entries_as_strings(&panel, 0..50, cx),
1448            &[
1449                "v root1",
1450                "    > .git",
1451                "    > a",
1452                "    > b",
1453                "    > C",
1454                "      .dockerignore",
1455                "v root2",
1456                "    > d",
1457                "    > e",
1458            ]
1459        );
1460
1461        toggle_expand_dir(&panel, "root1/b", cx);
1462        assert_eq!(
1463            visible_entries_as_strings(&panel, 0..50, cx),
1464            &[
1465                "v root1",
1466                "    > .git",
1467                "    > a",
1468                "    v b  <== selected",
1469                "        > 3",
1470                "        > 4",
1471                "    > C",
1472                "      .dockerignore",
1473                "v root2",
1474                "    > d",
1475                "    > e",
1476            ]
1477        );
1478
1479        assert_eq!(
1480            visible_entries_as_strings(&panel, 6..9, cx),
1481            &[
1482                //
1483                "    > C",
1484                "      .dockerignore",
1485                "v root2",
1486            ]
1487        );
1488    }
1489
1490    #[gpui::test(iterations = 30)]
1491    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1492        cx.foreground().forbid_parking();
1493        cx.update(|cx| {
1494            let settings = Settings::test(cx);
1495            cx.set_global(settings);
1496        });
1497
1498        let fs = FakeFs::new(cx.background());
1499        fs.insert_tree(
1500            "/root1",
1501            json!({
1502                ".dockerignore": "",
1503                ".git": {
1504                    "HEAD": "",
1505                },
1506                "a": {
1507                    "0": { "q": "", "r": "", "s": "" },
1508                    "1": { "t": "", "u": "" },
1509                    "2": { "v": "", "w": "", "x": "", "y": "" },
1510                },
1511                "b": {
1512                    "3": { "Q": "" },
1513                    "4": { "R": "", "S": "", "T": "", "U": "" },
1514                },
1515                "C": {
1516                    "5": {},
1517                    "6": { "V": "", "W": "" },
1518                    "7": { "X": "" },
1519                    "8": { "Y": {}, "Z": "" }
1520                }
1521            }),
1522        )
1523        .await;
1524        fs.insert_tree(
1525            "/root2",
1526            json!({
1527                "d": {
1528                    "9": ""
1529                },
1530                "e": {}
1531            }),
1532        )
1533        .await;
1534
1535        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1536        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1537        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1538
1539        select_path(&panel, "root1", cx);
1540        assert_eq!(
1541            visible_entries_as_strings(&panel, 0..10, cx),
1542            &[
1543                "v root1  <== selected",
1544                "    > .git",
1545                "    > a",
1546                "    > b",
1547                "    > C",
1548                "      .dockerignore",
1549                "v root2",
1550                "    > d",
1551                "    > e",
1552            ]
1553        );
1554
1555        // Add a file with the root folder selected. The filename editor is placed
1556        // before the first file in the root folder.
1557        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1558        assert!(panel.read_with(cx, |panel, cx| panel.filename_editor.is_focused(cx)));
1559        assert_eq!(
1560            visible_entries_as_strings(&panel, 0..10, cx),
1561            &[
1562                "v root1",
1563                "    > .git",
1564                "    > a",
1565                "    > b",
1566                "    > C",
1567                "      [EDITOR: '']  <== selected",
1568                "      .dockerignore",
1569                "v root2",
1570                "    > d",
1571                "    > e",
1572            ]
1573        );
1574
1575        let confirm = panel.update(cx, |panel, cx| {
1576            panel
1577                .filename_editor
1578                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1579            panel.confirm(&Confirm, cx).unwrap()
1580        });
1581        assert_eq!(
1582            visible_entries_as_strings(&panel, 0..10, cx),
1583            &[
1584                "v root1",
1585                "    > .git",
1586                "    > a",
1587                "    > b",
1588                "    > C",
1589                "      [PROCESSING: 'the-new-filename']  <== selected",
1590                "      .dockerignore",
1591                "v root2",
1592                "    > d",
1593                "    > e",
1594            ]
1595        );
1596
1597        confirm.await.unwrap();
1598        assert_eq!(
1599            visible_entries_as_strings(&panel, 0..10, cx),
1600            &[
1601                "v root1",
1602                "    > .git",
1603                "    > a",
1604                "    > b",
1605                "    > C",
1606                "      .dockerignore",
1607                "      the-new-filename  <== selected",
1608                "v root2",
1609                "    > d",
1610                "    > e",
1611            ]
1612        );
1613
1614        select_path(&panel, "root1/b", cx);
1615        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1616        assert_eq!(
1617            visible_entries_as_strings(&panel, 0..10, cx),
1618            &[
1619                "v root1",
1620                "    > .git",
1621                "    > a",
1622                "    v b",
1623                "        > 3",
1624                "        > 4",
1625                "          [EDITOR: '']  <== selected",
1626                "    > C",
1627                "      .dockerignore",
1628                "      the-new-filename",
1629            ]
1630        );
1631
1632        panel
1633            .update(cx, |panel, cx| {
1634                panel
1635                    .filename_editor
1636                    .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1637                panel.confirm(&Confirm, cx).unwrap()
1638            })
1639            .await
1640            .unwrap();
1641        assert_eq!(
1642            visible_entries_as_strings(&panel, 0..10, cx),
1643            &[
1644                "v root1",
1645                "    > .git",
1646                "    > a",
1647                "    v b",
1648                "        > 3",
1649                "        > 4",
1650                "          another-filename  <== selected",
1651                "    > C",
1652                "      .dockerignore",
1653                "      the-new-filename",
1654            ]
1655        );
1656
1657        select_path(&panel, "root1/b/another-filename", cx);
1658        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1659        assert_eq!(
1660            visible_entries_as_strings(&panel, 0..10, cx),
1661            &[
1662                "v root1",
1663                "    > .git",
1664                "    > a",
1665                "    v b",
1666                "        > 3",
1667                "        > 4",
1668                "          [EDITOR: 'another-filename']  <== selected",
1669                "    > C",
1670                "      .dockerignore",
1671                "      the-new-filename",
1672            ]
1673        );
1674
1675        let confirm = panel.update(cx, |panel, cx| {
1676            panel
1677                .filename_editor
1678                .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1679            panel.confirm(&Confirm, cx).unwrap()
1680        });
1681        assert_eq!(
1682            visible_entries_as_strings(&panel, 0..10, cx),
1683            &[
1684                "v root1",
1685                "    > .git",
1686                "    > a",
1687                "    v b",
1688                "        > 3",
1689                "        > 4",
1690                "          [PROCESSING: 'a-different-filename']  <== selected",
1691                "    > C",
1692                "      .dockerignore",
1693                "      the-new-filename",
1694            ]
1695        );
1696
1697        confirm.await.unwrap();
1698        assert_eq!(
1699            visible_entries_as_strings(&panel, 0..10, cx),
1700            &[
1701                "v root1",
1702                "    > .git",
1703                "    > a",
1704                "    v b",
1705                "        > 3",
1706                "        > 4",
1707                "          a-different-filename  <== selected",
1708                "    > C",
1709                "      .dockerignore",
1710                "      the-new-filename",
1711            ]
1712        );
1713
1714        panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1715        assert_eq!(
1716            visible_entries_as_strings(&panel, 0..10, cx),
1717            &[
1718                "v root1",
1719                "    > .git",
1720                "    > a",
1721                "    v b",
1722                "        > [EDITOR: '']  <== selected",
1723                "        > 3",
1724                "        > 4",
1725                "          a-different-filename",
1726                "    > C",
1727                "      .dockerignore",
1728            ]
1729        );
1730
1731        let confirm = panel.update(cx, |panel, cx| {
1732            panel
1733                .filename_editor
1734                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1735            panel.confirm(&Confirm, cx).unwrap()
1736        });
1737        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1738        assert_eq!(
1739            visible_entries_as_strings(&panel, 0..10, cx),
1740            &[
1741                "v root1",
1742                "    > .git",
1743                "    > a",
1744                "    v b",
1745                "        > [PROCESSING: 'new-dir']",
1746                "        > 3  <== selected",
1747                "        > 4",
1748                "          a-different-filename",
1749                "    > C",
1750                "      .dockerignore",
1751            ]
1752        );
1753
1754        confirm.await.unwrap();
1755        assert_eq!(
1756            visible_entries_as_strings(&panel, 0..10, cx),
1757            &[
1758                "v root1",
1759                "    > .git",
1760                "    > a",
1761                "    v b",
1762                "        > 3  <== selected",
1763                "        > 4",
1764                "        > new-dir",
1765                "          a-different-filename",
1766                "    > C",
1767                "      .dockerignore",
1768            ]
1769        );
1770
1771        panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1772        assert_eq!(
1773            visible_entries_as_strings(&panel, 0..10, cx),
1774            &[
1775                "v root1",
1776                "    > .git",
1777                "    > a",
1778                "    v b",
1779                "        > [EDITOR: '3']  <== selected",
1780                "        > 4",
1781                "        > new-dir",
1782                "          a-different-filename",
1783                "    > C",
1784                "      .dockerignore",
1785            ]
1786        );
1787
1788        // Dismiss the rename editor when it loses focus.
1789        workspace.update(cx, |_, cx| cx.focus_self());
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  <== selected",
1798                "        > 4",
1799                "        > new-dir",
1800                "          a-different-filename",
1801                "    > C",
1802                "      .dockerignore",
1803            ]
1804        );
1805    }
1806
1807    #[gpui::test]
1808    async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1809        cx.foreground().forbid_parking();
1810        cx.update(|cx| {
1811            let settings = Settings::test(cx);
1812            cx.set_global(settings);
1813        });
1814
1815        let fs = FakeFs::new(cx.background());
1816        fs.insert_tree(
1817            "/root1",
1818            json!({
1819                "one.two.txt": "",
1820                "one.txt": ""
1821            }),
1822        )
1823        .await;
1824
1825        let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1826        let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1827        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1828
1829        panel.update(cx, |panel, cx| {
1830            panel.select_next(&Default::default(), cx);
1831            panel.select_next(&Default::default(), cx);
1832        });
1833
1834        assert_eq!(
1835            visible_entries_as_strings(&panel, 0..50, cx),
1836            &[
1837                //
1838                "v root1",
1839                "      one.two.txt  <== selected",
1840                "      one.txt",
1841            ]
1842        );
1843
1844        // Regression test - file name is created correctly when
1845        // the copied file's name contains multiple dots.
1846        panel.update(cx, |panel, cx| {
1847            panel.copy(&Default::default(), cx);
1848            panel.paste(&Default::default(), cx);
1849        });
1850        cx.foreground().run_until_parked();
1851
1852        assert_eq!(
1853            visible_entries_as_strings(&panel, 0..50, cx),
1854            &[
1855                //
1856                "v root1",
1857                "      one.two copy.txt",
1858                "      one.two.txt  <== selected",
1859                "      one.txt",
1860            ]
1861        );
1862
1863        panel.update(cx, |panel, cx| {
1864            panel.paste(&Default::default(), cx);
1865        });
1866        cx.foreground().run_until_parked();
1867
1868        assert_eq!(
1869            visible_entries_as_strings(&panel, 0..50, cx),
1870            &[
1871                //
1872                "v root1",
1873                "      one.two copy 1.txt",
1874                "      one.two copy.txt",
1875                "      one.two.txt  <== selected",
1876                "      one.txt",
1877            ]
1878        );
1879    }
1880
1881    fn toggle_expand_dir(
1882        panel: &ViewHandle<ProjectPanel>,
1883        path: impl AsRef<Path>,
1884        cx: &mut TestAppContext,
1885    ) {
1886        let path = path.as_ref();
1887        panel.update(cx, |panel, cx| {
1888            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1889                let worktree = worktree.read(cx);
1890                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1891                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1892                    panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
1893                    return;
1894                }
1895            }
1896            panic!("no worktree for path {:?}", path);
1897        });
1898    }
1899
1900    fn select_path(
1901        panel: &ViewHandle<ProjectPanel>,
1902        path: impl AsRef<Path>,
1903        cx: &mut TestAppContext,
1904    ) {
1905        let path = path.as_ref();
1906        panel.update(cx, |panel, cx| {
1907            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1908                let worktree = worktree.read(cx);
1909                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1910                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1911                    panel.selection = Some(Selection {
1912                        worktree_id: worktree.id(),
1913                        entry_id,
1914                    });
1915                    return;
1916                }
1917            }
1918            panic!("no worktree for path {:?}", path);
1919        });
1920    }
1921
1922    fn visible_entries_as_strings(
1923        panel: &ViewHandle<ProjectPanel>,
1924        range: Range<usize>,
1925        cx: &mut TestAppContext,
1926    ) -> Vec<String> {
1927        let mut result = Vec::new();
1928        let mut project_entries = HashSet::new();
1929        let mut has_editor = false;
1930        cx.render(panel, |panel, cx| {
1931            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1932                if details.is_editing {
1933                    assert!(!has_editor, "duplicate editor entry");
1934                    has_editor = true;
1935                } else {
1936                    assert!(
1937                        project_entries.insert(project_entry),
1938                        "duplicate project entry {:?} {:?}",
1939                        project_entry,
1940                        details
1941                    );
1942                }
1943
1944                let indent = "    ".repeat(details.depth);
1945                let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
1946                    if details.is_expanded {
1947                        "v "
1948                    } else {
1949                        "> "
1950                    }
1951                } else {
1952                    "  "
1953                };
1954                let name = if details.is_editing {
1955                    format!("[EDITOR: '{}']", details.filename)
1956                } else if details.is_processing {
1957                    format!("[PROCESSING: '{}']", details.filename)
1958                } else {
1959                    details.filename.clone()
1960                };
1961                let selected = if details.is_selected {
1962                    "  <== selected"
1963                } else {
1964                    ""
1965                };
1966                result.push(format!("{indent}{icon}{name}{selected}"));
1967            });
1968        });
1969
1970        result
1971    }
1972}