/******************************************************************************* | |
* Copyright (c) 2008, 2023 SAP AG and IBM Corporation. | |
* All rights reserved. This program and the accompanying materials | |
* are made available under the terms of the Eclipse Public License 2.0 | |
* which accompanies this distribution, and is available at | |
* https://www.eclipse.org/legal/epl-2.0/ | |
* | |
* SPDX-License-Identifier: EPL-2.0 | |
* | |
* Contributors: | |
* SAP AG - initial API and implementation | |
* Andrew Johnson - improved undo | |
*******************************************************************************/ | |
package org.eclipse.mat.ui.internal.views; | |
import java.io.BufferedOutputStream; | |
import java.io.BufferedReader; | |
import java.io.File; | |
import java.io.FileInputStream; | |
import java.io.FileNotFoundException; | |
import java.io.FileOutputStream; | |
import java.io.IOException; | |
import java.io.InputStreamReader; | |
import java.io.OutputStream; | |
import java.io.OutputStreamWriter; | |
import java.io.UncheckedIOException; | |
import java.io.UnsupportedEncodingException; | |
import java.util.ArrayList; | |
import java.util.HashMap; | |
import java.util.List; | |
import java.util.Map; | |
import java.util.regex.Pattern; | |
import java.util.zip.CRC32; | |
import org.eclipse.core.runtime.IProgressMonitor; | |
import org.eclipse.core.runtime.Platform; | |
import org.eclipse.jface.action.Action; | |
import org.eclipse.jface.action.IMenuListener; | |
import org.eclipse.jface.action.IMenuManager; | |
import org.eclipse.jface.action.MenuManager; | |
import org.eclipse.jface.action.Separator; | |
import org.eclipse.jface.preference.JFacePreferences; | |
import org.eclipse.jface.resource.JFaceResources; | |
import org.eclipse.jface.text.BadLocationException; | |
import org.eclipse.jface.text.Document; | |
import org.eclipse.jface.text.IDocument; | |
import org.eclipse.jface.text.IRegion; | |
import org.eclipse.jface.text.ITextListener; | |
import org.eclipse.jface.text.ITextOperationTarget; | |
import org.eclipse.jface.text.ITextViewer; | |
import org.eclipse.jface.text.Region; | |
import org.eclipse.jface.text.TextEvent; | |
import org.eclipse.jface.text.TextPresentation; | |
import org.eclipse.jface.text.TextViewer; | |
import org.eclipse.jface.text.TextViewerUndoManager; | |
import org.eclipse.jface.text.hyperlink.AbstractHyperlinkDetector; | |
import org.eclipse.jface.text.hyperlink.DefaultHyperlinkPresenter; | |
import org.eclipse.jface.text.hyperlink.IHyperlink; | |
import org.eclipse.jface.text.hyperlink.IHyperlinkDetector; | |
import org.eclipse.jface.viewers.ISelectionChangedListener; | |
import org.eclipse.jface.viewers.SelectionChangedEvent; | |
import org.eclipse.jface.viewers.StructuredSelection; | |
import org.eclipse.mat.SnapshotException; | |
import org.eclipse.mat.query.ContextProvider; | |
import org.eclipse.mat.query.IContextObject; | |
import org.eclipse.mat.ui.editor.MultiPaneEditor; | |
import org.eclipse.mat.ui.util.PopupMenu; | |
import org.eclipse.mat.ui.util.QueryContextMenu; | |
import org.eclipse.swt.SWT; | |
import org.eclipse.swt.custom.StyleRange; | |
import org.eclipse.swt.custom.StyledText; | |
import org.eclipse.swt.graphics.Color; | |
import org.eclipse.swt.graphics.Font; | |
import org.eclipse.swt.layout.FillLayout; | |
import org.eclipse.swt.widgets.Composite; | |
import org.eclipse.swt.widgets.Menu; | |
import org.eclipse.ui.IActionBars; | |
import org.eclipse.ui.IPartListener; | |
import org.eclipse.ui.ISaveablePart; | |
import org.eclipse.ui.ISaveablePart2; | |
import org.eclipse.ui.IWorkbenchPage; | |
import org.eclipse.ui.IWorkbenchPart; | |
import org.eclipse.ui.IWorkbenchWindow; | |
import org.eclipse.ui.PlatformUI; | |
import org.eclipse.ui.actions.ActionFactory; | |
import org.eclipse.ui.actions.ActionFactory.IWorkbenchAction; | |
import org.eclipse.ui.part.ViewPart; | |
public class NotesView extends ViewPart implements IPartListener, ISaveablePart, ISaveablePart2 | |
{ | |
private static final String NOTES_ENCODING = "UTF8"; //$NON-NLS-1$ | |
private static final int UNDO_LEVEL = 25; | |
private File resource; | |
private Action undo; | |
private Action redo; | |
private Menu menu; | |
private Font font; | |
private Color hyperlinkColor; | |
private MultiPaneEditor editor; | |
private TextViewer textViewer; | |
private TextViewerUndoManager undoManager; | |
private Map<String, NotesViewAction> actions = new HashMap<String, NotesViewAction>(); | |
long hash; | |
boolean modified; | |
@Override | |
public void createPartControl(Composite parent) | |
{ | |
parent.setLayout(new FillLayout()); | |
// No need for a dispose listener - the SaveablePart will save it | |
textViewer = new TextViewer(parent, SWT.MULTI | SWT.V_SCROLL | SWT.LEFT | SWT.H_SCROLL); | |
textViewer.setDocument(new Document()); | |
textViewer.getControl().setEnabled(false); | |
textViewer.getTextWidget().setWordWrap(false); | |
font = JFaceResources.getFont("org.eclipse.mat.ui.notesfont"); //$NON-NLS-1$ | |
textViewer.getControl().setFont(font); | |
hyperlinkColor = JFaceResources.getColorRegistry().get(JFacePreferences.HYPERLINK_COLOR); | |
getSite().getPage().addPartListener(this); | |
int undolevel = Platform.getPreferencesService().getInt("org.eclipse.ui.editors", "undoHistorySize", UNDO_LEVEL, null); //$NON-NLS-1$ //$NON-NLS-2$ | |
undoManager = new TextViewerUndoManager(undolevel); | |
undoManager.connect(textViewer); | |
textViewer.setUndoManager(undoManager); | |
textViewer.addSelectionChangedListener(new ISelectionChangedListener() | |
{ | |
public void selectionChanged(SelectionChangedEvent event) | |
{ | |
updateActions(); | |
} | |
}); | |
textViewer.addTextListener(new ITextListener() | |
{ | |
public void textChanged(TextEvent event) | |
{ | |
modified = true; | |
searchForHyperlinks(textViewer.getDocument().get(), 0); | |
firePropertyChange(PROP_DIRTY); | |
} | |
}); | |
textViewer.setHyperlinkPresenter(new DefaultHyperlinkPresenter(hyperlinkColor)); | |
textViewer.setHyperlinkDetectors(new IHyperlinkDetector[] { new ObjectAddressHyperlinkDetector() }, SWT.MOD1); | |
makeActions(); | |
hookContextMenu(); | |
showBootstrapPart(); | |
updateActions(); | |
} | |
private void updateActions() | |
{ | |
for (NotesViewAction a : actions.values()) | |
a.setEnabled(textViewer.canDoOperation(a.actionId)); | |
} | |
private void hookContextMenu() | |
{ | |
MenuManager menuMgr = new MenuManager("#PopupMenu"); //$NON-NLS-1$ | |
menuMgr.setRemoveAllWhenShown(true); | |
menuMgr.addMenuListener(new IMenuListener() | |
{ | |
public void menuAboutToShow(IMenuManager manager) | |
{ | |
textEditorContextMenuAboutToShow(manager); | |
} | |
}); | |
Menu menu = menuMgr.createContextMenu(textViewer.getControl()); | |
textViewer.getControl().setMenu(menu); | |
} | |
private void textEditorContextMenuAboutToShow(IMenuManager manager) | |
{ | |
if (textViewer != null) | |
{ | |
undo.setEnabled(undoManager.undoable()); | |
redo.setEnabled(undoManager.redoable()); | |
manager.add(undo); | |
manager.add(redo); | |
manager.add(new Separator()); | |
manager.add(getAction(ActionFactory.CUT.getId())); | |
manager.add(getAction(ActionFactory.COPY.getId())); | |
manager.add(getAction(ActionFactory.PASTE.getId())); | |
manager.add(new Separator()); | |
manager.add(getAction(ActionFactory.DELETE.getId())); | |
manager.add(getAction(ActionFactory.SELECT_ALL.getId())); | |
} | |
} | |
private NotesViewAction getAction(String actionID) | |
{ | |
return actions.get(actionID); | |
} | |
private Action addAction(ActionFactory actionFactory, int textOperation, String actionDefinitionId) | |
{ | |
IWorkbenchWindow window = getViewSite().getWorkbenchWindow(); | |
IWorkbenchAction globalAction = actionFactory.create(window); | |
// Create our text action. | |
NotesViewAction action = new NotesViewAction(textOperation, actionDefinitionId); | |
actions.put(actionFactory.getId(), action); | |
// Copy its properties from the global action. | |
action.setText(globalAction.getText()); | |
action.setToolTipText(globalAction.getToolTipText()); | |
action.setDescription(globalAction.getDescription()); | |
action.setImageDescriptor(globalAction.getImageDescriptor()); | |
action.setDisabledImageDescriptor(globalAction.getDisabledImageDescriptor()); | |
action.setAccelerator(globalAction.getAccelerator()); | |
// Register our text action with the global action handler. | |
IActionBars actionBars = getViewSite().getActionBars(); | |
actionBars.setGlobalActionHandler(actionFactory.getId(), action); | |
return action; | |
} | |
@Override | |
public void setFocus() | |
{ | |
textViewer.getControl().setFocus(); | |
} | |
public void partActivated(IWorkbenchPart part) | |
{ | |
if (!supportsNotes(part)) | |
return; | |
textViewer.getControl().setEnabled(true); | |
editor = (MultiPaneEditor) part; | |
File path = editor.getResourceFile(); | |
if (path != null && !path.equals(resource)) | |
{ | |
if (isDirty()) | |
saveNotes(); | |
resource = path; | |
updateTextViewer(); | |
} | |
} | |
public void partBroughtToTop(IWorkbenchPart part) | |
{ | |
partActivated(part); | |
} | |
public void partClosed(IWorkbenchPart part) | |
{ | |
if (!supportsNotes(part)) { return; } | |
MultiPaneEditor editor = (MultiPaneEditor) part; | |
File resource = editor.getResourceFile(); | |
if (resource.equals(this.resource)) | |
{ | |
// Saving usually done as SaveablePart except when snapshot is closed | |
if (isDirty()) | |
saveNotes(); | |
this.resource = null; | |
this.editor = null; | |
this.updateTextViewer(); | |
} | |
} | |
public void partDeactivated(IWorkbenchPart part) | |
{} | |
public void partOpened(IWorkbenchPart part) | |
{} | |
private void showBootstrapPart() | |
{ | |
IWorkbenchPage page = getSite().getPage(); | |
if (page != null) | |
partActivated(page.getActiveEditor()); | |
} | |
private void makeActions() | |
{ | |
// Install the standard text actions. | |
addAction(ActionFactory.CUT, ITextOperationTarget.CUT, "org.eclipse.ui.edit.cut");//$NON-NLS-1$ | |
addAction(ActionFactory.COPY, ITextOperationTarget.COPY, "org.eclipse.ui.edit.copy");//$NON-NLS-1$ | |
addAction(ActionFactory.PASTE, ITextOperationTarget.PASTE, "org.eclipse.ui.edit.paste");//$NON-NLS-1$ | |
addAction(ActionFactory.DELETE, ITextOperationTarget.DELETE, "org.eclipse.ui.edit.delete");//$NON-NLS-1$ | |
addAction(ActionFactory.SELECT_ALL, ITextOperationTarget.SELECT_ALL, "org.eclipse.ui.edit.selectAll");//$NON-NLS-1$ | |
undo = addAction(ActionFactory.UNDO, ITextOperationTarget.UNDO, "org.eclipse.ui.edit.undo");//$NON-NLS-1$ | |
redo = addAction(ActionFactory.REDO, ITextOperationTarget.REDO, "org.eclipse.ui.edit.redo");//$NON-NLS-1$ | |
} | |
private long hash() { | |
// Used to detect if document has changed. | |
CRC32 crc = new CRC32(); | |
try | |
{ | |
crc.update(textViewer.getDocument().get().getBytes(NOTES_ENCODING)); | |
} | |
catch (UnsupportedEncodingException e) | |
{ | |
// Won't happen | |
return textViewer.getDocument().get().hashCode(); | |
} | |
return crc.getValue(); | |
} | |
private void updateTextViewer() | |
{ | |
// get notes.txt and if it's not null set is as input | |
if (resource != null) | |
{ | |
String buffer = readNotes(resource); | |
if (buffer != null) | |
{ | |
Document document = new Document(buffer); | |
textViewer.setDocument(document); | |
revealEndOfDocument(); | |
} | |
else | |
{ | |
textViewer.setDocument(new Document(""));//$NON-NLS-1$ | |
} | |
} | |
else | |
{ | |
textViewer.setDocument(new Document(""));//$NON-NLS-1$ | |
textViewer.getControl().setEnabled(false); | |
} | |
hash = hash(); | |
modified = false; | |
firePropertyChange(PROP_DIRTY); | |
} | |
private void searchForHyperlinks(String allText, int offset) | |
{ | |
if (resource == null) | |
return; | |
Pattern addressPattern = Pattern.compile("0x\\p{XDigit}+");//$NON-NLS-1$ | |
String[] fields = allText.split("\\W", 0);//$NON-NLS-1$ | |
List<IdHyperlink> hyperlinks = new ArrayList<IdHyperlink>(); | |
for (String field : fields) | |
{ | |
if (addressPattern.matcher(field).matches()) | |
{ | |
IRegion idRegion = new Region(offset, field.length()); | |
IdHyperlink hyperlink = new IdHyperlink(field, editor, idRegion); | |
hyperlinks.add(hyperlink); | |
} | |
offset = offset + field.length() + 1; // length of the splitter | |
} | |
if (!hyperlinks.isEmpty()) | |
highlightHyperlinks(hyperlinks); | |
} | |
private void highlightHyperlinks(List<IdHyperlink> hyperlinks) | |
{ | |
TextPresentation style = new TextPresentation(); | |
for (IHyperlink hyperlink : hyperlinks) | |
{ | |
int startIndex = hyperlink.getHyperlinkRegion().getOffset(); | |
int length = hyperlink.getHyperlinkRegion().getLength(); | |
StyleRange styleRange = new StyleRange(startIndex, length, hyperlinkColor, null, SWT.ITALIC); | |
styleRange.underline = true; | |
style.addStyleRange(styleRange); | |
} | |
textViewer.changeTextPresentation(style, true); | |
} | |
private void saveNotes() | |
{ | |
if (resource != null) | |
{ | |
String text = textViewer.getDocument().get(); | |
if (text != null) | |
saveNotes(resource, text); | |
resetUndoManager(); | |
hash = hash(); | |
modified = false; | |
} | |
} | |
@Override | |
public void dispose() | |
{ | |
undoManager.disconnect(); | |
getSite().getPage().removePartListener(this); | |
// The parent composite has been disposed, so there is no need to remove the disposeListener. | |
if (menu != null) | |
menu.dispose(); | |
super.dispose(); | |
} | |
private boolean supportsNotes(IWorkbenchPart part) | |
{ | |
if (part instanceof MultiPaneEditor) | |
{ | |
return true; | |
} | |
return false; | |
} | |
protected void revealEndOfDocument() | |
{ | |
IDocument doc = textViewer.getDocument(); | |
int docLength = doc.getLength(); | |
if (docLength > 0) | |
{ | |
textViewer.revealRange(docLength - 1, 1); | |
StyledText widget = textViewer.getTextWidget(); | |
widget.setCaretOffset(docLength); | |
} | |
} | |
public void resetUndoManager() | |
{ | |
undoManager.reset(); | |
} | |
private final class IdHyperlink implements IHyperlink | |
{ | |
String id; | |
MultiPaneEditor editor; | |
IRegion region; | |
public IdHyperlink(String id, MultiPaneEditor editor, IRegion region) | |
{ | |
this.id = id; | |
this.editor = editor; | |
this.region = region; | |
} | |
public IRegion getHyperlinkRegion() | |
{ | |
return region; | |
} | |
public String getHyperlinkText() | |
{ | |
return null; | |
} | |
public String getTypeLabel() | |
{ | |
return null; | |
} | |
public void open() | |
{ | |
try | |
{ | |
final int objectId = editor.getQueryContext().mapToObjectId(id); | |
if (objectId < 0) | |
return; | |
QueryContextMenu contextMenu = new QueryContextMenu(editor, new ContextProvider((String) null) | |
{ | |
@Override | |
public IContextObject getContext(final Object row) | |
{ | |
return new IContextObject() | |
{ | |
public int getObjectId() | |
{ | |
return (Integer) row; | |
} | |
}; | |
} | |
}); | |
PopupMenu popupMenu = new PopupMenu(); | |
contextMenu.addContextActions(popupMenu, new StructuredSelection(objectId), null); | |
if (menu != null && !menu.isDisposed()) | |
menu.dispose(); | |
menu = popupMenu.createMenu(getViewSite().getActionBars().getStatusLineManager(), PlatformUI | |
.getWorkbench().getDisplay().getActiveShell()); | |
menu.setVisible(true); | |
} | |
catch (NumberFormatException ignore) | |
{ | |
// $JL-EXC$ | |
// shouldn't happen and if it does nothing can be done | |
} | |
catch (SnapshotException ignore) | |
{ | |
// $JL-EXC$ | |
// mapToAddress throws exception on illegal values | |
} | |
} | |
} | |
private class ObjectAddressHyperlinkDetector extends AbstractHyperlinkDetector | |
{ | |
public IHyperlink[] detectHyperlinks(ITextViewer textViewer, IRegion region, boolean canShowMultipleHyperlinks) | |
{ | |
if (region == null || textViewer == null) | |
return null; | |
IDocument document = textViewer.getDocument(); | |
int offset = region.getOffset(); | |
if (document == null) | |
return null; | |
IRegion lineInfo; | |
String text; | |
try | |
{ | |
lineInfo = document.getLineInformationOfOffset(offset); | |
text = document.get(lineInfo.getOffset(), lineInfo.getLength()); | |
} | |
catch (BadLocationException ex) | |
{ | |
return null; | |
} | |
int index = offset - lineInfo.getOffset(); | |
// to the left from offset | |
char ch; | |
do | |
{ | |
index--; | |
ch = ' '; | |
if (index > -1) | |
ch = text.charAt(index); | |
} | |
while (Character.isLetterOrDigit(ch)); | |
int startIndex = index + 1; | |
// to the right from offset | |
index = offset - lineInfo.getOffset() - 1; | |
do | |
{ | |
index++; | |
if (index >= text.length()) | |
{ | |
break; | |
} | |
ch = text.charAt(index); | |
} | |
while (Character.isLetterOrDigit(ch)); | |
Pattern addressPattern = Pattern.compile("0x\\p{XDigit}+");//$NON-NLS-1$ | |
String address = text.substring(startIndex, index); | |
if (address != null && addressPattern.matcher(address).matches()) | |
{ | |
IRegion idRegion = new Region(startIndex, address.length()); | |
return new IHyperlink[] { new IdHyperlink(address, editor, idRegion) }; | |
} | |
else | |
return null; | |
} | |
} | |
private class NotesViewAction extends Action | |
{ | |
private int actionId; | |
NotesViewAction(int actionId, String actionDefinitionId) | |
{ | |
this.actionId = actionId; | |
this.setActionDefinitionId(actionDefinitionId); | |
} | |
@Override | |
public boolean isEnabled() | |
{ | |
return textViewer.canDoOperation(actionId); | |
} | |
public void run() | |
{ | |
textViewer.doOperation(actionId); | |
} | |
} | |
// ////////////////////////////////////////////////////////////// | |
// notes management | |
// ////////////////////////////////////////////////////////////// | |
/** | |
* Read the contents of the notes file, based on the | |
* snapshot resource. | |
* @param resourcePath The editor file (snapshot or index file). | |
* @return The contents of the notes file, lines separated by \n. | |
* @throws {@link UncheckedIOException} for some IO errors. | |
*/ | |
public static String readNotes(File resourcePath) | |
{ | |
if (resourcePath != null) | |
{ | |
File notesFile = getDefaultNotesFile(resourcePath); | |
if (notesFile.canRead() && notesFile.isFile()) | |
{ | |
try (FileInputStream fileInput = new FileInputStream(notesFile)) | |
{ | |
BufferedReader myInput = new BufferedReader(new InputStreamReader(fileInput, NOTES_ENCODING)); | |
try | |
{ | |
String s; | |
StringBuffer b = new StringBuffer(); | |
while ((s = myInput.readLine()) != null) | |
{ | |
b.append(s); | |
b.append("\n");//$NON-NLS-1$ | |
} | |
return b.toString(); | |
} | |
finally | |
{ | |
try | |
{ | |
myInput.close(); | |
} | |
catch (IOException ignore) | |
{} | |
} | |
} | |
catch (FileNotFoundException e) | |
{ | |
throw new UncheckedIOException(notesFile.getPath(), e); | |
} | |
catch (IOException e) | |
{ | |
throw new UncheckedIOException(notesFile.getPath(), e); | |
} | |
} | |
} | |
return null; | |
} | |
private static void saveNotes(File resource, String notes) | |
{ | |
OutputStream fout = null; | |
try | |
{ | |
File notesFile = getDefaultNotesFile(resource); | |
fout = new FileOutputStream(notesFile); | |
OutputStreamWriter out = new OutputStreamWriter(new BufferedOutputStream(fout), NOTES_ENCODING); | |
try | |
{ | |
out.write(notes); | |
out.flush(); | |
} | |
finally | |
{ | |
out.close(); | |
} | |
} | |
catch (FileNotFoundException e) | |
{ | |
throw new RuntimeException(e); | |
} | |
catch (IOException e) | |
{ | |
throw new RuntimeException(e); | |
} | |
finally | |
{ | |
try | |
{ | |
if (fout != null) | |
fout.close(); | |
} | |
catch (IOException ignore) | |
{} | |
} | |
} | |
private static File getDefaultNotesFile(File resource) | |
{ | |
String filename = resource.getName(); | |
int p = filename.lastIndexOf('.'); | |
if (p >= 0) | |
filename = filename.substring(0, p); | |
return new File(resource.getParentFile(), filename + ".notes.txt");//$NON-NLS-1$ | |
} | |
public void doSave(IProgressMonitor monitor) | |
{ | |
saveNotes(); | |
firePropertyChange(PROP_DIRTY); | |
} | |
public void doSaveAs() | |
{ | |
} | |
public boolean isDirty() | |
{ | |
return undoManager.undoable() || modified && hash != hash(); | |
} | |
public boolean isSaveAsAllowed() | |
{ | |
return false; | |
} | |
public boolean isSaveOnCloseNeeded() | |
{ | |
return true; | |
} | |
public int promptToSaveOnClose() | |
{ | |
return ISaveablePart2.YES; | |
} | |
} |