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
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use gpui::{TestAppContext, ViewHandle};
907 use project::FakeFs;
908 use serde_json::json;
909 use std::{collections::HashSet, path::Path};
910 use workspace::WorkspaceParams;
911
912 #[gpui::test]
913 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
914 cx.foreground().forbid_parking();
915
916 let fs = FakeFs::new(cx.background());
917 fs.insert_tree(
918 "/root1",
919 json!({
920 ".dockerignore": "",
921 ".git": {
922 "HEAD": "",
923 },
924 "a": {
925 "0": { "q": "", "r": "", "s": "" },
926 "1": { "t": "", "u": "" },
927 "2": { "v": "", "w": "", "x": "", "y": "" },
928 },
929 "b": {
930 "3": { "Q": "" },
931 "4": { "R": "", "S": "", "T": "", "U": "" },
932 },
933 "C": {
934 "5": {},
935 "6": { "V": "", "W": "" },
936 "7": { "X": "" },
937 "8": { "Y": {}, "Z": "" }
938 }
939 }),
940 )
941 .await;
942 fs.insert_tree(
943 "/root2",
944 json!({
945 "d": {
946 "9": ""
947 },
948 "e": {}
949 }),
950 )
951 .await;
952
953 let project = Project::test(fs.clone(), ["/root1", "/root2"], cx).await;
954 let params = cx.update(WorkspaceParams::test);
955 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
956 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
957 assert_eq!(
958 visible_entries_as_strings(&panel, 0..50, cx),
959 &[
960 "v root1",
961 " > a",
962 " > b",
963 " > C",
964 " .dockerignore",
965 "v root2",
966 " > d",
967 " > e",
968 ]
969 );
970
971 toggle_expand_dir(&panel, "root1/b", cx);
972 assert_eq!(
973 visible_entries_as_strings(&panel, 0..50, cx),
974 &[
975 "v root1",
976 " > a",
977 " v b <== selected",
978 " > 3",
979 " > 4",
980 " > C",
981 " .dockerignore",
982 "v root2",
983 " > d",
984 " > e",
985 ]
986 );
987
988 assert_eq!(
989 visible_entries_as_strings(&panel, 5..8, cx),
990 &[
991 //
992 " > C",
993 " .dockerignore",
994 "v root2",
995 ]
996 );
997 }
998
999 #[gpui::test(iterations = 30)]
1000 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1001 cx.foreground().forbid_parking();
1002
1003 let fs = FakeFs::new(cx.background());
1004 fs.insert_tree(
1005 "/root1",
1006 json!({
1007 ".dockerignore": "",
1008 ".git": {
1009 "HEAD": "",
1010 },
1011 "a": {
1012 "0": { "q": "", "r": "", "s": "" },
1013 "1": { "t": "", "u": "" },
1014 "2": { "v": "", "w": "", "x": "", "y": "" },
1015 },
1016 "b": {
1017 "3": { "Q": "" },
1018 "4": { "R": "", "S": "", "T": "", "U": "" },
1019 },
1020 "C": {
1021 "5": {},
1022 "6": { "V": "", "W": "" },
1023 "7": { "X": "" },
1024 "8": { "Y": {}, "Z": "" }
1025 }
1026 }),
1027 )
1028 .await;
1029 fs.insert_tree(
1030 "/root2",
1031 json!({
1032 "d": {
1033 "9": ""
1034 },
1035 "e": {}
1036 }),
1037 )
1038 .await;
1039
1040 let project = Project::test(fs.clone(), ["/root1", "/root2"], cx).await;
1041 let params = cx.update(WorkspaceParams::test);
1042 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
1043 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
1044
1045 select_path(&panel, "root1", cx);
1046 assert_eq!(
1047 visible_entries_as_strings(&panel, 0..10, cx),
1048 &[
1049 "v root1 <== selected",
1050 " > a",
1051 " > b",
1052 " > C",
1053 " .dockerignore",
1054 "v root2",
1055 " > d",
1056 " > e",
1057 ]
1058 );
1059
1060 // Add a file with the root folder selected. The filename editor is placed
1061 // before the first file in the root folder.
1062 panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1063 assert!(panel.read_with(cx, |panel, cx| panel.filename_editor.is_focused(cx)));
1064 assert_eq!(
1065 visible_entries_as_strings(&panel, 0..10, cx),
1066 &[
1067 "v root1",
1068 " > a",
1069 " > b",
1070 " > C",
1071 " [EDITOR: ''] <== selected",
1072 " .dockerignore",
1073 "v root2",
1074 " > d",
1075 " > e",
1076 ]
1077 );
1078
1079 let confirm = panel.update(cx, |panel, cx| {
1080 panel
1081 .filename_editor
1082 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1083 panel.confirm(&Confirm, cx).unwrap()
1084 });
1085 assert_eq!(
1086 visible_entries_as_strings(&panel, 0..10, cx),
1087 &[
1088 "v root1",
1089 " > a",
1090 " > b",
1091 " > C",
1092 " [PROCESSING: 'the-new-filename'] <== selected",
1093 " .dockerignore",
1094 "v root2",
1095 " > d",
1096 " > e",
1097 ]
1098 );
1099
1100 confirm.await.unwrap();
1101 assert_eq!(
1102 visible_entries_as_strings(&panel, 0..10, cx),
1103 &[
1104 "v root1",
1105 " > a",
1106 " > b",
1107 " > C",
1108 " .dockerignore",
1109 " the-new-filename <== selected",
1110 "v root2",
1111 " > d",
1112 " > e",
1113 ]
1114 );
1115
1116 select_path(&panel, "root1/b", cx);
1117 panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
1118 assert_eq!(
1119 visible_entries_as_strings(&panel, 0..9, cx),
1120 &[
1121 "v root1",
1122 " > a",
1123 " v b",
1124 " > 3",
1125 " > 4",
1126 " [EDITOR: ''] <== selected",
1127 " > C",
1128 " .dockerignore",
1129 " the-new-filename",
1130 ]
1131 );
1132
1133 panel
1134 .update(cx, |panel, cx| {
1135 panel
1136 .filename_editor
1137 .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1138 panel.confirm(&Confirm, cx).unwrap()
1139 })
1140 .await
1141 .unwrap();
1142 assert_eq!(
1143 visible_entries_as_strings(&panel, 0..9, cx),
1144 &[
1145 "v root1",
1146 " > a",
1147 " v b",
1148 " > 3",
1149 " > 4",
1150 " another-filename <== selected",
1151 " > C",
1152 " .dockerignore",
1153 " the-new-filename",
1154 ]
1155 );
1156
1157 select_path(&panel, "root1/b/another-filename", cx);
1158 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1159 assert_eq!(
1160 visible_entries_as_strings(&panel, 0..9, cx),
1161 &[
1162 "v root1",
1163 " > a",
1164 " v b",
1165 " > 3",
1166 " > 4",
1167 " [EDITOR: 'another-filename'] <== selected",
1168 " > C",
1169 " .dockerignore",
1170 " the-new-filename",
1171 ]
1172 );
1173
1174 let confirm = panel.update(cx, |panel, cx| {
1175 panel
1176 .filename_editor
1177 .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1178 panel.confirm(&Confirm, cx).unwrap()
1179 });
1180 assert_eq!(
1181 visible_entries_as_strings(&panel, 0..9, cx),
1182 &[
1183 "v root1",
1184 " > a",
1185 " v b",
1186 " > 3",
1187 " > 4",
1188 " [PROCESSING: 'a-different-filename'] <== selected",
1189 " > C",
1190 " .dockerignore",
1191 " the-new-filename",
1192 ]
1193 );
1194
1195 confirm.await.unwrap();
1196 assert_eq!(
1197 visible_entries_as_strings(&panel, 0..9, cx),
1198 &[
1199 "v root1",
1200 " > a",
1201 " v b",
1202 " > 3",
1203 " > 4",
1204 " a-different-filename <== selected",
1205 " > C",
1206 " .dockerignore",
1207 " the-new-filename",
1208 ]
1209 );
1210
1211 panel.update(cx, |panel, cx| panel.add_directory(&AddDirectory, cx));
1212 assert_eq!(
1213 visible_entries_as_strings(&panel, 0..9, cx),
1214 &[
1215 "v root1",
1216 " > a",
1217 " v b",
1218 " > [EDITOR: ''] <== selected",
1219 " > 3",
1220 " > 4",
1221 " a-different-filename",
1222 " > C",
1223 " .dockerignore",
1224 ]
1225 );
1226
1227 let confirm = panel.update(cx, |panel, cx| {
1228 panel
1229 .filename_editor
1230 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1231 panel.confirm(&Confirm, cx).unwrap()
1232 });
1233 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1234 assert_eq!(
1235 visible_entries_as_strings(&panel, 0..9, cx),
1236 &[
1237 "v root1",
1238 " > a",
1239 " v b",
1240 " > [PROCESSING: 'new-dir']",
1241 " > 3 <== selected",
1242 " > 4",
1243 " a-different-filename",
1244 " > C",
1245 " .dockerignore",
1246 ]
1247 );
1248
1249 confirm.await.unwrap();
1250 assert_eq!(
1251 visible_entries_as_strings(&panel, 0..9, cx),
1252 &[
1253 "v root1",
1254 " > a",
1255 " v b",
1256 " > 3 <== selected",
1257 " > 4",
1258 " > new-dir",
1259 " a-different-filename",
1260 " > C",
1261 " .dockerignore",
1262 ]
1263 );
1264 }
1265
1266 fn toggle_expand_dir(
1267 panel: &ViewHandle<ProjectPanel>,
1268 path: impl AsRef<Path>,
1269 cx: &mut TestAppContext,
1270 ) {
1271 let path = path.as_ref();
1272 panel.update(cx, |panel, cx| {
1273 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1274 let worktree = worktree.read(cx);
1275 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1276 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1277 panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
1278 return;
1279 }
1280 }
1281 panic!("no worktree for path {:?}", path);
1282 });
1283 }
1284
1285 fn select_path(
1286 panel: &ViewHandle<ProjectPanel>,
1287 path: impl AsRef<Path>,
1288 cx: &mut TestAppContext,
1289 ) {
1290 let path = path.as_ref();
1291 panel.update(cx, |panel, cx| {
1292 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1293 let worktree = worktree.read(cx);
1294 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1295 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1296 panel.selection = Some(Selection {
1297 worktree_id: worktree.id(),
1298 entry_id,
1299 });
1300 return;
1301 }
1302 }
1303 panic!("no worktree for path {:?}", path);
1304 });
1305 }
1306
1307 fn visible_entries_as_strings(
1308 panel: &ViewHandle<ProjectPanel>,
1309 range: Range<usize>,
1310 cx: &mut TestAppContext,
1311 ) -> Vec<String> {
1312 let mut result = Vec::new();
1313 let mut project_entries = HashSet::new();
1314 let mut has_editor = false;
1315 panel.update(cx, |panel, cx| {
1316 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1317 if details.is_editing {
1318 assert!(!has_editor, "duplicate editor entry");
1319 has_editor = true;
1320 } else {
1321 assert!(
1322 project_entries.insert(project_entry),
1323 "duplicate project entry {:?} {:?}",
1324 project_entry,
1325 details
1326 );
1327 }
1328
1329 let indent = " ".repeat(details.depth);
1330 let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
1331 if details.is_expanded {
1332 "v "
1333 } else {
1334 "> "
1335 }
1336 } else {
1337 " "
1338 };
1339 let name = if details.is_editing {
1340 format!("[EDITOR: '{}']", details.filename)
1341 } else if details.is_processing {
1342 format!("[PROCESSING: '{}']", details.filename)
1343 } else {
1344 details.filename.clone()
1345 };
1346 let selected = if details.is_selected {
1347 " <== selected"
1348 } else {
1349 ""
1350 };
1351 result.push(format!("{indent}{icon}{name}{selected}"));
1352 });
1353 });
1354
1355 result
1356 }
1357}