// 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.document;
import com.google.collide.client.AppContext;
import com.google.collide.client.editor.Editor;
import com.google.collide.client.util.PathUtil;
import com.google.collide.client.workspace.FileTreeModel;
import com.google.collide.client.workspace.FileTreeNode;
import com.google.collide.dto.ConflictChunk;
import com.google.collide.dto.FileContents;
import com.google.collide.dto.NodeConflictDto.ConflictHandle;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.json.shared.JsonStringMap;
import com.google.collide.shared.Pair;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.Line;
import com.google.collide.shared.util.JsonCollections;
import com.google.collide.shared.util.ListenerManager;
import com.google.collide.shared.util.ListenerRegistrar;
import com.google.collide.shared.util.ListenerManager.Dispatcher;
/**
* Manager for documents and editors.
*
* Note that a document can be unlinked from a file <em>while</em> it is open
* in an editor!
*
*/
public class DocumentManager {
public static DocumentManager create(FileTreeModel fileTreeModel, AppContext appContext) {
return new DocumentManager(fileTreeModel, appContext);
}
/**
* Listener for changes to the lifecycle of individual documents.
*/
public interface LifecycleListener {
/**
* Called after the document is created.
*/
void onDocumentCreated(Document document);
/**
* Called after the document is linked to a file.
*/
void onDocumentLinkedToFile(Document document, FileContents fileContents);
/**
* Called after the document is opened in an editor.
*/
void onDocumentOpened(Document document, Editor editor);
/**
* Called after the document is no longer open in an editor.
*/
void onDocumentClosed(Document document, Editor editor);
/**
* Called <em>before</em> the document is unlinked to its file (calling the
* {@link DocumentMetadata} getters for file-related metadata is okay.)
*/
void onDocumentUnlinkingFromFile(Document document);
/**
* Called after the document has been garbage collected.
*/
void onDocumentGarbageCollected(Document document);
}
/**
* Listener for the loading of a document.
*/
public interface GetDocumentCallback {
void onDocumentReceived(Document document);
void onUneditableFileContentsReceived(FileContents contents);
void onFileNotFoundReceived();
}
private static final int MAX_CACHED_DOCUMENTS = 4;
private final FileTreeModel fileTreeModel;
private final DocumentManagerNetworkController networkController;
private final DocumentManagerFileTreeModelListener fileTreeModelListener;
/**
* All of the documents, ordered by least-recently used documents (index 0 is
* the least recently used).
*/
private final JsonArray<Document> documents = JsonCollections.createArray();
private final JsonStringMap<Document> documentsByFileEditSessionKey = JsonCollections.createMap();
private final ListenerManager<LifecycleListener> lifecycleListenerManager =
ListenerManager.create();
/*
* TODO: this will need to become a Document -> Editors map
* eventually
*/
private Editor editor;
private DocumentManager(FileTreeModel fileTreeModel, AppContext appContext) {
this.fileTreeModel = fileTreeModel;
networkController = new DocumentManagerNetworkController(this, appContext);
fileTreeModelListener = new DocumentManagerFileTreeModelListener(this, fileTreeModel);
}
public void cleanup() {
fileTreeModelListener.teardown();
networkController.teardown();
while (documents.size() > 0) {
garbageCollectDocument(documents.get(0));
}
}
public ListenerRegistrar<LifecycleListener> getLifecycleListenerRegistrar() {
return lifecycleListenerManager;
}
/**
* Returns a copy of the list of documents managed by this class.
*/
public JsonArray<Document> getDocuments() {
return documents.copy();
}
public Document getDocumentByFileEditSessionKey(String fileEditSessionKey) {
return documentsByFileEditSessionKey.get(fileEditSessionKey);
}
public void attachToEditor(final Document document, final Editor editor) {
final Document oldDocument = editor.getDocument();
if (oldDocument != null) {
detachFromEditor(editor, oldDocument);
}
this.editor = editor;
markAsActive(document);
editor.setDocument(document);
lifecycleListenerManager.dispatch(new Dispatcher<LifecycleListener>() {
@Override
public void dispatch(LifecycleListener listener) {
listener.onDocumentOpened(document, editor);
}
});
}
private void detachFromEditor(final Editor editor, final Document document) {
lifecycleListenerManager.dispatch(new Dispatcher<LifecycleListener>() {
@Override
public void dispatch(LifecycleListener listener) {
listener.onDocumentClosed(document, editor);
}
});
clearDocumentState(document);
}
/*
* TODO: in the future, different features will remove
* non-persistent stuff themselves. For now, clear everything.
*/
private void clearDocumentState(final Document document) {
for (Line line = document.getFirstLine(); line != null; line = line.getNextLine()) {
line.clearTags();
}
// Column anchors exist on the line via a tag, so those get cleared above
document.getAnchorManager().clearLineAnchors();
}
public void getDocument(PathUtil path, GetDocumentCallback callback) {
if (fileTreeModel.getWorkspaceRoot() != null) {
// FileTreeModel is populated so get the file edit session key for this path
FileTreeNode node = fileTreeModel.getWorkspaceRoot().findChildNode(path);
if (node != null && node.getFileEditSessionKey() != null) {
String fileEditSessionKey = node.getFileEditSessionKey();
Document document = documentsByFileEditSessionKey.get(fileEditSessionKey);
if (document != null) {
callback.onDocumentReceived(document);
return;
}
}
}
networkController.load(path, callback);
// handleEditableFileReceived will be called async
}
void handleEditableFileReceived(
FileContents fileContents, JsonArray<GetDocumentCallback> callbacks) {
/*
* One last check to make sure we don't already have a Document for this
* file
*/
Document document = documentsByFileEditSessionKey.get(fileContents.getFileEditSessionKey());
if (document == null) {
document = createDocument(fileContents.getContents(), new PathUtil(fileContents.getPath()),
fileContents.getFileEditSessionKey(), fileContents.getCcRevision(),
fileContents.getConflicts(), fileContents.getConflictHandle(), fileContents);
tryGarbageCollect();
} else {
/*
* Ensure we have the latest path stashed in the metadata. One case where
* this matters is if a file is renamed, we will have had the old path --
* this logic will update its path.
*/
DocumentMetadata.putPath(document, new PathUtil(fileContents.getPath()));
}
for (int i = 0, n = callbacks.size(); i < n; i++) {
callbacks.get(i).onDocumentReceived(document);
}
}
/**
* @param conflicts only required for documents that are in a conflicted state
* @param conflictHandle only required for documents that are in a conflicted state
*/
private Document createDocument(String contents, PathUtil path, String fileEditSessionKey,
int ccRevision, JsonArray<ConflictChunk> conflicts, ConflictHandle conflictHandle,
final FileContents fileContents) {
final Document document = Document.createFromString(contents);
documents.add(document);
boolean isLinkedToFile = fileEditSessionKey != null;
if (isLinkedToFile) {
documentsByFileEditSessionKey.put(fileEditSessionKey, document);
}
DocumentMetadata.putLinkedToFile(document, isLinkedToFile);
DocumentMetadata.putPath(document, path);
DocumentMetadata.putFileEditSessionKey(document, fileEditSessionKey);
DocumentMetadata.putBeginCcRevision(document, ccRevision);
DocumentMetadata.putConflicts(document, conflicts);
DocumentMetadata.putConflictHandle(document, conflictHandle);
lifecycleListenerManager.dispatch(new Dispatcher<LifecycleListener>() {
@Override
public void dispatch(LifecycleListener listener) {
listener.onDocumentCreated(document);
}
});
if (isLinkedToFile) {
lifecycleListenerManager.dispatch(new Dispatcher<LifecycleListener>() {
@Override
public void dispatch(LifecycleListener listener) {
listener.onDocumentLinkedToFile(document, fileContents);
}
});
}
// Save the fileEditSessionKey into the tree node.
if (fileTreeModel.getWorkspaceRoot() != null) {
FileTreeNode node = fileTreeModel.getWorkspaceRoot().findChildNode(path);
if (node != null) {
node.setFileEditSessionKey(fileEditSessionKey);
}
}
return document;
}
private void markAsActive(Document document) {
if (documents.peek() != document) {
// Ensure it is at the top
documents.remove(document);
documents.add(document);
}
}
private void tryGarbageCollect() {
int removeCount = documents.size() - MAX_CACHED_DOCUMENTS;
for (int i = 0; i < documents.size() && removeCount > 0;) {
Document document = documents.get(i);
boolean documentIsOpen = editor != null && editor.getDocument() == document;
if (documentIsOpen) {
i++;
continue;
}
garbageCollectDocument(document);
removeCount--;
}
}
void garbageCollectDocument(final Document document) {
if (DocumentMetadata.isLinkedToFile(document)) {
unlinkFromFile(document);
}
documents.remove(document);
lifecycleListenerManager.dispatch(new Dispatcher<LifecycleListener>() {
@Override
public void dispatch(LifecycleListener listener) {
listener.onDocumentGarbageCollected(document);
}
});
}
public void unlinkFromFile(final Document document) {
lifecycleListenerManager.dispatch(new Dispatcher<DocumentManager.LifecycleListener>() {
@Override
public void dispatch(LifecycleListener listener) {
listener.onDocumentUnlinkingFromFile(document);
}
});
documentsByFileEditSessionKey.remove(DocumentMetadata.getFileEditSessionKey(document));
DocumentMetadata.putLinkedToFile(document, false);
}
Document getMostRecentlyActiveDocument() {
return documents.peek();
}
/**
* Returns a potentially empty list of pairs of a document and an editor.
*/
JsonArray<Pair<Document, Editor>> getOpenDocuments() {
JsonArray<Pair<Document, Editor>> result = JsonCollections.createArray();
if (editor == null || editor.getDocument() == null) {
return result;
}
/*
* TODO: When there are more than one editor, this will not be
* trivial
*/
result.add(Pair.of(editor.getDocument(), editor));
return result;
}
public Document getDocumentById(int documentId) {
for (int i = 0, n = documents.size(); i < n; i++) {
if (documents.get(i).getId() == documentId) {
return documents.get(i);
}
}
return null;
}
}