1mod project_panel_settings;
2
3use context_menu::{ContextMenu, ContextMenuItem};
4use db::kvp::KEY_VALUE_STORE;
5use drag_and_drop::{DragAndDrop, Draggable};
6use editor::{Cancel, Editor};
7use futures::stream::StreamExt;
8use gpui::{
9 actions,
10 anyhow::{self, anyhow, Result},
11 elements::{
12 AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
13 ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
14 },
15 geometry::vector::Vector2F,
16 keymap_matcher::KeymapContext,
17 platform::{CursorStyle, MouseButton, PromptLevel},
18 Action, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, ModelHandle,
19 Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
20};
21use menu::{Confirm, SelectNext, SelectPrev};
22use project::{
23 repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
24 Worktree, WorktreeId,
25};
26use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings};
27use serde::{Deserialize, Serialize};
28use settings::SettingsStore;
29use std::{
30 cmp::Ordering,
31 collections::{hash_map, HashMap},
32 ffi::OsStr,
33 ops::Range,
34 path::Path,
35 sync::Arc,
36};
37use theme::ProjectPanelEntry;
38use unicase::UniCase;
39use util::{ResultExt, TryFutureExt};
40use workspace::{
41 dock::{DockPosition, Panel},
42 Workspace,
43};
44
45const PROJECT_PANEL_KEY: &'static str = "ProjectPanel";
46const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
47
48pub struct ProjectPanel {
49 project: ModelHandle<Project>,
50 fs: Arc<dyn Fs>,
51 list: UniformListState,
52 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
53 last_worktree_root_id: Option<ProjectEntryId>,
54 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
55 selection: Option<Selection>,
56 edit_state: Option<EditState>,
57 filename_editor: ViewHandle<Editor>,
58 clipboard_entry: Option<ClipboardEntry>,
59 context_menu: ViewHandle<ContextMenu>,
60 dragged_entry_destination: Option<Arc<Path>>,
61 workspace: WeakViewHandle<Workspace>,
62 has_focus: bool,
63 width: Option<f32>,
64 pending_serialization: Task<Option<()>>,
65}
66
67#[derive(Copy, Clone)]
68struct Selection {
69 worktree_id: WorktreeId,
70 entry_id: ProjectEntryId,
71}
72
73#[derive(Clone, Debug)]
74struct EditState {
75 worktree_id: WorktreeId,
76 entry_id: ProjectEntryId,
77 is_new_entry: bool,
78 is_dir: bool,
79 processing_filename: Option<String>,
80}
81
82#[derive(Copy, Clone)]
83pub enum ClipboardEntry {
84 Copied {
85 worktree_id: WorktreeId,
86 entry_id: ProjectEntryId,
87 },
88 Cut {
89 worktree_id: WorktreeId,
90 entry_id: ProjectEntryId,
91 },
92}
93
94#[derive(Debug, PartialEq, Eq)]
95pub struct EntryDetails {
96 filename: String,
97 path: Arc<Path>,
98 depth: usize,
99 kind: EntryKind,
100 is_ignored: bool,
101 is_expanded: bool,
102 is_selected: bool,
103 is_editing: bool,
104 is_processing: bool,
105 is_cut: bool,
106 git_status: Option<GitFileStatus>,
107}
108
109actions!(
110 project_panel,
111 [
112 ExpandSelectedEntry,
113 CollapseSelectedEntry,
114 NewDirectory,
115 NewFile,
116 Copy,
117 CopyPath,
118 CopyRelativePath,
119 RevealInFinder,
120 Cut,
121 Paste,
122 Delete,
123 Rename,
124 ToggleFocus
125 ]
126);
127
128pub fn init_settings(cx: &mut AppContext) {
129 settings::register::<ProjectPanelSettings>(cx);
130}
131
132pub fn init(cx: &mut AppContext) {
133 init_settings(cx);
134 cx.add_action(ProjectPanel::expand_selected_entry);
135 cx.add_action(ProjectPanel::collapse_selected_entry);
136 cx.add_action(ProjectPanel::select_prev);
137 cx.add_action(ProjectPanel::select_next);
138 cx.add_action(ProjectPanel::new_file);
139 cx.add_action(ProjectPanel::new_directory);
140 cx.add_action(ProjectPanel::rename);
141 cx.add_async_action(ProjectPanel::delete);
142 cx.add_async_action(ProjectPanel::confirm);
143 cx.add_action(ProjectPanel::cancel);
144 cx.add_action(ProjectPanel::cut);
145 cx.add_action(ProjectPanel::copy);
146 cx.add_action(ProjectPanel::copy_path);
147 cx.add_action(ProjectPanel::copy_relative_path);
148 cx.add_action(ProjectPanel::reveal_in_finder);
149 cx.add_action(
150 |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
151 this.paste(action, cx);
152 },
153 );
154}
155
156pub enum Event {
157 OpenedEntry {
158 entry_id: ProjectEntryId,
159 focus_opened_item: bool,
160 },
161 DockPositionChanged,
162 Focus,
163}
164
165#[derive(Serialize, Deserialize)]
166struct SerializedProjectPanel {
167 width: Option<f32>,
168}
169
170impl ProjectPanel {
171 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
172 let project = workspace.project().clone();
173 let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
174 cx.observe(&project, |this, _, cx| {
175 this.update_visible_entries(None, cx);
176 cx.notify();
177 })
178 .detach();
179 cx.subscribe(&project, |this, project, event, cx| match event {
180 project::Event::ActiveEntryChanged(Some(entry_id)) => {
181 if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
182 {
183 this.expand_entry(worktree_id, *entry_id, cx);
184 this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
185 this.autoscroll(cx);
186 cx.notify();
187 }
188 }
189 project::Event::WorktreeRemoved(id) => {
190 this.expanded_dir_ids.remove(id);
191 this.update_visible_entries(None, cx);
192 cx.notify();
193 }
194 _ => {}
195 })
196 .detach();
197
198 let filename_editor = cx.add_view(|cx| {
199 Editor::single_line(
200 Some(Arc::new(|theme| {
201 let mut style = theme.project_panel.filename_editor.clone();
202 style.container.background_color.take();
203 style
204 })),
205 cx,
206 )
207 });
208
209 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
210 editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
211 this.autoscroll(cx);
212 }
213 _ => {}
214 })
215 .detach();
216 cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
217 if !is_focused
218 && this
219 .edit_state
220 .as_ref()
221 .map_or(false, |state| state.processing_filename.is_none())
222 {
223 this.edit_state = None;
224 this.update_visible_entries(None, cx);
225 }
226 })
227 .detach();
228
229 let view_id = cx.view_id();
230 let mut this = Self {
231 project: project.clone(),
232 fs: workspace.app_state().fs.clone(),
233 list: Default::default(),
234 visible_entries: Default::default(),
235 last_worktree_root_id: Default::default(),
236 expanded_dir_ids: Default::default(),
237 selection: None,
238 edit_state: None,
239 filename_editor,
240 clipboard_entry: None,
241 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
242 dragged_entry_destination: None,
243 workspace: workspace.weak_handle(),
244 has_focus: false,
245 width: None,
246 pending_serialization: Task::ready(None),
247 };
248 this.update_visible_entries(None, cx);
249
250 // Update the dock position when the setting changes.
251 let mut old_dock_position = this.position(cx);
252 cx.observe_global::<SettingsStore, _>(move |this, cx| {
253 let new_dock_position = this.position(cx);
254 if new_dock_position != old_dock_position {
255 old_dock_position = new_dock_position;
256 cx.emit(Event::DockPositionChanged);
257 }
258 })
259 .detach();
260
261 this
262 });
263
264 cx.subscribe(&project_panel, {
265 let project_panel = project_panel.downgrade();
266 move |workspace, _, event, cx| match event {
267 &Event::OpenedEntry {
268 entry_id,
269 focus_opened_item,
270 } => {
271 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
272 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
273 workspace
274 .open_path(
275 ProjectPath {
276 worktree_id: worktree.read(cx).id(),
277 path: entry.path.clone(),
278 },
279 None,
280 focus_opened_item,
281 cx,
282 )
283 .detach_and_log_err(cx);
284 if !focus_opened_item {
285 if let Some(project_panel) = project_panel.upgrade(cx) {
286 cx.focus(&project_panel);
287 }
288 }
289 }
290 }
291 }
292 _ => {}
293 }
294 })
295 .detach();
296
297 project_panel
298 }
299
300 pub fn load(
301 workspace: WeakViewHandle<Workspace>,
302 cx: AsyncAppContext,
303 ) -> Task<Result<ViewHandle<Self>>> {
304 cx.spawn(|mut cx| async move {
305 let serialized_panel = if let Some(panel) = cx
306 .background()
307 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
308 .await
309 .log_err()
310 .flatten()
311 {
312 Some(serde_json::from_str::<SerializedProjectPanel>(&panel)?)
313 } else {
314 None
315 };
316 workspace.update(&mut cx, |workspace, cx| {
317 let panel = ProjectPanel::new(workspace, cx);
318 if let Some(serialized_panel) = serialized_panel {
319 panel.update(cx, |panel, cx| {
320 panel.width = serialized_panel.width;
321 cx.notify();
322 });
323 }
324 panel
325 })
326 })
327 }
328
329 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
330 let width = self.width;
331 self.pending_serialization = cx.background().spawn(
332 async move {
333 KEY_VALUE_STORE
334 .write_kvp(
335 PROJECT_PANEL_KEY.into(),
336 serde_json::to_string(&SerializedProjectPanel { width })?,
337 )
338 .await?;
339 anyhow::Ok(())
340 }
341 .log_err(),
342 );
343 }
344
345 fn deploy_context_menu(
346 &mut self,
347 position: Vector2F,
348 entry_id: ProjectEntryId,
349 cx: &mut ViewContext<Self>,
350 ) {
351 let project = self.project.read(cx);
352
353 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
354 id
355 } else {
356 return;
357 };
358
359 self.selection = Some(Selection {
360 worktree_id,
361 entry_id,
362 });
363
364 let mut menu_entries = Vec::new();
365 if let Some((worktree, entry)) = self.selected_entry(cx) {
366 let is_root = Some(entry) == worktree.root_entry();
367 if !project.is_remote() {
368 menu_entries.push(ContextMenuItem::action(
369 "Add Folder to Project",
370 workspace::AddFolderToProject,
371 ));
372 if is_root {
373 let project = self.project.clone();
374 menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
375 project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
376 }));
377 }
378 }
379 menu_entries.push(ContextMenuItem::action("New File", NewFile));
380 menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
381 menu_entries.push(ContextMenuItem::Separator);
382 menu_entries.push(ContextMenuItem::action("Cut", Cut));
383 menu_entries.push(ContextMenuItem::action("Copy", Copy));
384 menu_entries.push(ContextMenuItem::Separator);
385 menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
386 menu_entries.push(ContextMenuItem::action(
387 "Copy Relative Path",
388 CopyRelativePath,
389 ));
390 menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
391 if let Some(clipboard_entry) = self.clipboard_entry {
392 if clipboard_entry.worktree_id() == worktree.id() {
393 menu_entries.push(ContextMenuItem::action("Paste", Paste));
394 }
395 }
396 menu_entries.push(ContextMenuItem::Separator);
397 menu_entries.push(ContextMenuItem::action("Rename", Rename));
398 if !is_root {
399 menu_entries.push(ContextMenuItem::action("Delete", Delete));
400 }
401 }
402
403 self.context_menu.update(cx, |menu, cx| {
404 menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
405 });
406
407 cx.notify();
408 }
409
410 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
411 if let Some((worktree, entry)) = self.selected_entry(cx) {
412 if entry.is_dir() {
413 let expanded_dir_ids =
414 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
415 expanded_dir_ids
416 } else {
417 return;
418 };
419
420 match expanded_dir_ids.binary_search(&entry.id) {
421 Ok(_) => self.select_next(&SelectNext, cx),
422 Err(ix) => {
423 expanded_dir_ids.insert(ix, entry.id);
424 self.update_visible_entries(None, cx);
425 cx.notify();
426 }
427 }
428 }
429 }
430 }
431
432 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
433 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
434 let expanded_dir_ids =
435 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
436 expanded_dir_ids
437 } else {
438 return;
439 };
440
441 loop {
442 match expanded_dir_ids.binary_search(&entry.id) {
443 Ok(ix) => {
444 expanded_dir_ids.remove(ix);
445 self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
446 cx.notify();
447 break;
448 }
449 Err(_) => {
450 if let Some(parent_entry) =
451 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
452 {
453 entry = parent_entry;
454 } else {
455 break;
456 }
457 }
458 }
459 }
460 }
461 }
462
463 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
464 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
465 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
466 match expanded_dir_ids.binary_search(&entry_id) {
467 Ok(ix) => {
468 expanded_dir_ids.remove(ix);
469 }
470 Err(ix) => {
471 expanded_dir_ids.insert(ix, entry_id);
472 }
473 }
474 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
475 cx.focus_self();
476 cx.notify();
477 }
478 }
479 }
480
481 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
482 if let Some(selection) = self.selection {
483 let (mut worktree_ix, mut entry_ix, _) =
484 self.index_for_selection(selection).unwrap_or_default();
485 if entry_ix > 0 {
486 entry_ix -= 1;
487 } else if worktree_ix > 0 {
488 worktree_ix -= 1;
489 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
490 } else {
491 return;
492 }
493
494 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
495 self.selection = Some(Selection {
496 worktree_id: *worktree_id,
497 entry_id: worktree_entries[entry_ix].id,
498 });
499 self.autoscroll(cx);
500 cx.notify();
501 } else {
502 self.select_first(cx);
503 }
504 }
505
506 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
507 if let Some(task) = self.confirm_edit(cx) {
508 Some(task)
509 } else if let Some((_, entry)) = self.selected_entry(cx) {
510 if entry.is_file() {
511 self.open_entry(entry.id, true, cx);
512 }
513 None
514 } else {
515 None
516 }
517 }
518
519 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
520 let edit_state = self.edit_state.as_mut()?;
521 cx.focus_self();
522
523 let worktree_id = edit_state.worktree_id;
524 let is_new_entry = edit_state.is_new_entry;
525 let is_dir = edit_state.is_dir;
526 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
527 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
528 let filename = self.filename_editor.read(cx).text(cx);
529
530 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
531 let edit_task;
532 let edited_entry_id;
533 if is_new_entry {
534 self.selection = Some(Selection {
535 worktree_id,
536 entry_id: NEW_ENTRY_ID,
537 });
538 let new_path = entry.path.join(&filename);
539 if path_already_exists(new_path.as_path()) {
540 return None;
541 }
542
543 edited_entry_id = NEW_ENTRY_ID;
544 edit_task = self.project.update(cx, |project, cx| {
545 project.create_entry((worktree_id, &new_path), is_dir, cx)
546 })?;
547 } else {
548 let new_path = if let Some(parent) = entry.path.clone().parent() {
549 parent.join(&filename)
550 } else {
551 filename.clone().into()
552 };
553 if path_already_exists(new_path.as_path()) {
554 return None;
555 }
556
557 edited_entry_id = entry.id;
558 edit_task = self.project.update(cx, |project, cx| {
559 project.rename_entry(entry.id, new_path.as_path(), cx)
560 })?;
561 };
562
563 edit_state.processing_filename = Some(filename);
564 cx.notify();
565
566 Some(cx.spawn(|this, mut cx| async move {
567 let new_entry = edit_task.await;
568 this.update(&mut cx, |this, cx| {
569 this.edit_state.take();
570 cx.notify();
571 })?;
572
573 let new_entry = new_entry?;
574 this.update(&mut cx, |this, cx| {
575 if let Some(selection) = &mut this.selection {
576 if selection.entry_id == edited_entry_id {
577 selection.worktree_id = worktree_id;
578 selection.entry_id = new_entry.id;
579 }
580 }
581 this.update_visible_entries(None, cx);
582 if is_new_entry && !is_dir {
583 this.open_entry(new_entry.id, true, cx);
584 }
585 cx.notify();
586 })?;
587 Ok(())
588 }))
589 }
590
591 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
592 self.edit_state = None;
593 self.update_visible_entries(None, cx);
594 cx.focus_self();
595 cx.notify();
596 }
597
598 fn open_entry(
599 &mut self,
600 entry_id: ProjectEntryId,
601 focus_opened_item: bool,
602 cx: &mut ViewContext<Self>,
603 ) {
604 cx.emit(Event::OpenedEntry {
605 entry_id,
606 focus_opened_item,
607 });
608 }
609
610 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
611 self.add_entry(false, cx)
612 }
613
614 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
615 self.add_entry(true, cx)
616 }
617
618 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
619 if let Some(Selection {
620 worktree_id,
621 entry_id,
622 }) = self.selection
623 {
624 let directory_id;
625 if let Some((worktree, expanded_dir_ids)) = self
626 .project
627 .read(cx)
628 .worktree_for_id(worktree_id, cx)
629 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
630 {
631 let worktree = worktree.read(cx);
632 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
633 loop {
634 if entry.is_dir() {
635 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
636 expanded_dir_ids.insert(ix, entry.id);
637 }
638 directory_id = entry.id;
639 break;
640 } else {
641 if let Some(parent_path) = entry.path.parent() {
642 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
643 entry = parent_entry;
644 continue;
645 }
646 }
647 return;
648 }
649 }
650 } else {
651 return;
652 };
653 } else {
654 return;
655 };
656
657 self.edit_state = Some(EditState {
658 worktree_id,
659 entry_id: directory_id,
660 is_new_entry: true,
661 is_dir,
662 processing_filename: None,
663 });
664 self.filename_editor
665 .update(cx, |editor, cx| editor.clear(cx));
666 cx.focus(&self.filename_editor);
667 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
668 self.autoscroll(cx);
669 cx.notify();
670 }
671 }
672
673 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
674 if let Some(Selection {
675 worktree_id,
676 entry_id,
677 }) = self.selection
678 {
679 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
680 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
681 self.edit_state = Some(EditState {
682 worktree_id,
683 entry_id,
684 is_new_entry: false,
685 is_dir: entry.is_dir(),
686 processing_filename: None,
687 });
688 let filename = entry
689 .path
690 .file_name()
691 .map_or(String::new(), |s| s.to_string_lossy().to_string());
692 self.filename_editor.update(cx, |editor, cx| {
693 editor.set_text(filename, cx);
694 editor.select_all(&Default::default(), cx);
695 });
696 cx.focus(&self.filename_editor);
697 self.update_visible_entries(None, cx);
698 self.autoscroll(cx);
699 cx.notify();
700 }
701 }
702
703 cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
704 drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
705 })
706 }
707 }
708
709 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
710 let Selection { entry_id, .. } = self.selection?;
711 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
712 let file_name = path.file_name()?;
713
714 let mut answer = cx.prompt(
715 PromptLevel::Info,
716 &format!("Delete {file_name:?}?"),
717 &["Delete", "Cancel"],
718 );
719 Some(cx.spawn(|this, mut cx| async move {
720 if answer.next().await != Some(0) {
721 return Ok(());
722 }
723 this.update(&mut cx, |this, cx| {
724 this.project
725 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
726 .ok_or_else(|| anyhow!("no such entry"))
727 })??
728 .await
729 }))
730 }
731
732 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
733 if let Some(selection) = self.selection {
734 let (mut worktree_ix, mut entry_ix, _) =
735 self.index_for_selection(selection).unwrap_or_default();
736 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
737 if entry_ix + 1 < worktree_entries.len() {
738 entry_ix += 1;
739 } else {
740 worktree_ix += 1;
741 entry_ix = 0;
742 }
743 }
744
745 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
746 if let Some(entry) = worktree_entries.get(entry_ix) {
747 self.selection = Some(Selection {
748 worktree_id: *worktree_id,
749 entry_id: entry.id,
750 });
751 self.autoscroll(cx);
752 cx.notify();
753 }
754 }
755 } else {
756 self.select_first(cx);
757 }
758 }
759
760 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
761 let worktree = self
762 .visible_entries
763 .first()
764 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
765 if let Some(worktree) = worktree {
766 let worktree = worktree.read(cx);
767 let worktree_id = worktree.id();
768 if let Some(root_entry) = worktree.root_entry() {
769 self.selection = Some(Selection {
770 worktree_id,
771 entry_id: root_entry.id,
772 });
773 self.autoscroll(cx);
774 cx.notify();
775 }
776 }
777 }
778
779 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
780 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
781 self.list.scroll_to(ScrollTarget::Show(index));
782 cx.notify();
783 }
784 }
785
786 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
787 if let Some((worktree, entry)) = self.selected_entry(cx) {
788 self.clipboard_entry = Some(ClipboardEntry::Cut {
789 worktree_id: worktree.id(),
790 entry_id: entry.id,
791 });
792 cx.notify();
793 }
794 }
795
796 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
797 if let Some((worktree, entry)) = self.selected_entry(cx) {
798 self.clipboard_entry = Some(ClipboardEntry::Copied {
799 worktree_id: worktree.id(),
800 entry_id: entry.id,
801 });
802 cx.notify();
803 }
804 }
805
806 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
807 if let Some((worktree, entry)) = self.selected_entry(cx) {
808 let clipboard_entry = self.clipboard_entry?;
809 if clipboard_entry.worktree_id() != worktree.id() {
810 return None;
811 }
812
813 let clipboard_entry_file_name = self
814 .project
815 .read(cx)
816 .path_for_entry(clipboard_entry.entry_id(), cx)?
817 .path
818 .file_name()?
819 .to_os_string();
820
821 let mut new_path = entry.path.to_path_buf();
822 if entry.is_file() {
823 new_path.pop();
824 }
825
826 new_path.push(&clipboard_entry_file_name);
827 let extension = new_path.extension().map(|e| e.to_os_string());
828 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
829 let mut ix = 0;
830 while worktree.entry_for_path(&new_path).is_some() {
831 new_path.pop();
832
833 let mut new_file_name = file_name_without_extension.to_os_string();
834 new_file_name.push(" copy");
835 if ix > 0 {
836 new_file_name.push(format!(" {}", ix));
837 }
838 if let Some(extension) = extension.as_ref() {
839 new_file_name.push(".");
840 new_file_name.push(extension);
841 }
842
843 new_path.push(new_file_name);
844 ix += 1;
845 }
846
847 if clipboard_entry.is_cut() {
848 if let Some(task) = self.project.update(cx, |project, cx| {
849 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
850 }) {
851 task.detach_and_log_err(cx)
852 }
853 } else if let Some(task) = self.project.update(cx, |project, cx| {
854 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
855 }) {
856 task.detach_and_log_err(cx)
857 }
858 }
859 None
860 }
861
862 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
863 if let Some((worktree, entry)) = self.selected_entry(cx) {
864 cx.write_to_clipboard(ClipboardItem::new(
865 worktree
866 .abs_path()
867 .join(&entry.path)
868 .to_string_lossy()
869 .to_string(),
870 ));
871 }
872 }
873
874 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
875 if let Some((_, entry)) = self.selected_entry(cx) {
876 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
877 }
878 }
879
880 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
881 if let Some((worktree, entry)) = self.selected_entry(cx) {
882 cx.reveal_path(&worktree.abs_path().join(&entry.path));
883 }
884 }
885
886 fn move_entry(
887 &mut self,
888 entry_to_move: ProjectEntryId,
889 destination: ProjectEntryId,
890 destination_is_file: bool,
891 cx: &mut ViewContext<Self>,
892 ) {
893 let destination_worktree = self.project.update(cx, |project, cx| {
894 let entry_path = project.path_for_entry(entry_to_move, cx)?;
895 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
896
897 let mut destination_path = destination_entry_path.as_ref();
898 if destination_is_file {
899 destination_path = destination_path.parent()?;
900 }
901
902 let mut new_path = destination_path.to_path_buf();
903 new_path.push(entry_path.path.file_name()?);
904 if new_path != entry_path.path.as_ref() {
905 let task = project.rename_entry(entry_to_move, new_path, cx)?;
906 cx.foreground().spawn(task).detach_and_log_err(cx);
907 }
908
909 Some(project.worktree_id_for_entry(destination, cx)?)
910 });
911
912 if let Some(destination_worktree) = destination_worktree {
913 self.expand_entry(destination_worktree, destination, cx);
914 }
915 }
916
917 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
918 let mut entry_index = 0;
919 let mut visible_entries_index = 0;
920 for (worktree_index, (worktree_id, worktree_entries)) in
921 self.visible_entries.iter().enumerate()
922 {
923 if *worktree_id == selection.worktree_id {
924 for entry in worktree_entries {
925 if entry.id == selection.entry_id {
926 return Some((worktree_index, entry_index, visible_entries_index));
927 } else {
928 visible_entries_index += 1;
929 entry_index += 1;
930 }
931 }
932 break;
933 } else {
934 visible_entries_index += worktree_entries.len();
935 }
936 }
937 None
938 }
939
940 fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
941 let selection = self.selection?;
942 let project = self.project.read(cx);
943 let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
944 Some((worktree, worktree.entry_for_id(selection.entry_id)?))
945 }
946
947 fn update_visible_entries(
948 &mut self,
949 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
950 cx: &mut ViewContext<Self>,
951 ) {
952 let project = self.project.read(cx);
953 self.last_worktree_root_id = project
954 .visible_worktrees(cx)
955 .rev()
956 .next()
957 .and_then(|worktree| worktree.read(cx).root_entry())
958 .map(|entry| entry.id);
959
960 self.visible_entries.clear();
961 for worktree in project.visible_worktrees(cx) {
962 let snapshot = worktree.read(cx).snapshot();
963 let worktree_id = snapshot.id();
964
965 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
966 hash_map::Entry::Occupied(e) => e.into_mut(),
967 hash_map::Entry::Vacant(e) => {
968 // The first time a worktree's root entry becomes available,
969 // mark that root entry as expanded.
970 if let Some(entry) = snapshot.root_entry() {
971 e.insert(vec![entry.id]).as_slice()
972 } else {
973 &[]
974 }
975 }
976 };
977
978 let mut new_entry_parent_id = None;
979 let mut new_entry_kind = EntryKind::Dir;
980 if let Some(edit_state) = &self.edit_state {
981 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
982 new_entry_parent_id = Some(edit_state.entry_id);
983 new_entry_kind = if edit_state.is_dir {
984 EntryKind::Dir
985 } else {
986 EntryKind::File(Default::default())
987 };
988 }
989 }
990
991 let mut visible_worktree_entries = Vec::new();
992 let mut entry_iter = snapshot.entries(true);
993
994 while let Some(entry) = entry_iter.entry() {
995 visible_worktree_entries.push(entry.clone());
996 if Some(entry.id) == new_entry_parent_id {
997 visible_worktree_entries.push(Entry {
998 id: NEW_ENTRY_ID,
999 kind: new_entry_kind,
1000 path: entry.path.join("\0").into(),
1001 inode: 0,
1002 mtime: entry.mtime,
1003 is_symlink: false,
1004 is_ignored: false,
1005 });
1006 }
1007 if expanded_dir_ids.binary_search(&entry.id).is_err()
1008 && entry_iter.advance_to_sibling()
1009 {
1010 continue;
1011 }
1012 entry_iter.advance();
1013 }
1014 visible_worktree_entries.sort_by(|entry_a, entry_b| {
1015 let mut components_a = entry_a.path.components().peekable();
1016 let mut components_b = entry_b.path.components().peekable();
1017 loop {
1018 match (components_a.next(), components_b.next()) {
1019 (Some(component_a), Some(component_b)) => {
1020 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
1021 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
1022 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
1023 let name_a =
1024 UniCase::new(component_a.as_os_str().to_string_lossy());
1025 let name_b =
1026 UniCase::new(component_b.as_os_str().to_string_lossy());
1027 name_a.cmp(&name_b)
1028 });
1029 if !ordering.is_eq() {
1030 return ordering;
1031 }
1032 }
1033 (Some(_), None) => break Ordering::Greater,
1034 (None, Some(_)) => break Ordering::Less,
1035 (None, None) => break Ordering::Equal,
1036 }
1037 }
1038 });
1039 self.visible_entries
1040 .push((worktree_id, visible_worktree_entries));
1041 }
1042
1043 if let Some((worktree_id, entry_id)) = new_selected_entry {
1044 self.selection = Some(Selection {
1045 worktree_id,
1046 entry_id,
1047 });
1048 }
1049 }
1050
1051 fn expand_entry(
1052 &mut self,
1053 worktree_id: WorktreeId,
1054 entry_id: ProjectEntryId,
1055 cx: &mut ViewContext<Self>,
1056 ) {
1057 let project = self.project.read(cx);
1058 if let Some((worktree, expanded_dir_ids)) = project
1059 .worktree_for_id(worktree_id, cx)
1060 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1061 {
1062 let worktree = worktree.read(cx);
1063
1064 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1065 loop {
1066 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1067 expanded_dir_ids.insert(ix, entry.id);
1068 }
1069
1070 if let Some(parent_entry) =
1071 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1072 {
1073 entry = parent_entry;
1074 } else {
1075 break;
1076 }
1077 }
1078 }
1079 }
1080 }
1081
1082 fn for_each_visible_entry(
1083 &self,
1084 range: Range<usize>,
1085 cx: &mut ViewContext<ProjectPanel>,
1086 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1087 ) {
1088 let mut ix = 0;
1089 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
1090 if ix >= range.end {
1091 return;
1092 }
1093
1094 if ix + visible_worktree_entries.len() <= range.start {
1095 ix += visible_worktree_entries.len();
1096 continue;
1097 }
1098
1099 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1100 let git_status_setting = settings::get::<ProjectPanelSettings>(cx).git_status;
1101 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1102 let snapshot = worktree.read(cx).snapshot();
1103 let root_name = OsStr::new(snapshot.root_name());
1104 let expanded_entry_ids = self
1105 .expanded_dir_ids
1106 .get(&snapshot.id())
1107 .map(Vec::as_slice)
1108 .unwrap_or(&[]);
1109
1110 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1111 for (entry, repo) in
1112 snapshot.entries_with_repositories(visible_worktree_entries[entry_range].iter())
1113 {
1114 let status = (git_status_setting
1115 && entry.path.parent().is_some()
1116 && !entry.is_ignored)
1117 .then(|| repo.and_then(|repo| repo.status_for_path(&snapshot, &entry.path)))
1118 .flatten();
1119
1120 let mut details = EntryDetails {
1121 filename: entry
1122 .path
1123 .file_name()
1124 .unwrap_or(root_name)
1125 .to_string_lossy()
1126 .to_string(),
1127 path: entry.path.clone(),
1128 depth: entry.path.components().count(),
1129 kind: entry.kind,
1130 is_ignored: entry.is_ignored,
1131 is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
1132 is_selected: self.selection.map_or(false, |e| {
1133 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1134 }),
1135 is_editing: false,
1136 is_processing: false,
1137 is_cut: self
1138 .clipboard_entry
1139 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1140 git_status: status,
1141 };
1142
1143 if let Some(edit_state) = &self.edit_state {
1144 let is_edited_entry = if edit_state.is_new_entry {
1145 entry.id == NEW_ENTRY_ID
1146 } else {
1147 entry.id == edit_state.entry_id
1148 };
1149
1150 if is_edited_entry {
1151 if let Some(processing_filename) = &edit_state.processing_filename {
1152 details.is_processing = true;
1153 details.filename.clear();
1154 details.filename.push_str(processing_filename);
1155 } else {
1156 if edit_state.is_new_entry {
1157 details.filename.clear();
1158 }
1159 details.is_editing = true;
1160 }
1161 }
1162 }
1163
1164 callback(entry.id, details, cx);
1165 }
1166 }
1167 ix = end_ix;
1168 }
1169 }
1170
1171 fn render_entry_visual_element<V: View>(
1172 details: &EntryDetails,
1173 editor: Option<&ViewHandle<Editor>>,
1174 padding: f32,
1175 row_container_style: ContainerStyle,
1176 style: &ProjectPanelEntry,
1177 cx: &mut ViewContext<V>,
1178 ) -> AnyElement<V> {
1179 let kind = details.kind;
1180 let show_editor = details.is_editing && !details.is_processing;
1181
1182 let mut filename_text_style = style.text.clone();
1183 filename_text_style.color = details
1184 .git_status
1185 .as_ref()
1186 .map(|status| match status {
1187 GitFileStatus::Added => style.status.git.inserted,
1188 GitFileStatus::Modified => style.status.git.modified,
1189 GitFileStatus::Conflict => style.status.git.conflict,
1190 })
1191 .unwrap_or(style.text.color);
1192
1193 Flex::row()
1194 .with_child(
1195 if kind == EntryKind::Dir {
1196 if details.is_expanded {
1197 Svg::new("icons/chevron_down_8.svg").with_color(style.icon_color)
1198 } else {
1199 Svg::new("icons/chevron_right_8.svg").with_color(style.icon_color)
1200 }
1201 .constrained()
1202 } else {
1203 Empty::new().constrained()
1204 }
1205 .with_max_width(style.icon_size)
1206 .with_max_height(style.icon_size)
1207 .aligned()
1208 .constrained()
1209 .with_width(style.icon_size),
1210 )
1211 .with_child(if show_editor && editor.is_some() {
1212 ChildView::new(editor.as_ref().unwrap(), cx)
1213 .contained()
1214 .with_margin_left(style.icon_spacing)
1215 .aligned()
1216 .left()
1217 .flex(1.0, true)
1218 .into_any()
1219 } else {
1220 Label::new(details.filename.clone(), filename_text_style)
1221 .contained()
1222 .with_margin_left(style.icon_spacing)
1223 .aligned()
1224 .left()
1225 .into_any()
1226 })
1227 .constrained()
1228 .with_height(style.height)
1229 .contained()
1230 .with_style(row_container_style)
1231 .with_padding_left(padding)
1232 .into_any_named("project panel entry visual element")
1233 }
1234
1235 fn render_entry(
1236 entry_id: ProjectEntryId,
1237 details: EntryDetails,
1238 editor: &ViewHandle<Editor>,
1239 dragged_entry_destination: &mut Option<Arc<Path>>,
1240 theme: &theme::ProjectPanel,
1241 cx: &mut ViewContext<Self>,
1242 ) -> AnyElement<Self> {
1243 let kind = details.kind;
1244 let path = details.path.clone();
1245 let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
1246
1247 let entry_style = if details.is_cut {
1248 &theme.cut_entry
1249 } else if details.is_ignored {
1250 &theme.ignored_entry
1251 } else {
1252 &theme.entry
1253 };
1254
1255 let show_editor = details.is_editing && !details.is_processing;
1256
1257 MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
1258 let mut style = entry_style.style_for(state, details.is_selected).clone();
1259
1260 if cx
1261 .global::<DragAndDrop<Workspace>>()
1262 .currently_dragged::<ProjectEntryId>(cx.window_id())
1263 .is_some()
1264 && dragged_entry_destination
1265 .as_ref()
1266 .filter(|destination| details.path.starts_with(destination))
1267 .is_some()
1268 {
1269 style = entry_style.active.clone().unwrap();
1270 }
1271
1272 let row_container_style = if show_editor {
1273 theme.filename_editor.container
1274 } else {
1275 style.container
1276 };
1277
1278 Self::render_entry_visual_element(
1279 &details,
1280 Some(editor),
1281 padding,
1282 row_container_style,
1283 &style,
1284 cx,
1285 )
1286 })
1287 .on_click(MouseButton::Left, move |event, this, cx| {
1288 if !show_editor {
1289 if kind == EntryKind::Dir {
1290 this.toggle_expanded(entry_id, cx);
1291 } else {
1292 this.open_entry(entry_id, event.click_count > 1, cx);
1293 }
1294 }
1295 })
1296 .on_down(MouseButton::Right, move |event, this, cx| {
1297 this.deploy_context_menu(event.position, entry_id, cx);
1298 })
1299 .on_up(MouseButton::Left, move |_, this, cx| {
1300 if let Some((_, dragged_entry)) = cx
1301 .global::<DragAndDrop<Workspace>>()
1302 .currently_dragged::<ProjectEntryId>(cx.window_id())
1303 {
1304 this.move_entry(
1305 *dragged_entry,
1306 entry_id,
1307 matches!(details.kind, EntryKind::File(_)),
1308 cx,
1309 );
1310 }
1311 })
1312 .on_move(move |_, this, cx| {
1313 if cx
1314 .global::<DragAndDrop<Workspace>>()
1315 .currently_dragged::<ProjectEntryId>(cx.window_id())
1316 .is_some()
1317 {
1318 this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1319 path.parent().map(|parent| Arc::from(parent))
1320 } else {
1321 Some(path.clone())
1322 };
1323 }
1324 })
1325 .as_draggable(entry_id, {
1326 let row_container_style = theme.dragged_entry.container;
1327
1328 move |_, cx: &mut ViewContext<Workspace>| {
1329 let theme = theme::current(cx).clone();
1330 Self::render_entry_visual_element(
1331 &details,
1332 None,
1333 padding,
1334 row_container_style,
1335 &theme.project_panel.dragged_entry,
1336 cx,
1337 )
1338 }
1339 })
1340 .with_cursor_style(CursorStyle::PointingHand)
1341 .into_any_named("project panel entry")
1342 }
1343}
1344
1345impl View for ProjectPanel {
1346 fn ui_name() -> &'static str {
1347 "ProjectPanel"
1348 }
1349
1350 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1351 enum ProjectPanel {}
1352 let theme = &theme::current(cx).project_panel;
1353 let mut container_style = theme.container;
1354 let padding = std::mem::take(&mut container_style.padding);
1355 let last_worktree_root_id = self.last_worktree_root_id;
1356
1357 let has_worktree = self.visible_entries.len() != 0;
1358
1359 if has_worktree {
1360 Stack::new()
1361 .with_child(
1362 MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1363 UniformList::new(
1364 self.list.clone(),
1365 self.visible_entries
1366 .iter()
1367 .map(|(_, worktree_entries)| worktree_entries.len())
1368 .sum(),
1369 cx,
1370 move |this, range, items, cx| {
1371 let theme = theme::current(cx).clone();
1372 let mut dragged_entry_destination =
1373 this.dragged_entry_destination.clone();
1374 this.for_each_visible_entry(range, cx, |id, details, cx| {
1375 items.push(Self::render_entry(
1376 id,
1377 details,
1378 &this.filename_editor,
1379 &mut dragged_entry_destination,
1380 &theme.project_panel,
1381 cx,
1382 ));
1383 });
1384 this.dragged_entry_destination = dragged_entry_destination;
1385 },
1386 )
1387 .with_padding_top(padding.top)
1388 .with_padding_bottom(padding.bottom)
1389 .contained()
1390 .with_style(container_style)
1391 .expanded()
1392 })
1393 .on_down(MouseButton::Right, move |event, this, cx| {
1394 // When deploying the context menu anywhere below the last project entry,
1395 // act as if the user clicked the root of the last worktree.
1396 if let Some(entry_id) = last_worktree_root_id {
1397 this.deploy_context_menu(event.position, entry_id, cx);
1398 }
1399 }),
1400 )
1401 .with_child(ChildView::new(&self.context_menu, cx))
1402 .into_any_named("project panel")
1403 } else {
1404 Flex::column()
1405 .with_child(
1406 MouseEventHandler::<Self, _>::new(2, cx, {
1407 let button_style = theme.open_project_button.clone();
1408 let context_menu_item_style = theme::current(cx).context_menu.item.clone();
1409 move |state, cx| {
1410 let button_style = button_style.style_for(state, false).clone();
1411 let context_menu_item =
1412 context_menu_item_style.style_for(state, true).clone();
1413
1414 theme::ui::keystroke_label(
1415 "Open a project",
1416 &button_style,
1417 &context_menu_item.keystroke,
1418 Box::new(workspace::Open),
1419 cx,
1420 )
1421 }
1422 })
1423 .on_click(MouseButton::Left, move |_, this, cx| {
1424 if let Some(workspace) = this.workspace.upgrade(cx) {
1425 workspace.update(cx, |workspace, cx| {
1426 if let Some(task) = workspace.open(&Default::default(), cx) {
1427 task.detach_and_log_err(cx);
1428 }
1429 })
1430 }
1431 })
1432 .with_cursor_style(CursorStyle::PointingHand),
1433 )
1434 .contained()
1435 .with_style(container_style)
1436 .into_any_named("empty project panel")
1437 }
1438 }
1439
1440 fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1441 Self::reset_to_default_keymap_context(keymap);
1442 keymap.add_identifier("menu");
1443 }
1444
1445 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
1446 if !self.has_focus {
1447 self.has_focus = true;
1448 cx.emit(Event::Focus);
1449 }
1450 }
1451
1452 fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
1453 self.has_focus = false;
1454 }
1455}
1456
1457impl Entity for ProjectPanel {
1458 type Event = Event;
1459}
1460
1461impl workspace::dock::Panel for ProjectPanel {
1462 fn position(&self, cx: &WindowContext) -> DockPosition {
1463 match settings::get::<ProjectPanelSettings>(cx).dock {
1464 ProjectPanelDockPosition::Left => DockPosition::Left,
1465 ProjectPanelDockPosition::Right => DockPosition::Right,
1466 }
1467 }
1468
1469 fn position_is_valid(&self, position: DockPosition) -> bool {
1470 matches!(position, DockPosition::Left | DockPosition::Right)
1471 }
1472
1473 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1474 settings::update_settings_file::<ProjectPanelSettings>(
1475 self.fs.clone(),
1476 cx,
1477 move |settings| {
1478 let dock = match position {
1479 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1480 DockPosition::Right => ProjectPanelDockPosition::Right,
1481 };
1482 settings.dock = Some(dock);
1483 },
1484 );
1485 }
1486
1487 fn size(&self, cx: &WindowContext) -> f32 {
1488 self.width
1489 .unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
1490 }
1491
1492 fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
1493 self.width = Some(size);
1494 self.serialize(cx);
1495 cx.notify();
1496 }
1497
1498 fn should_zoom_in_on_event(_: &Self::Event) -> bool {
1499 false
1500 }
1501
1502 fn should_zoom_out_on_event(_: &Self::Event) -> bool {
1503 false
1504 }
1505
1506 fn is_zoomed(&self, _: &WindowContext) -> bool {
1507 false
1508 }
1509
1510 fn set_zoomed(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1511
1512 fn set_active(&mut self, _: bool, _: &mut ViewContext<Self>) {}
1513
1514 fn icon_path(&self) -> &'static str {
1515 "icons/folder_tree_16.svg"
1516 }
1517
1518 fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
1519 ("Project Panel".into(), Some(Box::new(ToggleFocus)))
1520 }
1521
1522 fn should_change_position_on_event(event: &Self::Event) -> bool {
1523 matches!(event, Event::DockPositionChanged)
1524 }
1525
1526 fn should_activate_on_event(_: &Self::Event) -> bool {
1527 false
1528 }
1529
1530 fn should_close_on_event(_: &Self::Event) -> bool {
1531 false
1532 }
1533
1534 fn has_focus(&self, _: &WindowContext) -> bool {
1535 self.has_focus
1536 }
1537
1538 fn is_focus_event(event: &Self::Event) -> bool {
1539 matches!(event, Event::Focus)
1540 }
1541}
1542
1543impl ClipboardEntry {
1544 fn is_cut(&self) -> bool {
1545 matches!(self, Self::Cut { .. })
1546 }
1547
1548 fn entry_id(&self) -> ProjectEntryId {
1549 match self {
1550 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1551 *entry_id
1552 }
1553 }
1554 }
1555
1556 fn worktree_id(&self) -> WorktreeId {
1557 match self {
1558 ClipboardEntry::Copied { worktree_id, .. }
1559 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1560 }
1561 }
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566 use super::*;
1567 use gpui::{TestAppContext, ViewHandle};
1568 use project::FakeFs;
1569 use serde_json::json;
1570 use settings::SettingsStore;
1571 use std::{collections::HashSet, path::Path};
1572 use workspace::{pane, AppState};
1573
1574 #[gpui::test]
1575 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1576 init_test(cx);
1577
1578 let fs = FakeFs::new(cx.background());
1579 fs.insert_tree(
1580 "/root1",
1581 json!({
1582 ".dockerignore": "",
1583 ".git": {
1584 "HEAD": "",
1585 },
1586 "a": {
1587 "0": { "q": "", "r": "", "s": "" },
1588 "1": { "t": "", "u": "" },
1589 "2": { "v": "", "w": "", "x": "", "y": "" },
1590 },
1591 "b": {
1592 "3": { "Q": "" },
1593 "4": { "R": "", "S": "", "T": "", "U": "" },
1594 },
1595 "C": {
1596 "5": {},
1597 "6": { "V": "", "W": "" },
1598 "7": { "X": "" },
1599 "8": { "Y": {}, "Z": "" }
1600 }
1601 }),
1602 )
1603 .await;
1604 fs.insert_tree(
1605 "/root2",
1606 json!({
1607 "d": {
1608 "9": ""
1609 },
1610 "e": {}
1611 }),
1612 )
1613 .await;
1614
1615 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1616 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1617 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1618 assert_eq!(
1619 visible_entries_as_strings(&panel, 0..50, cx),
1620 &[
1621 "v root1",
1622 " > .git",
1623 " > a",
1624 " > b",
1625 " > C",
1626 " .dockerignore",
1627 "v root2",
1628 " > d",
1629 " > e",
1630 ]
1631 );
1632
1633 toggle_expand_dir(&panel, "root1/b", cx);
1634 assert_eq!(
1635 visible_entries_as_strings(&panel, 0..50, cx),
1636 &[
1637 "v root1",
1638 " > .git",
1639 " > a",
1640 " v b <== selected",
1641 " > 3",
1642 " > 4",
1643 " > C",
1644 " .dockerignore",
1645 "v root2",
1646 " > d",
1647 " > e",
1648 ]
1649 );
1650
1651 assert_eq!(
1652 visible_entries_as_strings(&panel, 6..9, cx),
1653 &[
1654 //
1655 " > C",
1656 " .dockerignore",
1657 "v root2",
1658 ]
1659 );
1660 }
1661
1662 #[gpui::test(iterations = 30)]
1663 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1664 init_test(cx);
1665
1666 let fs = FakeFs::new(cx.background());
1667 fs.insert_tree(
1668 "/root1",
1669 json!({
1670 ".dockerignore": "",
1671 ".git": {
1672 "HEAD": "",
1673 },
1674 "a": {
1675 "0": { "q": "", "r": "", "s": "" },
1676 "1": { "t": "", "u": "" },
1677 "2": { "v": "", "w": "", "x": "", "y": "" },
1678 },
1679 "b": {
1680 "3": { "Q": "" },
1681 "4": { "R": "", "S": "", "T": "", "U": "" },
1682 },
1683 "C": {
1684 "5": {},
1685 "6": { "V": "", "W": "" },
1686 "7": { "X": "" },
1687 "8": { "Y": {}, "Z": "" }
1688 }
1689 }),
1690 )
1691 .await;
1692 fs.insert_tree(
1693 "/root2",
1694 json!({
1695 "d": {
1696 "9": ""
1697 },
1698 "e": {}
1699 }),
1700 )
1701 .await;
1702
1703 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1704 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1705 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1706
1707 select_path(&panel, "root1", cx);
1708 assert_eq!(
1709 visible_entries_as_strings(&panel, 0..10, cx),
1710 &[
1711 "v root1 <== selected",
1712 " > .git",
1713 " > a",
1714 " > b",
1715 " > C",
1716 " .dockerignore",
1717 "v root2",
1718 " > d",
1719 " > e",
1720 ]
1721 );
1722
1723 // Add a file with the root folder selected. The filename editor is placed
1724 // before the first file in the root folder.
1725 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1726 cx.read_window(window_id, |cx| {
1727 let panel = panel.read(cx);
1728 assert!(panel.filename_editor.is_focused(cx));
1729 });
1730 assert_eq!(
1731 visible_entries_as_strings(&panel, 0..10, cx),
1732 &[
1733 "v root1",
1734 " > .git",
1735 " > a",
1736 " > b",
1737 " > C",
1738 " [EDITOR: ''] <== selected",
1739 " .dockerignore",
1740 "v root2",
1741 " > d",
1742 " > e",
1743 ]
1744 );
1745
1746 let confirm = panel.update(cx, |panel, cx| {
1747 panel
1748 .filename_editor
1749 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1750 panel.confirm(&Confirm, cx).unwrap()
1751 });
1752 assert_eq!(
1753 visible_entries_as_strings(&panel, 0..10, cx),
1754 &[
1755 "v root1",
1756 " > .git",
1757 " > a",
1758 " > b",
1759 " > C",
1760 " [PROCESSING: 'the-new-filename'] <== selected",
1761 " .dockerignore",
1762 "v root2",
1763 " > d",
1764 " > e",
1765 ]
1766 );
1767
1768 confirm.await.unwrap();
1769 assert_eq!(
1770 visible_entries_as_strings(&panel, 0..10, cx),
1771 &[
1772 "v root1",
1773 " > .git",
1774 " > a",
1775 " > b",
1776 " > C",
1777 " .dockerignore",
1778 " the-new-filename <== selected",
1779 "v root2",
1780 " > d",
1781 " > e",
1782 ]
1783 );
1784
1785 select_path(&panel, "root1/b", cx);
1786 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1787 assert_eq!(
1788 visible_entries_as_strings(&panel, 0..10, cx),
1789 &[
1790 "v root1",
1791 " > .git",
1792 " > a",
1793 " v b",
1794 " > 3",
1795 " > 4",
1796 " [EDITOR: ''] <== selected",
1797 " > C",
1798 " .dockerignore",
1799 " the-new-filename",
1800 ]
1801 );
1802
1803 panel
1804 .update(cx, |panel, cx| {
1805 panel
1806 .filename_editor
1807 .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1808 panel.confirm(&Confirm, cx).unwrap()
1809 })
1810 .await
1811 .unwrap();
1812 assert_eq!(
1813 visible_entries_as_strings(&panel, 0..10, cx),
1814 &[
1815 "v root1",
1816 " > .git",
1817 " > a",
1818 " v b",
1819 " > 3",
1820 " > 4",
1821 " another-filename <== selected",
1822 " > C",
1823 " .dockerignore",
1824 " the-new-filename",
1825 ]
1826 );
1827
1828 select_path(&panel, "root1/b/another-filename", cx);
1829 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1830 assert_eq!(
1831 visible_entries_as_strings(&panel, 0..10, cx),
1832 &[
1833 "v root1",
1834 " > .git",
1835 " > a",
1836 " v b",
1837 " > 3",
1838 " > 4",
1839 " [EDITOR: 'another-filename'] <== selected",
1840 " > C",
1841 " .dockerignore",
1842 " the-new-filename",
1843 ]
1844 );
1845
1846 let confirm = panel.update(cx, |panel, cx| {
1847 panel
1848 .filename_editor
1849 .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1850 panel.confirm(&Confirm, cx).unwrap()
1851 });
1852 assert_eq!(
1853 visible_entries_as_strings(&panel, 0..10, cx),
1854 &[
1855 "v root1",
1856 " > .git",
1857 " > a",
1858 " v b",
1859 " > 3",
1860 " > 4",
1861 " [PROCESSING: 'a-different-filename'] <== selected",
1862 " > C",
1863 " .dockerignore",
1864 " the-new-filename",
1865 ]
1866 );
1867
1868 confirm.await.unwrap();
1869 assert_eq!(
1870 visible_entries_as_strings(&panel, 0..10, cx),
1871 &[
1872 "v root1",
1873 " > .git",
1874 " > a",
1875 " v b",
1876 " > 3",
1877 " > 4",
1878 " a-different-filename <== selected",
1879 " > C",
1880 " .dockerignore",
1881 " the-new-filename",
1882 ]
1883 );
1884
1885 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1886 assert_eq!(
1887 visible_entries_as_strings(&panel, 0..10, cx),
1888 &[
1889 "v root1",
1890 " > .git",
1891 " > a",
1892 " v b",
1893 " > [EDITOR: ''] <== selected",
1894 " > 3",
1895 " > 4",
1896 " a-different-filename",
1897 " > C",
1898 " .dockerignore",
1899 ]
1900 );
1901
1902 let confirm = panel.update(cx, |panel, cx| {
1903 panel
1904 .filename_editor
1905 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1906 panel.confirm(&Confirm, cx).unwrap()
1907 });
1908 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1909 assert_eq!(
1910 visible_entries_as_strings(&panel, 0..10, cx),
1911 &[
1912 "v root1",
1913 " > .git",
1914 " > a",
1915 " v b",
1916 " > [PROCESSING: 'new-dir']",
1917 " > 3 <== selected",
1918 " > 4",
1919 " a-different-filename",
1920 " > C",
1921 " .dockerignore",
1922 ]
1923 );
1924
1925 confirm.await.unwrap();
1926 assert_eq!(
1927 visible_entries_as_strings(&panel, 0..10, cx),
1928 &[
1929 "v root1",
1930 " > .git",
1931 " > a",
1932 " v b",
1933 " > 3 <== selected",
1934 " > 4",
1935 " > new-dir",
1936 " a-different-filename",
1937 " > C",
1938 " .dockerignore",
1939 ]
1940 );
1941
1942 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1943 assert_eq!(
1944 visible_entries_as_strings(&panel, 0..10, cx),
1945 &[
1946 "v root1",
1947 " > .git",
1948 " > a",
1949 " v b",
1950 " > [EDITOR: '3'] <== selected",
1951 " > 4",
1952 " > new-dir",
1953 " a-different-filename",
1954 " > C",
1955 " .dockerignore",
1956 ]
1957 );
1958
1959 // Dismiss the rename editor when it loses focus.
1960 workspace.update(cx, |_, cx| cx.focus_self());
1961 assert_eq!(
1962 visible_entries_as_strings(&panel, 0..10, cx),
1963 &[
1964 "v root1",
1965 " > .git",
1966 " > a",
1967 " v b",
1968 " > 3 <== selected",
1969 " > 4",
1970 " > new-dir",
1971 " a-different-filename",
1972 " > C",
1973 " .dockerignore",
1974 ]
1975 );
1976 }
1977
1978 #[gpui::test]
1979 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1980 init_test(cx);
1981
1982 let fs = FakeFs::new(cx.background());
1983 fs.insert_tree(
1984 "/root1",
1985 json!({
1986 "one.two.txt": "",
1987 "one.txt": ""
1988 }),
1989 )
1990 .await;
1991
1992 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1993 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1994 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1995
1996 panel.update(cx, |panel, cx| {
1997 panel.select_next(&Default::default(), cx);
1998 panel.select_next(&Default::default(), cx);
1999 });
2000
2001 assert_eq!(
2002 visible_entries_as_strings(&panel, 0..50, cx),
2003 &[
2004 //
2005 "v root1",
2006 " one.two.txt <== selected",
2007 " one.txt",
2008 ]
2009 );
2010
2011 // Regression test - file name is created correctly when
2012 // the copied file's name contains multiple dots.
2013 panel.update(cx, |panel, cx| {
2014 panel.copy(&Default::default(), cx);
2015 panel.paste(&Default::default(), cx);
2016 });
2017 cx.foreground().run_until_parked();
2018
2019 assert_eq!(
2020 visible_entries_as_strings(&panel, 0..50, cx),
2021 &[
2022 //
2023 "v root1",
2024 " one.two copy.txt",
2025 " one.two.txt <== selected",
2026 " one.txt",
2027 ]
2028 );
2029
2030 panel.update(cx, |panel, cx| {
2031 panel.paste(&Default::default(), cx);
2032 });
2033 cx.foreground().run_until_parked();
2034
2035 assert_eq!(
2036 visible_entries_as_strings(&panel, 0..50, cx),
2037 &[
2038 //
2039 "v root1",
2040 " one.two copy 1.txt",
2041 " one.two copy.txt",
2042 " one.two.txt <== selected",
2043 " one.txt",
2044 ]
2045 );
2046 }
2047
2048 #[gpui::test]
2049 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2050 init_test_with_editor(cx);
2051
2052 let fs = FakeFs::new(cx.background());
2053 fs.insert_tree(
2054 "/src",
2055 json!({
2056 "test": {
2057 "first.rs": "// First Rust file",
2058 "second.rs": "// Second Rust file",
2059 "third.rs": "// Third Rust file",
2060 }
2061 }),
2062 )
2063 .await;
2064
2065 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2066 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2067 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2068
2069 toggle_expand_dir(&panel, "src/test", cx);
2070 select_path(&panel, "src/test/first.rs", cx);
2071 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2072 cx.foreground().run_until_parked();
2073 assert_eq!(
2074 visible_entries_as_strings(&panel, 0..10, cx),
2075 &[
2076 "v src",
2077 " v test",
2078 " first.rs <== selected",
2079 " second.rs",
2080 " third.rs"
2081 ]
2082 );
2083 ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
2084
2085 submit_deletion(window_id, &panel, cx);
2086 assert_eq!(
2087 visible_entries_as_strings(&panel, 0..10, cx),
2088 &[
2089 "v src",
2090 " v test",
2091 " second.rs",
2092 " third.rs"
2093 ],
2094 "Project panel should have no deleted file, no other file is selected in it"
2095 );
2096 ensure_no_open_items_and_panes(window_id, &workspace, cx);
2097
2098 select_path(&panel, "src/test/second.rs", cx);
2099 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2100 cx.foreground().run_until_parked();
2101 assert_eq!(
2102 visible_entries_as_strings(&panel, 0..10, cx),
2103 &[
2104 "v src",
2105 " v test",
2106 " second.rs <== selected",
2107 " third.rs"
2108 ]
2109 );
2110 ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
2111
2112 cx.update_window(window_id, |cx| {
2113 let active_items = workspace
2114 .read(cx)
2115 .panes()
2116 .iter()
2117 .filter_map(|pane| pane.read(cx).active_item())
2118 .collect::<Vec<_>>();
2119 assert_eq!(active_items.len(), 1);
2120 let open_editor = active_items
2121 .into_iter()
2122 .next()
2123 .unwrap()
2124 .downcast::<Editor>()
2125 .expect("Open item should be an editor");
2126 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
2127 });
2128 submit_deletion(window_id, &panel, cx);
2129 assert_eq!(
2130 visible_entries_as_strings(&panel, 0..10, cx),
2131 &["v src", " v test", " third.rs"],
2132 "Project panel should have no deleted file, with one last file remaining"
2133 );
2134 ensure_no_open_items_and_panes(window_id, &workspace, cx);
2135 }
2136
2137 #[gpui::test]
2138 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
2139 init_test_with_editor(cx);
2140
2141 let fs = FakeFs::new(cx.background());
2142 fs.insert_tree(
2143 "/src",
2144 json!({
2145 "test": {
2146 "first.rs": "// First Rust file",
2147 "second.rs": "// Second Rust file",
2148 "third.rs": "// Third Rust file",
2149 }
2150 }),
2151 )
2152 .await;
2153
2154 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2155 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2156 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
2157
2158 select_path(&panel, "src/", cx);
2159 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2160 cx.foreground().run_until_parked();
2161 assert_eq!(
2162 visible_entries_as_strings(&panel, 0..10, cx),
2163 &["v src <== selected", " > test"]
2164 );
2165 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2166 cx.read_window(window_id, |cx| {
2167 let panel = panel.read(cx);
2168 assert!(panel.filename_editor.is_focused(cx));
2169 });
2170 assert_eq!(
2171 visible_entries_as_strings(&panel, 0..10, cx),
2172 &["v src", " > [EDITOR: ''] <== selected", " > test"]
2173 );
2174 panel.update(cx, |panel, cx| {
2175 panel
2176 .filename_editor
2177 .update(cx, |editor, cx| editor.set_text("test", cx));
2178 assert!(
2179 panel.confirm(&Confirm, cx).is_none(),
2180 "Should not allow to confirm on conflicting new directory name"
2181 )
2182 });
2183 assert_eq!(
2184 visible_entries_as_strings(&panel, 0..10, cx),
2185 &["v src", " > test"],
2186 "File list should be unchanged after failed folder create confirmation"
2187 );
2188
2189 select_path(&panel, "src/test/", cx);
2190 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2191 cx.foreground().run_until_parked();
2192 assert_eq!(
2193 visible_entries_as_strings(&panel, 0..10, cx),
2194 &["v src", " > test <== selected"]
2195 );
2196 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2197 cx.read_window(window_id, |cx| {
2198 let panel = panel.read(cx);
2199 assert!(panel.filename_editor.is_focused(cx));
2200 });
2201 assert_eq!(
2202 visible_entries_as_strings(&panel, 0..10, cx),
2203 &[
2204 "v src",
2205 " v test",
2206 " [EDITOR: ''] <== selected",
2207 " first.rs",
2208 " second.rs",
2209 " third.rs"
2210 ]
2211 );
2212 panel.update(cx, |panel, cx| {
2213 panel
2214 .filename_editor
2215 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
2216 assert!(
2217 panel.confirm(&Confirm, cx).is_none(),
2218 "Should not allow to confirm on conflicting new file name"
2219 )
2220 });
2221 assert_eq!(
2222 visible_entries_as_strings(&panel, 0..10, cx),
2223 &[
2224 "v src",
2225 " v test",
2226 " first.rs",
2227 " second.rs",
2228 " third.rs"
2229 ],
2230 "File list should be unchanged after failed file create confirmation"
2231 );
2232
2233 select_path(&panel, "src/test/first.rs", cx);
2234 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
2235 cx.foreground().run_until_parked();
2236 assert_eq!(
2237 visible_entries_as_strings(&panel, 0..10, cx),
2238 &[
2239 "v src",
2240 " v test",
2241 " first.rs <== selected",
2242 " second.rs",
2243 " third.rs"
2244 ],
2245 );
2246 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2247 cx.read_window(window_id, |cx| {
2248 let panel = panel.read(cx);
2249 assert!(panel.filename_editor.is_focused(cx));
2250 });
2251 assert_eq!(
2252 visible_entries_as_strings(&panel, 0..10, cx),
2253 &[
2254 "v src",
2255 " v test",
2256 " [EDITOR: 'first.rs'] <== selected",
2257 " second.rs",
2258 " third.rs"
2259 ]
2260 );
2261 panel.update(cx, |panel, cx| {
2262 panel
2263 .filename_editor
2264 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
2265 assert!(
2266 panel.confirm(&Confirm, cx).is_none(),
2267 "Should not allow to confirm on conflicting file rename"
2268 )
2269 });
2270 assert_eq!(
2271 visible_entries_as_strings(&panel, 0..10, cx),
2272 &[
2273 "v src",
2274 " v test",
2275 " first.rs <== selected",
2276 " second.rs",
2277 " third.rs"
2278 ],
2279 "File list should be unchanged after failed rename confirmation"
2280 );
2281 }
2282
2283 fn toggle_expand_dir(
2284 panel: &ViewHandle<ProjectPanel>,
2285 path: impl AsRef<Path>,
2286 cx: &mut TestAppContext,
2287 ) {
2288 let path = path.as_ref();
2289 panel.update(cx, |panel, cx| {
2290 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2291 let worktree = worktree.read(cx);
2292 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2293 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2294 panel.toggle_expanded(entry_id, cx);
2295 return;
2296 }
2297 }
2298 panic!("no worktree for path {:?}", path);
2299 });
2300 }
2301
2302 fn select_path(
2303 panel: &ViewHandle<ProjectPanel>,
2304 path: impl AsRef<Path>,
2305 cx: &mut TestAppContext,
2306 ) {
2307 let path = path.as_ref();
2308 panel.update(cx, |panel, cx| {
2309 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
2310 let worktree = worktree.read(cx);
2311 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
2312 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
2313 panel.selection = Some(Selection {
2314 worktree_id: worktree.id(),
2315 entry_id,
2316 });
2317 return;
2318 }
2319 }
2320 panic!("no worktree for path {:?}", path);
2321 });
2322 }
2323
2324 fn visible_entries_as_strings(
2325 panel: &ViewHandle<ProjectPanel>,
2326 range: Range<usize>,
2327 cx: &mut TestAppContext,
2328 ) -> Vec<String> {
2329 let mut result = Vec::new();
2330 let mut project_entries = HashSet::new();
2331 let mut has_editor = false;
2332
2333 panel.update(cx, |panel, cx| {
2334 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
2335 if details.is_editing {
2336 assert!(!has_editor, "duplicate editor entry");
2337 has_editor = true;
2338 } else {
2339 assert!(
2340 project_entries.insert(project_entry),
2341 "duplicate project entry {:?} {:?}",
2342 project_entry,
2343 details
2344 );
2345 }
2346
2347 let indent = " ".repeat(details.depth);
2348 let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
2349 if details.is_expanded {
2350 "v "
2351 } else {
2352 "> "
2353 }
2354 } else {
2355 " "
2356 };
2357 let name = if details.is_editing {
2358 format!("[EDITOR: '{}']", details.filename)
2359 } else if details.is_processing {
2360 format!("[PROCESSING: '{}']", details.filename)
2361 } else {
2362 details.filename.clone()
2363 };
2364 let selected = if details.is_selected {
2365 " <== selected"
2366 } else {
2367 ""
2368 };
2369 result.push(format!("{indent}{icon}{name}{selected}"));
2370 });
2371 });
2372
2373 result
2374 }
2375
2376 fn init_test(cx: &mut TestAppContext) {
2377 cx.foreground().forbid_parking();
2378 cx.update(|cx| {
2379 cx.set_global(SettingsStore::test(cx));
2380 init_settings(cx);
2381 theme::init((), cx);
2382 language::init(cx);
2383 editor::init_settings(cx);
2384 crate::init(cx);
2385 workspace::init_settings(cx);
2386 Project::init_settings(cx);
2387 });
2388 }
2389
2390 fn init_test_with_editor(cx: &mut TestAppContext) {
2391 cx.foreground().forbid_parking();
2392 cx.update(|cx| {
2393 let app_state = AppState::test(cx);
2394 theme::init((), cx);
2395 init_settings(cx);
2396 language::init(cx);
2397 editor::init(cx);
2398 pane::init(cx);
2399 crate::init(cx);
2400 workspace::init(app_state.clone(), cx);
2401 Project::init_settings(cx);
2402 });
2403 }
2404
2405 fn ensure_single_file_is_opened(
2406 window_id: usize,
2407 workspace: &ViewHandle<Workspace>,
2408 expected_path: &str,
2409 cx: &mut TestAppContext,
2410 ) {
2411 cx.read_window(window_id, |cx| {
2412 let workspace = workspace.read(cx);
2413 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
2414 assert_eq!(worktrees.len(), 1);
2415 let worktree_id = WorktreeId::from_usize(worktrees[0].id());
2416
2417 let open_project_paths = workspace
2418 .panes()
2419 .iter()
2420 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2421 .collect::<Vec<_>>();
2422 assert_eq!(
2423 open_project_paths,
2424 vec![ProjectPath {
2425 worktree_id,
2426 path: Arc::from(Path::new(expected_path))
2427 }],
2428 "Should have opened file, selected in project panel"
2429 );
2430 });
2431 }
2432
2433 fn submit_deletion(
2434 window_id: usize,
2435 panel: &ViewHandle<ProjectPanel>,
2436 cx: &mut TestAppContext,
2437 ) {
2438 assert!(
2439 !cx.has_pending_prompt(window_id),
2440 "Should have no prompts before the deletion"
2441 );
2442 panel.update(cx, |panel, cx| {
2443 panel
2444 .delete(&Delete, cx)
2445 .expect("Deletion start")
2446 .detach_and_log_err(cx);
2447 });
2448 assert!(
2449 cx.has_pending_prompt(window_id),
2450 "Should have a prompt after the deletion"
2451 );
2452 cx.simulate_prompt_answer(window_id, 0);
2453 assert!(
2454 !cx.has_pending_prompt(window_id),
2455 "Should have no prompts after prompt was replied to"
2456 );
2457 cx.foreground().run_until_parked();
2458 }
2459
2460 fn ensure_no_open_items_and_panes(
2461 window_id: usize,
2462 workspace: &ViewHandle<Workspace>,
2463 cx: &mut TestAppContext,
2464 ) {
2465 assert!(
2466 !cx.has_pending_prompt(window_id),
2467 "Should have no prompts after deletion operation closes the file"
2468 );
2469 cx.read_window(window_id, |cx| {
2470 let open_project_paths = workspace
2471 .read(cx)
2472 .panes()
2473 .iter()
2474 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
2475 .collect::<Vec<_>>();
2476 assert!(
2477 open_project_paths.is_empty(),
2478 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
2479 );
2480 });
2481 }
2482}