// 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.server.documents; import com.google.collide.dto.DocOp; import com.google.collide.dto.DocumentSelection; import com.google.collide.server.documents.VersionedDocument.DocumentOperationException; import com.google.collide.server.documents.VersionedDocument.VersionedText; import com.google.collide.server.shared.merge.ConflictChunk; import com.google.collide.server.shared.merge.MergeChunk; import com.google.collide.server.shared.merge.MergeResult; import com.google.collide.server.shared.util.FileHasher; import com.google.collide.shared.document.anchor.Anchor; import com.google.collide.shared.document.anchor.Anchor.ShiftListener; import com.google.collide.shared.document.anchor.AnchorManager; import com.google.collide.shared.document.anchor.AnchorType; import com.google.collide.shared.document.anchor.InsertionPlacementStrategy; import com.google.collide.shared.ot.DocOpUtils; import com.google.common.base.Charsets; import com.google.common.collect.Lists; import com.google.common.io.Files; import com.google.protobuf.ByteString; import org.vertx.java.core.logging.Logger; import java.io.File; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.util.List; import javax.annotation.Nullable; /** * Default implementation of {@link FileEditSession}. * * <p> * This class is thread-safe. * */ final class FileEditSessionImpl implements FileEditSession { /** * Bundles together a snapshot of the text of this file with any conflict chunks. */ private static class VersionedTextAndConflictChunksImpl implements VersionedTextAndConflictChunks { private final VersionedText text; private final List<AnchoredConflictChunk> conflictChunks; VersionedTextAndConflictChunksImpl( VersionedText text, List<AnchoredConflictChunk> conflictChunks) { this.text = text; this.conflictChunks = conflictChunks; } @Override public VersionedText getVersionedText() { return text; } @Override public List<AnchoredConflictChunk> getConflictChunks() { return conflictChunks; } } private static class AnchoredConflictChunk extends ConflictChunk { private static final AnchorType CONFLICT_CHUNK_START_LINE = AnchorType.create(FileEditSessionImpl.class, "conflictChunkStart"); private static final AnchorType CONFLICT_CHUNK_END_LINE = AnchorType.create(FileEditSessionImpl.class, "conflictChunkEnd"); public final Anchor startLineAnchor; public final Anchor endLineAnchor; public AnchoredConflictChunk(ConflictChunk chunk, VersionedDocument doc) { super(chunk, chunk.isResolved()); // Add anchors at the conflict regions' boundaries, so their position/size // gets adjusted automatically as the user enters text in and around them. startLineAnchor = doc.addAnchor( CONFLICT_CHUNK_START_LINE, chunk.getStartLine(), AnchorManager.IGNORE_COLUMN); startLineAnchor.setRemovalStrategy(Anchor.RemovalStrategy.SHIFT); startLineAnchor.getShiftListenerRegistrar().add(new ShiftListener() { @Override public void onAnchorShifted(Anchor anchor) { setStartLine(anchor.getLineNumber()); } }); endLineAnchor = doc.addAnchor(CONFLICT_CHUNK_END_LINE, chunk.getEndLine(), AnchorManager.IGNORE_COLUMN); endLineAnchor.setInsertionPlacementStrategy(InsertionPlacementStrategy.LATER); endLineAnchor.setRemovalStrategy(Anchor.RemovalStrategy.SHIFT); endLineAnchor.getShiftListenerRegistrar().add(new ShiftListener() { @Override public void onAnchorShifted(Anchor anchor) { setEndLine(anchor.getLineNumber()); } }); } } /** * Given a merge result from the originally conflicted state, construct conflict chunks for it. */ private static List<ConflictChunk> constructConflictChunks(MergeResult mergeResult) { List<ConflictChunk> conflicts = Lists.newArrayList(); for (MergeChunk mergeChunk : mergeResult.getMergeChunks()) { if (mergeChunk.hasConflict()) { conflicts.add(new ConflictChunk(mergeChunk)); } } return conflicts; } /** * Document that contains the file contents. */ private VersionedDocument contents; /** The list of conflict chunks for this file. */ private final List<AnchoredConflictChunk> conflictChunks = Lists.newArrayList(); /* * The size and sha1 fields don't actually need to stay in lock-step with the doc contents since * there's no public API for retrieving a snapshot of both values. Thus, we don't need blocking * synchronization. We do however need to ensure that updates made by one thread are seen by other * threads, so they must be declared volatile. */ /** Size of the file, in bytes. Lazily computed by {@link #getSize()}. */ private Integer size = null; /** SHA-1 hash of the file contents. Lazily computed by {@link #getSha1()}. */ private ByteString sha1 = null; /** CC revision of the document that we last saved */ private int lastSavedCcRevision; /** CC revision of the document after the last mutation was applied */ private int lastMutationCcRevision; /** True if the file-edit session has been closed */ private boolean closed = false; /** When this file edit session was closed. Makes sense only if closed = true. */ private long closedTimeMs; /** Time that this FileEditSession was created (millis since epoch) */ private final long createdAt = System.currentTimeMillis(); private OnCloseListener onCloseListener; /** The ID of the resource this edit session is opened for. */ private final String resourceId; private final Logger logger; protected String lastSavedPath; /** * Constructs a {@link FileEditSessionImpl} for a file. * * @param resourceId the identifier for the resource we are editing. * @param initialContents the initial contents of the file * @param mergeResult if non-null the merge info related to the out of date */ FileEditSessionImpl(String resourceId, String path, String initialContents, @Nullable MergeResult mergeResult, Logger logger) { this.resourceId = resourceId; this.lastSavedPath = path; this.logger = logger; this.contents = new VersionedDocument(initialContents, logger); if (mergeResult != null) { // Construct conflict chunks. List<ConflictChunk> chunks = constructConflictChunks(mergeResult); this.contents = new VersionedDocument(mergeResult.getMergedText(), logger); if (chunks.size() == 0) { logger.error(String.format("Non-null MergeResult passed to FileEditSession for file that" + " should have merged cleanly: [%s]", this)); } for (ConflictChunk chunk : chunks) { this.conflictChunks.add(new AnchoredConflictChunk(chunk, contents)); } } this.lastSavedCcRevision = contents.getCcRevision(); this.lastMutationCcRevision = 0; logger.debug(String.format("FileEditSession [%s] was created at [%d]", this, createdAt)); } @Override protected void finalize() throws Throwable { try { if (!closed) { logger.warn( String.format("FileEditSession [%s] finalized without being closed first", this)); close(); } } catch (Throwable thrown) { logger.error( String.format("Uncaught Throwable in FileEditSessionImpl.finalize of [%s]", this), thrown); } finally { super.finalize(); } } private void checkNotClosed() { if (closed) { throw new FileEditSessionClosedException(resourceId, closedTimeMs); } } @Override public void close() { // if already closed, do nothing and silently return if (!closed) { closed = true; return; } closedTimeMs = System.currentTimeMillis(); // TODO: Maybe change the semantics of this method to block until // all outstanding calls to other methods guarded by checkNotClosed() // finish. IncrementableCountDownLatch would do the trick. if (hasChanges()) { logger.warn(String.format("FileEditSession [%s] closed while dirty", this)); } if (onCloseListener != null) { onCloseListener.onClosed(); } } @Override public synchronized void setOnCloseListener(OnCloseListener listener) { if (this.onCloseListener != null) { throw new IllegalStateException("One listener already registered."); } this.onCloseListener = listener; } @Override public VersionedDocument.ConsumeResult consume(List<DocOp> docOps, String authorClientId, int intendedCcRevision, DocumentSelection selection) throws DocumentOperationException { checkNotClosed(); boolean containsMutation = DocOpUtils.containsMutation(docOps); VersionedDocument.ConsumeResult result = contents.consume(docOps, authorClientId, intendedCcRevision, selection); if (containsMutation) { lastMutationCcRevision = contents.getCcRevision(); // Reset the cached size and SHA-1. We'll wait until someone actually calls getSize() or // getSha1() to recompute them. size = null; sha1 = null; } return result; } private String getText() { return contents.asText().text; } @Override public String getContents() { checkNotClosed(); return getText(); } public int getCcRevision() { return lastMutationCcRevision; } @Override public int getSize() { checkNotClosed(); if (size == null) { try { size = getText().getBytes("UTF-8").length; } catch (UnsupportedEncodingException e) { // UTF-8 is a charset required by Java spec, per javadoc of // java.nio.charset.Charset, so this can't happen. throw new RuntimeException("UTF-8 not supported in this JVM?!", e); } } return size; } @Override public ByteString getSha1() { checkNotClosed(); if (sha1 == null) { sha1 = FileHasher.getSha1(getText()); } return sha1; } @Override public VersionedDocument getDocument() { checkNotClosed(); return contents; } @Override public String getFileEditSessionKey() { // probably ok to call on a closed FileEditSession return resourceId; } @Override public boolean hasChanges() { return lastSavedCcRevision < lastMutationCcRevision; } @Override public void save(String currentPath) throws IOException { checkNotClosed(); if (currentPath == null) { logger.fatal(String.format("We do do not know the path for edit session [%s]!", this)); return; } // Get a consistent snapshot of the raw text and conflict chunks VersionedTextAndConflictChunksImpl snapshot = getContentsAndConflictChunks(); String text = snapshot.getVersionedText().text; List<AnchoredConflictChunk> conflictChunks = snapshot.getConflictChunks(); if (hasUnresolvedConflictChunks(conflictChunks)) { // TODO: There are conflict chunks in this file that need resolving. saveConflictChunks(currentPath, text, conflictChunks); } else { // Remove all conflict chunk anchors from the document for (AnchoredConflictChunk conflict : conflictChunks) { contents.removeAnchor(conflict.startLineAnchor); contents.removeAnchor(conflict.endLineAnchor); } conflictChunks.clear(); } saveChanges(currentPath, text); lastSavedCcRevision = snapshot.getVersionedText().ccRevision; lastSavedPath = currentPath; logger.debug(String.format("Saved file [%s]", this)); } private void saveChanges(String path, String text) throws IOException { /* * TODO: what we really should do is track lastModified. Then we can lock, * check the lastModified, and merge in any local FS changes that happened * since we last saved. * * We should also listen on "documents.fileSystemEvents" for to get * notified instantly when the FS version changes, so we can eagerly apply * the delta and push to clients. */ logger.debug(String.format("Saving file [%s]", path)); File file = new File(path); Files.write(text, file, Charsets.UTF_8); } private void saveConflictChunks( String path, String text, List<AnchoredConflictChunk> conflictChunks) { // TODO: Write the conflict chunks to some out of band location. } @Override public List<ConflictChunk> getConflictChunks() { return Lists.newArrayList((Iterable<? extends ConflictChunk>) conflictChunks); } @Override public VersionedTextAndConflictChunksImpl getContentsAndConflictChunks() { checkNotClosed(); return new VersionedTextAndConflictChunksImpl(contents.asText(), Lists.newArrayList( conflictChunks)); } @Override public boolean resolveConflictChunk(int chunkIndex) throws IOException { checkNotClosed(); AnchoredConflictChunk chunk = conflictChunks.get(chunkIndex); if (chunk.isResolved()) { /* * This chunk can't be resolved because it is already resolved. This can happen if another * collaborator resolved the chunk, but this client did not get the notification until they * sent their own resolve message. */ return false; } chunk.markResolved(true); logger.debug(String.format("Resolved conflict #%d in file [%s]", chunkIndex, this)); /* * Immediately save the file. * * TODO: how to store chunk resolution? */ // TODO: Resolve path prior to calling save. save(getSavedPath()); return true; } @Override public boolean hasUnresolvedConflictChunks() { return hasUnresolvedConflictChunks(getConflictChunks()); } private static boolean hasUnresolvedConflictChunks(List<? extends ConflictChunk> conflictChunks) { for (ConflictChunk conflict : conflictChunks) { if (!conflict.isResolved()) { return true; } } return false; } @Override public String toString() { return resourceId; } @Override public String getSavedPath() { return lastSavedPath; } }