// Copyright (c) 2010 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.debug.ui.liveedit; import java.util.ArrayList; import java.util.EnumMap; import java.util.List; import java.util.Map; import org.chromium.debug.core.util.RangeBinarySearch; import org.chromium.debug.ui.PluginUtil; import org.chromium.sdk.UpdatableScript; import org.eclipse.jface.text.Document; import org.eclipse.jface.text.TextPresentation; import org.eclipse.jface.text.source.SourceViewer; import org.eclipse.jface.viewers.ILabelProvider; import org.eclipse.jface.viewers.ILabelProviderListener; import org.eclipse.jface.viewers.ISelection; import org.eclipse.jface.viewers.ISelectionChangedListener; import org.eclipse.jface.viewers.IStructuredSelection; import org.eclipse.jface.viewers.ITreeContentProvider; import org.eclipse.jface.viewers.ITreeViewerListener; import org.eclipse.jface.viewers.SelectionChangedEvent; import org.eclipse.jface.viewers.TreeExpansionEvent; import org.eclipse.jface.viewers.TreeViewer; import org.eclipse.jface.viewers.Viewer; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.LineBackgroundEvent; import org.eclipse.swt.custom.LineBackgroundListener; import org.eclipse.swt.custom.StyleRange; import org.eclipse.swt.events.DisposeEvent; import org.eclipse.swt.events.DisposeListener; import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.FontMetrics; import org.eclipse.swt.graphics.Image; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.graphics.RGB; import org.eclipse.swt.layout.FillLayout; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Event; import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Listener; import org.eclipse.swt.widgets.ScrollBar; import org.eclipse.swt.widgets.Text; /** * A UI control that shows V8 update preview. It consists of function tree structure and source * text viewer each for both old and new version of the script. The tree viewers are * synchronized together: their selection, expansion and scroll position are synchronized. */ public class LiveEditDiffViewer { public static LiveEditDiffViewer create(Composite parent, Configuration configuration) { return new LiveEditDiffViewer(parent, configuration); } /** * A static parameters for the viewer. They should not change. */ public interface Configuration { String getOldLabel(); String getNewLabel(); boolean oldOnLeft(); } /** * An input for the viewer. */ public interface Input { /** * The root of JavaScript function tree. The tree combines functions from old and new versions * of the script. * @return */ FunctionNode getRootFunction(); SourceText getOldSource(); SourceText getNewSource(); UpdatableScript.TextualDiff getTextualDiff(); } public interface SourceText { String getText(); String getTitle(); } /** * A function in old and/or new version of the script. */ public interface FunctionNode { String getName(); String getStatus(); List<? extends FunctionNode> children(); /** * @return positions inside a particular version of the script, or null if function does not * linked to this version */ SourcePosition getPosition(Side side); FunctionNode getParent(); } /** * A version of the script. */ public enum Side { OLD, NEW } public interface SourcePosition { int getStart(); int getEnd(); } private final Composite mainControl; private final SideControls oldSideView; private final SideControls newSideView; private final TreeLinkMonitor linkMonitor; private final Text functionStatusText; private final Colors colors; private InputData currentInput = null; private LiveEditDiffViewer(Composite parent, Configuration configuration) { colors = new Colors(parent.getDisplay()); FontMetrics defaultFontMetrics = PluginUtil.getFontMetrics(parent, null); Composite composite = new Composite(parent, SWT.NONE); { composite.setLayoutData(new GridData(GridData.FILL_BOTH)); GridLayout topLayout = new GridLayout(); topLayout.numColumns = 1; composite.setLayout(topLayout); } Composite labelPairComposite = new Composite(composite, SWT.NONE); { labelPairComposite.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); FillLayout fillLayout = new FillLayout(); fillLayout.type = SWT.HORIZONTAL; fillLayout.spacing = 5; labelPairComposite.setLayout(fillLayout); } Label labelLeft = new Label(labelPairComposite, SWT.NONE); Label labelRight = new Label(labelPairComposite, SWT.NONE); Composite fourCells = new Composite(composite, SWT.NONE); { GridData gd = new GridData(GridData.FILL_BOTH); gd.heightHint = defaultFontMetrics.getHeight() * 30; gd.widthHint = defaultFontMetrics.getAverageCharWidth() * 85; fourCells.setLayoutData(gd); FillLayout fillLayout = new FillLayout(); fillLayout.type = SWT.VERTICAL; fillLayout.spacing = 5; fourCells.setLayout(fillLayout); } Composite treePairComposite = new Composite(fourCells, SWT.NONE); { FillLayout fillLayout = new FillLayout(); fillLayout.type = SWT.HORIZONTAL; fillLayout.spacing = 5; treePairComposite.setLayout(fillLayout); } linkMonitor = new TreeLinkMonitor(); TreeViewer treeViewerLeft = new TreeViewer(treePairComposite); TreeViewer treeViewerRight = new TreeViewer(treePairComposite); Composite sourcePairComposite = new Composite(fourCells, SWT.NONE); { FillLayout fillLayout = new FillLayout(); fillLayout.type = SWT.HORIZONTAL; fillLayout.spacing = 5; sourcePairComposite.setLayout(fillLayout); } SourceViewer sourceViewerLeft = new SourceViewer(sourcePairComposite, null, SWT.BORDER | SWT.H_SCROLL | SWT.V_SCROLL); sourceViewerLeft.getTextWidget().setEditable(false); SourceViewer sourceViewerRight = new SourceViewer(sourcePairComposite, null, SWT.BORDER | SWT.H_SCROLL | SWT.V_SCROLL); sourceViewerRight.getTextWidget().setEditable(false); { functionStatusText = new Text(composite, SWT.READ_ONLY | SWT.MULTI | SWT.WRAP | SWT.V_SCROLL); Display display = composite.getDisplay(); functionStatusText.setBackground(display.getSystemColor(SWT.COLOR_WIDGET_BACKGROUND)); GridData gd = new GridData(GridData.FILL_BOTH); gd.minimumHeight = defaultFontMetrics.getHeight() * 3; gd.heightHint = gd.minimumHeight; gd.grabExcessHorizontalSpace = true; gd.horizontalAlignment = GridData.FILL; functionStatusText.setLayoutData(gd); } SideControls sideViewLeft = new SideControls(labelLeft, treeViewerLeft, sourceViewerLeft); SideControls sideViewRight = new SideControls(labelRight, treeViewerRight, sourceViewerRight); if (configuration.oldOnLeft()) { oldSideView = sideViewLeft; newSideView = sideViewRight; } else { oldSideView = sideViewRight; newSideView = sideViewLeft; } oldSideView.label.setText(configuration.getOldLabel()); newSideView.label.setText(configuration.getNewLabel()); configureSide(oldSideView, newSideView, Side.OLD); configureSide(newSideView, oldSideView, Side.NEW); mainControl = composite; mainControl.addDisposeListener(new DisposeListener() { public void widgetDisposed(DisposeEvent event) { handleDispose(event); } }); } private void configureSide(SideControls sideControls, SideControls opposite, Side side) { configureTreeViewer(sideControls.treeViewer, opposite.treeViewer, side); configureSourceViewer(sideControls.sourceViewer, opposite.sourceViewer, side); } private void configureTreeViewer(TreeViewer treeViewer, TreeViewer opposite, Side side) { treeViewer.setContentProvider(new FunctionTreeContentProvider()); treeViewer.setLabelProvider(new LabelProviderImpl(side)); treeViewer.addSelectionChangedListener(new SelectionChangeListener(opposite)); treeViewer.addTreeListener(new TreeListenerImpl(opposite)); treeViewer.getTree().getVerticalBar().addListener(SWT.Selection, new TreeScrollBarListener(opposite)); } private void configureSourceViewer(SourceViewer sourceViewer, SourceViewer opposite, Side side) { sourceViewer.getTextWidget().getVerticalBar().addListener(SWT.Selection, new SourceScrollBarListener(sourceViewer, opposite, side)); sourceViewer.getTextWidget().addLineBackgroundListener(new LineBackgroundListenerImpl(side)); } private static class SideControls { final Label label; final TreeViewer treeViewer; final SourceViewer sourceViewer; SideControls(Label label, TreeViewer treeViewer, SourceViewer sourceViewer) { this.label = label; this.treeViewer = treeViewer; this.sourceViewer = sourceViewer; } } private void handleDispose(DisposeEvent event) { colors.dispose(); } public Control getControl() { return mainControl; } public void setInput(Input input) { linkMonitor.block(); try { oldSideView.treeViewer.setInput(input); newSideView.treeViewer.setInput(input); oldSideView.treeViewer.expandAll(); newSideView.treeViewer.expandAll(); Document oldDocument; if (input == null) { oldDocument = null; } else { oldDocument = new Document(input.getOldSource().getText()); } oldSideView.sourceViewer.setDocument(oldDocument); Document newDocument; if (input == null) { newDocument = null; } else { newDocument = new Document(input.getNewSource().getText()); } newSideView.sourceViewer.setDocument(newDocument); if (input != null) { applyDiffPresentation(oldSideView.sourceViewer, newSideView.sourceViewer, input.getTextualDiff()); } } finally { linkMonitor.unblock(); } currentInput = buildInputData(input); setSelectedFunction(null); } private static class FunctionTreeContentProvider implements ITreeContentProvider { public Object[] getChildren(Object parentElement) { FunctionNode functionNode = (FunctionNode) parentElement; return functionNode.children().toArray(); } public Object getParent(Object element) { FunctionNode functionNode = (FunctionNode) element; return functionNode.getParent(); } public boolean hasChildren(Object element) { return getChildren(element).length != 0; } public Object[] getElements(Object inputElement) { Input input = (Input) inputElement; if (input == null) { return new Object[] { }; } else { return new Object[] { input.getRootFunction() }; } } public void dispose() { } public void inputChanged(Viewer viewer, Object oldInput, Object newInput) { } } private static class LabelProviderImpl implements ILabelProvider { private final Side side; LabelProviderImpl(Side side) { this.side = side; } public Image getImage(Object element) { return null; } public String getText(Object element) { FunctionNode functionNode = (FunctionNode) element; SourcePosition position = functionNode.getPosition(side); if (position == null) { return "."; //$NON-NLS-1$ } else { if (functionNode.getParent() == null) { return Messages.LiveEditDiffViewer_SCRIPT; } else { String name = functionNode.getName(); if (name == null || name.trim().length() == 0) { return Messages.LiveEditDiffViewer_UNNAMED; } else { return name; } } } } public void addListener(ILabelProviderListener listener) { } public void removeListener(ILabelProviderListener listener) { } public boolean isLabelProperty(Object element, String property) { return false; } public void dispose() { } } private class SelectionChangeListener implements ISelectionChangedListener { private final TreeViewer oppositeViewer; SelectionChangeListener(TreeViewer oppositeViewer) { this.oppositeViewer = oppositeViewer; } public void selectionChanged(SelectionChangedEvent event) { if (linkMonitor.isBlocked()) { return; } linkMonitor.block(); try { ISelection selection = event.getSelection(); oppositeViewer.setSelection(selection); updateFunctionSelection(selection); } finally { linkMonitor.unblock(); } } } private abstract class ScrollListenerBase implements Listener { public void handleEvent(Event e) { if (linkMonitor.isBlocked()) { return; } linkMonitor.block(); try { handleScroll((ScrollBar)e.widget); } finally { linkMonitor.unblock(); } } protected abstract void handleScroll(ScrollBar scrollBar); } private class TreeScrollBarListener extends ScrollListenerBase { private final TreeViewer oppositeViewer; TreeScrollBarListener(TreeViewer oppositeViewer) { this.oppositeViewer = oppositeViewer; } @Override protected void handleScroll(ScrollBar scrollBar) { int vpos = scrollBar.getSelection(); oppositeViewer.getTree().getVerticalBar().setSelection(vpos); } } private class SourceScrollBarListener extends ScrollListenerBase { private final SourceViewer sourceViewer; private final SourceViewer opposite; private final Side side; SourceScrollBarListener(SourceViewer sourceViewer, SourceViewer opposite, Side side) { this.sourceViewer = sourceViewer; this.opposite = opposite; this.side = side; } @Override protected void handleScroll(ScrollBar scrollBar) { if (currentInput == null) { return; } int topPos = sourceViewer.getTopIndex(); int bottomPos = sourceViewer.getBottomIndex(); TextChangesMap changesMap = currentInput.getMap(side); int neededOppositeTopPos = changesMap.translateLineNumber(topPos, true); int neededOppositeBottomPos = changesMap.translateLineNumber(bottomPos, false); int actualOppositeTopPos = opposite.getTopIndex(); int actualOppositeBottomPos = opposite.getBottomIndex(); int topFreeSpace = actualOppositeTopPos - neededOppositeTopPos; int bottomFreeSpace = neededOppositeBottomPos - actualOppositeBottomPos; if (topFreeSpace > 0 && bottomFreeSpace < 0) { // Move up. int moveUpValue = Math.min(topFreeSpace, -bottomFreeSpace); opposite.setTopIndex(actualOppositeTopPos - moveUpValue); } else if (topFreeSpace < 0 && bottomFreeSpace > 0) { // Move down. int moveDownValue = Math.min(-topFreeSpace, bottomFreeSpace); opposite.setTopIndex(actualOppositeTopPos + moveDownValue); } } } private static InputData buildInputData(Input input) { if (input == null) { return null; } List<Long> chunkArray = input.getTextualDiff().getChunks(); String oldText = input.getOldSource().getText(); String newText = input.getNewSource().getText(); int arrayLengthExpected = chunkArray.size() / 3; List<ChunkData> oldLineNumbers = new ArrayList<ChunkData>(arrayLengthExpected); List<ChunkData> newLineNumbers = new ArrayList<ChunkData>(arrayLengthExpected); { int oldPos = 0; int currentOldLineNumber = 0; int newPos = 0; int currentNewLineNumber = 0; for (int i = 0; i < chunkArray.size(); i += 3) { int oldStart = chunkArray.get(i + 0).intValue(); int newStart = oldStart - oldPos + newPos; int oldEnd = chunkArray.get(i + 1).intValue(); int newEnd = chunkArray.get(i + 2).intValue(); currentOldLineNumber += countLineEnds(oldText, oldPos, oldStart); currentNewLineNumber += countLineEnds(newText, newPos, newStart); int oldLineStart = currentOldLineNumber; int newLineStart = currentNewLineNumber; currentOldLineNumber += countLineEnds(oldText, oldStart, oldEnd); currentNewLineNumber += countLineEnds(newText, newStart, newEnd); oldLineNumbers.add(new ChunkData(oldLineStart, currentOldLineNumber, oldStart, oldEnd)); newLineNumbers.add(new ChunkData(newLineStart, currentNewLineNumber, newStart, newEnd)); oldPos = oldEnd; newPos = newEnd; } } return new InputData(new TextChangesMap(oldLineNumbers, newLineNumbers), new TextChangesMap(newLineNumbers, oldLineNumbers)); } private static int countLineEnds(String str, int start, int end) { int result = 0; for (int i = start; i < end; i++) { if (str.charAt(i) == '\n') { result++; } } return result; } private void applyDiffPresentation(SourceViewer oldViewer, SourceViewer newViewer, UpdatableScript.TextualDiff textualDiff) { TextPresentation oldPresentation = new TextPresentation(); TextPresentation newPresentation = new TextPresentation(); List<Long> chunkNumbers = textualDiff.getChunks(); int posOld = 0; int posNew = 0; for (int i = 0; i < chunkNumbers.size(); i += 3) { int startOld = chunkNumbers.get(i + 0).intValue(); int endOld = chunkNumbers.get(i + 1).intValue(); int endNew = chunkNumbers.get(i + 2).intValue(); int startNew = startOld - posOld + posNew; if (startOld == endOld) { // Add newPresentation.addStyleRange(new StyleRange(startNew, endNew - startNew, null, colors.get(ColorName.ADDED_BACKGROUND))); } else if (startNew == endNew) { // Remove oldPresentation.addStyleRange(new StyleRange(startOld, endOld - startOld, null, colors.get(ColorName.ADDED_BACKGROUND))); } else { // Replace newPresentation.addStyleRange(new StyleRange(startNew, endNew - startNew, null, colors.get(ColorName.CHANGED_BACKGROUND))); oldPresentation.addStyleRange(new StyleRange(startOld, endOld - startOld, null, colors.get(ColorName.CHANGED_BACKGROUND))); } posOld = endOld; posNew = endNew; } oldViewer.changeTextPresentation(oldPresentation, true); newViewer.changeTextPresentation(newPresentation, true); } private class LineBackgroundListenerImpl implements LineBackgroundListener { private final Side side; LineBackgroundListenerImpl(Side side) { this.side = side; } @Override public void lineGetBackground(LineBackgroundEvent event) { if (currentInput == null) { return; } TextChangesMap changesMap = currentInput.getMap(side); ColorName colorName = changesMap.getLineColorName(event.lineOffset, event.lineText.length() + 1); if (colorName != null) { event.lineBackground = colors.get(colorName); } } } private void updateFunctionSelection(ISelection selection) { FunctionNode functionNode = null; if (selection instanceof IStructuredSelection) { IStructuredSelection structuredSelection = (IStructuredSelection) selection; if (structuredSelection.size() == 1) { Object element = structuredSelection.getFirstElement(); functionNode = (FunctionNode) element; } } setSelectedFunction(functionNode); } private void setSelectedFunction(FunctionNode functionNode) { String text; if (functionNode == null) { text = ""; //$NON-NLS-1$ } else { text = functionNode.getStatus(); highlightCode(functionNode, Side.OLD, oldSideView.sourceViewer); highlightCode(functionNode, Side.NEW, newSideView.sourceViewer); } functionStatusText.setText(text); } private void highlightCode(FunctionNode node, Side side, SourceViewer sourceViewer) { SourcePosition position = node.getPosition(side); if (position == null) { Point oldSelection = sourceViewer.getSelectedRange(); sourceViewer.setSelectedRange(oldSelection.x, 0); } else { sourceViewer.setSelectedRange(position.getStart(), position.getEnd() - position.getStart()); sourceViewer.revealRange(position.getStart(), position.getEnd() - position.getStart()); } } private class TreeListenerImpl implements ITreeViewerListener { private final TreeViewer oppositeViewer; TreeListenerImpl(TreeViewer oppositeViewer) { this.oppositeViewer = oppositeViewer; } public void treeExpanded(TreeExpansionEvent event) { if (linkMonitor.isBlocked()) { return; } linkMonitor.block(); try { oppositeViewer.expandToLevel(event.getElement(), 1); } finally { linkMonitor.unblock(); } } public void treeCollapsed(TreeExpansionEvent event) { if (linkMonitor.isBlocked()) { return; } linkMonitor.block(); try { oppositeViewer.collapseToLevel(event.getElement(), 1); } finally { linkMonitor.unblock(); } } } /** * A monitor that helps in cross-tree synchronizations. Changes in one tree are propagated to * the other one, but this monitor helps block a recursive propagation. */ private static class TreeLinkMonitor { private boolean blocked = false; private final Thread accessThread = Thread.currentThread(); void block() { assert accessThread == Thread.currentThread(); if (blocked) { throw new IllegalStateException(); } blocked = true; } void unblock() { blocked = false; } boolean isBlocked() { return blocked; } } private static class InputData { private final Map<Side, TextChangesMap> sideToMap; InputData(TextChangesMap oldSideMap, TextChangesMap newSideMap) { this.sideToMap = new EnumMap<Side, TextChangesMap>(Side.class); sideToMap.put(Side.OLD, oldSideMap); sideToMap.put(Side.NEW, newSideMap); } TextChangesMap getMap(Side side) { return sideToMap.get(side); } } private static class TextChangesMap { private final List<ChunkData> sourceChunks; private final List<ChunkData> targetChunks; TextChangesMap(List<ChunkData> sourceChunks, List<ChunkData> targetChunks) { this.sourceChunks = sourceChunks; this.targetChunks = targetChunks; } public ColorName getLineColorName(int lineStartOffset, int lineLen) { if (isChangedLine(lineStartOffset, lineLen)) { return ColorName.CHANGED_LINE_BACKGROUND; } else { return null; } } private boolean isChangedLine(final int lineStartOffset, int lineLen) { RangeBinarySearch.Input searchInput = new RangeBinarySearch.Input() { @Override public int pinPointsNumber() { return sourceChunks.size(); } @Override public boolean isPointXLessThanPinPoint(int pinPointIndex) { return lineStartOffset <= sourceChunks.get(pinPointIndex).endPosition; } }; int chunkIndex = RangeBinarySearch.find(searchInput); if (chunkIndex == sourceChunks.size()) { return false; } return lineStartOffset + lineLen > sourceChunks.get(chunkIndex).startPosition; } int translateLineNumber(final int lineNumber, final boolean preferAboveNotBelow) { // Represents chunk starts and chunk ends as one list of pin-points. RangeBinarySearch.Input searchInput = new RangeBinarySearch.Input() { @Override public int pinPointsNumber() { return sourceChunks.size() * 2; } @Override public boolean isPointXLessThanPinPoint(int pinPointIndex) { int chunkIndex = pinPointIndex / 2; int number; if (pinPointIndex % 2 == 0) { number = sourceChunks.get(chunkIndex).startLineNumber; } else { number = sourceChunks.get(chunkIndex).endLineNumber; } return preferAboveNotBelow ? lineNumber <= number : lineNumber < number; } }; int pointIndex = RangeBinarySearch.find(searchInput); int chunkIndex = pointIndex / 2; if (pointIndex % 2 == 0) { // Unmodified part of source. int diff; if (chunkIndex == 0) { diff = 0; } else { diff = targetChunks.get(chunkIndex - 1).endLineNumber - sourceChunks.get(chunkIndex - 1).endLineNumber; } return lineNumber + diff; } else { if (preferAboveNotBelow) { return targetChunks.get(chunkIndex).startLineNumber; } else { return targetChunks.get(chunkIndex).endLineNumber; } } } } private static class ChunkData { final int startLineNumber; final int endLineNumber; final int startPosition; final int endPosition; ChunkData(int startLineNumber, int endLineNumber, int startPosition, int endPosition) { this.startLineNumber = startLineNumber; this.endLineNumber = endLineNumber; this.startPosition = startPosition; this.endPosition = endPosition; } } private enum ColorName { ADDED_BACKGROUND(new RGB(220, 255, 220)), CHANGED_BACKGROUND(new RGB(220, 220, 255)), CHANGED_LINE_BACKGROUND(new RGB(240, 240, 240)); private final RGB rgb; private ColorName(RGB rgb) { this.rgb = rgb; } public RGB getRgb() { return rgb; } } private static class Colors { private final Display display; private final Map<ColorName, Color> colorMap = new EnumMap<ColorName, Color>(ColorName.class); public Colors(Display display) { this.display = display; } Color get(ColorName name) { Color result = colorMap.get(name); if (result == null) { result = new Color(display, name.getRgb()); colorMap.put(name, result); } return result; } void dispose() { for (Color color : colorMap.values()) { color.dispose(); } } } }