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