blob: 5ac5f5c4e8d69878a16f2fdeb250de50fb602599 [file] [log] [blame]
/*
* Copyright (C) 2009 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.refactorings.extractstring;
import com.android.SdkConstants;
import com.android.ide.common.resources.configuration.FolderConfiguration;
import com.android.ide.eclipse.adt.AdtConstants;
import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector;
import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector.SelectorMode;
import com.android.resources.ResourceFolderType;
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.jface.wizard.WizardPage;
import org.eclipse.ltk.ui.refactoring.UserInputWizardPage;
import org.eclipse.swt.SWT;
import org.eclipse.swt.events.ModifyEvent;
import org.eclipse.swt.events.ModifyListener;
import org.eclipse.swt.events.SelectionAdapter;
import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.events.SelectionListener;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.layout.GridLayout;
import org.eclipse.swt.widgets.Button;
import org.eclipse.swt.widgets.Combo;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Group;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Text;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @see ExtractStringRefactoring
*/
class ExtractStringInputPage extends UserInputWizardPage {
/** Last res file path used, shared across the session instances but specific to the
* current project. The default for unknown projects is {@link #DEFAULT_RES_FILE_PATH}. */
private static HashMap<String, String> sLastResFilePath = new HashMap<String, String>();
/** The project where the user selection happened. */
private final IProject mProject;
/** Text field where the user enters the new ID to be generated or replaced with. */
private Combo mStringIdCombo;
/** Text field where the user enters the new string value. */
private Text mStringValueField;
/** The configuration selector, to select the resource path of the XML file. */
private ConfigurationSelector mConfigSelector;
/** The combo to display the existing XML files or enter a new one. */
private Combo mResFileCombo;
/** Checkbox asking whether to replace in all Java files. */
private Button mReplaceAllJava;
/** Checkbox asking whether to replace in all XML files with same name but other res config */
private Button mReplaceAllXml;
/** Regex pattern to read a valid res XML file path. It checks that the are 2 folders and
* a leaf file name ending with .xml */
private static final Pattern RES_XML_FILE_REGEX = Pattern.compile(
"/res/[a-z][a-zA-Z0-9_-]+/[^.]+\\.xml"); //$NON-NLS-1$
/** Absolute destination folder root, e.g. "/res/" */
private static final String RES_FOLDER_ABS =
AdtConstants.WS_RESOURCES + AdtConstants.WS_SEP;
/** Relative destination folder root, e.g. "res/" */
private static final String RES_FOLDER_REL =
SdkConstants.FD_RESOURCES + AdtConstants.WS_SEP;
private static final String DEFAULT_RES_FILE_PATH = "/res/values/strings.xml"; //$NON-NLS-1$
private XmlStringFileHelper mXmlHelper = new XmlStringFileHelper();
private final OnConfigSelectorUpdated mOnConfigSelectorUpdated = new OnConfigSelectorUpdated();
private ModifyListener mValidateOnModify = new ModifyListener() {
@Override
public void modifyText(ModifyEvent e) {
validatePage();
}
};
private SelectionListener mValidateOnSelection = new SelectionAdapter() {
@Override
public void widgetSelected(SelectionEvent e) {
validatePage();
}
};
public ExtractStringInputPage(IProject project) {
super("ExtractStringInputPage"); //$NON-NLS-1$
mProject = project;
}
/**
* Create the UI for the refactoring wizard.
* <p/>
* Note that at that point the initial conditions have been checked in
* {@link ExtractStringRefactoring}.
* <p/>
*
* Note: the special tag below defines this as the entry point for the WindowsDesigner Editor.
* @wbp.parser.entryPoint
*/
@Override
public void createControl(Composite parent) {
Composite content = new Composite(parent, SWT.NONE);
GridLayout layout = new GridLayout();
content.setLayout(layout);
createStringGroup(content);
createResFileGroup(content);
createOptionGroup(content);
initUi();
setControl(content);
}
/**
* Creates the top group with the field to replace which string and by what
* and by which options.
*
* @param content A composite with a 1-column grid layout
*/
public void createStringGroup(Composite content) {
final ExtractStringRefactoring ref = getOurRefactoring();
Group group = new Group(content, SWT.NONE);
group.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
group.setText("New String");
if (ref.getMode() == ExtractStringRefactoring.Mode.EDIT_SOURCE) {
group.setText("String Replacement");
}
GridLayout layout = new GridLayout();
layout.numColumns = 2;
group.setLayout(layout);
// line: Textfield for string value (based on selection, if any)
Label label = new Label(group, SWT.NONE);
label.setText("&String");
String selectedString = ref.getTokenString();
mStringValueField = new Text(group, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
mStringValueField.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
mStringValueField.setText(selectedString != null ? selectedString : ""); //$NON-NLS-1$
ref.setNewStringValue(mStringValueField.getText());
mStringValueField.addModifyListener(new ModifyListener() {
@Override
public void modifyText(ModifyEvent e) {
validatePage();
}
});
// line : Textfield for new ID
label = new Label(group, SWT.NONE);
label.setText("ID &R.string.");
if (ref.getMode() == ExtractStringRefactoring.Mode.EDIT_SOURCE) {
label.setText("&Replace by R.string.");
} else if (ref.getMode() == ExtractStringRefactoring.Mode.SELECT_NEW_ID) {
label.setText("New &R.string.");
}
mStringIdCombo = new Combo(group, SWT.SINGLE | SWT.LEFT | SWT.BORDER | SWT.DROP_DOWN);
mStringIdCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
mStringIdCombo.setText(guessId(selectedString));
mStringIdCombo.forceFocus();
ref.setNewStringId(mStringIdCombo.getText().trim());
mStringIdCombo.addModifyListener(mValidateOnModify);
mStringIdCombo.addSelectionListener(mValidateOnSelection);
}
/**
* Creates the lower group with the fields to choose the resource confirmation and
* the target XML file.
*
* @param content A composite with a 1-column grid layout
*/
private void createResFileGroup(Composite content) {
Group group = new Group(content, SWT.NONE);
GridData gd = new GridData(GridData.FILL_HORIZONTAL);
gd.grabExcessVerticalSpace = true;
group.setLayoutData(gd);
group.setText("XML resource to edit");
GridLayout layout = new GridLayout();
layout.numColumns = 2;
group.setLayout(layout);
// line: selection of the res config
Label label;
label = new Label(group, SWT.NONE);
label.setText("&Configuration:");
mConfigSelector = new ConfigurationSelector(group, SelectorMode.DEFAULT);
gd = new GridData(GridData.GRAB_HORIZONTAL | GridData.GRAB_VERTICAL);
gd.horizontalSpan = 2;
gd.widthHint = ConfigurationSelector.WIDTH_HINT;
gd.heightHint = ConfigurationSelector.HEIGHT_HINT;
mConfigSelector.setLayoutData(gd);
mConfigSelector.setOnChangeListener(mOnConfigSelectorUpdated);
// line: selection of the output file
label = new Label(group, SWT.NONE);
label.setText("Resource &file:");
mResFileCombo = new Combo(group, SWT.DROP_DOWN);
mResFileCombo.select(0);
mResFileCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
mResFileCombo.addModifyListener(mOnConfigSelectorUpdated);
}
/**
* Creates the bottom option groups with a few checkboxes.
*
* @param content A composite with a 1-column grid layout
*/
private void createOptionGroup(Composite content) {
Group options = new Group(content, SWT.NONE);
options.setText("Options");
GridData gd_Options = new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1);
gd_Options.widthHint = 77;
options.setLayoutData(gd_Options);
options.setLayout(new GridLayout(1, false));
mReplaceAllJava = new Button(options, SWT.CHECK);
mReplaceAllJava.setToolTipText("When checked, the exact same string literal will be replaced in all Java files.");
mReplaceAllJava.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1));
mReplaceAllJava.setText("Replace in all &Java files");
mReplaceAllJava.addSelectionListener(mValidateOnSelection);
mReplaceAllXml = new Button(options, SWT.CHECK);
mReplaceAllXml.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1));
mReplaceAllXml.setToolTipText("When checked, string literals will be replaced in other XML resource files having the same name but located in different resource configuration folders.");
mReplaceAllXml.setText("Replace in all &XML files for different configuration");
mReplaceAllXml.addSelectionListener(mValidateOnSelection);
}
// -- Start of internal part ----------
// Hide everything down-below from WindowsDesigner Editor
//$hide>>$
/**
* Init UI just after it has been created the first time.
*/
private void initUi() {
// set output file name to the last one used
String projPath = mProject.getFullPath().toPortableString();
String filePath = sLastResFilePath.get(projPath);
mResFileCombo.setText(filePath != null ? filePath : DEFAULT_RES_FILE_PATH);
mOnConfigSelectorUpdated.run();
validatePage();
}
/**
* Utility method to guess a suitable new XML ID based on the selected string.
*/
public static String guessId(String text) {
if (text == null) {
return ""; //$NON-NLS-1$
}
// make lower case
text = text.toLowerCase(Locale.US);
// everything not alphanumeric becomes an underscore
text = text.replaceAll("[^a-zA-Z0-9]+", "_"); //$NON-NLS-1$ //$NON-NLS-2$
// the id must be a proper Java identifier, so it can't start with a number
if (text.length() > 0 && !Character.isJavaIdentifierStart(text.charAt(0))) {
text = "_" + text; //$NON-NLS-1$
}
return text;
}
/**
* Returns the {@link ExtractStringRefactoring} instance used by this wizard page.
*/
private ExtractStringRefactoring getOurRefactoring() {
return (ExtractStringRefactoring) getRefactoring();
}
/**
* Validates fields of the wizard input page. Displays errors as appropriate and
* enable the "Next" button (or not) by calling {@link #setPageComplete(boolean)}.
*
* If validation succeeds, this updates the text id & value in the refactoring object.
*
* @return True if the page has been positively validated. It may still have warnings.
*/
private boolean validatePage() {
boolean success = true;
ExtractStringRefactoring ref = getOurRefactoring();
ref.setReplaceAllJava(mReplaceAllJava.getSelection());
ref.setReplaceAllXml(mReplaceAllXml.isEnabled() && mReplaceAllXml.getSelection());
// Analyze fatal errors.
String text = mStringIdCombo.getText().trim();
if (text == null || text.length() < 1) {
setErrorMessage("Please provide a resource ID.");
success = false;
} else {
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
boolean ok = i == 0 ?
Character.isJavaIdentifierStart(c) :
Character.isJavaIdentifierPart(c);
if (!ok) {
setErrorMessage(String.format(
"The resource ID must be a valid Java identifier. The character %1$c at position %2$d is not acceptable.",
c, i+1));
success = false;
break;
}
}
// update the field in the refactoring object in case of success
if (success) {
ref.setNewStringId(text);
}
}
String resFile = mResFileCombo.getText();
if (success) {
if (resFile == null || resFile.length() == 0) {
setErrorMessage("A resource file name is required.");
success = false;
} else if (!RES_XML_FILE_REGEX.matcher(resFile).matches()) {
setErrorMessage("The XML file name is not valid.");
success = false;
}
}
// Analyze info & warnings.
if (success) {
setErrorMessage(null);
ref.setTargetFile(resFile);
sLastResFilePath.put(mProject.getFullPath().toPortableString(), resFile);
String idValue = mXmlHelper.valueOfStringId(mProject, resFile, text);
if (idValue != null) {
String msg = String.format("%1$s already contains a string ID '%2$s' with value '%3$s'.",
resFile,
text,
idValue);
if (ref.getMode() == ExtractStringRefactoring.Mode.SELECT_NEW_ID) {
setErrorMessage(msg);
success = false;
} else {
setMessage(msg, WizardPage.WARNING);
}
} else if (mProject.findMember(resFile) == null) {
setMessage(
String.format("File %2$s does not exist and will be created.",
text, resFile),
WizardPage.INFORMATION);
} else {
setMessage(null);
}
}
if (success) {
// Also update the text value in case of success.
ref.setNewStringValue(mStringValueField.getText());
}
setPageComplete(success);
return success;
}
private void updateStringValueCombo() {
String resFile = mResFileCombo.getText();
Map<String, String> ids = mXmlHelper.getResIdsForFile(mProject, resFile);
// get the current text from the combo, to make sure we don't change it
String currText = mStringIdCombo.getText();
// erase the choices and fill with the given ids
mStringIdCombo.removeAll();
mStringIdCombo.setItems(ids.keySet().toArray(new String[ids.size()]));
// set the current text to preserve it in case it changed
if (!currText.equals(mStringIdCombo.getText())) {
mStringIdCombo.setText(currText);
}
}
private class OnConfigSelectorUpdated implements Runnable, ModifyListener {
/** Regex pattern to parse a valid res path: it reads (/res/folder-name/)+(filename). */
private final Pattern mPathRegex = Pattern.compile(
"(/res/[a-z][a-zA-Z0-9_-]+/)(.+)"); //$NON-NLS-1$
/** Temporary config object used to retrieve the Config Selector value. */
private FolderConfiguration mTempConfig = new FolderConfiguration();
private HashMap<String, TreeSet<String>> mFolderCache =
new HashMap<String, TreeSet<String>>();
private String mLastFolderUsedInCombo = null;
private boolean mInternalConfigChange;
private boolean mInternalFileComboChange;
/**
* Callback invoked when the {@link ConfigurationSelector} has been changed.
* <p/>
* The callback does the following:
* <ul>
* <li> Examine the current file name to retrieve the XML filename, if any.
* <li> Recompute the path based on the configuration selector (e.g. /res/values-fr/).
* <li> Examine the path to retrieve all the files in it. Keep those in a local cache.
* <li> If the XML filename from step 1 is not in the file list, it's a custom file name.
* Insert it and sort it.
* <li> Re-populate the file combo with all the choices.
* <li> Select the original XML file.
*/
@Override
public void run() {
if (mInternalConfigChange) {
return;
}
// get current leafname, if any
String leafName = ""; //$NON-NLS-1$
String currPath = mResFileCombo.getText();
Matcher m = mPathRegex.matcher(currPath);
if (m.matches()) {
// Note: groups 1 and 2 cannot be null.
leafName = m.group(2);
currPath = m.group(1);
} else {
// There was a path but it was invalid. Ignore it.
currPath = ""; //$NON-NLS-1$
}
// recreate the res path from the current configuration
mConfigSelector.getConfiguration(mTempConfig);
StringBuffer sb = new StringBuffer(RES_FOLDER_ABS);
sb.append(mTempConfig.getFolderName(ResourceFolderType.VALUES));
sb.append(AdtConstants.WS_SEP);
String newPath = sb.toString();
if (newPath.equals(currPath) && newPath.equals(mLastFolderUsedInCombo)) {
// Path has not changed. No need to reload.
return;
}
// Get all the files at the new path
TreeSet<String> filePaths = mFolderCache.get(newPath);
if (filePaths == null) {
filePaths = new TreeSet<String>();
IFolder folder = mProject.getFolder(newPath);
if (folder != null && folder.exists()) {
try {
for (IResource res : folder.members()) {
String name = res.getName();
if (res.getType() == IResource.FILE && name.endsWith(".xml")) {
filePaths.add(newPath + name);
}
}
} catch (CoreException e) {
// Ignore.
}
}
mFolderCache.put(newPath, filePaths);
}
currPath = newPath + leafName;
if (leafName.length() > 0 && !filePaths.contains(currPath)) {
filePaths.add(currPath);
}
// Fill the combo
try {
mInternalFileComboChange = true;
mResFileCombo.removeAll();
for (String filePath : filePaths) {
mResFileCombo.add(filePath);
}
int index = -1;
if (leafName.length() > 0) {
index = mResFileCombo.indexOf(currPath);
if (index >= 0) {
mResFileCombo.select(index);
}
}
if (index == -1) {
mResFileCombo.setText(currPath);
}
mLastFolderUsedInCombo = newPath;
} finally {
mInternalFileComboChange = false;
}
// finally validate the whole page
updateStringValueCombo();
validatePage();
}
/**
* Callback invoked when {@link ExtractStringInputPage#mResFileCombo} has been
* modified.
*/
@Override
public void modifyText(ModifyEvent e) {
if (mInternalFileComboChange) {
return;
}
String wsFolderPath = mResFileCombo.getText();
// This is a custom path, we need to sanitize it.
// First it should start with "/res/". Then we need to make sure there are no
// relative paths, things like "../" or "./" or even "//".
wsFolderPath = wsFolderPath.replaceAll("/+\\.\\./+|/+\\./+|//+|\\\\+|^/+", "/"); //$NON-NLS-1$ //$NON-NLS-2$
wsFolderPath = wsFolderPath.replaceAll("^\\.\\./+|^\\./+", ""); //$NON-NLS-1$ //$NON-NLS-2$
wsFolderPath = wsFolderPath.replaceAll("/+\\.\\.$|/+\\.$|/+$", ""); //$NON-NLS-1$ //$NON-NLS-2$
// We get "res/foo" from selections relative to the project when we want a "/res/foo" path.
if (wsFolderPath.startsWith(RES_FOLDER_REL)) {
wsFolderPath = RES_FOLDER_ABS + wsFolderPath.substring(RES_FOLDER_REL.length());
mInternalFileComboChange = true;
mResFileCombo.setText(wsFolderPath);
mInternalFileComboChange = false;
}
if (wsFolderPath.startsWith(RES_FOLDER_ABS)) {
wsFolderPath = wsFolderPath.substring(RES_FOLDER_ABS.length());
int pos = wsFolderPath.indexOf(AdtConstants.WS_SEP_CHAR);
if (pos >= 0) {
wsFolderPath = wsFolderPath.substring(0, pos);
}
String[] folderSegments = wsFolderPath.split(SdkConstants.RES_QUALIFIER_SEP);
if (folderSegments.length > 0) {
String folderName = folderSegments[0];
if (folderName != null && !folderName.equals(wsFolderPath)) {
// update config selector
mInternalConfigChange = true;
mConfigSelector.setConfiguration(folderSegments);
mInternalConfigChange = false;
}
}
}
updateStringValueCombo();
validatePage();
}
}
// End of hiding from SWT Designer
//$hide<<$
}