/*
* 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.ide.common.layout.LayoutConstants.ANDROID_NS_PREFIX;
import static com.android.ide.common.layout.LayoutConstants.ANDROID_URI;
import static com.android.ide.common.layout.LayoutConstants.ATTR_ID;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_HEIGHT;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_PREFIX;
import static com.android.ide.common.layout.LayoutConstants.ATTR_LAYOUT_WIDTH;
import static com.android.ide.common.layout.LayoutConstants.ID_PREFIX;
import static com.android.ide.common.layout.LayoutConstants.NEW_ID_PREFIX;
import static com.android.ide.common.layout.LayoutConstants.VALUE_WRAP_CONTENT;
import static com.android.ide.eclipse.adt.AndroidConstants.DOT_XML;
import static com.android.ide.eclipse.adt.AndroidConstants.EXT_XML;
import static com.android.ide.eclipse.adt.AndroidConstants.WS_SEP;
import static com.android.ide.eclipse.adt.internal.editors.descriptors.XmlnsAttributeDescriptor.XMLNS;
import static com.android.ide.eclipse.adt.internal.editors.descriptors.XmlnsAttributeDescriptor.XMLNS_COLON;
import static com.android.resources.ResourceType.LAYOUT;
import com.android.ide.eclipse.adt.AdtPlugin;
import com.android.ide.eclipse.adt.AndroidConstants;
import com.android.ide.eclipse.adt.internal.editors.layout.LayoutEditor;
import com.android.ide.eclipse.adt.internal.editors.layout.descriptors.LayoutDescriptors;
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.resources.ResourceNameValidator;
import com.android.util.Pair;
import org.eclipse.core.resources.IContainer;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IProject;
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.internal.provisional.IStructuredModel;
import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import java.util.ArrayList;
import java.util.List;
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 static final String KEY_UPDATE_REFS = "update-refs"; //$NON-NLS-1$
private String mLayoutName;
private boolean mReplaceOccurrences;
private boolean mUpdateReferences;
/**
* 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);
mUpdateReferences = Boolean.parseBoolean(arguments.get(KEY_UPDATE_REFS));
mReplaceOccurrences = Boolean.parseBoolean(arguments.get(KEY_OCCURRENCES));
}
public ExtractIncludeRefactoring(IFile file, LayoutEditor editor, ITextSelection selection,
ITreeSelection treeSelection) {
super(file, editor, selection, treeSelection);
}
@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(LayoutDescriptors.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:
mElements = getElements();
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_UPDATE_REFS, Boolean.toString(mUpdateReferences));
args.put(KEY_OCCURRENCES, Boolean.toString(mReplaceOccurrences));
return args;
}
@Override
public String getName() {
return "Extract as Include";
}
void setLayoutName(String layoutName) {
mLayoutName = layoutName;
}
void setUpdateReferences(boolean selection) {
mUpdateReferences = selection;
}
void setReplaceOccurrences(boolean selection) {
mReplaceOccurrences = selection;
}
// ---- Actual implementation of Extract as Include modification computation ----
@Override
protected List<Change> computeChanges() {
String extractedText = getExtractedText();
Pair<String, String> namespace = computeNamespaces();
String androidNsPrefix = namespace.getFirst();
String namespaceDeclarations = namespace.getSecond();
// 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 = mEditor.getProject();
IFile sourceFile = mEditor.getInputFile();
TextFileChange change = new TextFileChange(sourceFile.getName(), sourceFile);
MultiTextEdit rootEdit = new MultiTextEdit();
change.setEdit(rootEdit);
change.setTextType(EXT_XML);
changes.add(change);
String referenceId = getReferenceId();
// Replace existing elements in the source file and insert <include>
String include = computeIncludeString(mLayoutName, androidNsPrefix, referenceId);
int length = mSelectionEnd - mSelectionStart;
ReplaceEdit replace = new ReplaceEdit(mSelectionStart, length, include);
rootEdit.addChild(replace);
// Update any layout references to the old id with the new id
if (mUpdateReferences && referenceId != null) {
String rootId = getRootId();
IStructuredModel model = mEditor.getModelForRead();
try {
IStructuredDocument doc = model.getStructuredDocument();
if (doc != null) {
List<TextEdit> replaceIds = replaceIds(doc, mSelectionStart,
mSelectionEnd, rootId, referenceId);
for (TextEdit edit : replaceIds) {
rootEdit.addChild(edit);
}
}
} finally {
model.releaseFromRead();
}
}
// Add change to create the new file
IContainer parent = sourceFile.getParent();
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(AndroidConstants.EXT_XML);
changes.add(addFile);
addFile.setEdit(new InsertEdit(0, sb.toString()));
Change finishHook = createFinishHook(file);
changes.add(finishHook);
return changes;
}
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
defaultName = id.substring(id.indexOf('/') + 1).toLowerCase();
IInputValidator validator = ResourceNameValidator.create(true, mProject, LAYOUT);
if (validator.isValid(defaultName) != null) { // Already exists?
defaultName = ""; //$NON-NLS-1$
}
}
}
return defaultName;
}
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() {
public void run() {
openFile(file);
mEditor.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 = mEditor.getEditorSite().getPage();
page.saveEditor(mEditor, false);
}
});
// Not undoable: just return null instead of an undo-change.
return null;
}
};
}
private Pair<String, String> computeNamespaces() {
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_COLON)) {
androidNsPrefix = androidNsPrefix.substring(XMLNS_COLON.length());
}
}
sb.append(DomUtilities.toXmlAttributeValue(value));
sb.append('"');
}
}
namespaceDeclarations = sb.toString();
if (androidNsPrefix == null) {
androidNsPrefix = ANDROID_NS_PREFIX;
}
if (namespaceDeclarations.length() == 0) {
sb.setLength(0);
sb.append(' ');
sb.append(XMLNS_COLON);
sb.append(androidNsPrefix);
sb.append('=').append('"');
sb.append(ANDROID_URI);
sb.append('"');
namespaceDeclarations = sb.toString();
}
return Pair.of(androidNsPrefix, 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 String computeIncludeString(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
Element primaryNode = getPrimaryElement();
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(DomUtilities.toXmlAttributeValue(width));
sb.append('"');
}
if (height != null) {
sb.append(' ');
sb.append(androidNsPrefix);
sb.append(':');
sb.append(ATTR_LAYOUT_HEIGHT);
sb.append('=').append('"');
sb.append(DomUtilities.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_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(DomUtilities.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;
}
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);
}
}
}