1 /* 2 * Copyright (C) 2009 The Android Open Source Project 3 * 4 * Licensed under the Eclipse Public License, Version 1.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.eclipse.org/org/documents/epl-v10.php 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.ide.eclipse.adt.internal.refactorings.extractstring; 18 19 20 import com.android.SdkConstants; 21 import com.android.ide.common.resources.configuration.FolderConfiguration; 22 import com.android.ide.eclipse.adt.AdtConstants; 23 import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector; 24 import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector.SelectorMode; 25 import com.android.resources.ResourceFolderType; 26 27 import org.eclipse.core.resources.IFolder; 28 import org.eclipse.core.resources.IProject; 29 import org.eclipse.core.resources.IResource; 30 import org.eclipse.core.runtime.CoreException; 31 import org.eclipse.jface.wizard.WizardPage; 32 import org.eclipse.ltk.ui.refactoring.UserInputWizardPage; 33 import org.eclipse.swt.SWT; 34 import org.eclipse.swt.events.ModifyEvent; 35 import org.eclipse.swt.events.ModifyListener; 36 import org.eclipse.swt.events.SelectionAdapter; 37 import org.eclipse.swt.events.SelectionEvent; 38 import org.eclipse.swt.events.SelectionListener; 39 import org.eclipse.swt.layout.GridData; 40 import org.eclipse.swt.layout.GridLayout; 41 import org.eclipse.swt.widgets.Button; 42 import org.eclipse.swt.widgets.Combo; 43 import org.eclipse.swt.widgets.Composite; 44 import org.eclipse.swt.widgets.Group; 45 import org.eclipse.swt.widgets.Label; 46 import org.eclipse.swt.widgets.Text; 47 48 import java.util.HashMap; 49 import java.util.Locale; 50 import java.util.Map; 51 import java.util.TreeSet; 52 import java.util.regex.Matcher; 53 import java.util.regex.Pattern; 54 55 /** 56 * @see ExtractStringRefactoring 57 */ 58 class ExtractStringInputPage extends UserInputWizardPage { 59 60 /** Last res file path used, shared across the session instances but specific to the 61 * current project. The default for unknown projects is {@link #DEFAULT_RES_FILE_PATH}. */ 62 private static HashMap<String, String> sLastResFilePath = new HashMap<String, String>(); 63 64 /** The project where the user selection happened. */ 65 private final IProject mProject; 66 67 /** Text field where the user enters the new ID to be generated or replaced with. */ 68 private Combo mStringIdCombo; 69 /** Text field where the user enters the new string value. */ 70 private Text mStringValueField; 71 /** The configuration selector, to select the resource path of the XML file. */ 72 private ConfigurationSelector mConfigSelector; 73 /** The combo to display the existing XML files or enter a new one. */ 74 private Combo mResFileCombo; 75 /** Checkbox asking whether to replace in all Java files. */ 76 private Button mReplaceAllJava; 77 /** Checkbox asking whether to replace in all XML files with same name but other res config */ 78 private Button mReplaceAllXml; 79 80 /** Regex pattern to read a valid res XML file path. It checks that the are 2 folders and 81 * a leaf file name ending with .xml */ 82 private static final Pattern RES_XML_FILE_REGEX = Pattern.compile( 83 "/res/[a-z][a-zA-Z0-9_-]+/[^.]+\\.xml"); //$NON-NLS-1$ 84 /** Absolute destination folder root, e.g. "/res/" */ 85 private static final String RES_FOLDER_ABS = 86 AdtConstants.WS_RESOURCES + AdtConstants.WS_SEP; 87 /** Relative destination folder root, e.g. "res/" */ 88 private static final String RES_FOLDER_REL = 89 SdkConstants.FD_RESOURCES + AdtConstants.WS_SEP; 90 91 private static final String DEFAULT_RES_FILE_PATH = "/res/values/strings.xml"; //$NON-NLS-1$ 92 93 private XmlStringFileHelper mXmlHelper = new XmlStringFileHelper(); 94 95 private final OnConfigSelectorUpdated mOnConfigSelectorUpdated = new OnConfigSelectorUpdated(); 96 97 private ModifyListener mValidateOnModify = new ModifyListener() { 98 @Override 99 public void modifyText(ModifyEvent e) { 100 validatePage(); 101 } 102 }; 103 104 private SelectionListener mValidateOnSelection = new SelectionAdapter() { 105 @Override 106 public void widgetSelected(SelectionEvent e) { 107 validatePage(); 108 } 109 }; 110 ExtractStringInputPage(IProject project)111 public ExtractStringInputPage(IProject project) { 112 super("ExtractStringInputPage"); //$NON-NLS-1$ 113 mProject = project; 114 } 115 116 /** 117 * Create the UI for the refactoring wizard. 118 * <p/> 119 * Note that at that point the initial conditions have been checked in 120 * {@link ExtractStringRefactoring}. 121 * <p/> 122 * 123 * Note: the special tag below defines this as the entry point for the WindowsDesigner Editor. 124 * @wbp.parser.entryPoint 125 */ 126 @Override createControl(Composite parent)127 public void createControl(Composite parent) { 128 Composite content = new Composite(parent, SWT.NONE); 129 GridLayout layout = new GridLayout(); 130 content.setLayout(layout); 131 132 createStringGroup(content); 133 createResFileGroup(content); 134 createOptionGroup(content); 135 136 initUi(); 137 setControl(content); 138 } 139 140 /** 141 * Creates the top group with the field to replace which string and by what 142 * and by which options. 143 * 144 * @param content A composite with a 1-column grid layout 145 */ createStringGroup(Composite content)146 public void createStringGroup(Composite content) { 147 148 final ExtractStringRefactoring ref = getOurRefactoring(); 149 150 Group group = new Group(content, SWT.NONE); 151 group.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 152 group.setText("New String"); 153 if (ref.getMode() == ExtractStringRefactoring.Mode.EDIT_SOURCE) { 154 group.setText("String Replacement"); 155 } 156 157 GridLayout layout = new GridLayout(); 158 layout.numColumns = 2; 159 group.setLayout(layout); 160 161 // line: Textfield for string value (based on selection, if any) 162 163 Label label = new Label(group, SWT.NONE); 164 label.setText("&String"); 165 166 String selectedString = ref.getTokenString(); 167 168 mStringValueField = new Text(group, SWT.SINGLE | SWT.LEFT | SWT.BORDER); 169 mStringValueField.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 170 mStringValueField.setText(selectedString != null ? selectedString : ""); //$NON-NLS-1$ 171 172 ref.setNewStringValue(mStringValueField.getText()); 173 174 mStringValueField.addModifyListener(new ModifyListener() { 175 @Override 176 public void modifyText(ModifyEvent e) { 177 validatePage(); 178 } 179 }); 180 181 // line : Textfield for new ID 182 183 label = new Label(group, SWT.NONE); 184 label.setText("ID &R.string."); 185 if (ref.getMode() == ExtractStringRefactoring.Mode.EDIT_SOURCE) { 186 label.setText("&Replace by R.string."); 187 } else if (ref.getMode() == ExtractStringRefactoring.Mode.SELECT_NEW_ID) { 188 label.setText("New &R.string."); 189 } 190 191 mStringIdCombo = new Combo(group, SWT.SINGLE | SWT.LEFT | SWT.BORDER | SWT.DROP_DOWN); 192 mStringIdCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 193 mStringIdCombo.setText(guessId(selectedString)); 194 mStringIdCombo.forceFocus(); 195 196 ref.setNewStringId(mStringIdCombo.getText().trim()); 197 198 mStringIdCombo.addModifyListener(mValidateOnModify); 199 mStringIdCombo.addSelectionListener(mValidateOnSelection); 200 } 201 202 /** 203 * Creates the lower group with the fields to choose the resource confirmation and 204 * the target XML file. 205 * 206 * @param content A composite with a 1-column grid layout 207 */ createResFileGroup(Composite content)208 private void createResFileGroup(Composite content) { 209 210 Group group = new Group(content, SWT.NONE); 211 GridData gd = new GridData(GridData.FILL_HORIZONTAL); 212 gd.grabExcessVerticalSpace = true; 213 group.setLayoutData(gd); 214 group.setText("XML resource to edit"); 215 216 GridLayout layout = new GridLayout(); 217 layout.numColumns = 2; 218 group.setLayout(layout); 219 220 // line: selection of the res config 221 222 Label label; 223 label = new Label(group, SWT.NONE); 224 label.setText("&Configuration:"); 225 226 mConfigSelector = new ConfigurationSelector(group, SelectorMode.DEFAULT); 227 gd = new GridData(GridData.GRAB_HORIZONTAL | GridData.GRAB_VERTICAL); 228 gd.horizontalSpan = 2; 229 gd.widthHint = ConfigurationSelector.WIDTH_HINT; 230 gd.heightHint = ConfigurationSelector.HEIGHT_HINT; 231 mConfigSelector.setLayoutData(gd); 232 mConfigSelector.setOnChangeListener(mOnConfigSelectorUpdated); 233 234 // line: selection of the output file 235 236 label = new Label(group, SWT.NONE); 237 label.setText("Resource &file:"); 238 239 mResFileCombo = new Combo(group, SWT.DROP_DOWN); 240 mResFileCombo.select(0); 241 mResFileCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 242 mResFileCombo.addModifyListener(mOnConfigSelectorUpdated); 243 } 244 245 /** 246 * Creates the bottom option groups with a few checkboxes. 247 * 248 * @param content A composite with a 1-column grid layout 249 */ createOptionGroup(Composite content)250 private void createOptionGroup(Composite content) { 251 Group options = new Group(content, SWT.NONE); 252 options.setText("Options"); 253 GridData gd_Options = new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1); 254 gd_Options.widthHint = 77; 255 options.setLayoutData(gd_Options); 256 options.setLayout(new GridLayout(1, false)); 257 258 mReplaceAllJava = new Button(options, SWT.CHECK); 259 mReplaceAllJava.setToolTipText("When checked, the exact same string literal will be replaced in all Java files."); 260 mReplaceAllJava.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); 261 mReplaceAllJava.setText("Replace in all &Java files"); 262 mReplaceAllJava.addSelectionListener(mValidateOnSelection); 263 264 mReplaceAllXml = new Button(options, SWT.CHECK); 265 mReplaceAllXml.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1)); 266 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."); 267 mReplaceAllXml.setText("Replace in all &XML files for different configuration"); 268 mReplaceAllXml.addSelectionListener(mValidateOnSelection); 269 } 270 271 // -- Start of internal part ---------- 272 // Hide everything down-below from WindowsDesigner Editor 273 //$hide>>$ 274 275 /** 276 * Init UI just after it has been created the first time. 277 */ initUi()278 private void initUi() { 279 // set output file name to the last one used 280 String projPath = mProject.getFullPath().toPortableString(); 281 String filePath = sLastResFilePath.get(projPath); 282 283 mResFileCombo.setText(filePath != null ? filePath : DEFAULT_RES_FILE_PATH); 284 mOnConfigSelectorUpdated.run(); 285 validatePage(); 286 } 287 288 /** 289 * Utility method to guess a suitable new XML ID based on the selected string. 290 */ guessId(String text)291 public static String guessId(String text) { 292 if (text == null) { 293 return ""; //$NON-NLS-1$ 294 } 295 296 // make lower case 297 text = text.toLowerCase(Locale.US); 298 299 // everything not alphanumeric becomes an underscore 300 text = text.replaceAll("[^a-zA-Z0-9]+", "_"); //$NON-NLS-1$ //$NON-NLS-2$ 301 302 // the id must be a proper Java identifier, so it can't start with a number 303 if (text.length() > 0 && !Character.isJavaIdentifierStart(text.charAt(0))) { 304 text = "_" + text; //$NON-NLS-1$ 305 } 306 return text; 307 } 308 309 /** 310 * Returns the {@link ExtractStringRefactoring} instance used by this wizard page. 311 */ getOurRefactoring()312 private ExtractStringRefactoring getOurRefactoring() { 313 return (ExtractStringRefactoring) getRefactoring(); 314 } 315 316 /** 317 * Validates fields of the wizard input page. Displays errors as appropriate and 318 * enable the "Next" button (or not) by calling {@link #setPageComplete(boolean)}. 319 * 320 * If validation succeeds, this updates the text id & value in the refactoring object. 321 * 322 * @return True if the page has been positively validated. It may still have warnings. 323 */ validatePage()324 private boolean validatePage() { 325 boolean success = true; 326 327 ExtractStringRefactoring ref = getOurRefactoring(); 328 329 ref.setReplaceAllJava(mReplaceAllJava.getSelection()); 330 ref.setReplaceAllXml(mReplaceAllXml.isEnabled() && mReplaceAllXml.getSelection()); 331 332 // Analyze fatal errors. 333 334 String text = mStringIdCombo.getText().trim(); 335 if (text == null || text.length() < 1) { 336 setErrorMessage("Please provide a resource ID."); 337 success = false; 338 } else { 339 for (int i = 0; i < text.length(); i++) { 340 char c = text.charAt(i); 341 boolean ok = i == 0 ? 342 Character.isJavaIdentifierStart(c) : 343 Character.isJavaIdentifierPart(c); 344 if (!ok) { 345 setErrorMessage(String.format( 346 "The resource ID must be a valid Java identifier. The character %1$c at position %2$d is not acceptable.", 347 c, i+1)); 348 success = false; 349 break; 350 } 351 } 352 353 // update the field in the refactoring object in case of success 354 if (success) { 355 ref.setNewStringId(text); 356 } 357 } 358 359 String resFile = mResFileCombo.getText(); 360 if (success) { 361 if (resFile == null || resFile.length() == 0) { 362 setErrorMessage("A resource file name is required."); 363 success = false; 364 } else if (!RES_XML_FILE_REGEX.matcher(resFile).matches()) { 365 setErrorMessage("The XML file name is not valid."); 366 success = false; 367 } 368 } 369 370 // Analyze info & warnings. 371 372 if (success) { 373 setErrorMessage(null); 374 375 ref.setTargetFile(resFile); 376 sLastResFilePath.put(mProject.getFullPath().toPortableString(), resFile); 377 378 String idValue = mXmlHelper.valueOfStringId(mProject, resFile, text); 379 if (idValue != null) { 380 String msg = String.format("%1$s already contains a string ID '%2$s' with value '%3$s'.", 381 resFile, 382 text, 383 idValue); 384 if (ref.getMode() == ExtractStringRefactoring.Mode.SELECT_NEW_ID) { 385 setErrorMessage(msg); 386 success = false; 387 } else { 388 setMessage(msg, WizardPage.WARNING); 389 } 390 } else if (mProject.findMember(resFile) == null) { 391 setMessage( 392 String.format("File %2$s does not exist and will be created.", 393 text, resFile), 394 WizardPage.INFORMATION); 395 } else { 396 setMessage(null); 397 } 398 } 399 400 if (success) { 401 // Also update the text value in case of success. 402 ref.setNewStringValue(mStringValueField.getText()); 403 } 404 405 setPageComplete(success); 406 return success; 407 } 408 updateStringValueCombo()409 private void updateStringValueCombo() { 410 String resFile = mResFileCombo.getText(); 411 Map<String, String> ids = mXmlHelper.getResIdsForFile(mProject, resFile); 412 413 // get the current text from the combo, to make sure we don't change it 414 String currText = mStringIdCombo.getText(); 415 416 // erase the choices and fill with the given ids 417 mStringIdCombo.removeAll(); 418 mStringIdCombo.setItems(ids.keySet().toArray(new String[ids.size()])); 419 420 // set the current text to preserve it in case it changed 421 if (!currText.equals(mStringIdCombo.getText())) { 422 mStringIdCombo.setText(currText); 423 } 424 } 425 426 private class OnConfigSelectorUpdated implements Runnable, ModifyListener { 427 428 /** Regex pattern to parse a valid res path: it reads (/res/folder-name/)+(filename). */ 429 private final Pattern mPathRegex = Pattern.compile( 430 "(/res/[a-z][a-zA-Z0-9_-]+/)(.+)"); //$NON-NLS-1$ 431 432 /** Temporary config object used to retrieve the Config Selector value. */ 433 private FolderConfiguration mTempConfig = new FolderConfiguration(); 434 435 private HashMap<String, TreeSet<String>> mFolderCache = 436 new HashMap<String, TreeSet<String>>(); 437 private String mLastFolderUsedInCombo = null; 438 private boolean mInternalConfigChange; 439 private boolean mInternalFileComboChange; 440 441 /** 442 * Callback invoked when the {@link ConfigurationSelector} has been changed. 443 * <p/> 444 * The callback does the following: 445 * <ul> 446 * <li> Examine the current file name to retrieve the XML filename, if any. 447 * <li> Recompute the path based on the configuration selector (e.g. /res/values-fr/). 448 * <li> Examine the path to retrieve all the files in it. Keep those in a local cache. 449 * <li> If the XML filename from step 1 is not in the file list, it's a custom file name. 450 * Insert it and sort it. 451 * <li> Re-populate the file combo with all the choices. 452 * <li> Select the original XML file. 453 */ 454 @Override run()455 public void run() { 456 if (mInternalConfigChange) { 457 return; 458 } 459 460 // get current leafname, if any 461 String leafName = ""; //$NON-NLS-1$ 462 String currPath = mResFileCombo.getText(); 463 Matcher m = mPathRegex.matcher(currPath); 464 if (m.matches()) { 465 // Note: groups 1 and 2 cannot be null. 466 leafName = m.group(2); 467 currPath = m.group(1); 468 } else { 469 // There was a path but it was invalid. Ignore it. 470 currPath = ""; //$NON-NLS-1$ 471 } 472 473 // recreate the res path from the current configuration 474 mConfigSelector.getConfiguration(mTempConfig); 475 StringBuffer sb = new StringBuffer(RES_FOLDER_ABS); 476 sb.append(mTempConfig.getFolderName(ResourceFolderType.VALUES)); 477 sb.append(AdtConstants.WS_SEP); 478 479 String newPath = sb.toString(); 480 481 if (newPath.equals(currPath) && newPath.equals(mLastFolderUsedInCombo)) { 482 // Path has not changed. No need to reload. 483 return; 484 } 485 486 // Get all the files at the new path 487 488 TreeSet<String> filePaths = mFolderCache.get(newPath); 489 490 if (filePaths == null) { 491 filePaths = new TreeSet<String>(); 492 493 IFolder folder = mProject.getFolder(newPath); 494 if (folder != null && folder.exists()) { 495 try { 496 for (IResource res : folder.members()) { 497 String name = res.getName(); 498 if (res.getType() == IResource.FILE && name.endsWith(".xml")) { 499 filePaths.add(newPath + name); 500 } 501 } 502 } catch (CoreException e) { 503 // Ignore. 504 } 505 } 506 507 mFolderCache.put(newPath, filePaths); 508 } 509 510 currPath = newPath + leafName; 511 if (leafName.length() > 0 && !filePaths.contains(currPath)) { 512 filePaths.add(currPath); 513 } 514 515 // Fill the combo 516 try { 517 mInternalFileComboChange = true; 518 519 mResFileCombo.removeAll(); 520 521 for (String filePath : filePaths) { 522 mResFileCombo.add(filePath); 523 } 524 525 int index = -1; 526 if (leafName.length() > 0) { 527 index = mResFileCombo.indexOf(currPath); 528 if (index >= 0) { 529 mResFileCombo.select(index); 530 } 531 } 532 533 if (index == -1) { 534 mResFileCombo.setText(currPath); 535 } 536 537 mLastFolderUsedInCombo = newPath; 538 539 } finally { 540 mInternalFileComboChange = false; 541 } 542 543 // finally validate the whole page 544 updateStringValueCombo(); 545 validatePage(); 546 } 547 548 /** 549 * Callback invoked when {@link ExtractStringInputPage#mResFileCombo} has been 550 * modified. 551 */ 552 @Override modifyText(ModifyEvent e)553 public void modifyText(ModifyEvent e) { 554 if (mInternalFileComboChange) { 555 return; 556 } 557 558 String wsFolderPath = mResFileCombo.getText(); 559 560 // This is a custom path, we need to sanitize it. 561 // First it should start with "/res/". Then we need to make sure there are no 562 // relative paths, things like "../" or "./" or even "//". 563 wsFolderPath = wsFolderPath.replaceAll("/+\\.\\./+|/+\\./+|//+|\\\\+|^/+", "/"); //$NON-NLS-1$ //$NON-NLS-2$ 564 wsFolderPath = wsFolderPath.replaceAll("^\\.\\./+|^\\./+", ""); //$NON-NLS-1$ //$NON-NLS-2$ 565 wsFolderPath = wsFolderPath.replaceAll("/+\\.\\.$|/+\\.$|/+$", ""); //$NON-NLS-1$ //$NON-NLS-2$ 566 567 // We get "res/foo" from selections relative to the project when we want a "/res/foo" path. 568 if (wsFolderPath.startsWith(RES_FOLDER_REL)) { 569 wsFolderPath = RES_FOLDER_ABS + wsFolderPath.substring(RES_FOLDER_REL.length()); 570 571 mInternalFileComboChange = true; 572 mResFileCombo.setText(wsFolderPath); 573 mInternalFileComboChange = false; 574 } 575 576 if (wsFolderPath.startsWith(RES_FOLDER_ABS)) { 577 wsFolderPath = wsFolderPath.substring(RES_FOLDER_ABS.length()); 578 579 int pos = wsFolderPath.indexOf(AdtConstants.WS_SEP_CHAR); 580 if (pos >= 0) { 581 wsFolderPath = wsFolderPath.substring(0, pos); 582 } 583 584 String[] folderSegments = wsFolderPath.split(SdkConstants.RES_QUALIFIER_SEP); 585 586 if (folderSegments.length > 0) { 587 String folderName = folderSegments[0]; 588 589 if (folderName != null && !folderName.equals(wsFolderPath)) { 590 // update config selector 591 mInternalConfigChange = true; 592 mConfigSelector.setConfiguration(folderSegments); 593 mInternalConfigChange = false; 594 } 595 } 596 } 597 598 updateStringValueCombo(); 599 validatePage(); 600 } 601 } 602 603 // End of hiding from SWT Designer 604 //$hide<<$ 605 606 } 607