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