1 /*
2  * Copyright (C) 2010 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.editors;
18 
19 import com.android.ide.eclipse.adt.AdtPlugin;
20 
21 import org.eclipse.core.internal.filebuffers.SynchronizableDocument;
22 import org.eclipse.core.resources.IFile;
23 import org.eclipse.core.resources.IProject;
24 import org.eclipse.core.resources.IResource;
25 import org.eclipse.core.resources.IResourceChangeEvent;
26 import org.eclipse.core.resources.IResourceChangeListener;
27 import org.eclipse.core.resources.ResourcesPlugin;
28 import org.eclipse.core.runtime.CoreException;
29 import org.eclipse.core.runtime.IProgressMonitor;
30 import org.eclipse.core.runtime.QualifiedName;
31 import org.eclipse.jface.action.IAction;
32 import org.eclipse.jface.dialogs.ErrorDialog;
33 import org.eclipse.jface.text.DocumentEvent;
34 import org.eclipse.jface.text.DocumentRewriteSession;
35 import org.eclipse.jface.text.DocumentRewriteSessionType;
36 import org.eclipse.jface.text.IDocument;
37 import org.eclipse.jface.text.IDocumentExtension4;
38 import org.eclipse.jface.text.IDocumentListener;
39 import org.eclipse.swt.widgets.Display;
40 import org.eclipse.ui.IActionBars;
41 import org.eclipse.ui.IEditorInput;
42 import org.eclipse.ui.IEditorPart;
43 import org.eclipse.ui.IEditorSite;
44 import org.eclipse.ui.IFileEditorInput;
45 import org.eclipse.ui.IWorkbenchPage;
46 import org.eclipse.ui.PartInitException;
47 import org.eclipse.ui.actions.ActionFactory;
48 import org.eclipse.ui.browser.IWorkbenchBrowserSupport;
49 import org.eclipse.ui.editors.text.TextEditor;
50 import org.eclipse.ui.forms.IManagedForm;
51 import org.eclipse.ui.forms.editor.FormEditor;
52 import org.eclipse.ui.forms.editor.IFormPage;
53 import org.eclipse.ui.forms.events.HyperlinkAdapter;
54 import org.eclipse.ui.forms.events.HyperlinkEvent;
55 import org.eclipse.ui.forms.events.IHyperlinkListener;
56 import org.eclipse.ui.forms.widgets.FormText;
57 import org.eclipse.ui.internal.browser.WorkbenchBrowserSupport;
58 import org.eclipse.ui.part.FileEditorInput;
59 import org.eclipse.ui.part.MultiPageEditorPart;
60 import org.eclipse.ui.part.WorkbenchPart;
61 import org.eclipse.ui.texteditor.IDocumentProvider;
62 import org.eclipse.wst.sse.ui.StructuredTextEditor;
63 
64 import java.net.MalformedURLException;
65 import java.net.URL;
66 
67 /**
68  * Multi-page form editor for Android text files.
69  * <p/>
70  * It is designed to work with a {@link TextEditor} that will display a text file.
71  * <br/>
72  * Derived classes must implement createFormPages to create the forms before the
73  * source editor. This can be a no-op if desired.
74  */
75 @SuppressWarnings("restriction")
76 public abstract class AndroidTextEditor extends FormEditor implements IResourceChangeListener {
77 
78     /** Preference name for the current page of this file */
79     private static final String PREF_CURRENT_PAGE = "_current_page";
80 
81     /** Id string used to create the Android SDK browser */
82     private static String BROWSER_ID = "android"; //$NON-NLS-1$
83 
84     /** Page id of the XML source editor, used for switching tabs programmatically */
85     public final static String TEXT_EDITOR_ID = "editor_part"; //$NON-NLS-1$
86 
87     /** Width hint for text fields. Helps the grid layout resize properly on smaller screens */
88     public static final int TEXT_WIDTH_HINT = 50;
89 
90     /** Page index of the text editor (always the last page) */
91     private int mTextPageIndex;
92 
93     /** The text editor */
94     private TextEditor mTextEditor;
95 
96     /** flag set during page creation */
97     private boolean mIsCreatingPage = false;
98 
99     private IDocument mDocument;
100 
101     /**
102      * Creates a form editor.
103      */
AndroidTextEditor()104     public AndroidTextEditor() {
105         super();
106     }
107 
108     // ---- Abstract Methods ----
109 
110     /**
111      * Creates the various form pages.
112      * <p/>
113      * Derived classes must implement this to add their own specific tabs.
114      */
createFormPages()115     abstract protected void createFormPages();
116 
117     /**
118      * Called by the base class {@link AndroidTextEditor} once all pages (custom form pages
119      * as well as text editor page) have been created. This give a chance to deriving
120      * classes to adjust behavior once the text page has been created.
121      */
postCreatePages()122     protected void postCreatePages() {
123         // Nothing in the base class.
124     }
125 
126     /**
127      * Subclasses should override this method to process the new text model.
128      * This is called after the document has been edited.
129      *
130      * The base implementation is empty.
131      *
132      * @param event Specification of changes applied to document.
133      */
onDocumentChanged(DocumentEvent event)134     protected void onDocumentChanged(DocumentEvent event) {
135         // pass
136     }
137 
138     // ---- Base Class Overrides, Interfaces Implemented ----
139 
140     /**
141      * Creates the pages of the multi-page editor.
142      */
143     @Override
addPages()144     protected void addPages() {
145         createAndroidPages();
146         selectDefaultPage(null /* defaultPageId */);
147     }
148 
149     /**
150      * Creates the page for the Android Editors
151      */
createAndroidPages()152     protected void createAndroidPages() {
153         mIsCreatingPage = true;
154         createFormPages();
155         createTextEditor();
156         createUndoRedoActions();
157         postCreatePages();
158         mIsCreatingPage = false;
159     }
160 
161     /**
162      * Returns whether the editor is currently creating its pages.
163      */
isCreatingPages()164     public boolean isCreatingPages() {
165         return mIsCreatingPage;
166     }
167 
168     /**
169      * Creates undo redo actions for the editor site (so that it works for any page of this
170      * multi-page editor) by re-using the actions defined by the {@link TextEditor}
171      * (aka the XML text editor.)
172      */
createUndoRedoActions()173     private void createUndoRedoActions() {
174         IActionBars bars = getEditorSite().getActionBars();
175         if (bars != null) {
176             IAction action = mTextEditor.getAction(ActionFactory.UNDO.getId());
177             bars.setGlobalActionHandler(ActionFactory.UNDO.getId(), action);
178 
179             action = mTextEditor.getAction(ActionFactory.REDO.getId());
180             bars.setGlobalActionHandler(ActionFactory.REDO.getId(), action);
181 
182             bars.updateActionBars();
183         }
184     }
185 
186     /**
187      * Selects the default active page.
188      * @param defaultPageId the id of the page to show. If <code>null</code> the editor attempts to
189      * find the default page in the properties of the {@link IResource} object being edited.
190      */
selectDefaultPage(String defaultPageId)191     protected void selectDefaultPage(String defaultPageId) {
192         if (defaultPageId == null) {
193             if (getEditorInput() instanceof IFileEditorInput) {
194                 IFile file = ((IFileEditorInput) getEditorInput()).getFile();
195 
196                 QualifiedName qname = new QualifiedName(AdtPlugin.PLUGIN_ID,
197                         getClass().getSimpleName() + PREF_CURRENT_PAGE);
198                 String pageId;
199                 try {
200                     pageId = file.getPersistentProperty(qname);
201                     if (pageId != null) {
202                         defaultPageId = pageId;
203                     }
204                 } catch (CoreException e) {
205                     // ignored
206                 }
207             }
208         }
209 
210         if (defaultPageId != null) {
211             try {
212                 setActivePage(Integer.parseInt(defaultPageId));
213             } catch (Exception e) {
214                 // We can get NumberFormatException from parseInt but also
215                 // AssertionError from setActivePage when the index is out of bounds.
216                 // Generally speaking we just want to ignore any exception and fall back on the
217                 // first page rather than crash the editor load. Logging the error is enough.
218                 AdtPlugin.log(e, "Selecting page '%s' in AndroidXmlEditor failed", defaultPageId);
219             }
220         }
221     }
222 
223     /**
224      * Removes all the pages from the editor.
225      */
removePages()226     protected void removePages() {
227         int count = getPageCount();
228         for (int i = count - 1 ; i >= 0 ; i--) {
229             removePage(i);
230         }
231     }
232 
233     /**
234      * Overrides the parent's setActivePage to be able to switch to the xml editor.
235      *
236      * If the special pageId TEXT_EDITOR_ID is given, switches to the mTextPageIndex page.
237      * This is needed because the editor doesn't actually derive from IFormPage and thus
238      * doesn't have the get-by-page-id method. In this case, the method returns null since
239      * IEditorPart does not implement IFormPage.
240      */
241     @Override
setActivePage(String pageId)242     public IFormPage setActivePage(String pageId) {
243         if (pageId.equals(TEXT_EDITOR_ID)) {
244             super.setActivePage(mTextPageIndex);
245             return null;
246         } else {
247             return super.setActivePage(pageId);
248         }
249     }
250 
251 
252     /**
253      * Notifies this multi-page editor that the page with the given id has been
254      * activated. This method is called when the user selects a different tab.
255      *
256      * @see MultiPageEditorPart#pageChange(int)
257      */
258     @Override
pageChange(int newPageIndex)259     protected void pageChange(int newPageIndex) {
260         super.pageChange(newPageIndex);
261 
262         // Do not record page changes during creation of pages
263         if (mIsCreatingPage) {
264             return;
265         }
266 
267         if (getEditorInput() instanceof IFileEditorInput) {
268             IFile file = ((IFileEditorInput) getEditorInput()).getFile();
269 
270             QualifiedName qname = new QualifiedName(AdtPlugin.PLUGIN_ID,
271                     getClass().getSimpleName() + PREF_CURRENT_PAGE);
272             try {
273                 file.setPersistentProperty(qname, Integer.toString(newPageIndex));
274             } catch (CoreException e) {
275                 // ignore
276             }
277         }
278     }
279 
280     /**
281      * Notifies this listener that some resource changes
282      * are happening, or have already happened.
283      *
284      * Closes all project files on project close.
285      * @see IResourceChangeListener
286      */
287     @Override
resourceChanged(final IResourceChangeEvent event)288     public void resourceChanged(final IResourceChangeEvent event) {
289         if (event.getType() == IResourceChangeEvent.PRE_CLOSE) {
290             Display.getDefault().asyncExec(new Runnable() {
291                 @Override
292                 public void run() {
293                     @SuppressWarnings("hiding")
294                     IWorkbenchPage[] pages = getSite().getWorkbenchWindow().getPages();
295                     for (int i = 0; i < pages.length; i++) {
296                         if (((FileEditorInput)mTextEditor.getEditorInput())
297                                 .getFile().getProject().equals(
298                                         event.getResource())) {
299                             IEditorPart editorPart = pages[i].findEditor(mTextEditor
300                                     .getEditorInput());
301                             pages[i].closeEditor(editorPart, true);
302                         }
303                     }
304                 }
305             });
306         }
307     }
308 
309     /**
310      * Initializes the editor part with a site and input.
311      * <p/>
312      * Checks that the input is an instance of {@link IFileEditorInput}.
313      *
314      * @see FormEditor
315      */
316     @Override
init(IEditorSite site, IEditorInput editorInput)317     public void init(IEditorSite site, IEditorInput editorInput) throws PartInitException {
318         if (!(editorInput instanceof IFileEditorInput))
319             throw new PartInitException("Invalid Input: Must be IFileEditorInput");
320         super.init(site, editorInput);
321     }
322 
323     /**
324      * Returns the {@link IFile} matching the editor's input or null.
325      * <p/>
326      * By construction, the editor input has to be an {@link IFileEditorInput} so it must
327      * have an associated {@link IFile}. Null can only be returned if this editor has no
328      * input somehow.
329      */
getFile()330     public IFile getFile() {
331         if (getEditorInput() instanceof IFileEditorInput) {
332             return ((IFileEditorInput) getEditorInput()).getFile();
333         }
334         return null;
335     }
336 
337     /**
338      * Removes attached listeners.
339      *
340      * @see WorkbenchPart
341      */
342     @Override
dispose()343     public void dispose() {
344         ResourcesPlugin.getWorkspace().removeResourceChangeListener(this);
345 
346         super.dispose();
347     }
348 
349     /**
350      * Commit all dirty pages then saves the contents of the text editor.
351      * <p/>
352      * This works by committing all data to the XML model and then
353      * asking the Structured XML Editor to save the XML.
354      *
355      * @see IEditorPart
356      */
357     @Override
doSave(IProgressMonitor monitor)358     public void doSave(IProgressMonitor monitor) {
359         commitPages(true /* onSave */);
360 
361         // The actual "save" operation is done by the Structured XML Editor
362         getEditor(mTextPageIndex).doSave(monitor);
363     }
364 
365     /* (non-Javadoc)
366      * Saves the contents of this editor to another object.
367      * <p>
368      * Subclasses must override this method to implement the open-save-close lifecycle
369      * for an editor.  For greater details, see <code>IEditorPart</code>
370      * </p>
371      *
372      * @see IEditorPart
373      */
374     @Override
doSaveAs()375     public void doSaveAs() {
376         commitPages(true /* onSave */);
377 
378         IEditorPart editor = getEditor(mTextPageIndex);
379         editor.doSaveAs();
380         setPageText(mTextPageIndex, editor.getTitle());
381         setInput(editor.getEditorInput());
382     }
383 
384     /**
385      * Commits all dirty pages in the editor. This method should
386      * be called as a first step of a 'save' operation.
387      * <p/>
388      * This is the same implementation as in {@link FormEditor}
389      * except it fixes two bugs: a cast to IFormPage is done
390      * from page.get(i) <em>before</em> being tested with instanceof.
391      * Another bug is that the last page might be a null pointer.
392      * <p/>
393      * The incorrect casting makes the original implementation crash due
394      * to our {@link StructuredTextEditor} not being an {@link IFormPage}
395      * so we have to override and duplicate to fix it.
396      *
397      * @param onSave <code>true</code> if commit is performed as part
398      * of the 'save' operation, <code>false</code> otherwise.
399      * @since 3.3
400      */
401     @Override
commitPages(boolean onSave)402     public void commitPages(boolean onSave) {
403         if (pages != null) {
404             for (int i = 0; i < pages.size(); i++) {
405                 Object page = pages.get(i);
406                 if (page != null && page instanceof IFormPage) {
407                     IFormPage form_page = (IFormPage) page;
408                     IManagedForm managed_form = form_page.getManagedForm();
409                     if (managed_form != null && managed_form.isDirty()) {
410                         managed_form.commit(onSave);
411                     }
412                 }
413             }
414         }
415     }
416 
417     /* (non-Javadoc)
418      * Returns whether the "save as" operation is supported by this editor.
419      * <p>
420      * Subclasses must override this method to implement the open-save-close lifecycle
421      * for an editor.  For greater details, see <code>IEditorPart</code>
422      * </p>
423      *
424      * @see IEditorPart
425      */
426     @Override
isSaveAsAllowed()427     public boolean isSaveAsAllowed() {
428         return false;
429     }
430 
431     // ---- Local methods ----
432 
433 
434     /**
435      * Helper method that creates a new hyper-link Listener.
436      * Used by derived classes which need active links in {@link FormText}.
437      * <p/>
438      * This link listener handles two kinds of URLs:
439      * <ul>
440      * <li> Links starting with "http" are simply sent to a local browser.
441      * <li> Links starting with "file:/" are simply sent to a local browser.
442      * <li> Links starting with "page:" are expected to be an editor page id to switch to.
443      * <li> Other links are ignored.
444      * </ul>
445      *
446      * @return A new hyper-link listener for FormText to use.
447      */
createHyperlinkListener()448     public final IHyperlinkListener createHyperlinkListener() {
449         return new HyperlinkAdapter() {
450             /**
451              * Switch to the page corresponding to the link that has just been clicked.
452              * For this purpose, the HREF of the &lt;a&gt; tags above is the page ID to switch to.
453              */
454             @Override
455             public void linkActivated(HyperlinkEvent e) {
456                 super.linkActivated(e);
457                 String link = e.data.toString();
458                 if (link.startsWith("http") ||          //$NON-NLS-1$
459                         link.startsWith("file:/")) {    //$NON-NLS-1$
460                     openLinkInBrowser(link);
461                 } else if (link.startsWith("page:")) {  //$NON-NLS-1$
462                     // Switch to an internal page
463                     setActivePage(link.substring(5 /* strlen("page:") */));
464                 }
465             }
466         };
467     }
468 
469     /**
470      * Open the http link into a browser
471      *
472      * @param link The URL to open in a browser
473      */
474     private void openLinkInBrowser(String link) {
475         try {
476             IWorkbenchBrowserSupport wbs = WorkbenchBrowserSupport.getInstance();
477             wbs.createBrowser(BROWSER_ID).openURL(new URL(link));
478         } catch (PartInitException e1) {
479             // pass
480         } catch (MalformedURLException e1) {
481             // pass
482         }
483     }
484 
485     /**
486      * Creates the XML source editor.
487      * <p/>
488      * Memorizes the index page of the source editor (it's always the last page, but the number
489      * of pages before can change.)
490      * <br/>
491      * Retrieves the underlying XML model from the StructuredEditor and attaches a listener to it.
492      * Finally triggers modelChanged() on the model listener -- derived classes can use this
493      * to initialize the model the first time.
494      * <p/>
495      * Called only once <em>after</em> createFormPages.
496      */
497     private void createTextEditor() {
498         try {
499             mTextEditor = new TextEditor();
500             int index = addPage(mTextEditor, getEditorInput());
501             mTextPageIndex = index;
502             setPageText(index, mTextEditor.getTitle());
503 
504             IDocumentProvider provider = mTextEditor.getDocumentProvider();
505             mDocument = provider.getDocument(getEditorInput());
506 
507             mDocument.addDocumentListener(new IDocumentListener() {
508                 @Override
509                 public void documentChanged(DocumentEvent event) {
510                     onDocumentChanged(event);
511                 }
512 
513                 @Override
514                 public void documentAboutToBeChanged(DocumentEvent event) {
515                     // ignore
516                 }
517             });
518 
519 
520         } catch (PartInitException e) {
521             ErrorDialog.openError(getSite().getShell(),
522                     "Android Text Editor Error", null, e.getStatus());
523         }
524     }
525 
526     /**
527      * Gives access to the {@link IDocument} from the {@link TextEditor}, corresponding to
528      * the current file input.
529      * <p/>
530      * All edits should be wrapped in a {@link #wrapRewriteSession(Runnable)}.
531      * The actual document instance is a {@link SynchronizableDocument}, which creates a lock
532      * around read/set operations. The base API provided by {@link IDocument} provides ways to
533      * manipulate the document line per line or as a bulk.
534      */
535     public IDocument getDocument() {
536         return mDocument;
537     }
538 
539     /**
540      * Returns the {@link IProject} for the edited file.
541      */
542     public IProject getProject() {
543         if (mTextEditor != null) {
544             IEditorInput input = mTextEditor.getEditorInput();
545             if (input instanceof FileEditorInput) {
546                 FileEditorInput fileInput = (FileEditorInput)input;
547                 IFile inputFile = fileInput.getFile();
548 
549                 if (inputFile != null) {
550                     return inputFile.getProject();
551                 }
552             }
553         }
554 
555         return null;
556     }
557 
558     /**
559      * Runs the given operation in the context of a document RewriteSession.
560      * Takes care of properly starting and stopping the operation.
561      * <p/>
562      * The operation itself should just access {@link #getDocument()} and use the
563      * normal document's API to manipulate it.
564      *
565      * @see #getDocument()
566      */
567     public void wrapRewriteSession(Runnable operation) {
568         if (mDocument instanceof IDocumentExtension4) {
569             IDocumentExtension4 doc4 = (IDocumentExtension4) mDocument;
570 
571             DocumentRewriteSession session = null;
572             try {
573                 session = doc4.startRewriteSession(DocumentRewriteSessionType.UNRESTRICTED_SMALL);
574 
575                 operation.run();
576             } catch(IllegalStateException e) {
577                 AdtPlugin.log(e, "wrapRewriteSession failed");
578                 e.printStackTrace();
579             } finally {
580                 if (session != null) {
581                     doc4.stopRewriteSession(session);
582                 }
583             }
584 
585         } else {
586             // Not an IDocumentExtension4? Unlikely. Try the operation anyway.
587             operation.run();
588         }
589     }
590 
591 }
592