diff --git a/src/main/java/de/vette/idea/neos/lang/fusion/refactoring/MovePrototypeDialog.java b/src/main/java/de/vette/idea/neos/lang/fusion/refactoring/MovePrototypeDialog.java new file mode 100644 index 00000000..8fd08147 --- /dev/null +++ b/src/main/java/de/vette/idea/neos/lang/fusion/refactoring/MovePrototypeDialog.java @@ -0,0 +1,375 @@ +package de.vette.idea.neos.lang.fusion.refactoring; + +import com.intellij.openapi.fileChooser.FileChooserDescriptor; +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.ex.IdeDocumentHistory; +import com.intellij.openapi.fileTypes.FileType; +import com.intellij.openapi.fileTypes.FileTypeManager; +import com.intellij.openapi.fileTypes.LanguageFileType; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.roots.ProjectRootManager; +import com.intellij.openapi.ui.TextComponentAccessors; +import com.intellij.openapi.ui.ValidationInfo; +import com.intellij.openapi.util.io.FileUtil; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.PsiManager; +import com.intellij.refactoring.classMembers.AbstractMemberInfoModel; +import com.intellij.refactoring.classMembers.MemberInfoBase; +import com.intellij.refactoring.classMembers.MemberInfoChange; +import com.intellij.refactoring.classMembers.MemberInfoModel; +import com.intellij.refactoring.ui.AbstractMemberSelectionPanel; +import com.intellij.refactoring.ui.AbstractMemberSelectionTable; +import com.intellij.refactoring.ui.RefactoringDialog; +import com.intellij.ui.DocumentAdapter; +import com.intellij.ui.ScrollPaneFactory; +import com.intellij.ui.SeparatorFactory; +import com.intellij.ui.TextFieldWithHistoryWithBrowseButton; +import com.intellij.util.PathUtil; +import de.vette.idea.neos.lang.fusion.FusionBundle; +import de.vette.idea.neos.lang.fusion.FusionLanguage; +import de.vette.idea.neos.lang.fusion.icons.FusionIcons; +import de.vette.idea.neos.lang.fusion.psi.FusionFile; +import de.vette.idea.neos.lang.fusion.psi.FusionPrototypeSignature; +import de.vette.idea.neos.lang.fusion.psi.FusionType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import java.awt.*; +import java.io.File; +import java.util.List; +import java.util.*; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +public class MovePrototypeDialog extends RefactoringDialog { + private final List myPrototypeInfos; + private final VirtualFile myContextFile; + private final List mySelectedPrototypes; + private final TextFieldWithHistoryWithBrowseButton myTargetFileField; + private final Pattern PRIVATE_RESOURCE_PATH_PATTERN = Pattern.compile("(\\w+(\\.\\w+)+)/Resources/Private"); + + private MovePrototypeDialog.PrototypeSelectionTable myTable; + + protected MovePrototypeDialog( + @NotNull Project project, + List allSignatures, + List preselectedSignatures + ) { + super(project, true, true); + this.myContextFile = allSignatures.get(0).getContainingFile().getVirtualFile(); + this.setTitle(FusionBundle.message("refactoring.move.prototype.title")); + this.mySelectedPrototypes = preselectedSignatures; + this.myTargetFileField = createTargetFileField(); + + List prototypeInfos = new ArrayList<>(); + for (FusionPrototypeSignature prototype : allSignatures) { + PrototypeInfo info = new PrototypeInfo(prototype); + if (preselectedSignatures.contains(prototype)) { + info.setChecked(true); + } + prototypeInfos.add(info); + } + this.myPrototypeInfos = prototypeInfos; + + init(); + } + + @Override + protected @NotNull String getRefactoringId() { + return "MovePrototype"; + } + + @Override + protected void doAction() { + if (doValidateTargetFile() != null) { + return; + } + List selectedPrototypes = getSelectedPrototypes(); + if (selectedPrototypes.isEmpty()) { + return; + } + invokeRefactoring(new MovePrototypeProcessor( + myProject, + getTitle(), + getTargetFilePath(), + getSelectedPrototypes(), + isOpenInEditor() + )); + } + + private List getSelectedPrototypes() { + return myTable.getSelectedMemberInfos().stream().map(MemberInfoBase::getMember).collect(Collectors.toList()); + } + + private @Nullable ValidationInfo doValidateTargetFile() { + String targetPath = getTargetFilePath(); + if (StringUtil.isEmptyOrSpaces(targetPath)) { + return new ValidationInfo(FusionBundle.message("refactoring.move.prototype.target.file.not.specified"), myTargetFileField); + } + + String path = FileUtil.toSystemIndependentName(targetPath); + VirtualFile file = LocalFileSystem.getInstance().findFileByPath(path); + if (file != null) { + if (file.equals(myContextFile)) { + return new ValidationInfo(FusionBundle.message("refactoring.move.prototype.source.target.files.should.be.different"), myTargetFileField); + } + PsiFile psiFile = PsiManager.getInstance(myProject).findFile(file); + if (psiFile instanceof FusionFile) { + return null; + } + return new ValidationInfo(FusionBundle.message("refactoring.move.prototype.target.not.a.fusion.file"), myTargetFileField); + } + + String fileName = PathUtil.getFileName(path); + if (fileName.isEmpty()) { + return new ValidationInfo(FusionBundle.message("refactoring.move.prototype.target.not.a.fusion.file"), myTargetFileField); + } + + FileType fileType = FileTypeManager.getInstance().getFileTypeByFileName(fileName); + if (!(fileType instanceof LanguageFileType) || !((LanguageFileType) fileType).getLanguage().isKindOf(FusionLanguage.INSTANCE)) { + return new ValidationInfo(FusionBundle.message("refactoring.move.prototype.target.not.a.fusion.file"), myTargetFileField); + } + + return null; + } + + @Override + protected boolean hasHelpAction() { + return false; + } + + @Override + protected boolean hasPreviewButton() { + return false; + } + + private @Nullable String getPackageName(String path) { + var matcher = PRIVATE_RESOURCE_PATH_PATTERN.matcher(path); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + private @Nullable ValidationInfo doValidateSamePackage(String sourcePath, String targetPath) { + var sourcePackage = getPackageName(sourcePath); + var targetPackage = getPackageName(targetPath); + + if (sourcePackage != null && targetPackage != null && !sourcePackage.equals(targetPackage)) { + return new ValidationInfo(FusionBundle.message("refactoring.move.prototype.moving.between.packages", sourcePackage, targetPackage), myTable); + } + + return null; + } + + @Override + protected @Nullable ValidationInfo doValidate() { + if (getSelectedPrototypes().isEmpty()) { + return new ValidationInfo(FusionBundle.message("refactoring.move.prototype.no.prototypes.selected"), myTable); + } + + ValidationInfo targetFileValidation = doValidateTargetFile(); + if (targetFileValidation != null) { + return targetFileValidation; + } + + ValidationInfo samePackageInfo = doValidateSamePackage(myContextFile.getPath(), getTargetFilePath()); + if (samePackageInfo != null) { + return samePackageInfo; + } + return super.doValidate(); + } + + private String getTargetFilePath() { + return myTargetFileField.getChildComponent().getText(); + } + + + @Override + protected boolean areButtonsValid() { + return !getSelectedPrototypes().isEmpty() && doValidateTargetFile() == null; + } + + @Override + protected @Nullable JComponent createCenterPanel() { + JPanel panel = new JPanel(new BorderLayout()); + JPanel _panel; + Box box = Box.createVerticalBox(); + + _panel = new JPanel(new BorderLayout()); + _panel.add(new JLabel(FusionBundle.message("refactoring.move.prototype.target.file")), BorderLayout.NORTH); + _panel.add(myTargetFileField, BorderLayout.CENTER); + myTargetFileField.getChildComponent().addDocumentListener(new DocumentAdapter() { + @Override + protected void textChanged(@NotNull DocumentEvent e) { + validateButtons(); + } + }); + box.add(_panel); + + final PrototypeSelectionPanel prototypeSelectionPanel = new PrototypeSelectionPanel("Prototype", myPrototypeInfos); + myTable = prototypeSelectionPanel.getTable(); + MemberInfoModel mySignatureInfoModel = new PrototypeInfoModel(); + mySignatureInfoModel.memberInfoChanged(new MemberInfoChange<>(myPrototypeInfos)); + prototypeSelectionPanel.getTable().setMemberInfoModel(mySignatureInfoModel); + prototypeSelectionPanel.getTable().addMemberInfoChangeListener(mySignatureInfoModel); + prototypeSelectionPanel.getTable().addMemberInfoChangeListener((members) -> validateButtons()); + box.add(prototypeSelectionPanel); + + panel.add(box, BorderLayout.CENTER); + + validateButtons(); + + return panel; + } + + /** + * Returns a suggested file name for the move operation based on the selected prototypes. + * This will use the last name part of a prototype (e.g. Vendor.Package:Prototype.Name -> Name.fusion). + * The name will be derived from the first given prototype not matching the source file name. + * + * @param sourceFilePath Path to the current file to use as base path and fallback + * @param signatures List of prototypes to consider for suggestions + * @return A file path to a fusion file + */ + public static String getSuggestedTargetFileName(String sourceFilePath, List signatures) { + String sourceFileName = PathUtil.getFileName(sourceFilePath); + String sourceExtension = PathUtil.getFileExtension(sourceFilePath); + for (FusionPrototypeSignature signature : signatures) { + Optional prototypeName = Optional.of(signature) + .map(FusionPrototypeSignature::getType) + .map(FusionType::getUnqualifiedType) + .map(PsiElement::getText); + + if (prototypeName.isEmpty()) { + continue; + } + + String[] prototypeNameParts = prototypeName.get().split("\\."); + String lastPrototypeNamePart = prototypeNameParts[prototypeNameParts.length - 1]; + String fileName = PathUtil.makeFileName(lastPrototypeNamePart, sourceExtension); + + if (fileName.equals(sourceFileName)) { + continue; + } + + return PathUtil.getParentPath(sourceFilePath) + File.separator + fileName; + } + return sourceFilePath; + } + + private TextFieldWithHistoryWithBrowseButton createTargetFileField() { + TextFieldWithHistoryWithBrowseButton field = new TextFieldWithHistoryWithBrowseButton(); + Set items = new LinkedHashSet<>(); + appendPossibleTargetFiles(items); + field.getChildComponent().setModel(new DefaultComboBoxModel(items.toArray(String[]::new))); + String title = FusionBundle.message("refactoring.move.prototype.target.file"); + FileChooserDescriptor descriptor = FileChooserDescriptorFactory.createSingleLocalFileDescriptor() + .withFileFilter((file) -> file.getFileType() instanceof LanguageFileType && ((LanguageFileType) file.getFileType()).getLanguage().isKindOf(FusionLanguage.INSTANCE)) + .withRoots(ProjectRootManager.getInstance(myProject).getContentRoots()) + .withTreeRootVisible(true) + .withTitle(title); + field.addBrowseFolderListener(title, null, myProject, descriptor, TextComponentAccessors.TEXT_FIELD_WITH_HISTORY_WHOLE_TEXT); + String initialPath = myContextFile.getPresentableUrl(); + String suggestedTargetFileName = getSuggestedTargetFileName(initialPath, mySelectedPrototypes); + int lastSlash = suggestedTargetFileName.lastIndexOf(File.separatorChar); + field.setText(suggestedTargetFileName); + field.getChildComponent().getTextEditor().select(lastSlash + 1, suggestedTargetFileName.length()); + return field; + } + + private void appendPossibleTargetFiles(Set items) { + // it feels weird, if you select a file and the previous/initial "selection" is no longer available + items.add(myContextFile.getPresentableUrl()); + + VirtualFile[] openFiles = FileEditorManager.getInstance(myProject).getOpenFiles(); + for (VirtualFile file : openFiles) { + if (file.equals(myContextFile)) { + continue; + } + + if (file.getFileType() instanceof LanguageFileType && ((LanguageFileType) file.getFileType()).getLanguage().isKindOf(FusionLanguage.INSTANCE)) { + items.add(file.getPresentableUrl()); + } + } + + IdeDocumentHistory.getInstance(myProject).getChangedFiles().forEach(file -> { + if (file.equals(myContextFile)) { + return; + } + + if (file.getFileType() instanceof LanguageFileType && ((LanguageFileType) file.getFileType()).getLanguage().isKindOf(FusionLanguage.INSTANCE)) { + items.add(file.getPresentableUrl()); + } + }); + } + + private static class PrototypeInfo extends MemberInfoBase { + public PrototypeInfo(FusionPrototypeSignature member) { + super(member); + this.displayName = member.getName(); + } + } + + private static class PrototypeInfoModel extends AbstractMemberInfoModel { + } + + private static class PrototypeSelectionPanel extends AbstractMemberSelectionPanel { + + private final PrototypeSelectionTable myTable; + + public PrototypeSelectionPanel(String title, List memberInfos) { + super(); + setLayout(new BorderLayout()); + myTable = new PrototypeSelectionTable(memberInfos, null); + JScrollPane scrollPane = ScrollPaneFactory.createScrollPane(myTable); + add(SeparatorFactory.createSeparator(title, myTable), BorderLayout.NORTH); + add(scrollPane, BorderLayout.CENTER); + } + + @Override + public MovePrototypeDialog.PrototypeSelectionTable getTable() { + return myTable; + } + } + + private static class PrototypeSelectionTable extends AbstractMemberSelectionTable { + public PrototypeSelectionTable(Collection memberInfos, @Nullable MemberInfoModel memberInfoModel) { + super(memberInfos, memberInfoModel, null); + } + + @Override + protected @Nullable Object getAbstractColumnValue(PrototypeInfo memberInfo) { + return null; + } + + @Override + public String getColumnName(int column) { + if (column == AbstractMemberSelectionTable.DISPLAY_NAME_COLUMN) { + return "Prototype"; + } + return super.getColumnName(column); + } + + @Override + protected boolean isAbstractColumnEditable(int rowIndex) { + return false; + } + + protected void setVisibilityIcon(PrototypeInfo memberInfo, com.intellij.ui.RowIcon icon) { + // this threw an exception, although it doesn't seem to be "required" by the interface + } + + @Override + protected Icon getOverrideIcon(PrototypeInfo memberInfo) { + return FusionIcons.PROTOTYPE; + } + } +} diff --git a/src/main/java/de/vette/idea/neos/lang/fusion/refactoring/MovePrototypeProcessor.java b/src/main/java/de/vette/idea/neos/lang/fusion/refactoring/MovePrototypeProcessor.java new file mode 100644 index 00000000..99d6d344 --- /dev/null +++ b/src/main/java/de/vette/idea/neos/lang/fusion/refactoring/MovePrototypeProcessor.java @@ -0,0 +1,226 @@ +package de.vette.idea.neos.lang.fusion.refactoring; + +import com.intellij.codeInsight.FileModificationService; +import com.intellij.ide.util.EditorHelper; +import com.intellij.notification.NotificationGroupManager; +import com.intellij.notification.NotificationType; +import com.intellij.openapi.application.WriteAction; +import com.intellij.openapi.command.CommandProcessor; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.NlsContexts; +import com.intellij.openapi.util.Ref; +import com.intellij.openapi.util.io.FileUtil; +import com.intellij.openapi.vfs.LocalFileSystem; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.*; +import com.intellij.psi.impl.source.tree.LeafPsiElement; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.refactoring.BaseRefactoringProcessor; +import com.intellij.refactoring.util.CommonRefactoringUtil; +import com.intellij.usageView.BaseUsageViewDescriptor; +import com.intellij.usageView.UsageInfo; +import com.intellij.usageView.UsageViewDescriptor; +import com.intellij.util.PathUtil; +import de.vette.idea.neos.lang.fusion.FusionBundle; +import de.vette.idea.neos.lang.fusion.psi.FusionFile; +import de.vette.idea.neos.lang.fusion.psi.FusionPrototypeSignature; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class MovePrototypeProcessor extends BaseRefactoringProcessor { + + private final String myTitle; + private final @Nullable PsiFile myTargetFile; + private final PsiElement[] myAffectedElements; + private final boolean myOpenInEditor; + private @Nullable String myTargetFilePath = null; + + public MovePrototypeProcessor( + @NotNull Project project, + String title, + @NotNull PsiFile targetFile, + Iterable signaturesToMove, + boolean openInEditor + ) { + super(project); + this.myTitle = title; + this.myTargetFile = targetFile; + this.myAffectedElements = MovePrototypeProcessor.collectAffectedElements(signaturesToMove, project); + this.myOpenInEditor = openInEditor; + } + + public MovePrototypeProcessor( + @NotNull Project project, + String title, + @NotNull String targetFilePath, + Iterable signaturesToMove, + boolean openInEditor + ) { + super(project); + this.myTitle = title; + this.myTargetFile = getOrCreateFileFromPath(targetFilePath); + this.myTargetFilePath = targetFilePath; + this.myAffectedElements = MovePrototypeProcessor.collectAffectedElements(signaturesToMove, project); + this.myOpenInEditor = openInEditor; + } + + private static boolean isMultilineComment(@Nullable PsiElement element) { + return element instanceof PsiComment && element.getText().startsWith("/*"); + } + + private @Nullable FusionFile getOrCreateFileFromPath(String targetFilePath) { + String path = FileUtil.toSystemIndependentName(targetFilePath); + VirtualFile file = LocalFileSystem.getInstance().findFileByPath(path); + if (file != null) { + PsiFile psiFile = PsiManager.getInstance(myProject).findFile(file); + return psiFile instanceof FusionFile ? (FusionFile) psiFile : null; + } + + String fileName = PathUtil.getFileName(path); + Ref fileRef = Ref.create(); + CommandProcessor.getInstance().executeCommand(myProject, () -> { + try { + WriteAction.run(() -> { + VirtualFile parentDir = VfsUtil.createDirectories(PathUtil.getParentPath(path)); + fileRef.set(parentDir.createChildData(this, fileName)); + }); + } catch (IOException e) { + CommonRefactoringUtil.showErrorMessage(myTitle, FusionBundle.message("refactoring.move.prototype.error.creating.file", e.getMessage()), null, myProject); + } + }, FusionBundle.message("refactoring.move.prototype.create.file", fileName), "movePrototypeRefactoring"); + + if (fileRef.isNull()) { + return null; + } + + PsiFile psiFile = PsiManager.getInstance(myProject).findFile(fileRef.get()); + return psiFile instanceof FusionFile ? (FusionFile) psiFile : null; + } + + public static PsiElement[] collectAffectedElements(Iterable prototypes, Project project) { + List elements = new ArrayList<>(); + for (FusionPrototypeSignature prototype : prototypes) { + elements.addAll(getPsiElementsForPrototypeSignature(prototype, project)); + } + return elements.toArray(PsiElement.EMPTY_ARRAY); + } + + private static List getPsiElementsForPrototypeSignature(FusionPrototypeSignature prototype, Project project) { + PsiElement topLevelElement = prototype; + while (!(topLevelElement.getParent() instanceof PsiFile) && topLevelElement.getParent() != null) { + topLevelElement = topLevelElement.getParent(); + } + PsiElement firstElement = topLevelElement; + + // collect preceding comments + do { + PsiElement prevSibling = firstElement.getPrevSibling(); + if (prevSibling instanceof PsiWhiteSpace) { + // between multiline-comments and a prototype seems to be a whitespace + if (isMultilineComment(prevSibling.getPrevSibling())) { + firstElement = prevSibling.getPrevSibling(); + } + break; + } else if (prevSibling instanceof PsiComment) { + firstElement = prevSibling; + } else { + break; + } + } while (true); + + // collect elements to move in correct order + List elementsToMove = new ArrayList<>(); + while (firstElement != null) { + elementsToMove.add(firstElement); + if (firstElement == topLevelElement) { + break; + } + firstElement = firstElement.getNextSibling(); + } + elementsToMove.add(PsiParserFacade.getInstance(project).createWhiteSpaceFromText("\n")); + + return elementsToMove; + } + + @Override + protected @NotNull UsageViewDescriptor createUsageViewDescriptor(UsageInfo @NotNull [] usages) { + return new BaseUsageViewDescriptor(myAffectedElements); + } + + @Override + protected UsageInfo @NotNull [] findUsages() { + return new UsageInfo[0]; + } + + @Override + protected void performRefactoring(UsageInfo @NotNull [] usages) { + PsiFile targetFile = myTargetFile != null + ? myTargetFile + : myTargetFilePath != null ? getOrCreateFileFromPath(myTargetFilePath) : null; + if (targetFile == null) { + // there could be other errors as well, but we assume, they have been validated before + if (myTargetFilePath != null) { + CommonRefactoringUtil.showErrorMessage(myTitle, FusionBundle.message("refactoring.move.prototype.error.creating.file", myTargetFilePath), null, myProject); + return; + } + return; + } + // we assume that everything is from the same file. we could alternatively go over everything by signature + PsiFile originalFile = myAffectedElements[0].getContainingFile(); + FileModificationService.getInstance().preparePsiElementsForWrite(originalFile, targetFile); + + if (targetFile.getChildren().length != 0) { + targetFile.add(PsiParserFacade.getInstance(myProject).createWhiteSpaceFromText("\n")); + } + + PsiElement firstElement = null; + int movedElements = 0; + + for (PsiElement element : myAffectedElements) { + PsiElement newElement = targetFile.add(element); + if (PsiTreeUtil.findChildOfType(element, FusionPrototypeSignature.class) != null) { + movedElements++; + } + if (firstElement == null) { + firstElement = newElement; + } + } + + // deleting the elements when moving creates an error, as parent elements for subsequent elements may be deleted + for (PsiElement element : myAffectedElements) { + // there were a lot of issues with deleting elements, so we try to go over them kind of gracefully + if (element instanceof PsiWhiteSpace) { + continue; + } + if (element instanceof LeafPsiElement && ((LeafPsiElement) element).getTreeParent() == null) { + continue; + } + try { + element.delete(); + } catch (Throwable e) { + // ignore + } + } + + if (myOpenInEditor && firstElement != null) { + EditorHelper.openInEditor(firstElement); + } + + String message = FusionBundle.message("refactoring.move.prototype.0.moved.elements", movedElements); + NotificationGroupManager.getInstance() + .getNotificationGroup("Neos") + .createNotification(message, NotificationType.INFORMATION) + .notify(myProject); + } + + @Override + protected @NotNull @NlsContexts.Command String getCommandName() { + String path = myTargetFile != null ? myTargetFile.getVirtualFile().getPath() : myTargetFilePath; + return FusionBundle.message("refactoring.move.prototype.move.to", path); + } +} diff --git a/src/main/java/de/vette/idea/neos/lang/fusion/refactoring/MovePrototypeToFile.java b/src/main/java/de/vette/idea/neos/lang/fusion/refactoring/MovePrototypeToFile.java new file mode 100644 index 00000000..b04aa577 --- /dev/null +++ b/src/main/java/de/vette/idea/neos/lang/fusion/refactoring/MovePrototypeToFile.java @@ -0,0 +1,136 @@ +package de.vette.idea.neos.lang.fusion.refactoring; + +import com.intellij.lang.Language; +import com.intellij.openapi.actionSystem.DataContext; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.project.Project; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.refactoring.RefactoringActionHandler; +import com.intellij.refactoring.actions.BaseRefactoringAction; +import de.vette.idea.neos.NeosProjectService; +import de.vette.idea.neos.lang.fusion.FusionLanguage; +import de.vette.idea.neos.lang.fusion.psi.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class MovePrototypeToFile extends BaseRefactoringAction implements RefactoringActionHandler { + + @Override + protected boolean isAvailableInEditorOnly() { + return true; + } + + @Override + protected boolean isAvailableForFile(PsiFile file) { + if (!(file instanceof FusionFile)) { + return false; + } + + if (findAllPrototypeSignatures(file).isEmpty()) { + return false; + } + + return super.isAvailableForFile(file); + } + + @Override + protected boolean isEnabledOnElements(PsiElement @NotNull [] psiElements) { + for (PsiElement element : psiElements) { + if (!(element instanceof FusionPrototypeSignature)) { + return false; + } + } + return true; + } + + @Override + protected boolean isAvailableForLanguage(Language language) { + return language == FusionLanguage.INSTANCE; + } + + @Override + protected @Nullable RefactoringActionHandler getHandler(@NotNull DataContext dataContext) { + return this; + } + + @Override + public void invoke(@NotNull Project project, Editor editor, PsiFile psiFile, DataContext dataContext) { + List selectedSignatures = new ArrayList<>(); + editor.getCaretModel().getAllCarets().forEach(caret -> { + PsiElement element = psiFile.findElementAt(caret.getOffset()); + PsiElement signature = PsiTreeUtil.findFirstParent(element, e -> e instanceof FusionPrototypeSignature); + if (isTopLevelPrototype(signature)) { + selectedSignatures.add((FusionPrototypeSignature) signature); + } + }); + List allSignatures = findAllPrototypeSignatures(psiFile); + startRefactoring(project, selectedSignatures, allSignatures); + } + + @Override + public void invoke(@NotNull Project project, PsiElement @NotNull [] psiElements, DataContext dataContext) { + // not sure when this is called + + List selectedSignatures = new ArrayList<>(); + List allSignatures = new ArrayList<>(); + Set visitedFiles = new HashSet<>(); + for (PsiElement element : psiElements) { + if (!isTopLevelPrototype(element)) { + continue; + } + selectedSignatures.add((FusionPrototypeSignature) element); + PsiFile file = element.getContainingFile(); + if (visitedFiles.contains(file)) { + continue; + } + visitedFiles.add(file); + allSignatures.addAll(findAllPrototypeSignatures(file)); + } + + startRefactoring(project, selectedSignatures, allSignatures); + } + + private void startRefactoring(Project project , List selectedSignatures, List allSignatures) { + if (allSignatures.isEmpty()) { + NeosProjectService.getLogger().debug("No prototypes found"); + + return; + } + + MovePrototypeDialog dialog = new MovePrototypeDialog(project, allSignatures, selectedSignatures); + dialog.show(); + } + + public static List findAllPrototypeSignatures(PsiFile psiFile) { + List signatures = new ArrayList<>(PsiTreeUtil.findChildrenOfType(psiFile, FusionPrototypeSignature.class)); + return signatures.stream().filter(MovePrototypeToFile::isTopLevelPrototype).collect(Collectors.toList()); + } + + /** + * Determines whether the prototype definition is an override on some path or not. + */ + private static boolean isTopLevelPrototype(@Nullable PsiElement prototypeSignature) { + if (!(prototypeSignature instanceof FusionPrototypeSignature)) { + return false; + } + + PsiElement parent = prototypeSignature.getParent(); + while (parent != null) { + // TODO: there might be a better way to check this + if (parent instanceof FusionBlock || parent instanceof FusionPrototypeSignature) { + return false; + } + parent = parent.getParent(); + } + + return true; + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 8f637cf4..a32a833a 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -168,5 +168,8 @@ + + + diff --git a/src/main/resources/messages/FusionBundle.properties b/src/main/resources/messages/FusionBundle.properties index 6867f2a4..6dcbb511 100644 --- a/src/main/resources/messages/FusionBundle.properties +++ b/src/main/resources/messages/FusionBundle.properties @@ -1,4 +1,15 @@ usage.type.definition=Definition usage.type.deleted=Deleted usage.type.instance=Instance -usage.type.inherited=Inherited \ No newline at end of file +usage.type.inherited=Inherited +refactoring.move.prototype.title=Move Prototype +refactoring.move.prototype.target.file=Move to +refactoring.move.prototype.no.prototypes.selected=No prototype selected to be moved +refactoring.move.prototype.source.target.files.should.be.different=Source and target files should be different. +refactoring.move.prototype.target.file.not.specified=Target file not specified. +refactoring.move.prototype.target.not.a.fusion.file=Target file is not a fusion file. +refactoring.move.prototype.error.creating.file=Error creating file:\n{0} +refactoring.move.prototype.create.file=Create file ''{0}'' +refactoring.move.prototype.moving.between.packages=Target file seems to be a different package (moving from {0} to {1})! +refactoring.move.prototype.move.to=Move prototypes to file ''{0}'' +refactoring.move.prototype.0.moved.elements=Moved {0, choice, 1#prototype|2#{0} prototypes}. \ No newline at end of file diff --git a/src/test/java/de/vette/idea/neos/fusion/refactoring/MovePrototypeTest.java b/src/test/java/de/vette/idea/neos/fusion/refactoring/MovePrototypeTest.java new file mode 100644 index 00000000..d3e385fb --- /dev/null +++ b/src/test/java/de/vette/idea/neos/fusion/refactoring/MovePrototypeTest.java @@ -0,0 +1,64 @@ +package de.vette.idea.neos.fusion.refactoring; + +import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import de.vette.idea.neos.lang.fusion.refactoring.MovePrototypeDialog; +import de.vette.idea.neos.lang.fusion.refactoring.MovePrototypeProcessor; +import de.vette.idea.neos.lang.fusion.refactoring.MovePrototypeToFile; +import util.FusionTestUtils; + +import java.io.File; + +public class MovePrototypeTest extends BasePlatformTestCase { + + @Override + protected String getTestDataPath() { + return FusionTestUtils.BASE_TEST_DATA_PATH; + } + + public void testFindAllPrototypesInFile() { + myFixture.configureByFile("fusion/refactoring/move_prototypes_before.fusion"); + var signatures = MovePrototypeToFile.findAllPrototypeSignatures(myFixture.getFile()); + var signatureNames = signatures.stream().map(signature -> signature.getType().getText()); + assertContainsElements( + signatureNames.toList(), + "Vendor.Package:Prototype.CopiedFrom", + "Vendor.Package:Prototype.WithImplementation", + "Vendor.Package:Prototype.Example2" + ); + } + + public void testSuggestedTargetFileName() { + myFixture.configureByFile("fusion/refactoring/move_prototypes_before.fusion"); + var signatures = MovePrototypeToFile.findAllPrototypeSignatures(myFixture.getFile()); + String basePath = "current" + File.separator + "path" + File.separator; + String sourceFilePath = basePath + "CopiedFrom.fusion"; + + var suggestedFilename = MovePrototypeDialog.getSuggestedTargetFileName(sourceFilePath, signatures); + assertEquals(basePath + "WithImplementation.fusion", suggestedFilename); + } + + public void testMoveSinglePrototypes() { + myFixture.configureByFile("fusion/refactoring/move_prototypes_before.fusion"); + var signatures = MovePrototypeToFile.findAllPrototypeSignatures(myFixture.getFile()); + + var after = myFixture.addFileToProject("after.fusion", ""); + myFixture.configureFromTempProjectFile("after.fusion"); + + var processor = new MovePrototypeProcessor(getProject(), "Move Prototypes", after, signatures.subList(1, 2), false); + processor.run(); + myFixture.checkResultByFile("after.fusion", "fusion/refactoring/move_prototypes_target2.fusion", false); + } + + public void testMoveMultiplePrototypes() { + myFixture.configureByFile("fusion/refactoring/move_prototypes_before.fusion"); + var signatures = MovePrototypeToFile.findAllPrototypeSignatures(myFixture.getFile()); + + var after = myFixture.addFileToProject("after.fusion", ""); + myFixture.configureFromTempProjectFile("after.fusion"); + + var processor = new MovePrototypeProcessor(getProject(), "Move Prototypes", after, signatures.subList(0, 2), false); + processor.run(); + myFixture.checkResultByFile("fusion/refactoring/move_prototypes_before.fusion", "fusion/refactoring/move_prototypes_after1.fusion", false); + myFixture.checkResultByFile("after.fusion", "fusion/refactoring/move_prototypes_target1.fusion", false); + } +} diff --git a/testData/fusion/refactoring/move_prototypes_after1.fusion b/testData/fusion/refactoring/move_prototypes_after1.fusion new file mode 100644 index 00000000..af982fe4 --- /dev/null +++ b/testData/fusion/refactoring/move_prototypes_after1.fusion @@ -0,0 +1,14 @@ +Namespace.Controller.Action = Vendor.Package:Prototype.At.Path { + prototype(Vendor.Package:Prototype.OverrideInPath) { + } +} + +# Some global comment +# with multiple lines + + + +prototype(Vendor.Package:Prototype.Example2) { + prototype(Vendor.Package:OverrideInPrototype) { + } +} \ No newline at end of file diff --git a/testData/fusion/refactoring/move_prototypes_before.fusion b/testData/fusion/refactoring/move_prototypes_before.fusion new file mode 100644 index 00000000..9687716d --- /dev/null +++ b/testData/fusion/refactoring/move_prototypes_before.fusion @@ -0,0 +1,24 @@ +Namespace.Controller.Action = Vendor.Package:Prototype.At.Path { + prototype(Vendor.Package:Prototype.OverrideInPath) { + } +} + +# Some global comment +# with multiple lines + +# A comment for Prototype.CopiedFrom +# that can span multiple lines +prototype(Vendor.Package:Prototype.CopiedFrom) < prototype(Vendor.Package:Example1) { +} + +/** + * Comment block for Prototype.WithImplementation + */ +prototype(Vendor.Package:Prototype.WithImplementation) { + @class = 'Vendor\\Package\\Fusion\\WithImplementationImplementation' +} + +prototype(Vendor.Package:Prototype.Example2) { + prototype(Vendor.Package:OverrideInPrototype) { + } +} \ No newline at end of file diff --git a/testData/fusion/refactoring/move_prototypes_target1.fusion b/testData/fusion/refactoring/move_prototypes_target1.fusion new file mode 100644 index 00000000..4c78d61c --- /dev/null +++ b/testData/fusion/refactoring/move_prototypes_target1.fusion @@ -0,0 +1,10 @@ +# A comment for Prototype.CopiedFrom +# that can span multiple lines +prototype(Vendor.Package:Prototype.CopiedFrom) < prototype(Vendor.Package:Example1) { +} +/** + * Comment block for Prototype.WithImplementation + */ +prototype(Vendor.Package:Prototype.WithImplementation) { + @class = 'Vendor\\Package\\Fusion\\WithImplementationImplementation' +} diff --git a/testData/fusion/refactoring/move_prototypes_target2.fusion b/testData/fusion/refactoring/move_prototypes_target2.fusion new file mode 100644 index 00000000..cbb1cb31 --- /dev/null +++ b/testData/fusion/refactoring/move_prototypes_target2.fusion @@ -0,0 +1,6 @@ +/** + * Comment block for Prototype.WithImplementation + */ +prototype(Vendor.Package:Prototype.WithImplementation) { + @class = 'Vendor\\Package\\Fusion\\WithImplementationImplementation' +}