project_panel.rs

   1use editor::{Cancel, Editor};
   2use gpui::{
   3    actions,
   4    anyhow::Result,
   5    elements::{
   6        ChildView, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement,
   7        ScrollTarget, Svg, UniformList, UniformListState,
   8    },
   9    impl_internal_actions, keymap,
  10    platform::CursorStyle,
  11    AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task, View,
  12    ViewContext, ViewHandle, WeakViewHandle,
  13};
  14use project::{Entry, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
  15use settings::Settings;
  16use std::{
  17    cmp::Ordering,
  18    collections::{hash_map, HashMap},
  19    ffi::OsStr,
  20    ops::Range,
  21};
  22use unicase::UniCase;
  23use workspace::{
  24    menu::{Confirm, SelectNext, SelectPrev},
  25    Workspace,
  26};
  27
  28pub struct ProjectPanel {
  29    project: ModelHandle<Project>,
  30    list: UniformListState,
  31    visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
  32    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
  33    selection: Option<Selection>,
  34    edit_state: Option<EditState>,
  35    filename_editor: ViewHandle<Editor>,
  36    handle: WeakViewHandle<Self>,
  37}
  38
  39#[derive(Copy, Clone)]
  40struct Selection {
  41    worktree_id: WorktreeId,
  42    entry_id: ProjectEntryId,
  43}
  44
  45#[derive(Copy, Clone, Debug)]
  46struct EditState {
  47    worktree_id: WorktreeId,
  48    entry_id: ProjectEntryId,
  49    new_file: bool,
  50}
  51
  52#[derive(Debug, PartialEq, Eq)]
  53struct EntryDetails {
  54    filename: String,
  55    depth: usize,
  56    kind: EntryKind,
  57    is_expanded: bool,
  58    is_selected: bool,
  59}
  60
  61#[derive(Debug, PartialEq, Eq)]
  62enum EntryKind {
  63    File,
  64    Dir,
  65    FileRenameEditor,
  66    NewFileEditor,
  67}
  68
  69#[derive(Clone)]
  70pub struct ToggleExpanded(pub ProjectEntryId);
  71
  72#[derive(Clone)]
  73pub struct Open(pub ProjectEntryId);
  74
  75actions!(
  76    project_panel,
  77    [ExpandSelectedEntry, CollapseSelectedEntry, AddFile, Rename]
  78);
  79impl_internal_actions!(project_panel, [Open, ToggleExpanded]);
  80
  81pub fn init(cx: &mut MutableAppContext) {
  82    cx.add_action(ProjectPanel::expand_selected_entry);
  83    cx.add_action(ProjectPanel::collapse_selected_entry);
  84    cx.add_action(ProjectPanel::toggle_expanded);
  85    cx.add_action(ProjectPanel::select_prev);
  86    cx.add_action(ProjectPanel::select_next);
  87    cx.add_action(ProjectPanel::open_entry);
  88    cx.add_action(ProjectPanel::add_file);
  89    cx.add_action(ProjectPanel::rename);
  90    cx.add_async_action(ProjectPanel::confirm);
  91    cx.add_action(ProjectPanel::cancel);
  92}
  93
  94pub enum Event {
  95    OpenedEntry(ProjectEntryId),
  96}
  97
  98impl ProjectPanel {
  99    pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
 100        let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
 101            cx.observe(&project, |this, _, cx| {
 102                this.update_visible_entries(None, cx);
 103                cx.notify();
 104            })
 105            .detach();
 106            cx.subscribe(&project, |this, project, event, cx| match event {
 107                project::Event::ActiveEntryChanged(Some(entry_id)) => {
 108                    if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
 109                    {
 110                        this.expand_entry(worktree_id, *entry_id, cx);
 111                        this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
 112                        this.autoscroll();
 113                        cx.notify();
 114                    }
 115                }
 116                project::Event::WorktreeRemoved(id) => {
 117                    this.expanded_dir_ids.remove(id);
 118                    this.update_visible_entries(None, cx);
 119                    cx.notify();
 120                }
 121                _ => {}
 122            })
 123            .detach();
 124
 125            let editor = cx.add_view(|cx| Editor::single_line(None, cx));
 126            cx.subscribe(&editor, |this, _, event, cx| {
 127                if let editor::Event::Blurred = event {
 128                    this.editor_blurred(cx);
 129                }
 130            })
 131            .detach();
 132
 133            let mut this = Self {
 134                project: project.clone(),
 135                list: Default::default(),
 136                visible_entries: Default::default(),
 137                expanded_dir_ids: Default::default(),
 138                selection: None,
 139                edit_state: None,
 140                filename_editor: editor,
 141                handle: cx.weak_handle(),
 142            };
 143            this.update_visible_entries(None, cx);
 144            this
 145        });
 146        cx.subscribe(&project_panel, move |workspace, _, event, cx| match event {
 147            &Event::OpenedEntry(entry_id) => {
 148                if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
 149                    if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 150                        workspace
 151                            .open_path(
 152                                ProjectPath {
 153                                    worktree_id: worktree.read(cx).id(),
 154                                    path: entry.path.clone(),
 155                                },
 156                                cx,
 157                            )
 158                            .detach_and_log_err(cx);
 159                    }
 160                }
 161            }
 162        })
 163        .detach();
 164
 165        project_panel
 166    }
 167
 168    fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
 169        if let Some((worktree, entry)) = self.selected_entry(cx) {
 170            let expanded_dir_ids =
 171                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 172                    expanded_dir_ids
 173                } else {
 174                    return;
 175                };
 176
 177            if entry.is_dir() {
 178                match expanded_dir_ids.binary_search(&entry.id) {
 179                    Ok(_) => self.select_next(&SelectNext, cx),
 180                    Err(ix) => {
 181                        expanded_dir_ids.insert(ix, entry.id);
 182                        self.update_visible_entries(None, cx);
 183                        cx.notify();
 184                    }
 185                }
 186            } else {
 187                let event = Event::OpenedEntry(entry.id);
 188                cx.emit(event);
 189            }
 190        }
 191    }
 192
 193    fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
 194        if let Some((worktree, mut entry)) = self.selected_entry(cx) {
 195            let expanded_dir_ids =
 196                if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
 197                    expanded_dir_ids
 198                } else {
 199                    return;
 200                };
 201
 202            loop {
 203                match expanded_dir_ids.binary_search(&entry.id) {
 204                    Ok(ix) => {
 205                        expanded_dir_ids.remove(ix);
 206                        self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
 207                        cx.notify();
 208                        break;
 209                    }
 210                    Err(_) => {
 211                        if let Some(parent_entry) =
 212                            entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 213                        {
 214                            entry = parent_entry;
 215                        } else {
 216                            break;
 217                        }
 218                    }
 219                }
 220            }
 221        }
 222    }
 223
 224    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
 225        let entry_id = action.0;
 226        if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
 227            if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
 228                match expanded_dir_ids.binary_search(&entry_id) {
 229                    Ok(ix) => {
 230                        expanded_dir_ids.remove(ix);
 231                    }
 232                    Err(ix) => {
 233                        expanded_dir_ids.insert(ix, entry_id);
 234                    }
 235                }
 236                self.update_visible_entries(Some((worktree_id, entry_id)), cx);
 237                cx.focus_self();
 238            }
 239        }
 240    }
 241
 242    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
 243        if let Some(selection) = self.selection {
 244            let (mut worktree_ix, mut entry_ix, _) =
 245                self.index_for_selection(selection).unwrap_or_default();
 246            if entry_ix > 0 {
 247                entry_ix -= 1;
 248            } else {
 249                if worktree_ix > 0 {
 250                    worktree_ix -= 1;
 251                    entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
 252                } else {
 253                    return;
 254                }
 255            }
 256
 257            let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
 258            self.selection = Some(Selection {
 259                worktree_id: *worktree_id,
 260                entry_id: worktree_entries[entry_ix].id,
 261            });
 262            self.autoscroll();
 263            cx.notify();
 264        } else {
 265            self.select_first(cx);
 266        }
 267    }
 268
 269    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
 270        let edit_state = self.edit_state.take()?;
 271        cx.focus_self();
 272        let worktree = self
 273            .project
 274            .read(cx)
 275            .worktree_for_id(edit_state.worktree_id, cx)?;
 276
 277        // TODO - implement this for remote projects
 278        if !worktree.read(cx).is_local() {
 279            return None;
 280        }
 281
 282        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?;
 283        let filename = self.filename_editor.read(cx).text(cx);
 284
 285        if edit_state.new_file {
 286            let new_path = entry.path.join(filename);
 287            let save = worktree.update(cx, |worktree, cx| {
 288                worktree
 289                    .as_local()
 290                    .unwrap()
 291                    .save(new_path, Default::default(), cx)
 292            });
 293            Some(cx.spawn(|this, mut cx| async move {
 294                save.await?;
 295                this.update(&mut cx, |this, cx| {
 296                    this.update_visible_entries(None, cx);
 297                    cx.notify();
 298                });
 299                Ok(())
 300            }))
 301        } else {
 302            let old_path = entry.path.clone();
 303            let new_path = if let Some(parent) = old_path.parent() {
 304                parent.join(filename)
 305            } else {
 306                filename.into()
 307            };
 308            let rename = worktree.update(cx, |worktree, cx| {
 309                worktree.as_local().unwrap().rename(old_path, new_path, cx)
 310            });
 311            Some(cx.spawn(|this, mut cx| async move {
 312                let new_entry = rename.await?;
 313                this.update(&mut cx, |this, cx| {
 314                    this.update_visible_entries(Some((edit_state.worktree_id, new_entry.id)), cx);
 315                    cx.notify();
 316                });
 317                Ok(())
 318            }))
 319        }
 320    }
 321
 322    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
 323        self.edit_state = None;
 324        self.update_visible_entries(None, cx);
 325        cx.focus_self();
 326        cx.notify();
 327    }
 328
 329    fn open_entry(&mut self, action: &Open, cx: &mut ViewContext<Self>) {
 330        cx.emit(Event::OpenedEntry(action.0));
 331    }
 332
 333    fn add_file(&mut self, _: &AddFile, cx: &mut ViewContext<Self>) {
 334        if let Some(Selection {
 335            worktree_id,
 336            entry_id,
 337        }) = self.selection
 338        {
 339            let directory_id;
 340            if let Some((worktree, expanded_dir_ids)) = self
 341                .project
 342                .read(cx)
 343                .worktree_for_id(worktree_id, cx)
 344                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 345            {
 346                let worktree = worktree.read(cx);
 347                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 348                    loop {
 349                        if entry.is_dir() {
 350                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 351                                expanded_dir_ids.insert(ix, entry.id);
 352                            }
 353                            directory_id = entry.id;
 354                            break;
 355                        } else {
 356                            if let Some(parent_path) = entry.path.parent() {
 357                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
 358                                    entry = parent_entry;
 359                                    continue;
 360                                }
 361                            }
 362                            return;
 363                        }
 364                    }
 365                } else {
 366                    return;
 367                };
 368            } else {
 369                return;
 370            };
 371
 372            self.edit_state = Some(EditState {
 373                worktree_id,
 374                entry_id: directory_id,
 375                new_file: true,
 376            });
 377            self.filename_editor
 378                .update(cx, |editor, cx| editor.clear(cx));
 379            cx.focus(&self.filename_editor);
 380            self.update_visible_entries(None, cx);
 381            cx.notify();
 382        }
 383    }
 384
 385    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
 386        if let Some(Selection {
 387            worktree_id,
 388            entry_id,
 389        }) = self.selection
 390        {
 391            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
 392                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
 393                    self.edit_state = Some(EditState {
 394                        worktree_id,
 395                        entry_id,
 396                        new_file: false,
 397                    });
 398                    let filename = entry
 399                        .path
 400                        .file_name()
 401                        .map_or(String::new(), |s| s.to_string_lossy().to_string());
 402                    self.filename_editor
 403                        .update(cx, |editor, cx| editor.set_text(filename, cx));
 404                    cx.focus(&self.filename_editor);
 405                    self.update_visible_entries(None, cx);
 406                    cx.notify();
 407                }
 408            }
 409        }
 410    }
 411
 412    fn editor_blurred(&mut self, cx: &mut ViewContext<Self>) {
 413        self.edit_state = None;
 414        self.update_visible_entries(None, cx);
 415        cx.focus_self();
 416        cx.notify();
 417    }
 418
 419    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
 420        if let Some(selection) = self.selection {
 421            let (mut worktree_ix, mut entry_ix, _) =
 422                self.index_for_selection(selection).unwrap_or_default();
 423            if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 424                if entry_ix + 1 < worktree_entries.len() {
 425                    entry_ix += 1;
 426                } else {
 427                    worktree_ix += 1;
 428                    entry_ix = 0;
 429                }
 430            }
 431
 432            if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
 433                if let Some(entry) = worktree_entries.get(entry_ix) {
 434                    self.selection = Some(Selection {
 435                        worktree_id: *worktree_id,
 436                        entry_id: entry.id,
 437                    });
 438                    self.autoscroll();
 439                    cx.notify();
 440                }
 441            }
 442        } else {
 443            self.select_first(cx);
 444        }
 445    }
 446
 447    fn select_first(&mut self, cx: &mut ViewContext<Self>) {
 448        let worktree = self
 449            .visible_entries
 450            .first()
 451            .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
 452        if let Some(worktree) = worktree {
 453            let worktree = worktree.read(cx);
 454            let worktree_id = worktree.id();
 455            if let Some(root_entry) = worktree.root_entry() {
 456                self.selection = Some(Selection {
 457                    worktree_id,
 458                    entry_id: root_entry.id,
 459                });
 460                self.autoscroll();
 461                cx.notify();
 462            }
 463        }
 464    }
 465
 466    fn autoscroll(&mut self) {
 467        if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
 468            self.list.scroll_to(ScrollTarget::Show(index));
 469        }
 470    }
 471
 472    fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
 473        let mut worktree_index = 0;
 474        let mut entry_index = 0;
 475        let mut visible_entries_index = 0;
 476        for (worktree_id, worktree_entries) in &self.visible_entries {
 477            if *worktree_id == selection.worktree_id {
 478                for entry in worktree_entries {
 479                    if entry.id == selection.entry_id {
 480                        return Some((worktree_index, entry_index, visible_entries_index));
 481                    } else {
 482                        visible_entries_index += 1;
 483                        entry_index += 1;
 484                    }
 485                }
 486                break;
 487            } else {
 488                visible_entries_index += worktree_entries.len();
 489            }
 490            worktree_index += 1;
 491        }
 492        None
 493    }
 494
 495    fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
 496        let selection = self.selection?;
 497        let project = self.project.read(cx);
 498        let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
 499        Some((worktree, worktree.entry_for_id(selection.entry_id)?))
 500    }
 501
 502    fn update_visible_entries(
 503        &mut self,
 504        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
 505        cx: &mut ViewContext<Self>,
 506    ) {
 507        let worktrees = self
 508            .project
 509            .read(cx)
 510            .worktrees(cx)
 511            .filter(|worktree| worktree.read(cx).is_visible());
 512        self.visible_entries.clear();
 513
 514        for worktree in worktrees {
 515            let snapshot = worktree.read(cx).snapshot();
 516            let worktree_id = snapshot.id();
 517
 518            let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
 519                hash_map::Entry::Occupied(e) => e.into_mut(),
 520                hash_map::Entry::Vacant(e) => {
 521                    // The first time a worktree's root entry becomes available,
 522                    // mark that root entry as expanded.
 523                    if let Some(entry) = snapshot.root_entry() {
 524                        e.insert(vec![entry.id]).as_slice()
 525                    } else {
 526                        &[]
 527                    }
 528                }
 529            };
 530
 531            let new_file_parent_id = self.edit_state.and_then(|edit_state| {
 532                if edit_state.worktree_id == worktree_id && edit_state.new_file {
 533                    Some(edit_state.entry_id)
 534                } else {
 535                    None
 536                }
 537            });
 538
 539            let mut visible_worktree_entries = Vec::new();
 540            let mut entry_iter = snapshot.entries(false);
 541            while let Some(entry) = entry_iter.entry() {
 542                visible_worktree_entries.push(entry.clone());
 543                if Some(entry.id) == new_file_parent_id {
 544                    visible_worktree_entries.push(Entry {
 545                        id: entry.id,
 546                        kind: project::EntryKind::File(Default::default()),
 547                        path: entry.path.join("\0").into(),
 548                        inode: 0,
 549                        mtime: entry.mtime,
 550                        is_symlink: false,
 551                        is_ignored: false,
 552                    });
 553                }
 554                if expanded_dir_ids.binary_search(&entry.id).is_err() {
 555                    if entry_iter.advance_to_sibling() {
 556                        continue;
 557                    }
 558                }
 559                entry_iter.advance();
 560            }
 561            visible_worktree_entries.sort_by(|entry_a, entry_b| {
 562                let mut components_a = entry_a.path.components().peekable();
 563                let mut components_b = entry_b.path.components().peekable();
 564                loop {
 565                    match (components_a.next(), components_b.next()) {
 566                        (Some(component_a), Some(component_b)) => {
 567                            let a_is_file = components_a.peek().is_none() && entry_a.is_file();
 568                            let b_is_file = components_b.peek().is_none() && entry_b.is_file();
 569                            let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
 570                                let name_a =
 571                                    UniCase::new(component_a.as_os_str().to_string_lossy());
 572                                let name_b =
 573                                    UniCase::new(component_b.as_os_str().to_string_lossy());
 574                                name_a.cmp(&name_b)
 575                            });
 576                            if !ordering.is_eq() {
 577                                return ordering;
 578                            }
 579                        }
 580                        (Some(_), None) => break Ordering::Greater,
 581                        (None, Some(_)) => break Ordering::Less,
 582                        (None, None) => break Ordering::Equal,
 583                    }
 584                }
 585            });
 586            self.visible_entries
 587                .push((worktree_id, visible_worktree_entries));
 588        }
 589
 590        if let Some((worktree_id, entry_id)) = new_selected_entry {
 591            self.selection = Some(Selection {
 592                worktree_id,
 593                entry_id,
 594            });
 595        }
 596    }
 597
 598    fn expand_entry(
 599        &mut self,
 600        worktree_id: WorktreeId,
 601        entry_id: ProjectEntryId,
 602        cx: &mut ViewContext<Self>,
 603    ) {
 604        let project = self.project.read(cx);
 605        if let Some((worktree, expanded_dir_ids)) = project
 606            .worktree_for_id(worktree_id, cx)
 607            .zip(self.expanded_dir_ids.get_mut(&worktree_id))
 608        {
 609            let worktree = worktree.read(cx);
 610
 611            if let Some(mut entry) = worktree.entry_for_id(entry_id) {
 612                loop {
 613                    if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
 614                        expanded_dir_ids.insert(ix, entry.id);
 615                    }
 616
 617                    if let Some(parent_entry) =
 618                        entry.path.parent().and_then(|p| worktree.entry_for_path(p))
 619                    {
 620                        entry = parent_entry;
 621                    } else {
 622                        break;
 623                    }
 624                }
 625            }
 626        }
 627    }
 628
 629    fn for_each_visible_entry(
 630        &self,
 631        range: Range<usize>,
 632        cx: &mut ViewContext<ProjectPanel>,
 633        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
 634    ) {
 635        let mut ix = 0;
 636        for (worktree_id, visible_worktree_entries) in &self.visible_entries {
 637            if ix >= range.end {
 638                return;
 639            }
 640
 641            if ix + visible_worktree_entries.len() <= range.start {
 642                ix += visible_worktree_entries.len();
 643                continue;
 644            }
 645
 646            let end_ix = range.end.min(ix + visible_worktree_entries.len());
 647            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
 648                let snapshot = worktree.read(cx).snapshot();
 649                let expanded_entry_ids = self
 650                    .expanded_dir_ids
 651                    .get(&snapshot.id())
 652                    .map(Vec::as_slice)
 653                    .unwrap_or(&[]);
 654                let root_name = OsStr::new(snapshot.root_name());
 655                for entry in &visible_worktree_entries[range.start.saturating_sub(ix)..end_ix - ix]
 656                {
 657                    let mut details = EntryDetails {
 658                        filename: entry
 659                            .path
 660                            .file_name()
 661                            .unwrap_or(root_name)
 662                            .to_string_lossy()
 663                            .to_string(),
 664                        depth: entry.path.components().count(),
 665                        kind: if entry.is_dir() {
 666                            EntryKind::Dir
 667                        } else {
 668                            EntryKind::File
 669                        },
 670                        is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
 671                        is_selected: self.selection.map_or(false, |e| {
 672                            e.worktree_id == snapshot.id() && e.entry_id == entry.id
 673                        }),
 674                    };
 675                    if let Some(edit_state) = self.edit_state {
 676                        if edit_state.worktree_id == *worktree_id && edit_state.entry_id == entry.id
 677                        {
 678                            if edit_state.new_file {
 679                                if entry.is_file() {
 680                                    details.kind = EntryKind::NewFileEditor;
 681                                    details.filename = Default::default();
 682                                    details.is_expanded = false;
 683                                    details.is_selected = false;
 684                                }
 685                            } else {
 686                                details.kind = EntryKind::FileRenameEditor;
 687                            }
 688                        }
 689                    }
 690                    callback(entry.id, details, cx);
 691                }
 692            }
 693            ix = end_ix;
 694        }
 695    }
 696
 697    fn render_entry(
 698        entry_id: ProjectEntryId,
 699        details: EntryDetails,
 700        editor: &ViewHandle<Editor>,
 701        theme: &theme::ProjectPanel,
 702        cx: &mut ViewContext<Self>,
 703    ) -> ElementBox {
 704        let kind = details.kind;
 705        let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
 706
 707        if kind == EntryKind::FileRenameEditor || kind == EntryKind::NewFileEditor {
 708            return ChildView::new(editor.clone())
 709                .constrained()
 710                .with_height(theme.entry.default.height)
 711                .contained()
 712                .with_margin_left(
 713                    padding + theme.entry.default.icon_spacing + theme.entry.default.icon_size,
 714                )
 715                .boxed();
 716        }
 717
 718        MouseEventHandler::new::<Self, _, _>(entry_id.to_usize(), cx, |state, _| {
 719            let style = theme.entry.style_for(state, details.is_selected);
 720            Flex::row()
 721                .with_child(
 722                    ConstrainedBox::new(if kind == EntryKind::Dir {
 723                        if details.is_expanded {
 724                            Svg::new("icons/disclosure-open.svg")
 725                                .with_color(style.icon_color)
 726                                .boxed()
 727                        } else {
 728                            Svg::new("icons/disclosure-closed.svg")
 729                                .with_color(style.icon_color)
 730                                .boxed()
 731                        }
 732                    } else {
 733                        Empty::new().boxed()
 734                    })
 735                    .with_max_width(style.icon_size)
 736                    .with_max_height(style.icon_size)
 737                    .aligned()
 738                    .constrained()
 739                    .with_width(style.icon_size)
 740                    .boxed(),
 741                )
 742                .with_child(
 743                    Label::new(details.filename, style.text.clone())
 744                        .contained()
 745                        .with_margin_left(style.icon_spacing)
 746                        .aligned()
 747                        .left()
 748                        .boxed(),
 749                )
 750                .constrained()
 751                .with_height(theme.entry.default.height)
 752                .contained()
 753                .with_style(style.container)
 754                .with_padding_left(padding)
 755                .boxed()
 756        })
 757        .on_click(move |cx| {
 758            if kind == EntryKind::Dir {
 759                cx.dispatch_action(ToggleExpanded(entry_id))
 760            } else {
 761                cx.dispatch_action(Open(entry_id))
 762            }
 763        })
 764        .with_cursor_style(CursorStyle::PointingHand)
 765        .boxed()
 766    }
 767}
 768
 769impl View for ProjectPanel {
 770    fn ui_name() -> &'static str {
 771        "ProjectPanel"
 772    }
 773
 774    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
 775        let theme = &cx.global::<Settings>().theme.project_panel;
 776        let mut container_style = theme.container;
 777        let padding = std::mem::take(&mut container_style.padding);
 778        let handle = self.handle.clone();
 779        UniformList::new(
 780            self.list.clone(),
 781            self.visible_entries
 782                .iter()
 783                .map(|(_, worktree_entries)| worktree_entries.len())
 784                .sum(),
 785            move |range, items, cx| {
 786                let theme = cx.global::<Settings>().theme.clone();
 787                let this = handle.upgrade(cx).unwrap();
 788                this.update(cx.app, |this, cx| {
 789                    this.for_each_visible_entry(range.clone(), cx, |id, details, cx| {
 790                        items.push(Self::render_entry(
 791                            id,
 792                            details,
 793                            &this.filename_editor,
 794                            &theme.project_panel,
 795                            cx,
 796                        ));
 797                    });
 798                })
 799            },
 800        )
 801        .with_padding_top(padding.top)
 802        .with_padding_bottom(padding.bottom)
 803        .contained()
 804        .with_style(container_style)
 805        .boxed()
 806    }
 807
 808    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
 809        let mut cx = Self::default_keymap_context();
 810        cx.set.insert("menu".into());
 811        cx
 812    }
 813}
 814
 815impl Entity for ProjectPanel {
 816    type Event = Event;
 817}
 818
 819#[cfg(test)]
 820mod tests {
 821    use super::*;
 822    use gpui::{TestAppContext, ViewHandle};
 823    use project::FakeFs;
 824    use serde_json::json;
 825    use std::{collections::HashSet, path::Path};
 826    use workspace::WorkspaceParams;
 827
 828    #[gpui::test]
 829    async fn test_visible_list(cx: &mut gpui::TestAppContext) {
 830        cx.foreground().forbid_parking();
 831
 832        let fs = FakeFs::new(cx.background());
 833        fs.insert_tree(
 834            "/root1",
 835            json!({
 836                ".dockerignore": "",
 837                ".git": {
 838                    "HEAD": "",
 839                },
 840                "a": {
 841                    "0": { "q": "", "r": "", "s": "" },
 842                    "1": { "t": "", "u": "" },
 843                    "2": { "v": "", "w": "", "x": "", "y": "" },
 844                },
 845                "b": {
 846                    "3": { "Q": "" },
 847                    "4": { "R": "", "S": "", "T": "", "U": "" },
 848                },
 849                "C": {
 850                    "5": {},
 851                    "6": { "V": "", "W": "" },
 852                    "7": { "X": "" },
 853                    "8": { "Y": {}, "Z": "" }
 854                }
 855            }),
 856        )
 857        .await;
 858        fs.insert_tree(
 859            "/root2",
 860            json!({
 861                "d": {
 862                    "9": ""
 863                },
 864                "e": {}
 865            }),
 866        )
 867        .await;
 868
 869        let project = Project::test(fs.clone(), ["/root1", "/root2"], cx).await;
 870        let params = cx.update(WorkspaceParams::test);
 871        let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
 872        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
 873        assert_eq!(
 874            visible_entry_details(&panel, 0..50, cx),
 875            &[
 876                EntryDetails {
 877                    filename: "root1".to_string(),
 878                    depth: 0,
 879                    kind: EntryKind::Dir,
 880                    is_expanded: true,
 881                    is_selected: false,
 882                },
 883                EntryDetails {
 884                    filename: "a".to_string(),
 885                    depth: 1,
 886                    kind: EntryKind::Dir,
 887                    is_expanded: false,
 888                    is_selected: false,
 889                },
 890                EntryDetails {
 891                    filename: "b".to_string(),
 892                    depth: 1,
 893                    kind: EntryKind::Dir,
 894                    is_expanded: false,
 895                    is_selected: false,
 896                },
 897                EntryDetails {
 898                    filename: "C".to_string(),
 899                    depth: 1,
 900                    kind: EntryKind::Dir,
 901                    is_expanded: false,
 902                    is_selected: false,
 903                },
 904                EntryDetails {
 905                    filename: ".dockerignore".to_string(),
 906                    depth: 1,
 907                    kind: EntryKind::File,
 908                    is_expanded: false,
 909                    is_selected: false,
 910                },
 911                EntryDetails {
 912                    filename: "root2".to_string(),
 913                    depth: 0,
 914                    kind: EntryKind::Dir,
 915                    is_expanded: true,
 916                    is_selected: false
 917                },
 918                EntryDetails {
 919                    filename: "d".to_string(),
 920                    depth: 1,
 921                    kind: EntryKind::Dir,
 922                    is_expanded: false,
 923                    is_selected: false
 924                },
 925                EntryDetails {
 926                    filename: "e".to_string(),
 927                    depth: 1,
 928                    kind: EntryKind::Dir,
 929                    is_expanded: false,
 930                    is_selected: false
 931                },
 932            ],
 933        );
 934
 935        toggle_expand_dir(&panel, "root1/b", cx);
 936        assert_eq!(
 937            visible_entry_details(&panel, 0..50, cx),
 938            &[
 939                EntryDetails {
 940                    filename: "root1".to_string(),
 941                    depth: 0,
 942                    kind: EntryKind::Dir,
 943                    is_expanded: true,
 944                    is_selected: false,
 945                },
 946                EntryDetails {
 947                    filename: "a".to_string(),
 948                    depth: 1,
 949                    kind: EntryKind::Dir,
 950                    is_expanded: false,
 951                    is_selected: false,
 952                },
 953                EntryDetails {
 954                    filename: "b".to_string(),
 955                    depth: 1,
 956                    kind: EntryKind::Dir,
 957                    is_expanded: true,
 958                    is_selected: true,
 959                },
 960                EntryDetails {
 961                    filename: "3".to_string(),
 962                    depth: 2,
 963                    kind: EntryKind::Dir,
 964                    is_expanded: false,
 965                    is_selected: false,
 966                },
 967                EntryDetails {
 968                    filename: "4".to_string(),
 969                    depth: 2,
 970                    kind: EntryKind::Dir,
 971                    is_expanded: false,
 972                    is_selected: false,
 973                },
 974                EntryDetails {
 975                    filename: "C".to_string(),
 976                    depth: 1,
 977                    kind: EntryKind::Dir,
 978                    is_expanded: false,
 979                    is_selected: false,
 980                },
 981                EntryDetails {
 982                    filename: ".dockerignore".to_string(),
 983                    depth: 1,
 984                    kind: EntryKind::File,
 985                    is_expanded: false,
 986                    is_selected: false,
 987                },
 988                EntryDetails {
 989                    filename: "root2".to_string(),
 990                    depth: 0,
 991                    kind: EntryKind::Dir,
 992                    is_expanded: true,
 993                    is_selected: false
 994                },
 995                EntryDetails {
 996                    filename: "d".to_string(),
 997                    depth: 1,
 998                    kind: EntryKind::Dir,
 999                    is_expanded: false,
1000                    is_selected: false
1001                },
1002                EntryDetails {
1003                    filename: "e".to_string(),
1004                    depth: 1,
1005                    kind: EntryKind::Dir,
1006                    is_expanded: false,
1007                    is_selected: false
1008                },
1009            ]
1010        );
1011
1012        assert_eq!(
1013            visible_entry_details(&panel, 5..8, cx),
1014            [
1015                EntryDetails {
1016                    filename: "C".to_string(),
1017                    depth: 1,
1018                    kind: EntryKind::Dir,
1019                    is_expanded: false,
1020                    is_selected: false
1021                },
1022                EntryDetails {
1023                    filename: ".dockerignore".to_string(),
1024                    depth: 1,
1025                    kind: EntryKind::File,
1026                    is_expanded: false,
1027                    is_selected: false
1028                },
1029                EntryDetails {
1030                    filename: "root2".to_string(),
1031                    depth: 0,
1032                    kind: EntryKind::Dir,
1033                    is_expanded: true,
1034                    is_selected: false
1035                },
1036            ]
1037        );
1038    }
1039
1040    #[gpui::test]
1041    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1042        cx.foreground().forbid_parking();
1043
1044        let fs = FakeFs::new(cx.background());
1045        fs.insert_tree(
1046            "/root1",
1047            json!({
1048                ".dockerignore": "",
1049                ".git": {
1050                    "HEAD": "",
1051                },
1052                "a": {
1053                    "0": { "q": "", "r": "", "s": "" },
1054                    "1": { "t": "", "u": "" },
1055                    "2": { "v": "", "w": "", "x": "", "y": "" },
1056                },
1057                "b": {
1058                    "3": { "Q": "" },
1059                    "4": { "R": "", "S": "", "T": "", "U": "" },
1060                },
1061                "C": {
1062                    "5": {},
1063                    "6": { "V": "", "W": "" },
1064                    "7": { "X": "" },
1065                    "8": { "Y": {}, "Z": "" }
1066                }
1067            }),
1068        )
1069        .await;
1070        fs.insert_tree(
1071            "/root2",
1072            json!({
1073                "d": {
1074                    "9": ""
1075                },
1076                "e": {}
1077            }),
1078        )
1079        .await;
1080
1081        let project = Project::test(fs.clone(), ["/root1", "/root2"], cx).await;
1082        let params = cx.update(WorkspaceParams::test);
1083        let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
1084        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1085
1086        select_path(&panel, "root1", cx);
1087        assert_eq!(
1088            visible_entries_as_strings(&panel, 0..10, cx),
1089            &[
1090                "v root1  <== selected",
1091                "    > a",
1092                "    > b",
1093                "    > C",
1094                "      .dockerignore",
1095                "v root2",
1096                "    > d",
1097                "    > e",
1098            ]
1099        );
1100
1101        // Add a file with the root folder selected. The filename editor is placed
1102        // before the first file in the root folder.
1103        panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1104        assert!(panel.read_with(cx, |panel, cx| panel.filename_editor.is_focused(cx)));
1105        assert_eq!(
1106            visible_entries_as_strings(&panel, 0..10, cx),
1107            &[
1108                "v root1  <== selected",
1109                "    > a",
1110                "    > b",
1111                "    > C",
1112                "      [NEW FILE EDITOR]",
1113                "      .dockerignore",
1114                "v root2",
1115                "    > d",
1116                "    > e",
1117            ]
1118        );
1119
1120        panel
1121            .update(cx, |panel, cx| {
1122                panel
1123                    .filename_editor
1124                    .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1125                panel.confirm(&Confirm, cx).unwrap()
1126            })
1127            .await
1128            .unwrap();
1129        assert_eq!(
1130            visible_entries_as_strings(&panel, 0..10, cx),
1131            &[
1132                "v root1  <== selected",
1133                "    > a",
1134                "    > b",
1135                "    > C",
1136                "      .dockerignore",
1137                "      the-new-filename",
1138                "v root2",
1139                "    > d",
1140                "    > e",
1141            ]
1142        );
1143
1144        select_path(&panel, "root1/b", cx);
1145        panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1146        assert_eq!(
1147            visible_entries_as_strings(&panel, 0..9, cx),
1148            &[
1149                "v root1",
1150                "    > a",
1151                "    v b  <== selected",
1152                "        > 3",
1153                "        > 4",
1154                "          [NEW FILE EDITOR]",
1155                "    > C",
1156                "      .dockerignore",
1157                "      the-new-filename",
1158            ]
1159        );
1160
1161        panel
1162            .update(cx, |panel, cx| {
1163                panel
1164                    .filename_editor
1165                    .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1166                panel.confirm(&Confirm, cx).unwrap()
1167            })
1168            .await
1169            .unwrap();
1170        assert_eq!(
1171            visible_entries_as_strings(&panel, 0..9, cx),
1172            &[
1173                "v root1",
1174                "    > a",
1175                "    v b  <== selected",
1176                "        > 3",
1177                "        > 4",
1178                "          another-filename",
1179                "    > C",
1180                "      .dockerignore",
1181                "      the-new-filename",
1182            ]
1183        );
1184
1185        select_path(&panel, "root1/b/another-filename", cx);
1186        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1187        assert_eq!(
1188            visible_entries_as_strings(&panel, 0..9, cx),
1189            &[
1190                "v root1",
1191                "    > a",
1192                "    v b",
1193                "        > 3",
1194                "        > 4",
1195                "          [RENAME EDITOR]  <== selected",
1196                "    > C",
1197                "      .dockerignore",
1198                "      the-new-filename",
1199            ]
1200        );
1201
1202        panel
1203            .update(cx, |panel, cx| {
1204                panel
1205                    .filename_editor
1206                    .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1207                panel.confirm(&Confirm, cx).unwrap()
1208            })
1209            .await
1210            .unwrap();
1211        assert_eq!(
1212            visible_entries_as_strings(&panel, 0..9, cx),
1213            &[
1214                "v root1",
1215                "    > a",
1216                "    v b",
1217                "        > 3",
1218                "        > 4",
1219                "          a-different-filename  <== selected",
1220                "    > C",
1221                "      .dockerignore",
1222                "      the-new-filename",
1223            ]
1224        );
1225    }
1226
1227    fn toggle_expand_dir(
1228        panel: &ViewHandle<ProjectPanel>,
1229        path: impl AsRef<Path>,
1230        cx: &mut TestAppContext,
1231    ) {
1232        let path = path.as_ref();
1233        panel.update(cx, |panel, cx| {
1234            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1235                let worktree = worktree.read(cx);
1236                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1237                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1238                    panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
1239                    return;
1240                }
1241            }
1242            panic!("no worktree for path {:?}", path);
1243        });
1244    }
1245
1246    fn select_path(
1247        panel: &ViewHandle<ProjectPanel>,
1248        path: impl AsRef<Path>,
1249        cx: &mut TestAppContext,
1250    ) {
1251        let path = path.as_ref();
1252        panel.update(cx, |panel, cx| {
1253            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1254                let worktree = worktree.read(cx);
1255                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1256                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1257                    panel.selection = Some(Selection {
1258                        worktree_id: worktree.id(),
1259                        entry_id,
1260                    });
1261                    return;
1262                }
1263            }
1264            panic!("no worktree for path {:?}", path);
1265        });
1266    }
1267
1268    fn visible_entry_details(
1269        panel: &ViewHandle<ProjectPanel>,
1270        range: Range<usize>,
1271        cx: &mut TestAppContext,
1272    ) -> Vec<EntryDetails> {
1273        let mut result = Vec::new();
1274        let mut project_entries = HashSet::new();
1275        let mut has_editor = false;
1276        panel.update(cx, |panel, cx| {
1277            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1278                if details.kind == EntryKind::NewFileEditor
1279                    || details.kind == EntryKind::FileRenameEditor
1280                {
1281                    assert!(!has_editor, "duplicate editor entry");
1282                    has_editor = true;
1283                } else {
1284                    assert!(
1285                        project_entries.insert(project_entry),
1286                        "duplicate project entry {:?} {:?}",
1287                        project_entry,
1288                        details
1289                    );
1290                }
1291                result.push(details)
1292            });
1293        });
1294
1295        result
1296    }
1297
1298    fn visible_entries_as_strings(
1299        panel: &ViewHandle<ProjectPanel>,
1300        range: Range<usize>,
1301        cx: &mut TestAppContext,
1302    ) -> Vec<String> {
1303        visible_entry_details(panel, range, cx)
1304            .into_iter()
1305            .map(|details| {
1306                let indent = "    ".repeat(details.depth);
1307                let icon = if details.kind == EntryKind::Dir {
1308                    if details.is_expanded {
1309                        "v "
1310                    } else {
1311                        "> "
1312                    }
1313                } else {
1314                    "  "
1315                };
1316                let name = if details.kind == EntryKind::FileRenameEditor {
1317                    "[RENAME EDITOR]"
1318                } else if details.kind == EntryKind::NewFileEditor {
1319                    "[NEW FILE EDITOR]"
1320                } else {
1321                    &details.filename
1322                };
1323                let selected = if details.is_selected {
1324                    "  <== selected"
1325                } else {
1326                    ""
1327                };
1328                format!("{indent}{icon}{name}{selected}")
1329            })
1330            .collect()
1331    }
1332}