/* * Copyright (C) 2011 The Android Open Source Project * * Licensed under the Eclipse Public License, Version 1.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.eclipse.org/org/documents/epl-v10.php * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.ide.eclipse.adt.internal.editors.layout.refactoring; import static com.android.SdkConstants.ANDROID_NS_NAME; import static com.android.SdkConstants.ANDROID_URI; import static com.android.SdkConstants.ATTR_ID; import static com.android.SdkConstants.ATTR_LAYOUT_HEIGHT; import static com.android.SdkConstants.ATTR_LAYOUT_RESOURCE_PREFIX; import static com.android.SdkConstants.ATTR_LAYOUT_WIDTH; import static com.android.SdkConstants.DOT_XML; import static com.android.SdkConstants.EXT_XML; import static com.android.SdkConstants.FD_RES; import static com.android.SdkConstants.FD_RESOURCES; import static com.android.SdkConstants.FD_RES_LAYOUT; import static com.android.SdkConstants.ID_PREFIX; import static com.android.SdkConstants.NEW_ID_PREFIX; import static com.android.SdkConstants.VALUE_WRAP_CONTENT; import static com.android.SdkConstants.VIEW_INCLUDE; import static com.android.SdkConstants.XMLNS; import static com.android.SdkConstants.XMLNS_PREFIX; import static com.android.ide.eclipse.adt.AdtConstants.WS_SEP; import static com.android.resources.ResourceType.LAYOUT; import com.android.annotations.NonNull; import com.android.annotations.VisibleForTesting; import com.android.ide.eclipse.adt.AdtPlugin; import com.android.ide.eclipse.adt.internal.editors.formatting.XmlFormatPreferences; import com.android.ide.eclipse.adt.internal.editors.formatting.XmlFormatStyle; import com.android.ide.eclipse.adt.internal.editors.formatting.XmlPrettyPrinter; import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditorDelegate; import com.android.ide.eclipse.adt.internal.editors.layout.gle2.CanvasViewInfo; import com.android.ide.eclipse.adt.internal.editors.layout.gle2.DomUtilities; import com.android.ide.eclipse.adt.internal.editors.layout.uimodel.UiViewElementNode; import com.android.ide.eclipse.adt.internal.preferences.AdtPrefs; import com.android.ide.eclipse.adt.internal.resources.ResourceNameValidator; import com.android.utils.XmlUtils; import org.eclipse.core.resources.IContainer; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IPath; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.core.runtime.Path; import org.eclipse.jface.dialogs.IInputValidator; import org.eclipse.jface.text.ITextSelection; import org.eclipse.jface.viewers.ITreeSelection; import org.eclipse.ltk.core.refactoring.Change; import org.eclipse.ltk.core.refactoring.NullChange; import org.eclipse.ltk.core.refactoring.Refactoring; import org.eclipse.ltk.core.refactoring.RefactoringStatus; import org.eclipse.ltk.core.refactoring.TextFileChange; import org.eclipse.swt.widgets.Display; import org.eclipse.text.edits.InsertEdit; import org.eclipse.text.edits.MultiTextEdit; import org.eclipse.text.edits.ReplaceEdit; import org.eclipse.text.edits.TextEdit; import org.eclipse.ui.IWorkbenchPage; import org.eclipse.wst.sse.core.StructuredModelManager; import org.eclipse.wst.sse.core.internal.provisional.IModelManager; import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel; import org.eclipse.wst.sse.core.internal.provisional.IndexedRegion; import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; import org.eclipse.wst.xml.core.internal.provisional.document.IDOMDocument; import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel; import org.w3c.dom.Attr; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Map; /** * Extracts the selection and writes it out as a separate layout file, then adds an * include to that new layout file. Interactively asks the user for a new name for the * layout. */ @SuppressWarnings("restriction") // XML model public class ExtractIncludeRefactoring extends VisualRefactoring { private static final String KEY_NAME = "name"; //$NON-NLS-1$ private static final String KEY_OCCURRENCES = "all-occurrences"; //$NON-NLS-1$ private String mLayoutName; private boolean mReplaceOccurrences; /** * This constructor is solely used by {@link Descriptor}, * to replay a previous refactoring. * @param arguments argument map created by #createArgumentMap. */ ExtractIncludeRefactoring(Map<String, String> arguments) { super(arguments); mLayoutName = arguments.get(KEY_NAME); mReplaceOccurrences = Boolean.parseBoolean(arguments.get(KEY_OCCURRENCES)); } public ExtractIncludeRefactoring( IFile file, LayoutEditorDelegate delegate, ITextSelection selection, ITreeSelection treeSelection) { super(file, delegate, selection, treeSelection); } @VisibleForTesting ExtractIncludeRefactoring(List<Element> selectedElements, LayoutEditorDelegate editor) { super(selectedElements, editor); } @Override public RefactoringStatus checkInitialConditions(IProgressMonitor pm) throws CoreException, OperationCanceledException { RefactoringStatus status = new RefactoringStatus(); try { pm.beginTask("Checking preconditions...", 6); if (mSelectionStart == -1 || mSelectionEnd == -1) { status.addFatalError("No selection to extract"); return status; } // Make sure the selection is contiguous if (mTreeSelection != null) { // TODO - don't do this if we based the selection on text. In this case, // make sure we're -balanced-. List<CanvasViewInfo> infos = getSelectedViewInfos(); if (!validateNotEmpty(infos, status)) { return status; } if (!validateNotRoot(infos, status)) { return status; } // Disable if you've selected a single include tag if (infos.size() == 1) { UiViewElementNode uiNode = infos.get(0).getUiViewNode(); if (uiNode != null) { Node xmlNode = uiNode.getXmlNode(); if (xmlNode.getLocalName().equals(VIEW_INCLUDE)) { status.addWarning("No point in refactoring a single include tag"); } } } // Enforce that the selection is -contiguous- if (!validateContiguous(infos, status)) { return status; } } // This also ensures that we have a valid DOM model: if (mElements.size() == 0) { status.addFatalError("Nothing to extract"); return status; } pm.worked(1); return status; } finally { pm.done(); } } @Override protected VisualRefactoringDescriptor createDescriptor() { String comment = getName(); return new Descriptor( mProject.getName(), //project comment, //description comment, //comment createArgumentMap()); } @Override protected Map<String, String> createArgumentMap() { Map<String, String> args = super.createArgumentMap(); args.put(KEY_NAME, mLayoutName); args.put(KEY_OCCURRENCES, Boolean.toString(mReplaceOccurrences)); return args; } @Override public String getName() { return "Extract as Include"; } void setLayoutName(String layoutName) { mLayoutName = layoutName; } void setReplaceOccurrences(boolean selection) { mReplaceOccurrences = selection; } // ---- Actual implementation of Extract as Include modification computation ---- @Override protected @NonNull List<Change> computeChanges(IProgressMonitor monitor) { String extractedText = getExtractedText(); String namespaceDeclarations = computeNamespaceDeclarations(); // Insert namespace: extractedText = insertNamespace(extractedText, namespaceDeclarations); StringBuilder sb = new StringBuilder(); sb.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"); //$NON-NLS-1$ sb.append(extractedText); sb.append('\n'); List<Change> changes = new ArrayList<Change>(); String newFileName = mLayoutName + DOT_XML; IProject project = mDelegate.getEditor().getProject(); IFile sourceFile = mDelegate.getEditor().getInputFile(); if (sourceFile == null) { return changes; } // Replace extracted elements by <include> tag handleIncludingFile(changes, sourceFile, mSelectionStart, mSelectionEnd, getDomDocument(), getPrimaryElement()); // Also extract in other variations of the same file (landscape/portrait, etc) boolean haveVariations = false; if (mReplaceOccurrences) { List<IFile> layouts = getOtherLayouts(sourceFile); for (IFile file : layouts) { IModelManager modelManager = StructuredModelManager.getModelManager(); IStructuredModel model = null; // We could enhance this with a SubMonitor to make the progress bar move as // well. monitor.subTask(String.format("Looking for duplicates in %1$s", file.getProjectRelativePath())); if (monitor.isCanceled()) { throw new OperationCanceledException(); } try { model = modelManager.getModelForRead(file); if (model instanceof IDOMModel) { IDOMModel domModel = (IDOMModel) model; IDOMDocument otherDocument = domModel.getDocument(); List<Element> otherElements = new ArrayList<Element>(); Element otherPrimary = null; for (Element element : getElements()) { Element other = DomUtilities.findCorresponding(element, otherDocument); if (other != null) { // See if the structure is similar to what we have in this // document if (DomUtilities.isEquivalent(element, other)) { otherElements.add(other); if (element == getPrimaryElement()) { otherPrimary = other; } } } } // Only perform extract in the other file if we find a match for // ALL of elements being extracted, and if they too are contiguous if (otherElements.size() == getElements().size() && DomUtilities.isContiguous(otherElements)) { // Find the range int begin = Integer.MAX_VALUE; int end = Integer.MIN_VALUE; for (Element element : otherElements) { // Yes!! Extract this one as well! IndexedRegion region = getRegion(element); end = Math.max(end, region.getEndOffset()); begin = Math.min(begin, region.getStartOffset()); } handleIncludingFile(changes, file, begin, end, otherDocument, otherPrimary); haveVariations = true; } } } catch (IOException e) { AdtPlugin.log(e, null); } catch (CoreException e) { AdtPlugin.log(e, null); } finally { if (model != null) { model.releaseFromRead(); } } } } // Add change to create the new file IContainer parent = sourceFile.getParent(); if (haveVariations) { // If we're extracting from multiple configuration folders, then we need to // place the extracted include in the base layout folder (if not it goes next to // the including file) parent = mProject.getFolder(FD_RES).getFolder(FD_RES_LAYOUT); } IPath parentPath = parent.getProjectRelativePath(); final IFile file = project.getFile(new Path(parentPath + WS_SEP + newFileName)); TextFileChange addFile = new TextFileChange("Create new separate layout", file); addFile.setTextType(EXT_XML); changes.add(addFile); String newFile = sb.toString(); if (AdtPrefs.getPrefs().getFormatGuiXml()) { newFile = XmlPrettyPrinter.prettyPrint(newFile, XmlFormatPreferences.create(), XmlFormatStyle.LAYOUT, null /*lineSeparator*/); } addFile.setEdit(new InsertEdit(0, newFile)); Change finishHook = createFinishHook(file); changes.add(finishHook); return changes; } private void handleIncludingFile(List<Change> changes, IFile sourceFile, int begin, int end, Document document, Element primary) { TextFileChange change = new TextFileChange(sourceFile.getName(), sourceFile); MultiTextEdit rootEdit = new MultiTextEdit(); change.setTextType(EXT_XML); changes.add(change); String referenceId = getReferenceId(); // Replace existing elements in the source file and insert <include> String androidNsPrefix = getAndroidNamespacePrefix(document); String include = computeIncludeString(primary, mLayoutName, androidNsPrefix, referenceId); int length = end - begin; ReplaceEdit replace = new ReplaceEdit(begin, length, include); rootEdit.addChild(replace); // Update any layout references to the old id with the new id if (referenceId != null && primary != null) { String rootId = getId(primary); IStructuredModel model = null; try { model = StructuredModelManager.getModelManager().getModelForRead(sourceFile); IStructuredDocument doc = model.getStructuredDocument(); if (doc != null && rootId != null) { List<TextEdit> replaceIds = replaceIds(androidNsPrefix, doc, begin, end, rootId, referenceId); for (TextEdit edit : replaceIds) { rootEdit.addChild(edit); } if (AdtPrefs.getPrefs().getFormatGuiXml()) { MultiTextEdit formatted = reformat(doc.get(), rootEdit, XmlFormatStyle.LAYOUT); if (formatted != null) { rootEdit = formatted; } } } } catch (IOException e) { AdtPlugin.log(e, null); } catch (CoreException e) { AdtPlugin.log(e, null); } finally { if (model != null) { model.releaseFromRead(); } } } change.setEdit(rootEdit); } /** * Returns a list of all the other layouts (in all configurations) in the project other * than the given source layout where the refactoring was initiated. Never null. */ private List<IFile> getOtherLayouts(IFile sourceFile) { List<IFile> layouts = new ArrayList<IFile>(100); IPath sourcePath = sourceFile.getProjectRelativePath(); IFolder resources = mProject.getFolder(FD_RESOURCES); try { for (IResource folder : resources.members()) { if (folder.getName().startsWith(FD_RES_LAYOUT) && folder instanceof IFolder) { IFolder layoutFolder = (IFolder) folder; for (IResource file : layoutFolder.members()) { if (file.getName().endsWith(EXT_XML) && file instanceof IFile) { if (!file.getProjectRelativePath().equals(sourcePath)) { layouts.add((IFile) file); } } } } } } catch (CoreException e) { AdtPlugin.log(e, null); } return layouts; } String getInitialName() { String defaultName = ""; //$NON-NLS-1$ Element primary = getPrimaryElement(); if (primary != null) { String id = primary.getAttributeNS(ANDROID_URI, ATTR_ID); // id null check for https://bugs.eclipse.org/bugs/show_bug.cgi?id=272378 if (id != null && (id.startsWith(ID_PREFIX) || id.startsWith(NEW_ID_PREFIX))) { // Use everything following the id/, and make it lowercase since that is // the convention for layouts (and use Locale.US to ensure that "Image" becomes // "image" etc) defaultName = id.substring(id.indexOf('/') + 1).toLowerCase(Locale.US); IInputValidator validator = ResourceNameValidator.create(true, mProject, LAYOUT); if (validator.isValid(defaultName) != null) { // Already exists? defaultName = ""; //$NON-NLS-1$ } } } return defaultName; } IFile getSourceFile() { return mFile; } private Change createFinishHook(final IFile file) { return new NullChange("Open extracted layout and refresh resources") { @Override public Change perform(IProgressMonitor pm) throws CoreException { Display display = AdtPlugin.getDisplay(); display.asyncExec(new Runnable() { @Override public void run() { openFile(file); mDelegate.getGraphicalEditor().refreshProjectResources(); // Save file to trigger include finder scanning (as well as making // the // actual show-include feature work since it relies on reading // files from // disk, not a live buffer) IWorkbenchPage page = mDelegate.getEditor().getEditorSite().getPage(); page.saveEditor(mDelegate.getEditor(), false); } }); // Not undoable: just return null instead of an undo-change. return null; } }; } private String computeNamespaceDeclarations() { String androidNsPrefix = null; String namespaceDeclarations = null; StringBuilder sb = new StringBuilder(); List<Attr> attributeNodes = findNamespaceAttributes(); for (Node attributeNode : attributeNodes) { String prefix = attributeNode.getPrefix(); if (XMLNS.equals(prefix)) { sb.append(' '); String name = attributeNode.getNodeName(); sb.append(name); sb.append('=').append('"'); String value = attributeNode.getNodeValue(); if (value.equals(ANDROID_URI)) { androidNsPrefix = name; if (androidNsPrefix.startsWith(XMLNS_PREFIX)) { androidNsPrefix = androidNsPrefix.substring(XMLNS_PREFIX.length()); } } sb.append(XmlUtils.toXmlAttributeValue(value)); sb.append('"'); } } namespaceDeclarations = sb.toString(); if (androidNsPrefix == null) { androidNsPrefix = ANDROID_NS_NAME; } if (namespaceDeclarations.length() == 0) { sb.setLength(0); sb.append(' '); sb.append(XMLNS_PREFIX); sb.append(androidNsPrefix); sb.append('=').append('"'); sb.append(ANDROID_URI); sb.append('"'); namespaceDeclarations = sb.toString(); } return namespaceDeclarations; } /** Returns the id to be used for the include tag itself (may be null) */ private String getReferenceId() { String rootId = getRootId(); if (rootId != null) { return rootId + "_ref"; } return null; } /** * Compute the actual {@code <include>} string to be inserted in place of the old * selection */ private static String computeIncludeString(Element primaryNode, String newName, String androidNsPrefix, String referenceId) { StringBuilder sb = new StringBuilder(); sb.append("<include layout=\"@layout/"); //$NON-NLS-1$ sb.append(newName); sb.append('"'); sb.append(' '); // Create new id for the include itself if (referenceId != null) { sb.append(androidNsPrefix); sb.append(':'); sb.append(ATTR_ID); sb.append('=').append('"'); sb.append(referenceId); sb.append('"').append(' '); } // Add id string, unless it's a <merge>, since we may need to adjust any layout // references to apply to the <include> tag instead // I should move all the layout_ attributes as well // I also need to duplicate and modify the id and then replace // everything else in the file with this new id... // HACK: see issue 13494: We must duplicate the width/height attributes on the // <include> statement for designtime rendering only String width = null; String height = null; if (primaryNode == null) { // Multiple selection - in that case we will be creating an outer <merge> // so we need to set our own width/height on it width = height = VALUE_WRAP_CONTENT; } else { if (!primaryNode.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH)) { width = VALUE_WRAP_CONTENT; } else { width = primaryNode.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_WIDTH); } if (!primaryNode.hasAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT)) { height = VALUE_WRAP_CONTENT; } else { height = primaryNode.getAttributeNS(ANDROID_URI, ATTR_LAYOUT_HEIGHT); } } if (width != null) { sb.append(' '); sb.append(androidNsPrefix); sb.append(':'); sb.append(ATTR_LAYOUT_WIDTH); sb.append('=').append('"'); sb.append(XmlUtils.toXmlAttributeValue(width)); sb.append('"'); } if (height != null) { sb.append(' '); sb.append(androidNsPrefix); sb.append(':'); sb.append(ATTR_LAYOUT_HEIGHT); sb.append('=').append('"'); sb.append(XmlUtils.toXmlAttributeValue(height)); sb.append('"'); } // Duplicate all the other layout attributes as well if (primaryNode != null) { NamedNodeMap attributes = primaryNode.getAttributes(); for (int i = 0, n = attributes.getLength(); i < n; i++) { Node attr = attributes.item(i); String name = attr.getLocalName(); if (name.startsWith(ATTR_LAYOUT_RESOURCE_PREFIX) && ANDROID_URI.equals(attr.getNamespaceURI())) { if (name.equals(ATTR_LAYOUT_WIDTH) || name.equals(ATTR_LAYOUT_HEIGHT)) { // Already handled continue; } sb.append(' '); sb.append(androidNsPrefix); sb.append(':'); sb.append(name); sb.append('=').append('"'); sb.append(XmlUtils.toXmlAttributeValue(attr.getNodeValue())); sb.append('"'); } } } sb.append("/>"); return sb.toString(); } /** Return the text in the document in the range start to end */ private String getExtractedText() { String xml = getText(mSelectionStart, mSelectionEnd); Element primaryNode = getPrimaryElement(); xml = stripTopLayoutAttributes(primaryNode, mSelectionStart, xml); xml = dedent(xml); // Wrap siblings in <merge>? if (primaryNode == null) { StringBuilder sb = new StringBuilder(); sb.append("<merge>\n"); //$NON-NLS-1$ // indent an extra level for (String line : xml.split("\n")) { //$NON-NLS-1$ sb.append(" "); //$NON-NLS-1$ sb.append(line).append('\n'); } sb.append("</merge>\n"); //$NON-NLS-1$ xml = sb.toString(); } return xml; } @Override VisualRefactoringWizard createWizard() { return new ExtractIncludeWizard(this, mDelegate); } public static class Descriptor extends VisualRefactoringDescriptor { public Descriptor(String project, String description, String comment, Map<String, String> arguments) { super("com.android.ide.eclipse.adt.refactoring.extract.include", //$NON-NLS-1$ project, description, comment, arguments); } @Override protected Refactoring createRefactoring(Map<String, String> args) { return new ExtractIncludeRefactoring(args); } } }