diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4833f975c..b030ccb5f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -496,6 +496,7 @@ - [Simplify exception handling for polyglot exceptions][3981] - [Simplify compilation of nested patterns][4005] - [IGV can jump to JMH sources & more][4008] +- [Sync language server with file system after VCS restore][4020] [3227]: https://github.com/enso-org/enso/pull/3227 [3248]: https://github.com/enso-org/enso/pull/3248 @@ -575,6 +576,7 @@ [3981]: https://github.com/enso-org/enso/pull/3981 [4005]: https://github.com/enso-org/enso/pull/4005 [4008]: https://github.com/enso-org/enso/pull/4008 +[4020]: https://github.com/enso-org/enso/pull/4020 # Enso 2.0.0-alpha.18 (2021-10-12) diff --git a/docs/language-server/protocol-language-server.md b/docs/language-server/protocol-language-server.md index 12ac1f9bc2e..c09acfffef5 100644 --- a/docs/language-server/protocol-language-server.md +++ b/docs/language-server/protocol-language-server.md @@ -2804,6 +2804,17 @@ checkpoint recorded with `vcs/save`. If no save exists with a provided restore the project to the last saved state, will all current modifications forgotten. +If the contents of any open buffer has changed as a result of this operation, +all subscribed clients will be notified about the new version of the file via +`text/didChange` push notification. + +A file might have been removed during the operation while there were still open +buffers for that file. Any such clients will be modified of a file removal via +the `file/event` notification. + +The result of the call returns a list of files that have been modified during +the operation. + #### Parameters ```typescript @@ -2826,7 +2837,9 @@ forgotten. #### Result ```typescript -null; +{ + changed: [Path]; +} ``` ### `vcs/list` @@ -3485,7 +3498,7 @@ on the stack. In general, all consequent stack items should be `LocalCall`s. } ``` -Returns successful reponse. +Returns successful response. ```json { diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala index 87eef1fb5d0..c81993d18c6 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/protocol/json/JsonConnectionController.scala @@ -303,6 +303,9 @@ class JsonConnectionController( case TextProtocol.FileAutoSaved(path) => webActor ! Notification(FileAutoSaved, FileAutoSaved.Params(path)) + case TextProtocol.FileEvent(path, event) => + webActor ! Notification(EventFile, EventFile.Params(path, event)) + case PathWatcherProtocol.FileEventResult(event) => webActor ! Notification( EventFile, diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/vcs/RestoreVcsHandler.scala b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/vcs/RestoreVcsHandler.scala index 5e422233410..c7d33b8bab0 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/vcs/RestoreVcsHandler.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/requesthandler/vcs/RestoreVcsHandler.scala @@ -51,8 +51,8 @@ class RestoreVcsHandler( replyTo ! ResponseError(Some(id), Errors.RequestTimeout) context.stop(self) - case VcsProtocol.RestoreRepoResponse(Right(_)) => - replyTo ! ResponseResult(RestoreVcs, id, Unused) + case VcsProtocol.RestoreRepoResponse(Right(paths)) => + replyTo ! ResponseResult(RestoreVcs, id, RestoreVcs.Result(paths)) cancellable.cancel() context.stop(self) diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/Buffer.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/Buffer.scala index b5490f3182e..79be7adccba 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/text/Buffer.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/Buffer.scala @@ -1,9 +1,10 @@ package org.enso.languageserver.text import java.io.File - import org.enso.text.{ContentBasedVersioning, ContentVersion} import org.enso.text.buffer.Rope +import org.enso.text.editing.model.Position +import org.enso.text.editing.model.Range /** A buffer state representation. * @@ -17,7 +18,18 @@ case class Buffer( contents: Rope, inMemory: Boolean, version: ContentVersion -) +) { + + /** Returns a range covering the whole buffer. + */ + lazy val fullRange: Range = { + val lines = contents.lines.length + Range( + Position(0, 0), + Position(lines - 1, contents.lines.drop(lines - 1).characters.length) + ) + } +} object Buffer { diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala index f2141007cec..811a42bb055 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/BufferRegistry.scala @@ -13,8 +13,18 @@ import org.enso.languageserver.data.{CanEdit, CapabilityRegistration, ClientId} import org.enso.languageserver.event.InitializedEvent import org.enso.languageserver.filemanager.Path import org.enso.languageserver.monitoring.MonitoringProtocol.{Ping, Pong} -import org.enso.languageserver.text.BufferRegistry.SaveTimeout -import org.enso.languageserver.text.CollaborativeBuffer.ForceSave +import org.enso.languageserver.session.JsonSession +import org.enso.languageserver.text.BufferRegistry.{ + ReloadBufferTimeout, + SaveTimeout, + VcsTimeout +} +import org.enso.languageserver.text.CollaborativeBuffer.{ + ForceSave, + ReloadBuffer, + ReloadBufferFailed, + ReloadedBuffer +} import org.enso.languageserver.util.UnhandledLogging import org.enso.languageserver.text.TextProtocol.{ ApplyEdit, @@ -27,9 +37,11 @@ import org.enso.languageserver.text.TextProtocol.{ SaveFailed, SaveFile } +import org.enso.languageserver.vcsmanager.GenericVcsFailure import org.enso.languageserver.vcsmanager.VcsProtocol.{ InitRepo, RestoreRepo, + RestoreRepoResponse, SaveRepo } import org.enso.text.ContentBasedVersioning @@ -192,20 +204,25 @@ class BufferRegistry( } case msg @ InitRepo(clientId, path) => - waitOnVCSActionToComplete((msg, sender()), clientId, registry, path) + forwardMessageToVCS((msg, sender()), clientId, path, registry) case msg @ SaveRepo(clientId, path, _) => - waitOnVCSActionToComplete((msg, sender()), clientId, registry, path) + forwardMessageToVCS((msg, sender()), clientId, path, registry) case msg @ RestoreRepo(clientId, path, _) => - waitOnVCSActionToComplete((msg, sender()), clientId, registry, path) + forwardMessageToVCSAndReloadBuffers( + (msg, sender()), + clientId, + path, + registry + ) } - private def waitOnVCSActionToComplete( + private def forwardMessageToVCS( msgWithSender: (Any, ActorRef), clientId: ClientId, - registry: Map[Path, ActorRef], - root: Path + root: Path, + registry: Map[Path, ActorRef] ): Unit = { val openBuffers = registry.filter(_._1.startsWith(root)) val timeouts = openBuffers.map { case (_, actorRef) => @@ -232,6 +249,7 @@ class BufferRegistry( timeouts: Map[ActorRef, Cancellable] ): Receive = { case SaveTimeout(from) => + // TODO: log failure val timeouts1 = timeouts.removed(from) if (timeouts1.isEmpty) { vcsManager.tell(msg._1, msg._2) @@ -243,6 +261,7 @@ class BufferRegistry( ) } case SaveFailed | FileSaved => + // TODO: log failure timeouts.get(sender()).foreach(_.cancel()) val timeouts1 = timeouts.removed(sender()) if (timeouts1.isEmpty) { @@ -258,12 +277,203 @@ class BufferRegistry( stash() } + private def forwardMessageToVCSAndReloadBuffers( + msgWithSender: (Any, ActorRef), + clientId: ClientId, + root: Path, + registry: Map[Path, ActorRef] + ): Unit = { + val openBuffers = registry.filter(_._1.startsWith(root)) + val timeouts = openBuffers.map { case (_, actorRef) => + actorRef ! ForceSave(clientId) + ( + actorRef, + context.system.scheduler + .scheduleOnce(timingsConfig.requestTimeout, self, SaveTimeout) + ) + } + if (timeouts.isEmpty) { + vcsManager.tell(msgWithSender._1, self) + val timeout = context.system.scheduler + .scheduleOnce(timingsConfig.requestTimeout, self, VcsTimeout) + context.become( + waitOnVcsRestoreResponse(clientId, msgWithSender._2, timeout, registry) + ) + } else { + context.become( + waitOnSaveConfirmationForwardToVCSAndReload( + clientId, + msgWithSender, + registry, + timeouts + ) + ) + } + } + + private def waitOnSaveConfirmationForwardToVCSAndReload( + clientId: ClientId, + msg: (Any, ActorRef), + registry: Map[Path, ActorRef], + timeouts: Map[ActorRef, Cancellable] + ): Receive = { + case SaveTimeout(from) => + val timeouts1 = timeouts.removed(from) + if (timeouts1.isEmpty) { + vcsManager ! msg._1 + val vcsTimeout = context.system.scheduler + .scheduleOnce(timingsConfig.requestTimeout, self, SaveTimeout) + unstashAll() + context.become( + waitOnVcsRestoreResponse(clientId, msg._2, vcsTimeout, registry) + ) + } else { + context.become( + waitOnSaveConfirmationForwardToVCSAndReload( + clientId, + msg, + registry, + timeouts1 + ) + ) + } + + case SaveFailed | FileSaved => + timeouts.get(sender()).foreach(_.cancel()) + val timeouts1 = timeouts.removed(sender()) + if (timeouts1.isEmpty) { + vcsManager ! msg._1 + val vcsTimeout = context.system.scheduler + .scheduleOnce(timingsConfig.requestTimeout, self, VcsTimeout) + unstashAll() + context.become( + waitOnVcsRestoreResponse(clientId, msg._2, vcsTimeout, registry) + ) + } else { + context.become( + waitOnSaveConfirmationForwardToVCSAndReload( + clientId, + msg, + registry, + timeouts1 + ) + ) + } + + case _ => + stash() + } + + private def waitOnVcsRestoreResponse( + clientId: ClientId, + sender: ActorRef, + timeout: Cancellable, + registry: Map[Path, ActorRef] + ): Receive = { + case response @ RestoreRepoResponse(Right(_)) => + if (timeout != null) timeout.cancel() + reloadBuffers(clientId, sender, response, registry) + + case response @ RestoreRepoResponse(Left(_)) => + if (timeout != null) timeout.cancel() + sender ! response + unstashAll() + context.become(running(registry)) + + case VcsTimeout => + sender ! RestoreRepoResponse(Left(GenericVcsFailure("operation timeout"))) + unstashAll() + context.become(running(registry)) + + case _ => + stash() + } + + private def reloadBuffers( + clientId: ClientId, + from: ActorRef, + response: RestoreRepoResponse, + registry: Map[Path, ActorRef] + ): Unit = { + val filesDiff = response.result.getOrElse(Nil) + val timeouts = registry.filter(r => filesDiff.contains(r._1)).map { + case (path, collaborativeEditor) => + collaborativeEditor ! ReloadBuffer(JsonSession(clientId, from), path) + ( + path, + context.system.scheduler + .scheduleOnce( + timingsConfig.requestTimeout, + self, + ReloadBufferTimeout(path) + ) + ) + } + + if (timeouts.isEmpty) { + from ! response + context.become(running(registry)) + } else { + context.become( + waitingOnBuffersToReload(from, timeouts, registry, response) + ) + } + } + + private def waitingOnBuffersToReload( + from: ActorRef, + timeouts: Map[Path, Cancellable], + registry: Map[Path, ActorRef], + response: RestoreRepoResponse + ): Receive = { + case ReloadedBuffer(path) => + timeouts.get(path).foreach(_.cancel()) + val timeouts1 = timeouts.removed(path) + if (timeouts1.isEmpty) { + from ! response + context.become(running(registry)) + } else { + context.become( + waitingOnBuffersToReload(from, timeouts1, registry, response) + ) + } + + case ReloadBufferFailed(path, _) => + timeouts.get(path).foreach(_.cancel()) + val timeouts1 = timeouts.removed(path) + if (timeouts1.isEmpty) { + // TODO: log failure + from ! response + context.become(running(registry)) + } else { + context.become( + waitingOnBuffersToReload(from, timeouts1, registry, response) + ) + } + + case ReloadBufferTimeout(path) => + val timeouts1 = timeouts.removed(path) + if (timeouts1.isEmpty) { + // TODO: log failure + from ! response + context.become(running(registry)) + } else { + context.become( + waitingOnBuffersToReload(from, timeouts1, registry, response) + ) + } + } + } object BufferRegistry { case class SaveTimeout(ref: ActorRef) + case class ReloadBufferTimeout(path: Path) + + case object VcsTimeout + /** Creates a configuration object used to create a [[BufferRegistry]] * * @param fileManager a file manager actor diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala index 17f005c5dc1..b6e49d48cf6 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/CollaborativeBuffer.scala @@ -17,7 +17,9 @@ import org.enso.languageserver.filemanager.FileManagerProtocol.{ WriteFileResult } import org.enso.languageserver.filemanager.{ + FileEventKind, FileManagerProtocol, + FileNotFound, OperationTimeout, Path } @@ -25,7 +27,10 @@ import org.enso.languageserver.session.JsonSession import org.enso.languageserver.text.CollaborativeBuffer.{ AutoSave, ForceSave, - IOTimeout + IOTimeout, + ReloadBuffer, + ReloadBufferFailed, + ReloadedBuffer } import org.enso.languageserver.text.TextProtocol._ import org.enso.languageserver.util.UnhandledLogging @@ -185,6 +190,7 @@ class CollaborativeBuffer( isAutoSave = false, onClose = None ) + case AutoSave(clientId, clientVersion) => saveFile( buffer, @@ -196,6 +202,7 @@ class CollaborativeBuffer( isAutoSave = true, onClose = None ) + case ForceSave(clientId) => autoSave.get(clientId) match { case Some((contentVersion, cancellable)) => @@ -214,6 +221,95 @@ class CollaborativeBuffer( case None => sender() ! FileSaved } + + case ReloadBuffer(rpcSession, path) => + if (buffer.inMemory) { + fileManager ! FileManagerProtocol.OpenBuffer(path) + } else { + fileManager ! FileManagerProtocol.ReadFile(path) + } + val timeoutCancellable = context.system.scheduler + .scheduleOnce(timingsConfig.requestTimeout, self, IOTimeout) + context.become( + waitingOnReloadedContent( + sender(), + rpcSession, + path, + buffer, + timeoutCancellable, + clients, + buffer.inMemory + ) + ) + + } + + private def waitingOnReloadedContent( + replyTo: ActorRef, + rpcSession: JsonSession, + path: Path, + oldBuffer: Buffer, + timeoutCancellable: Cancellable, + clients: Map[ClientId, JsonSession], + inMemoryBuffer: Boolean + ): Receive = { + case ReadTextualFileResult(Right(file)) => + val buffer = Buffer(file.path, file.content, inMemoryBuffer) + + // Notify *all* clients about the new buffer + // This also ensures that the client that requested the restore operation + // also gets a notification. + val change = FileEdit( + path, + List(TextEdit(buffer.fullRange, file.content)), + oldBuffer.version.toHexString, + buffer.version.toHexString + ) + clients.values.foreach { _.rpcController ! TextDidChange(List(change)) } + timeoutCancellable.cancel() + unstashAll() + replyTo ! ReloadedBuffer(path) + context.become( + collaborativeEditing( + buffer, + clients, + lockHolder = Some(rpcSession), + Map.empty + ) + ) + + case ReadTextualFileResult(Left(FileNotFound)) => + clients.values.foreach { + _.rpcController ! TextProtocol.FileEvent(path, FileEventKind.Removed) + } + replyTo ! ReloadedBuffer(path) + timeoutCancellable.cancel() + stop(Map.empty) + + case ReadTextualFileResult(Left(err)) => + replyTo ! ReloadBufferFailed(path, "io failure: " + err.toString) + timeoutCancellable.cancel() + context.become( + collaborativeEditing( + oldBuffer, + clients, + lockHolder = Some(rpcSession), + Map.empty + ) + ) + + case IOTimeout => + replyTo ! ReloadBufferFailed(path, "io timeout") + context.become( + collaborativeEditing( + oldBuffer, + clients, + lockHolder = Some(rpcSession), + Map.empty + ) + ) + + case _ => stash() } private def saving( @@ -422,7 +518,7 @@ class CollaborativeBuffer( lockHolder: Option[JsonSession], clientId: ClientId, change: FileEdit - ): Either[ApplyEditFailure, Buffer] = + ): Either[ApplyEditFailure, Buffer] = { for { _ <- validateAccess(lockHolder, clientId) _ <- validateVersions(ContentVersion(change.oldVersion), buffer.version) @@ -432,6 +528,7 @@ class CollaborativeBuffer( modifiedBuffer.version ) } yield modifiedBuffer + } private def validateVersions( clientVersion: ContentVersion, @@ -675,6 +772,12 @@ object CollaborativeBuffer { case class ForceSave(clientId: ClientId) + case class ReloadBuffer(rpcSession: JsonSession, path: Path) + + case class ReloadBufferFailed(path: Path, reason: String) + + case class ReloadedBuffer(path: Path) + /** Creates a configuration object used to create a [[CollaborativeBuffer]] * * @param bufferPath a path to a file diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala b/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala index eb633fda946..668f3927f63 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/text/TextProtocol.scala @@ -1,7 +1,11 @@ package org.enso.languageserver.text import org.enso.languageserver.data.{CapabilityRegistration, ClientId} -import org.enso.languageserver.filemanager.{FileSystemFailure, Path} +import org.enso.languageserver.filemanager.{ + FileEventKind, + FileSystemFailure, + Path +} import org.enso.languageserver.session.JsonSession import org.enso.polyglot.runtime.Runtime.Api.ExpressionId import org.enso.text.editing.model.TextEdit @@ -132,6 +136,13 @@ object TextProtocol { */ case class FileAutoSaved(path: Path) + /** A notification sent by the Language Server, notifying a client about + * a file event after reloading the buffer to sync with file system + * + * @param path path to the file + */ + case class FileEvent(path: Path, event: FileEventKind) + /** Requests the language server to save a file on behalf of a given user. * * @param clientId the client closing the file. diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/EmptyUserConfigReader.scala b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/EmptyUserConfigReader.scala index c7916f49b39..1c7fd732001 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/EmptyUserConfigReader.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/EmptyUserConfigReader.scala @@ -1,7 +1,7 @@ package org.enso.languageserver.vcsmanager import org.apache.commons.io.FileUtils -import org.eclipse.jgit.lib.Config +import org.eclipse.jgit.lib.{Config, Constants} import org.eclipse.jgit.storage.file.FileBasedConfig import org.eclipse.jgit.util.{FS, SystemReader} @@ -21,8 +21,13 @@ final class EmptyUserConfigReader extends SystemReader { proxy.getHostname /** @inheritdoc */ - override def getenv(variable: String): String = - proxy.getenv(variable) + override def getenv(variable: String): String = { + if (Constants.GIT_CONFIG_NOSYSTEM_KEY.equals(variable)) { + "1" + } else { + proxy.getenv(variable) + } + } /** @inheritdoc */ override def getProperty(key: String): String = diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/Git.scala b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/Git.scala index db8a113e770..2d87511d06b 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/Git.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/Git.scala @@ -15,6 +15,8 @@ import org.eclipse.jgit.errors.{ import org.eclipse.jgit.lib.{ObjectId, Repository} import org.eclipse.jgit.storage.file.FileRepositoryBuilder import org.eclipse.jgit.revwalk.{RevCommit, RevWalk} +import org.eclipse.jgit.treewalk.filter.PathFilter +import org.eclipse.jgit.treewalk.{CanonicalTreeParser, FileTreeIterator} import org.eclipse.jgit.util.SystemReader import org.enso.languageserver.vcsmanager.Git.{ AuthorEmail, @@ -27,7 +29,6 @@ import scala.jdk.CollectionConverters._ import zio.blocking.effectBlocking import java.time.Instant -import scala.jdk.CollectionConverters.CollectionHasAsScala private class Git(ensoDataDirectory: Option[Path]) extends VcsApi[BlockingIO] { @@ -162,7 +163,7 @@ private class Git(ensoDataDirectory: Option[Path]) extends VcsApi[BlockingIO] { override def restore( root: Path, commitId: Option[String] - ): BlockingIO[VcsFailure, Unit] = { + ): BlockingIO[VcsFailure, List[Path]] = { effectBlocking { val repo = repository(root) @@ -176,19 +177,49 @@ private class Git(ensoDataDirectory: Option[Path]) extends VcsApi[BlockingIO] { val foundRev = findRevision(repo, name).getOrElse( throw new RefNotFoundException(name) ) + val diff = inferDiff(jgit, foundRev, repo) // Reset first to avoid checkout conflicts resetCmd.call() jgit .checkout() .setName(foundRev.getName) .call() + diff case None => + val latest = jgit.log.setMaxCount(1).call().iterator().next() + val diff = inferDiff(jgit, latest, repo) resetCmd.call() + diff } - () }.mapError(errorHandling) } + private def inferDiff( + jgit: JGit, + targetRevision: RevCommit, + repo: Repository + ): List[Path] = { + val oldTree = new FileTreeIterator(repo) + val newTree = { + val reader = repo.newObjectReader() + val treeId = targetRevision.getTree.getId + try new CanonicalTreeParser(null, reader, treeId) + finally if (reader != null) reader.close() + } + + val diffResult = jgit + .diff() + .setOldTree(oldTree) + .setNewTree(newTree) + .setPathFilter( + PathFilter.create(ensureUnixPathSeparator(gitDir.toString)).negate() + ) + .call() + diffResult.asScala.map { diff => + Path.of(diff.getOldPath) + }.toList + } + private def findRevision( repo: Repository, sha: String diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsApi.scala b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsApi.scala index 88888189ab0..dc97fadaae3 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsApi.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsApi.scala @@ -43,7 +43,7 @@ abstract class VcsApi[F[_, _]] { * @param commitId optional commit to which the project should be reverted to * @return any failures during the commit */ - def restore(root: Path, commitId: Option[String]): F[VcsFailure, Unit] + def restore(root: Path, commitId: Option[String]): F[VcsFailure, List[Path]] /** Report the current status of the project, reporting all modified, new or deleted projects. * diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsManager.scala b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsManager.scala index 2ed0d686233..6ba798e00a9 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsManager.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsManager.scala @@ -71,9 +71,9 @@ class VcsManager( case VcsProtocol.RestoreRepo(_, repoRoot, optRevName) => val result = for { - root <- resolvePath(repoRoot) - _ <- vcs.restore(root.toPath, optRevName) - } yield () + root <- resolvePath(repoRoot) + paths <- vcs.restore(root.toPath, optRevName) + } yield paths.map(p => Path.apply(repoRoot.rootId, p)) exec .execTimed(config.timeout, result) .map(VcsProtocol.RestoreRepoResponse) diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsManagerApi.scala b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsManagerApi.scala index caa12d0e43c..4484dd4c582 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsManagerApi.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsManagerApi.scala @@ -50,12 +50,13 @@ object VcsManagerApi { case object RestoreVcs extends Method("vcs/restore") { case class Params(root: Path, commitId: Option[String]) + case class Result(changed: List[Path]) implicit val hasParams = new HasParams[this.type] { type Params = RestoreVcs.Params } implicit val hasResult = new HasResult[this.type] { - type Result = Unused.type + type Result = RestoreVcs.Result } } diff --git a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsProtocol.scala b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsProtocol.scala index a9c6556f975..63be2bb7f90 100644 --- a/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsProtocol.scala +++ b/engine/language-server/src/main/scala/org/enso/languageserver/vcsmanager/VcsProtocol.scala @@ -25,8 +25,8 @@ object VcsProtocol { revName: Option[String] ) - case class RestoreRepoResponse(result: Either[VcsFailure, Unit]) - extends VCSResponse[Unit] + case class RestoreRepoResponse(result: Either[VcsFailure, List[Path]]) + extends VCSResponse[List[Path]] case class StatusRepo(clientId: ClientId, root: Path) diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/vcsmanager/GitSpec.scala b/engine/language-server/src/test/scala/org/enso/languageserver/vcsmanager/GitSpec.scala index 5e820ed9d6e..2fb48d17c04 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/vcsmanager/GitSpec.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/vcsmanager/GitSpec.scala @@ -211,11 +211,17 @@ class GitSpec extends AnyWordSpecLike with Matchers with Effects { "reset to last saved state" in new TestCtx with InitialRepoSetup { val fooFile = repoPath.resolve("Foo.enso") val barFile = repoPath.resolve("Bar.enso") + val bazFile = repoPath.resolve("Baz.enso") createStubFile(fooFile) should equal(true) Files.write( fooFile, "file contents".getBytes(StandardCharsets.UTF_8) ) + createStubFile(bazFile) should equal(true) + Files.write( + bazFile, + "baz file contents".getBytes(StandardCharsets.UTF_8) + ) val commitResult = vcs.commit(repoPath, "New files").unsafeRunSync() commitResult.isRight shouldBe true @@ -235,17 +241,23 @@ class GitSpec extends AnyWordSpecLike with Matchers with Effects { val restoreResult = vcs.restore(repoPath, commitId = None).unsafeRunSync() restoreResult.isRight shouldBe true + restoreResult.getOrElse(Nil) shouldEqual List( + Path.of("Bar.enso"), + Path.of("Foo.enso") + ) val text2 = Files.readAllLines(fooFile) text2.get(0) should equal("file contents") barFile.toFile should exist // TODO: verify this is the expected logic + bazFile.toFile should exist } - "reset to a named saved state" in new TestCtx with InitialRepoSetup { + "reset to a named saved state while preserving original line endings" in new TestCtx + with InitialRepoSetup { val fooFile = repoPath.resolve("Foo.enso") createStubFile(fooFile) should equal(true) - val text1 = "file contents" + val text1 = "file contents\r\nand more\u0000" Files.write( fooFile, text1.getBytes(StandardCharsets.UTF_8) @@ -254,7 +266,7 @@ class GitSpec extends AnyWordSpecLike with Matchers with Effects { commitResult.isRight shouldBe true val commitId = commitResult.getOrElse(null).commitId - val text2 = "different contents" + val text2 = "different contents\r\nanother line" Files.write( fooFile, text2.getBytes(StandardCharsets.UTF_8) @@ -263,15 +275,16 @@ class GitSpec extends AnyWordSpecLike with Matchers with Effects { val commitResult2 = vcs.commit(repoPath, "More changes").unsafeRunSync() commitResult2.isRight shouldBe true - val fileText1 = Files.readAllLines(fooFile) - fileText1.get(0) should equal("different contents") + val fileText1 = Files.readString(fooFile) + fileText1 should equal(text2) val restoreResult = vcs.restore(repoPath, Some(commitId)).unsafeRunSync() restoreResult.isRight shouldBe true + restoreResult.getOrElse(Nil) shouldEqual List(Path.of("Foo.enso")) - val fileText2 = Files.readAllLines(fooFile) - fileText2.get(0) should equal("file contents") + val fileText2 = Files.readString(fooFile) + fileText2 should equal(text1) } "report problem when named save does not exist" in new TestCtx diff --git a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/VcsManagerTest.scala b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/VcsManagerTest.scala index dca03cac2f9..c339b8360c9 100644 --- a/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/VcsManagerTest.scala +++ b/engine/language-server/src/test/scala/org/enso/languageserver/websocket/json/VcsManagerTest.scala @@ -1,6 +1,8 @@ package org.enso.languageserver.websocket.json import io.circe.literal._ +import io.circe.parser.parse + import org.apache.commons.io.FileUtils import org.eclipse.jgit.api.{Git => JGit} import org.eclipse.jgit.lib.Repository @@ -574,7 +576,14 @@ class VcsManagerTest extends BaseServerTest with RetrySpec { client.expectJson(json""" { "jsonrpc": "2.0", "id": 3, - "result": null + "result": { + "changed": [ + { + "rootId": $testContentRootId, + "segments": [ "src", "Foo.enso" ] + } + ] + } } """) @@ -609,6 +618,10 @@ class VcsManagerTest extends BaseServerTest with RetrySpec { } "reset to a named save" taggedAs Retry in withCleanRoot { client => + timingsConfig = timingsConfig.withAutoSave(0.5.seconds) + val sleepDuration: Long = 2 * 1000 // 2 seconds + val client2 = getInitialisedWsClient() + val testFileName = "Foo2.enso" client.send(json""" { "jsonrpc": "2.0", "method": "vcs/status", @@ -637,12 +650,14 @@ class VcsManagerTest extends BaseServerTest with RetrySpec { val srcDir = testContentRoot.file.toPath.resolve("src") Files.createDirectory(srcDir) - val fooPath = srcDir.resolve("Foo.enso") + val fooPath = srcDir.resolve(testFileName) fooPath.toFile.createNewFile() Files.write( fooPath, "file contents".getBytes(StandardCharsets.UTF_8) ) + // "file contents" version: 4d23065da489de360890285072c209b2b39d45d12283dbb5d1fa4389 + add(testContentRoot.file, srcDir) commit(testContentRoot.file, "Add missing files") val barPath = srcDir.resolve("Bar.enso") @@ -657,13 +672,176 @@ class VcsManagerTest extends BaseServerTest with RetrySpec { fooPath, "different contents".getBytes(StandardCharsets.UTF_8) ) + // "different contents" version: e2bf8493b00a13749e643e2f970b6025c227cc91340c2acb7d67e1da + add(testContentRoot.file, srcDir) commit(testContentRoot.file, "More changes") client.send(json""" { "jsonrpc": "2.0", - "method": "vcs/status", + "method": "text/openFile", "id": 2, + "params": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testFileName ] + } + } + } + """) + + client.expectJson(json""" + { "jsonrpc": "2.0", + "id": 2, + "result": { + "writeCapability": null, + "content": "different contents", + "currentVersion": "e2bf8493b00a13749e643e2f970b6025c227cc91340c2acb7d67e1da" + } + } + """) + client2.send(json""" + { "jsonrpc": "2.0", + "method": "text/openFile", + "id": 2, + "params": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testFileName ] + } + } + } + """) + client2.expectJson(json""" + { "jsonrpc": "2.0", + "id": 2, + "result": { + "writeCapability": null, + "content": "different contents", + "currentVersion": "e2bf8493b00a13749e643e2f970b6025c227cc91340c2acb7d67e1da" + } + } + """) + + client.send(json""" + { "jsonrpc": "2.0", + "method": "capability/acquire", + "id": 3, + "params": { + "method": "text/canEdit", + "registerOptions": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testFileName ] + } + } + } + } + """) + + client.expectJson(json""" + { "jsonrpc": "2.0", + "id": 3, + "result": null + } + """) + + client.send(json""" + { "jsonrpc": "2.0", + "method": "text/applyEdit", + "id": 4, + "params": { + "edit": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testFileName ] + }, + "oldVersion": "e2bf8493b00a13749e643e2f970b6025c227cc91340c2acb7d67e1da", + "newVersion": "e4bb87ced8ddafa060f08f2a79cc2861355eb9f596e462d7df462ef4", + "edits": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 9 } + }, + "text": "bar" + } + ] + } + } + } + """) + client.expectJson(json""" + { "jsonrpc": "2.0", + "id": 4, + "result": null + } + """) + client2.expectJson(json""" + { "jsonrpc" : "2.0", + "method" : "text/didChange", + "params" : { + "edits" : [ + { + "path" : { + "rootId" : $testContentRootId, + "segments" : [ + "src", + $testFileName + ] + }, + "edits" : [ + { + "range" : { + "start" : { + "line" : 0, + "character" : 0 + }, + "end" : { + "line" : 0, + "character" : 9 + } + }, + "text" : "bar" + } + ], + "oldVersion" : "e2bf8493b00a13749e643e2f970b6025c227cc91340c2acb7d67e1da", + "newVersion" : "e4bb87ced8ddafa060f08f2a79cc2861355eb9f596e462d7df462ef4" + } + ] + } + } + """) + + // Ensure auto-save kicks in + Thread.sleep(sleepDuration) + client.expectJson(json""" + { "jsonrpc": "2.0", + "method":"text/autoSave", + "params": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testFileName ] + } + } + } + """) + client2.expectJson(json""" + { "jsonrpc": "2.0", + "method":"text/autoSave", + "params": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testFileName ] + } + } + } + """) + + client.send(json""" + { "jsonrpc": "2.0", + "method": "vcs/status", + "id": 5, "params": { "root": { "rootId": $testContentRootId, @@ -674,10 +852,18 @@ class VcsManagerTest extends BaseServerTest with RetrySpec { """) client.fuzzyExpectJson(json""" { "jsonrpc": "2.0", - "id": 2, + "id": 5, "result": { - "dirty": false, - "changed": [], + "dirty": true, + "changed": [ + { + "rootId" : $testContentRootId, + "segments" : [ + "src", + $testFileName + ] + } + ], "lastSave": { "commitId": "*", "message": "More changes" @@ -685,12 +871,16 @@ class VcsManagerTest extends BaseServerTest with RetrySpec { } } """) - val sndToLast = commits(testContentRoot.file).tail.head + val allCommits = commits(testContentRoot.file) + val sndToLast = allCommits.tail.head + + val text0 = Files.readAllLines(fooPath) + text0.get(0) should equal("bar contents") client.send(json""" { "jsonrpc": "2.0", "method": "vcs/restore", - "id": 3, + "id": 6, "params": { "root": { "rootId": $testContentRootId, @@ -700,20 +890,189 @@ class VcsManagerTest extends BaseServerTest with RetrySpec { } } """) + client.expectJson(json""" + { "jsonrpc" : "2.0", + "method" : "text/didChange", + "params" : { + "edits" : [ + { + "path" : { + "rootId" : $testContentRootId, + "segments" : [ + "src", + $testFileName + ] + }, + "edits" : [ + { + "range" : { + "start" : { + "line" : 0, + "character" : 0 + }, + "end" : { + "line" : 0, + "character" : 13 + } + }, + "text" : "file contents" + } + ], + "oldVersion" : "e4bb87ced8ddafa060f08f2a79cc2861355eb9f596e462d7df462ef4", + "newVersion" : "4d23065da489de360890285072c209b2b39d45d12283dbb5d1fa4389" + } + ] + } + }""") client.expectJson(json""" { "jsonrpc": "2.0", - "id": 3, - "result": null + "id": 6, + "result": { + "changed": [ + { + "rootId": $testContentRootId, + "segments": [ "src", $testFileName ] + } + ] + } } """) + client2.expectJson(json""" + { "jsonrpc" : "2.0", + "method" : "text/didChange", + "params" : { + "edits" : [ + { + "path" : { + "rootId" : $testContentRootId, + "segments" : [ + "src", + $testFileName + ] + }, + "edits" : [ + { + "range" : { + "start" : { + "line" : 0, + "character" : 0 + }, + "end" : { + "line" : 0, + "character" : 13 + } + }, + "text" : "file contents" + } + ], + "oldVersion" : "e4bb87ced8ddafa060f08f2a79cc2861355eb9f596e462d7df462ef4", + "newVersion" : "4d23065da489de360890285072c209b2b39d45d12283dbb5d1fa4389" + } + ] + } + }""") val text1 = Files.readAllLines(fooPath) text1.get(0) should equal("file contents") + client.send(json""" + { "jsonrpc": "2.0", + "method": "text/applyEdit", + "id": 7, + "params": { + "edit": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testFileName ] + }, + "oldVersion": "4d23065da489de360890285072c209b2b39d45d12283dbb5d1fa4389", + "newVersion": "1141745721c08c1c1c26ca32b95f103c0721f70eedaa6db765dfc43e", + "edits": [ + { + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 4 } + }, + "text": "foo" + } + ] + } + } + } + """) + client.expectJson(json""" + { "jsonrpc": "2.0", + "id": 7, + "id": 7, + "result": null + } + """) + client2.expectJson(json""" + { "jsonrpc" : "2.0", + "method" : "text/didChange", + "params" : { + "edits" : [ + { + "path" : { + "rootId" : $testContentRootId, + "segments" : [ + "src", + $testFileName + ] + }, + "edits" : [ + { + "range" : { + "start" : { + "line" : 0, + "character" : 0 + }, + "end" : { + "line" : 0, + "character" : 4 + } + }, + "text" : "foo" + } + ], + "oldVersion" : "4d23065da489de360890285072c209b2b39d45d12283dbb5d1fa4389", + "newVersion" : "1141745721c08c1c1c26ca32b95f103c0721f70eedaa6db765dfc43e" + } + ] + } + }""") + + // Ensure auto-save kicks in + Thread.sleep(sleepDuration) + client.expectJson(json""" + { "jsonrpc": "2.0", + "method":"text/autoSave", + "params": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testFileName ] + } + } + } + """) + client2.expectJson(json""" + { "jsonrpc": "2.0", + "method":"text/autoSave", + "params": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testFileName ] + } + } + } + """) + val text2 = Files.readAllLines(fooPath) + text2.get(0) should equal("foo contents") + client.send(json""" { "jsonrpc": "2.0", "method": "vcs/restore", - "id": 4, + "id": 8, "params": { "root": { "rootId": $testContentRootId, @@ -725,7 +1084,7 @@ class VcsManagerTest extends BaseServerTest with RetrySpec { """) client.expectJson(json""" { "jsonrpc": "2.0", - "id": 4, + "id": 8, "error": { "code": 1004, "message": "Requested save not found" @@ -733,6 +1092,241 @@ class VcsManagerTest extends BaseServerTest with RetrySpec { } """) } + + "reset to a named save and notify about removed files" taggedAs Retry in withCleanRoot { + client => + timingsConfig = timingsConfig.withAutoSave(0.5.seconds) + val client2 = getInitialisedWsClient() + val testFooFileName = "Foo.enso" + val testBarFileName = "Bar.enso" + client.send(json""" + { "jsonrpc": "2.0", + "method": "vcs/status", + "id": 1, + "params": { + "root": { + "rootId": $testContentRootId, + "segments": [] + } + } + } + """) + client.fuzzyExpectJson(json""" + { "jsonrpc": "2.0", + "id": 1, + "result": { + "dirty": false, + "changed": [], + "lastSave": { + "commitId": "*", + "message": "Initial commit" + } + } + } + """) + + val srcDir = testContentRoot.file.toPath.resolve("src") + Files.createDirectory(srcDir) + val fooPath = srcDir.resolve(testFooFileName) + fooPath.toFile.createNewFile() + Files.write( + fooPath, + "file contents".getBytes(StandardCharsets.UTF_8) + ) + // "file contents" version: 4d23065da489de360890285072c209b2b39d45d12283dbb5d1fa4389 + + add(testContentRoot.file, srcDir) + commit(testContentRoot.file, "Add first file") + val barPath = srcDir.resolve(testBarFileName) + barPath.toFile.createNewFile() + Files.write( + barPath, + "file contents b".getBytes(StandardCharsets.UTF_8) + ) + // "file contents b" version: 4b6a8df62627ea7fbd1f4d9296d16c166b17b037c01d7298454cee99 + add(testContentRoot.file, srcDir) + commit(testContentRoot.file, "Add second file") + + client.send(json""" + { "jsonrpc": "2.0", + "method": "text/openFile", + "id": 2, + "params": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testFooFileName ] + } + } + } + """) + + client.expectJson(json""" + { "jsonrpc": "2.0", + "id": 2, + "result": { + "writeCapability": null, + "content": "file contents", + "currentVersion": "4d23065da489de360890285072c209b2b39d45d12283dbb5d1fa4389" + } + } + """) + + client.send(json""" + { "jsonrpc": "2.0", + "method": "text/openFile", + "id": 3, + "params": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testBarFileName ] + } + } + } + """) + + client.expectJson(json""" + { "jsonrpc": "2.0", + "id": 3, + "result": { + "writeCapability": { + "method" : "text/canEdit", + "registerOptions" : { + "path" : { + "rootId" : $testContentRootId, + "segments" : [ + "src", + $testBarFileName + ] + } + } + }, + "content": "file contents b", + "currentVersion": "4b6a8df62627ea7fbd1f4d9296d16c166b17b037c01d7298454cee99" + } + } + """) + client2.send(json""" + { "jsonrpc": "2.0", + "method": "text/openFile", + "id": 4, + "params": { + "path": { + "rootId": $testContentRootId, + "segments": [ "src", $testBarFileName ] + } + } + } + """) + client2.expectJson(json""" + { "jsonrpc": "2.0", + "id": 4, + "result": { + "writeCapability": null, + "content": "file contents b", + "currentVersion": "4b6a8df62627ea7fbd1f4d9296d16c166b17b037c01d7298454cee99" + } + } + """) + + client.send(json""" + { "jsonrpc": "2.0", + "method": "vcs/status", + "id": 5, + "params": { + "root": { + "rootId": $testContentRootId, + "segments": [] + } + } + } + """) + client.fuzzyExpectJson(json""" + { "jsonrpc": "2.0", + "id": 5, + "result": { + "dirty": false, + "changed": [], + "lastSave": { + "commitId": "*", + "message": "Add second file" + } + } + } + """) + val allCommits = commits(testContentRoot.file) + val sndToLast = allCommits.tail.head + + client.send(json""" + { "jsonrpc": "2.0", + "method": "vcs/restore", + "id": 6, + "params": { + "root": { + "rootId": $testContentRootId, + "segments": [] + }, + "commitId": ${sndToLast.getName} + } + } + """) + + // Additional logic to deal with out-of-order messages due + // to multiple actors being involved in forwarding messages. + val msg1 = parse(client.expectMessage()).getOrElse(fail()) + val msg2 = parse(client.expectMessage()).getOrElse(fail()) + val isFileEvent = msg1.hcursor.get[String]("method").toOption.isEmpty + val (methodsEvent, response) = if (isFileEvent) { + (msg2, msg1) + } else { + (msg1, msg2) + } + methodsEvent shouldEqual json""" + { "jsonrpc" : "2.0", + "method" : "file/event", + "params" : { + "path" : { + "rootId" : $testContentRootId, + "segments" : [ + "src", + $testBarFileName + ] + }, + "kind" : "Removed" + } + } + """ + + response shouldEqual json""" + { "jsonrpc": "2.0", + "id": 6, + "result": { + "changed": [ + { + "rootId": $testContentRootId, + "segments": [ "src", $testBarFileName ] + } + ] + } + } + """ + + client2.expectJson(json""" + { "jsonrpc" : "2.0", + "method" : "file/event", + "params" : { + "path" : { + "rootId" : $testContentRootId, + "segments" : [ + "src", + $testBarFileName + ] + }, + "kind" : "Removed" + } + } + """) + } + } "List project saves" must { @@ -894,7 +1488,7 @@ class VcsManagerTest extends BaseServerTest with RetrySpec { jgit.log().call().asScala.toList } - def commit(root: File, msg: String): Unit = { + def commit(root: File, msg: String): RevCommit = { val jgit = new JGit(repository(root.toPath)) jgit.commit.setMessage(msg).setAuthor("Enso VCS", "vcs@enso.io").call() }