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