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(¶ms, 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(¶ms, 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}