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