/*
* Copyright 2009 Google Inc.
*
* Licensed under the Apache License, Version 2.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.apache.org/licenses/LICENSE-2.0
*
* 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.google.speedtracer.client;
import com.google.gwt.chrome.crx.client.Chrome;
import com.google.gwt.dom.client.AnchorElement;
import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.HeadElement;
import com.google.gwt.dom.client.IFrameElement;
import com.google.gwt.dom.client.NodeList;
import com.google.gwt.dom.client.SpanElement;
import com.google.gwt.dom.client.StyleElement;
import com.google.gwt.dom.client.TableCellElement;
import com.google.gwt.dom.client.TableElement;
import com.google.gwt.dom.client.TableRowElement;
import com.google.gwt.dom.client.Style.Display;
import com.google.gwt.events.client.Event;
import com.google.gwt.events.client.EventListener;
import com.google.gwt.resources.client.ClientBundle;
import com.google.gwt.resources.client.CssResource;
import com.google.gwt.resources.client.ImageResource;
import com.google.gwt.topspin.ui.client.ClickEvent;
import com.google.gwt.topspin.ui.client.ClickListener;
import com.google.speedtracer.client.util.Command;
/**
* Class that exposes API for mapping a resource URL and line number to actual
* source. This wraps an iFrame which contains the source contents formatted as
* an HTML table. View and Model are coupled in that the presentation of the
* source (rendered as a styled table) also encodes the line number and line
* contents per row.
*
* WebKit's inspector allows for iFrames to take on the semantics of a
* view-source:// URL if you attach the appropriate attribute
* <code>viewSource="true"</code> to the iFrame element. Since we are not
* allowed to violate same origin, we rely on Cross Domain XHR functionality
* provided by Chromium Extensions. We use a simply proxy script embedded in a
* local "ResourceFetcher.html" file that fetches the resource, and formats it
* appropriately in the table structure that view-source:// Urls expect.
*/
public class SourceViewer {
/**
* Things that contain a SourceViewer can implement this interface to have
* external entities display source through them.
*
* TODO (jaimeyap): Remove the absoluteFilePath param when GPE is able to
* resolve classpath relative paths to files.
*/
public interface SourcePresenter {
void showSource(String resourceUrl, SourceViewerServer sourceViewerServer,
int lineNumber, int column, String absoluteFilePath);
}
/**
* Styles that get applied to the internals of the iFrame for highlighting
* line numbers and other code styling.
*/
public interface CodeCss extends CssResource {
String columnMarker();
String highlightedLine();
/**
* Some spacing between the top of the source viewer and the line that is
* scrolled into view.
*/
int scrollPadding();
}
/**
* Styles for styling the outside of this Widget.
*/
public interface Css extends CssResource {
String base();
String closeLink();
String frameWrapper();
String header();
String titleText();
}
/**
* Externalized ClientBundle Resource interface.
*/
public interface Resources extends ClientBundle {
@Source("resources/column-marker.png")
ImageResource columnMarker();
@Source("resources/SourceViewerCode.css")
CodeCss sourceViewerCodeCss();
@Source("resources/SourceViewer.css")
Css sourceViewerCss();
}
/**
* Callback invoked when the source that was requested has been loaded.
*/
public interface SourceViewerLoadedCallback {
void onSourceViewerLoaded(SourceViewer viewer);
void onSourceFetchFail(int statusCode, SourceViewer viewer);
}
/**
* Callback invoked when the SourceViewer instance is initialized and ready
* for use.
*/
public interface SourceViewerInitializedCallback {
void onSourceViewerInitialized(SourceViewer viewer);
}
/**
* Callback used internally to know when the SourceFetcher.html page has
* fetched the source resource.
*/
private interface SourceFetcherCallback {
void onContentReady(int statusCode);
}
private static final String LINE_CONTENT = "webkit-line-content";
/**
* Creates an instance of the SourceViewer and invokes the passed in callback
* when the iFrame is loaded with the target source.
*
* We first load the proxy html page. This proxy page uses cross site XHR
* enabled by Chrome extensions to fetch and format the target source. Once
* the target source is loaded, we consider the viewer initialized.
*
* @param parent the parent container element we will attach the SourceViewer
* to.
* @param resources the ClientBundle instance for this class.
* @param initializedCallback the {@link SourceViewerInitializedCallback} that
* we pass the loaded SourceViewer to.
*/
public static void create(Element parent, final Resources resources,
final SourceViewerInitializedCallback initializedCallback) {
Document document = parent.getOwnerDocument();
// Create the iframe within which we will load the source.
final IFrameElement sourceFrame = document.createIFrameElement();
Element frameWrapper = document.createDivElement();
frameWrapper.setClassName(resources.sourceViewerCss().frameWrapper());
frameWrapper.appendChild(sourceFrame);
final Element baseElement = document.createDivElement();
final Element headerElem = document.createDivElement();
headerElem.setClassName(resources.sourceViewerCss().header());
baseElement.appendChild(headerElem);
// IFrame must be attached to fire onload.
baseElement.appendChild(frameWrapper);
parent.appendChild(baseElement);
Event.addEventListener("load", sourceFrame, new EventListener() {
public void handleEvent(Event event) {
// The source fetcher should be loaded. Lets now point it at the source
// we want to load.
SourceViewer sourceViewer = new SourceViewer(baseElement, headerElem,
sourceFrame, resources);
initializedCallback.onSourceViewerInitialized(sourceViewer);
}
});
sourceFrame.setSrc(Chrome.getExtension().getUrl("monitor/SourceFetcher.html"));
}
/**
* Calls into the iframe's window context to call a method defined by
* SourceFetcher.html. This method does a Cross Domain XHR to fetch the source
* resource we want to view.
*/
private static native void fetchSource(IFrameElement sourceFrame,
String targetSource, SourceFetcherCallback callback) /*-{
var frameWindow = sourceFrame.contentWindow;
if (frameWindow && frameWindow._doFetchUrl) {
frameWindow._doFetchUrl(targetSource, function(statusCode) {
callback.@com.google.speedtracer.client.SourceViewer.SourceFetcherCallback::onContentReady(I)(statusCode);
});
}
}-*/;
private static void injectStyles(IFrameElement sourceFrame, String styleText) {
Document iframeDocument = sourceFrame.getContentDocument();
HeadElement head = iframeDocument.getElementsByTagName("head").getItem(0).cast();
StyleElement styleTag = iframeDocument.createStyleElement();
styleTag.setInnerText(styleText);
head.appendChild(styleTag);
}
private final SpanElement columnMarker;
private String currentResourceUrl;
// The base Element container for the SourceViewer.
private final Element element;
private TableRowElement highlightedRow;
private final IFrameElement sourceFrame;
private final CodeCss styles;
// The element where we display the URL of the currently loaded source.
private final Element titleElement;
protected SourceViewer(Element myElement, Element headerElem,
IFrameElement sourceFrame, Resources resources) {
this.element = myElement;
this.sourceFrame = sourceFrame;
this.styles = resources.sourceViewerCodeCss();
this.element.setClassName(resources.sourceViewerCss().base());
// Create the title element and the close link.
Document document = myElement.getOwnerDocument();
this.titleElement = document.createDivElement();
titleElement.setClassName(resources.sourceViewerCss().titleText());
AnchorElement closeLink = document.createAnchorElement();
closeLink.setClassName(resources.sourceViewerCss().closeLink());
closeLink.setHref("javascript:;");
closeLink.setInnerText("Close");
headerElem.appendChild(titleElement);
headerElem.appendChild(closeLink);
this.columnMarker = document.createSpanElement();
// TODO(jaimeyap): I guess this listener is going to leak.
ClickEvent.addClickListener(closeLink, closeLink, new ClickListener() {
public void onClick(ClickEvent event) {
hide();
}
});
injectStyles(sourceFrame, this.styles.getText());
}
public String getCurrentResourceUrl() {
return currentResourceUrl;
}
public Element getElement() {
return element;
}
/**
* Gets the source line contents for a specified line number.
*
* @param lineNumber the 1 based line index.
* @return the source line contents as a String.
*/
public String getLineContents(int lineNumber) {
TableRowElement row = getTableRowElement(lineNumber);
TableCellElement contents = getRowContentCell(row);
if (contents != null) {
return contents.getInnerText();
}
return null;
}
public void hide() {
getElement().getStyle().setDisplay(Display.NONE);
}
/**
* Highlights a specified line of source. Only one line of source will be
* highlighted at a time. Subsequent calls to this method will remove
* highlighting of other lines before highlighting the new line.
*
* @param lineNumber the 1 based line index this method will highlight.
*/
public void highlightLine(int lineNumber) {
if (highlightedRow != null) {
highlightedRow.removeClassName(styles.highlightedLine());
}
TableRowElement row = getTableRowElement(lineNumber);
if (row != null) {
highlightedRow = row;
highlightedRow.addClassName(styles.highlightedLine());
}
}
/**
* Points the SourceViewer at a particular resource URL and calls back once it
* has loaded. This method is asynchronous and will call you back some time
* later.
*
* @param resource the resource URL you wish to load.
* @param callback the {@link SourceViewerLoadedCallback} that gets invoked
* once the resource has been fetched.
*/
public void loadResource(final String resource,
final SourceViewerLoadedCallback callback) {
// Defense against empty urls.
if (resource == null || "".equals(resource)) {
Command.defer(new Command.Method() {
public void execute() {
callback.onSourceFetchFail(-1, SourceViewer.this);
}
});
return;
}
// Early out if this frame already points at the requested resource.
if (resource.equals(currentResourceUrl)) {
// This method is expected to be asynchronous.
Command.defer(new Command.Method() {
public void execute() {
callback.onSourceViewerLoaded(SourceViewer.this);
}
});
return;
}
fetchSource(sourceFrame, resource, new SourceFetcherCallback() {
public void onContentReady(int statusCode) {
if (statusCode == 200) {
// This target source resource should now be fetched and the table
// constructed.
sourceFrame.setAttribute("viewSource", "true");
currentResourceUrl = resource;
// Display the name of the resource URL and a link to close it.
titleElement.setInnerText("Viewing: " + currentResourceUrl);
callback.onSourceViewerLoaded(SourceViewer.this);
} else {
callback.onSourceFetchFail(statusCode, SourceViewer.this);
}
}
});
}
/**
* Places a marker in the source for the specified line number at the
* specified character offset.
*
* @param lineNumber the line which we will use for marking the column.
* @param columnNumber the offset from the start of the line to mark.
*/
public void markColumn(int lineNumber, int columnNumber) {
if (columnNumber <= 0) {
return;
}
TableCellElement contentCell = getRowContentCell(getTableRowElement(lineNumber));
columnMarker.removeFromParent();
int zeroIndexCol = columnNumber - 1;
String textBeforeMark = contentCell.getInnerText().substring(0,
zeroIndexCol);
String textAfterMark = contentCell.getInnerText().substring(zeroIndexCol);
Document document = contentCell.getOwnerDocument();
contentCell.setInnerText("");
contentCell.appendChild(document.createTextNode(textBeforeMark));
contentCell.appendChild(columnMarker);
contentCell.appendChild(document.createTextNode(textAfterMark));
columnMarker.setClassName(styles.columnMarker());
}
/**
* We scroll to the highlighted node to the top of the source viewer frame.
*/
public void scrollColumnMarkerIntoView() {
sourceFrame.getContentDocument().setScrollTop(
columnMarker.getOffsetTop() - styles.scrollPadding());
}
/**
* We scroll to the highlighted line to the top of the source viewer frame.
*/
public void scrollHighlightedLineIntoView() {
sourceFrame.getContentDocument().setScrollTop(
highlightedRow.getOffsetTop() - styles.scrollPadding());
}
public void show() {
getElement().getStyle().setDisplay(Display.BLOCK);
}
/**
* Getter for the <code>tr</code> element wrapping the line of code.
*
* @param lineNumber the 1 based index for the row.
* @return the {@link TableRowElement} wrapping the line of code.
*/
TableRowElement getTableRowElement(int lineNumber) {
NodeList<TableElement> tables = sourceFrame.getContentDocument().getElementsByTagName(
"table").cast();
TableElement sourceTable = tables.getItem(0);
assert (lineNumber > 0);
assert (sourceTable != null) : "No table loaded in source frame.";
return sourceTable.getRows().getItem(lineNumber - 1);
}
/**
* Returns the cell that contains the line contents for a row.
*/
private TableCellElement getRowContentCell(TableRowElement row) {
NodeList<TableCellElement> cells = row.getElementsByTagName("td").cast();
for (int i = 0, n = cells.getLength(); i < n; i++) {
TableCellElement cell = cells.getItem(i);
if (cell.getClassName().indexOf(LINE_CONTENT) >= 0) {
return cell;
}
}
return null;
}
}