project_panel.rs

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