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