// Copyright 2012 Google Inc. All Rights Reserved.
//
// 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.collide.client.code.debugging;
import com.google.collide.client.code.debugging.DebuggerApiTypes.CssStyleSheetHeader;
import com.google.collide.client.code.debugging.DebuggerApiTypes.OnAllCssStyleSheetsResponse;
import com.google.collide.client.code.debugging.DebuggerApiTypes.OnEvaluateExpressionResponse;
import com.google.collide.client.document.DocumentManager;
import com.google.collide.client.util.DeferredCommandExecutor;
import com.google.collide.client.util.PathUtil;
import com.google.collide.dto.FileContents;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.TextChange;
import com.google.collide.shared.util.JsonCollections;
import com.google.collide.shared.util.ListenerRegistrar.RemoverManager;
import com.google.common.base.Preconditions;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* Controller for live edits of CSS files during debugging.
*
* <p>Schedules propagation of CSS edits to the debugger.
*
*/
class CssLiveEditController {
/**
* Bean containing information about document binding and update status.
*/
private static class DocumentInfo {
@Nullable
private Document document;
@Nonnull
private final String styleSheetId;
@Nonnull
private final PathUtil path;
/**
* Indicator that file has changed, but changes hasn't been sent to debugger.
*/
private boolean dirty;
private DocumentInfo(@Nonnull String styleSheetId, @Nonnull PathUtil path) {
Preconditions.checkNotNull(styleSheetId);
Preconditions.checkNotNull(path);
this.path = path;
this.styleSheetId = styleSheetId;
}
}
private final DocumentManager documentManager;
private final DebuggerState debuggerState;
private final EventsListenerImpl eventsListener;
/**
* Executor that sends content of marked documents to debugger
* and then clears marks.
*/
private final DeferredCommandExecutor updatesApplier = new DeferredCommandExecutor(100) {
@Override
protected boolean execute() {
if (debuggerState.isActive()) {
for (DocumentInfo documentInfo : trackedDocuments.asIterable()) {
if (documentInfo.dirty) {
documentInfo.dirty = false;
Document document = Preconditions.checkNotNull(documentInfo.document);
String styleSheetId = Preconditions.checkNotNull(documentInfo.styleSheetId);
debuggerState.setStyleSheetText(styleSheetId, document.asText());
}
}
}
return false;
}
};
// TODO: Can Chrome notify us when list changes?
/**
* Executor that periodically requests list of used CSS from debugger.
*/
private final DeferredCommandExecutor trackListUpdater = new DeferredCommandExecutor(100) {
@Override
protected boolean execute() {
if (debuggerState.isActive()) {
debuggerState.requestAllCssStyleSheets();
}
return true;
}
};
/**
* List of documents that are currently tracked.
*/
private final JsonArray<DocumentInfo> trackedDocuments = JsonCollections.createArray();
private final RemoverManager removerManager = new RemoverManager();
private class EventsListenerImpl implements
DebuggerState.DebuggerStateListener,
DebuggerState.CssListener,
DebuggerState.EvaluateExpressionListener {
@Override
public void onAllCssStyleSheetsResponse(OnAllCssStyleSheetsResponse response) {
handleOnAllCssStyleSheetsResponse(response);
}
@Override
public void onDebuggerStateChange() {
updateLiveEditListenerState();
}
@Override
public void onEvaluateExpressionResponse(OnEvaluateExpressionResponse response) {
}
@Override
public void onGlobalObjectChanged() {
// Invalidate the cached stylesheet ID.
updateLiveEditListenerState();
}
}
/**
* Marks specified document for update and schedules update is necessary.
*/
private void scheduleUpdate(@Nonnull DocumentInfo documentInfo) {
Preconditions.checkNotNull(documentInfo);
if (!debuggerState.isActive()) {
return;
}
Preconditions.checkNotNull(documentInfo.document);
if (documentInfo.dirty) {
return;
}
documentInfo.dirty = true;
if (!updatesApplier.isScheduled()) {
updatesApplier.schedule(1);
}
}
CssLiveEditController(DebuggerState debuggerState, DocumentManager documentManager) {
this.debuggerState = debuggerState;
this.documentManager = documentManager;
this.eventsListener = new EventsListenerImpl();
debuggerState.getDebuggerStateListenerRegistrar().add(eventsListener);
debuggerState.getCssListenerRegistrar().add(eventsListener);
debuggerState.getEvaluateExpressionListenerRegistrar().add(eventsListener);
}
private void updateLiveEditListenerState() {
untrackAll();
trackListUpdater.cancel();
if (debuggerState.isActive()) {
trackListUpdater.schedule(1);
}
}
/**
* Registers all CSS paths / styleSheetIds that are in use.
*/
private void handleOnAllCssStyleSheetsResponse(OnAllCssStyleSheetsResponse response) {
if (!debuggerState.isActive()) {
return;
}
JsonArray<DocumentInfo> freshList = buildDocumentInfoList(response);
if (checkForEquality(freshList, trackedDocuments)) {
return;
}
untrackAll();
for (final DocumentInfo documentInfo : freshList.asIterable()) {
trackedDocuments.add(documentInfo);
documentManager.getDocument(documentInfo.path, new DocumentManager.GetDocumentCallback() {
@Override
public void onDocumentReceived(Document document) {
// This method can be called asynchronously. If item is not in list
// then ignore this notification. "equals" is not overridden, so
// "contains" reports that list contains given reference.
if (!trackedDocuments.contains(documentInfo)) {
return;
}
documentInfo.document = document;
removerManager.track(document.getTextListenerRegistrar().add(new Document.TextListener() {
@Override
public void onTextChange(Document document, JsonArray<TextChange> textChanges) {
scheduleUpdate(documentInfo);
}
}));
// Schedule update, because debugger can have stale version in use.
scheduleUpdate(documentInfo);
}
@Override
public void onUneditableFileContentsReceived(FileContents contents) {
// TODO: Well, is it unmodifiable at all, or only for us?
}
@Override
public void onFileNotFoundReceived() {
// Do nothing.
}
});
}
}
/**
* Checks equality of two track-lists.
*
* <p>Track lists are supposed to be equal if they contain the same set
* of (path, styleSheetId) pairs.
*
* <p>Note: currently we suggest, that list is ordered somehow, so pairs have
* matching positions.
*/
private boolean checkForEquality(JsonArray<DocumentInfo> list1, JsonArray<DocumentInfo> list2) {
if (list1.size() != list2.size()) {
return false;
}
for (int i = 0, n = list1.size(); i < n; i++) {
DocumentInfo item1 = list1.get(i);
DocumentInfo item2 = list2.get(i);
if (!item1.styleSheetId.equals(item2.styleSheetId)) {
return false;
}
if (!item1.path.equals(item2.path)) {
return false;
}
}
return true;
}
/**
* Builds track-list from debugger response.
*
* <p>Only ".css" files are accepted.
*
* <p>Note: For some items URL looks like "data:...".
* In that case {@link PathUtil} is {@code null}.
*/
private JsonArray<DocumentInfo> buildDocumentInfoList(OnAllCssStyleSheetsResponse response) {
JsonArray<DocumentInfo> result = JsonCollections.createArray();
JsonArray<CssStyleSheetHeader> headers = response.getHeaders();
for (CssStyleSheetHeader header : headers.asIterable()) {
String url = header.getUrl();
PathUtil path = debuggerState.getSourceMapping().getLocalSourcePath(url);
if (path == null) {
continue;
}
if (!path.getBaseName().endsWith(".css")) {
continue;
}
String styleSheetId = header.getId();
DocumentInfo docInfo = new DocumentInfo(styleSheetId, path);
result.add(docInfo);
}
return result;
}
/**
* Unregisters text-change listeners, stops appropriate executors and
* cleans track list.
*/
private void untrackAll() {
removerManager.remove();
trackedDocuments.clear();
updatesApplier.cancel();
}
}