mirror of
https://github.com/enso-org/enso.git
synced 2024-12-04 11:13:04 +03:00
File Manager (#46)
File Manager — an Akka-based service providing a basic filesystem-like API over network. Currently it just wraps the local filesystem operations. In the future support for other kinds of storage might get added. Ref #32
This commit is contained in:
parent
b1e0717d07
commit
9c525edbb9
80
FileManager/src/main/scala/org/enso/FileManager.scala
Normal file
80
FileManager/src/main/scala/org/enso/FileManager.scala
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package org.enso
|
||||||
|
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
import akka.actor.Scheduler
|
||||||
|
import akka.actor.typed.ActorRef
|
||||||
|
import akka.actor.typed.Behavior
|
||||||
|
import akka.actor.typed.scaladsl.AbstractBehavior
|
||||||
|
import akka.actor.typed.scaladsl.ActorContext
|
||||||
|
import akka.actor.typed.scaladsl.AskPattern.Askable
|
||||||
|
import akka.actor.typed.scaladsl.Behaviors
|
||||||
|
import akka.util.Timeout
|
||||||
|
import io.methvin.watcher.DirectoryWatcher
|
||||||
|
import org.enso.filemanager.API
|
||||||
|
import org.enso.filemanager.API._
|
||||||
|
|
||||||
|
import scala.collection.mutable
|
||||||
|
import scala.concurrent.Future
|
||||||
|
import scala.reflect.ClassTag
|
||||||
|
import scala.util.Failure
|
||||||
|
import scala.util.Success
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** The main actor class.
|
||||||
|
*
|
||||||
|
* Implements an RPC-like protocol. Please see member types of
|
||||||
|
* [[org.enso.filemanager.API]] for a list of supported operations and their
|
||||||
|
* respective request-response packages.
|
||||||
|
*/
|
||||||
|
case class FileManager(projectRoot: Path, context: ActorContext[InputMessage])
|
||||||
|
extends AbstractBehavior[API.InputMessage] {
|
||||||
|
|
||||||
|
/** Active filesystem subtree watchers */
|
||||||
|
val watchers: mutable.Map[UUID, DirectoryWatcher] = mutable.Map()
|
||||||
|
|
||||||
|
def onMessageTyped[response <: Response.Success: ClassTag](
|
||||||
|
message: Request[response]
|
||||||
|
): Unit = {
|
||||||
|
val response = try {
|
||||||
|
message.contents.validate(projectRoot)
|
||||||
|
val result = message.contents.handle(this)
|
||||||
|
Success(result)
|
||||||
|
} catch { case ex: Throwable => Failure(ex) }
|
||||||
|
context.log.debug(s"Responding with $response")
|
||||||
|
message.replyTo ! response
|
||||||
|
}
|
||||||
|
|
||||||
|
override def onMessage(message: InputMessage): this.type = {
|
||||||
|
context.log.debug(s"Received $message")
|
||||||
|
message.handle(this)
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object FileManager {
|
||||||
|
val API: org.enso.filemanager.API.type = org.enso.filemanager.API
|
||||||
|
|
||||||
|
/** Factory function for [[FileManager]] [[akka.actor.typed.Behavior]]. */
|
||||||
|
def apply(projectRoot: Path): Behavior[InputMessage] =
|
||||||
|
Behaviors.setup(context => FileManager(projectRoot, context))
|
||||||
|
|
||||||
|
/** Convenience wrapper for
|
||||||
|
* [[akka.actor.typed.scaladsl.AskPattern.Askable.ask]].
|
||||||
|
*
|
||||||
|
* It takes only the request payload (i.e. operation specific part of the
|
||||||
|
* request) and takes care of the rest, automatically deducing the expected
|
||||||
|
* response type.
|
||||||
|
*/
|
||||||
|
def ask[response <: Response.Success: ClassTag](
|
||||||
|
actor: ActorRef[API.InputMessage],
|
||||||
|
payload: Request.Payload[response]
|
||||||
|
)(implicit timeout: Timeout,
|
||||||
|
scheduler: Scheduler
|
||||||
|
): Future[Try[response]] = {
|
||||||
|
actor.ask { replyTo: ActorRef[Try[response]] =>
|
||||||
|
Request(replyTo, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
355
FileManager/src/main/scala/org/enso/filemanager/API.scala
Normal file
355
FileManager/src/main/scala/org/enso/filemanager/API.scala
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
package org.enso.filemanager
|
||||||
|
|
||||||
|
import akka.actor.typed.ActorRef
|
||||||
|
import io.methvin.watcher.DirectoryChangeEvent
|
||||||
|
import io.methvin.watcher.DirectoryWatcher
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.NoSuchFileException
|
||||||
|
import java.nio.file.NotDirectoryException
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
import org.apache.commons.io.FileUtils
|
||||||
|
import org.enso.FileManager
|
||||||
|
import org.enso.filemanager.Detail.EventNotifier
|
||||||
|
|
||||||
|
import scala.reflect.ClassTag
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** Container for types defined for File Manager API.
|
||||||
|
*
|
||||||
|
* Each File Manager operation is implemented as nested type with further
|
||||||
|
* `Request` and `Response` subtypes.
|
||||||
|
*/
|
||||||
|
object API {
|
||||||
|
import Request.Payload
|
||||||
|
import Response.Success
|
||||||
|
|
||||||
|
/** Base class for messages received by the [[FileManager]]. */
|
||||||
|
type InputMessage = Request[_]
|
||||||
|
|
||||||
|
/** Base class for messages that [[FileManager]] responds with. */
|
||||||
|
type OutputMessage = Try[Response.Success]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception type that is raised on attempt to access to file outside the
|
||||||
|
* project subtree.
|
||||||
|
*/
|
||||||
|
final case class PathOutsideProjectException(
|
||||||
|
projectRoot: Path,
|
||||||
|
accessedPath: Path)
|
||||||
|
extends Exception(
|
||||||
|
s"""Cannot access path $accessedPath because it does not belong to
|
||||||
|
|the project under root directory $projectRoot""".stripMargin
|
||||||
|
.replaceAll("\n", " ")
|
||||||
|
)
|
||||||
|
|
||||||
|
////////////////////////
|
||||||
|
//// RPC Definition ////
|
||||||
|
////////////////////////
|
||||||
|
|
||||||
|
/** Request template, parametrised by the response type.
|
||||||
|
*/
|
||||||
|
sealed case class Request[ResponseType <: Success: ClassTag](
|
||||||
|
replyTo: ActorRef[Try[ResponseType]],
|
||||||
|
contents: Payload[ResponseType]) {
|
||||||
|
|
||||||
|
def handle(fileManager: FileManager): Unit =
|
||||||
|
fileManager.onMessageTyped(this)
|
||||||
|
|
||||||
|
/** Throws a [[PathOutsideProjectException]] if request involves paths
|
||||||
|
* outside the project subtree. */
|
||||||
|
def validate(projectRoot: Path): Unit =
|
||||||
|
contents.validate(projectRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
object Request {
|
||||||
|
|
||||||
|
/** Base class for all the operation-specific contents of [[Request]]. */
|
||||||
|
abstract class Payload[+ResponseType <: Success: ClassTag] {
|
||||||
|
def touchedPaths: Seq[Path]
|
||||||
|
def handle(fileManager: FileManager): ResponseType
|
||||||
|
|
||||||
|
def validate(projectRoot: Path): Unit =
|
||||||
|
touchedPaths.foreach(Detail.validatePath(_, projectRoot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Response {
|
||||||
|
sealed abstract class Success
|
||||||
|
}
|
||||||
|
|
||||||
|
//////////////////////////////
|
||||||
|
//// Requests / Responses ////
|
||||||
|
//////////////////////////////
|
||||||
|
|
||||||
|
object CopyDirectory {
|
||||||
|
case class Response() extends Success
|
||||||
|
case class Request(from: Path, to: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(from, to)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
FileUtils.copyDirectory(from.toFile, to.toFile)
|
||||||
|
Response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object CopyFile {
|
||||||
|
case class Response() extends Success
|
||||||
|
case class Request(from: Path, to: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] =
|
||||||
|
Seq(from, to)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
Files.copy(from, to)
|
||||||
|
Response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object DeleteDirectory {
|
||||||
|
case class Response() extends Success
|
||||||
|
case class Request(path: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(path)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
// Despite what commons-io documentation says, the exception is not
|
||||||
|
// thrown when directory is missing, so we do it by hand.
|
||||||
|
if (Files.notExists(path))
|
||||||
|
throw new NoSuchFileException(path.toString)
|
||||||
|
|
||||||
|
FileUtils.deleteDirectory(path.toFile)
|
||||||
|
Response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object DeleteFile {
|
||||||
|
case class Response() extends Success
|
||||||
|
case class Request(path: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(path)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
Files.delete(path)
|
||||||
|
Response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Exists {
|
||||||
|
case class Response(exists: Boolean) extends Success
|
||||||
|
case class Request(path: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(path)
|
||||||
|
override def handle(fileManager: FileManager) =
|
||||||
|
Response(Files.exists(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object List {
|
||||||
|
case class Response(entries: Seq[Path]) extends Success
|
||||||
|
case class Request(path: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(path)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
val str = Files.list(path)
|
||||||
|
try {
|
||||||
|
Response(str.toArray.to[Vector].map(_.asInstanceOf[Path]))
|
||||||
|
} finally str.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object MoveDirectory {
|
||||||
|
case class Response() extends Success
|
||||||
|
case class Request(from: Path, to: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(from, to)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
FileUtils.moveDirectory(from.toFile, to.toFile)
|
||||||
|
Response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object MoveFile {
|
||||||
|
case class Response() extends Success
|
||||||
|
case class Request(from: Path, to: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(from, to)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
Files.move(from, to)
|
||||||
|
Response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Read {
|
||||||
|
case class Response(contents: Array[Byte]) extends Success
|
||||||
|
case class Request(path: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(path)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
val contents = Files.readAllBytes(path)
|
||||||
|
Response(contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Status {
|
||||||
|
case class Response(attributes: BasicFileAttributes) extends Success
|
||||||
|
case class Request(path: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(path)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
val attributes =
|
||||||
|
Files.readAttributes(path, classOf[BasicFileAttributes])
|
||||||
|
Response(attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Touch {
|
||||||
|
case class Response() extends Success
|
||||||
|
case class Request(path: Path) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(path)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
FileUtils.touch(path.toFile)
|
||||||
|
Response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operations for managing filesystem watches, please see details.
|
||||||
|
*
|
||||||
|
* The watch will send [[FileSystemEvent]] to the observing agent when any
|
||||||
|
* entry in the observed filesystem subtree is created, modified or deleted.
|
||||||
|
* As this mechanism is built on top of [[java.nio.file.WatchService]] its
|
||||||
|
* limitations and caveats apply. In particular:
|
||||||
|
* - events may come in different order;
|
||||||
|
* - events may not come at all if they undo each other (e.g. create and
|
||||||
|
* delete file in short time period, one modification may overshadow
|
||||||
|
* another);
|
||||||
|
* - duplicate notifications may be emitted for a single event;
|
||||||
|
* - deletion of child entries may not be observed if parent entry is;
|
||||||
|
* deleted;
|
||||||
|
* - all of the behaviors listed above are highly system dependent.
|
||||||
|
*
|
||||||
|
* Additionally:
|
||||||
|
* - watching is always recursive and must target a directory
|
||||||
|
* - the watched path must not be a symlink (though its parent path
|
||||||
|
* components are allowed to be symlinks)
|
||||||
|
* - if the observed path contains symlink, it will remain unresolved in the
|
||||||
|
* notification events (i.e. the event path prefix shall be the same as the
|
||||||
|
* observed subtree root).
|
||||||
|
* */
|
||||||
|
object Watch {
|
||||||
|
|
||||||
|
object Create {
|
||||||
|
case class Response(id: UUID) extends Success
|
||||||
|
case class Request(
|
||||||
|
observedDirPath: Path,
|
||||||
|
observer: ActorRef[FileSystemEvent])
|
||||||
|
extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(observedDirPath)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
// Watching a symlink target works only on Windows, presumably thanks
|
||||||
|
// to recursive watch being natively supported. We block this to keep
|
||||||
|
// thinks uniform between platforms.
|
||||||
|
if (Files.isSymbolicLink(observedDirPath))
|
||||||
|
throw new NotDirectoryException(observedDirPath.toString)
|
||||||
|
|
||||||
|
// Watching ordinary file throws an exception on Windows.
|
||||||
|
// To unify behavior, we do this on all platforms.
|
||||||
|
if (!Files.isDirectory(observedDirPath))
|
||||||
|
throw new NotDirectoryException(observedDirPath.toString)
|
||||||
|
|
||||||
|
val handler =
|
||||||
|
EventNotifier(observedDirPath, observer, fileManager)
|
||||||
|
val id = UUID.randomUUID()
|
||||||
|
val watcher = DirectoryWatcher.builder
|
||||||
|
.path(observedDirPath)
|
||||||
|
.listener(handler.notify(_))
|
||||||
|
.build()
|
||||||
|
watcher.watchAsync()
|
||||||
|
fileManager.watchers += (id -> watcher)
|
||||||
|
Response(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Remove {
|
||||||
|
case class Response() extends Success
|
||||||
|
case class Request(id: UUID) extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq()
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
val watcher = fileManager.watchers(id)
|
||||||
|
watcher.close()
|
||||||
|
fileManager.watchers -= id
|
||||||
|
Response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Write {
|
||||||
|
case class Response() extends Success
|
||||||
|
case class Request(path: Path, contents: Array[Byte])
|
||||||
|
extends Payload[Response] {
|
||||||
|
override def touchedPaths: Seq[Path] = Seq(path)
|
||||||
|
override def handle(fileManager: FileManager): Response = {
|
||||||
|
Files.write(path, contents)
|
||||||
|
Response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case class FileSystemEvent(
|
||||||
|
eventType: DirectoryChangeEvent.EventType,
|
||||||
|
path: Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Implementation details, not expected to be relied on as path of API. */
|
||||||
|
object Detail {
|
||||||
|
import API._
|
||||||
|
|
||||||
|
def validatePath(validatedPath: Path, projectRoot: Path): Unit = {
|
||||||
|
val normalized = validatedPath.toAbsolutePath.normalize()
|
||||||
|
if (!normalized.startsWith(projectRoot))
|
||||||
|
throw PathOutsideProjectException(projectRoot, validatedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper class used for sending filesystem event notifications. */
|
||||||
|
case class EventNotifier(
|
||||||
|
observedPath: Path,
|
||||||
|
observer: ActorRef[FileSystemEvent],
|
||||||
|
fileManager: FileManager) {
|
||||||
|
|
||||||
|
val realObservedPath: Path = observedPath.toRealPath()
|
||||||
|
val observingUnresolvedPath: Boolean = observedPath != realObservedPath
|
||||||
|
|
||||||
|
/** If the path prefix got resolved, restores the observed one.
|
||||||
|
*
|
||||||
|
* macOS generates events containing resolved path, i.e. with symlinks
|
||||||
|
* resolved. We don't really want this, as we want to be completely
|
||||||
|
* indifferent to symlink presence and still be able to easily compare
|
||||||
|
* paths. Therefore if we are under symlink and generated event uses
|
||||||
|
* real path, we replace it with path prefix that was observation
|
||||||
|
* target.
|
||||||
|
*/
|
||||||
|
def fixedPath(path: Path): Path = {
|
||||||
|
val needsFixing = observingUnresolvedPath && path.startsWith(
|
||||||
|
realObservedPath
|
||||||
|
)
|
||||||
|
needsFixing match {
|
||||||
|
case true => observedPath.resolve(realObservedPath.relativize(path))
|
||||||
|
case false => path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notifies the observer about a given filesystem event. */
|
||||||
|
def notify(event: DirectoryChangeEvent): Unit = {
|
||||||
|
val message = FileSystemEvent(
|
||||||
|
event.eventType,
|
||||||
|
fixedPath(event.path)
|
||||||
|
)
|
||||||
|
if (message.path != observedPath) {
|
||||||
|
val logText = s"Notifying $observer with $message"
|
||||||
|
fileManager.context.log.debug(logText)
|
||||||
|
observer ! message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,279 @@
|
|||||||
|
package org.enso.filemanager
|
||||||
|
|
||||||
|
import akka.actor.testkit.typed.scaladsl.BehaviorTestKit
|
||||||
|
import akka.actor.testkit.typed.scaladsl.TestInbox
|
||||||
|
|
||||||
|
import java.nio.file.FileAlreadyExistsException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.NoSuchFileException
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
import org.apache.commons.io.FileExistsException
|
||||||
|
import org.enso.FileManager
|
||||||
|
import org.enso.FileManager.API._
|
||||||
|
import org.scalatest.FunSuite
|
||||||
|
import org.scalatest.Matchers
|
||||||
|
import org.scalatest.Outcome
|
||||||
|
|
||||||
|
import scala.reflect.ClassTag
|
||||||
|
import scala.util.Failure
|
||||||
|
import scala.util.Success
|
||||||
|
|
||||||
|
class BehaviorTests extends FunSuite with Matchers with Helpers {
|
||||||
|
var testKit: BehaviorTestKit[InputMessage] = _
|
||||||
|
var inbox: TestInbox[OutputMessage] = _
|
||||||
|
|
||||||
|
override def withFixture(test: NoArgTest): Outcome = {
|
||||||
|
withTemporaryDirectory(_ => {
|
||||||
|
testKit = BehaviorTestKit(FileManager(tempDir))
|
||||||
|
inbox = TestInbox[OutputMessage]()
|
||||||
|
test()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
def expectSuccess[T <: Response.Success: ClassTag](): T = {
|
||||||
|
inbox.receiveMessage() match {
|
||||||
|
case Failure(err) =>
|
||||||
|
fail(s"Unexpected error message: $err")
|
||||||
|
case Success(msg) =>
|
||||||
|
msg shouldBe a[T]
|
||||||
|
msg.asInstanceOf[T]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def expectError[T <: Throwable: ClassTag](): T = {
|
||||||
|
inbox.receiveMessage() match {
|
||||||
|
case Failure(exception) =>
|
||||||
|
exception shouldBe a[T]
|
||||||
|
exception.asInstanceOf[T]
|
||||||
|
case Success(msg) =>
|
||||||
|
fail(s"Unexpected non-error message: $msg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def runRequest(contents: Request.Payload[Response.Success]): Unit =
|
||||||
|
testKit.run(Request(inbox.ref, contents))
|
||||||
|
|
||||||
|
def ask[res <: Response.Success: ClassTag](
|
||||||
|
contents: Request.Payload[res]
|
||||||
|
): res = {
|
||||||
|
runRequest(contents)
|
||||||
|
expectSuccess[res]()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask for something that is not allowed and is expected to cause exception
|
||||||
|
def abet[exception <: Throwable: ClassTag](
|
||||||
|
contents: Request.Payload[Response.Success]
|
||||||
|
): exception = {
|
||||||
|
runRequest(contents)
|
||||||
|
expectError[exception]()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Copy directory: empty directory") {
|
||||||
|
val subdir = createSubDir()
|
||||||
|
val destination = tempDir.resolve("target")
|
||||||
|
ask(CopyDirectory.Request(subdir, destination))
|
||||||
|
|
||||||
|
expectExist(subdir)
|
||||||
|
expectExist(destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Copy directory: non-empty directory") {
|
||||||
|
val subtree = createSubtree()
|
||||||
|
val destination = tempDir.resolve("target")
|
||||||
|
ask(CopyDirectory.Request(subtree.root, destination))
|
||||||
|
val subtreeExpected = subtree.rebase(destination)
|
||||||
|
expectSubtree(subtree)
|
||||||
|
expectSubtree(subtreeExpected)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Copy directory: target already exists") {
|
||||||
|
val subtree = createSubtree()
|
||||||
|
val destination = tempDir.resolve("target")
|
||||||
|
Files.createDirectory(destination)
|
||||||
|
// no exception should happen, but merge
|
||||||
|
ask(CopyDirectory.Request(subtree.root, destination))
|
||||||
|
val subtreeExpected = subtree.rebase(destination)
|
||||||
|
expectSubtree(subtree)
|
||||||
|
expectSubtree(subtreeExpected)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Copy file: plain") {
|
||||||
|
val srcFile = createSubFile()
|
||||||
|
val dstFile = tempDir.resolve("file2")
|
||||||
|
ask(CopyFile.Request(srcFile, dstFile))
|
||||||
|
expectExist(srcFile)
|
||||||
|
expectExist(dstFile)
|
||||||
|
assert(Files.readAllBytes(dstFile).sameElements(contents))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Copy file: target already exists") {
|
||||||
|
val srcFile = createSubFile()
|
||||||
|
val dstFile = createSubFile()
|
||||||
|
abet[FileAlreadyExistsException](CopyFile.Request(srcFile, dstFile))
|
||||||
|
expectExist(srcFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Delete directory: empty directory") {
|
||||||
|
val dir = createSubDir()
|
||||||
|
ask(DeleteDirectory.Request(dir))
|
||||||
|
expectNotExist(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Delete directory: non-empty directory") {
|
||||||
|
val subtree = createSubtree()
|
||||||
|
ask(DeleteDirectory.Request(subtree.root))
|
||||||
|
expectNotExist(subtree.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Delete directory: missing directory") {
|
||||||
|
val missingPath = tempDir.resolve("foo")
|
||||||
|
abet[NoSuchFileException](DeleteDirectory.Request(missingPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Delete file: simple") {
|
||||||
|
val file = createSubFile()
|
||||||
|
expectExist(file)
|
||||||
|
ask(DeleteFile.Request(file))
|
||||||
|
expectNotExist(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Delete file: missing file") {
|
||||||
|
val missingPath = tempDir.resolve("foo")
|
||||||
|
expectNotExist(missingPath)
|
||||||
|
abet[NoSuchFileException](DeleteFile.Request(missingPath))
|
||||||
|
expectNotExist(missingPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Exists: outside project by relative path") {
|
||||||
|
val path = tempDir.resolve("../foo")
|
||||||
|
// Make sure that our path seemingly may look like something under the project.
|
||||||
|
assert(path.startsWith(tempDir))
|
||||||
|
abet[PathOutsideProjectException](Exists.Request(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Exists: outside project by absolute path") {
|
||||||
|
abet[PathOutsideProjectException](Exists.Request(homeDirectory()))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Exists: existing file") {
|
||||||
|
val filePath = createSubFile()
|
||||||
|
val response = ask(Exists.Request(filePath))
|
||||||
|
response.exists should be(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Exists: existing directory") {
|
||||||
|
val dirPath = createSubDir()
|
||||||
|
val response = ask(Exists.Request(dirPath))
|
||||||
|
response.exists should be(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Exists: missing file") {
|
||||||
|
val filePath = tempDir.resolve("bar")
|
||||||
|
val response = ask(Exists.Request(filePath))
|
||||||
|
response.exists should be(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("List: empty directory") {
|
||||||
|
val requestContents = List.Request(tempDir)
|
||||||
|
val response = ask(requestContents)
|
||||||
|
response.entries should have length 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test("List: missing directory") {
|
||||||
|
val path = tempDir.resolve("bar")
|
||||||
|
abet[NoSuchFileException](List.Request(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("List: non-empty directory") {
|
||||||
|
val filePath = createSubFile()
|
||||||
|
val subdirPath = createSubDir()
|
||||||
|
val response = ask(List.Request(tempDir))
|
||||||
|
|
||||||
|
def expectPath(path: Path): Path = {
|
||||||
|
response.entries.find(_.toString == path.toString) match {
|
||||||
|
case Some(entry) => entry
|
||||||
|
case _ => fail(s"cannot find entry for path $path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.entries should have length 2
|
||||||
|
expectPath(filePath)
|
||||||
|
expectPath(subdirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("List: outside project") {
|
||||||
|
abet[PathOutsideProjectException](List.Request(homeDirectory()))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Move directory: empty directory") {
|
||||||
|
val subdir = createSubDir()
|
||||||
|
val destination = tempDir.resolve("target")
|
||||||
|
ask(MoveDirectory.Request(subdir, destination))
|
||||||
|
assert(!Files.exists(subdir))
|
||||||
|
assert(Files.exists(destination))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Move directory: non-empty directory") {
|
||||||
|
val subtree = createSubtree()
|
||||||
|
val destination = tempDir.resolve("target")
|
||||||
|
ask(MoveDirectory.Request(subtree.root, destination))
|
||||||
|
val subtreeExpected = subtree.rebase(destination)
|
||||||
|
assert(!Files.exists(subtree.root))
|
||||||
|
expectSubtree(subtreeExpected)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Move directory: target already exists") {
|
||||||
|
val subtree = createSubtree()
|
||||||
|
val destination = tempDir.resolve("target")
|
||||||
|
Files.createDirectory(destination)
|
||||||
|
abet[FileExistsException](MoveDirectory.Request(subtree.root, destination))
|
||||||
|
// Source was not destroyed by failed move.
|
||||||
|
expectSubtree(subtree)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Stat: missing file") {
|
||||||
|
val filePath = tempDir.resolve("bar")
|
||||||
|
abet[NoSuchFileException](Status.Request(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Read: file") {
|
||||||
|
val filePath = tempDir.resolve("bar")
|
||||||
|
Files.write(filePath, contents)
|
||||||
|
val response = ask(Read.Request(filePath))
|
||||||
|
response.contents should be(contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Touch: new file") {
|
||||||
|
val filePath = tempDir.resolve("bar")
|
||||||
|
ask(Touch.Request(filePath))
|
||||||
|
expectExist(filePath)
|
||||||
|
Files.size(filePath) should be(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Touch: update file") {
|
||||||
|
val filePath = createSubFile()
|
||||||
|
val initialTimestamp = Files.getLastModifiedTime(filePath).toInstant
|
||||||
|
Thread.sleep(1000)
|
||||||
|
ask(Touch.Request(filePath))
|
||||||
|
val finalTimestamp = Files.getLastModifiedTime(filePath).toInstant
|
||||||
|
assert(initialTimestamp.isBefore(finalTimestamp))
|
||||||
|
expectExist(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Write: file") {
|
||||||
|
val filePath = tempDir.resolve("bar")
|
||||||
|
ask(Write.Request(filePath, contents))
|
||||||
|
val actualFileContents = Files.readAllBytes(filePath)
|
||||||
|
actualFileContents should be(contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Status: normal file") {
|
||||||
|
val filePath = createSubFile()
|
||||||
|
val contents = "aaa"
|
||||||
|
Files.write(filePath, contents.getBytes())
|
||||||
|
val response = ask(Status.Request(filePath))
|
||||||
|
response.attributes.isDirectory should be(false)
|
||||||
|
response.attributes.size should be(contents.length)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
package org.enso.filemanager
|
||||||
|
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
|
||||||
|
import org.apache.commons.io.FileUtils
|
||||||
|
|
||||||
|
import org.scalatest.Matchers
|
||||||
|
|
||||||
|
trait Helpers extends Matchers {
|
||||||
|
var tempDir: Path = _
|
||||||
|
|
||||||
|
val contents: Array[Byte] =
|
||||||
|
"葦垣の中の和草にこやかに我れと笑まして人に知らゆな\nzażółć gęślą jaźń".getBytes
|
||||||
|
|
||||||
|
def createSubFile(): Path = {
|
||||||
|
val path = Files.createTempFile(tempDir, "foo", "")
|
||||||
|
Files.write(path, contents)
|
||||||
|
}
|
||||||
|
|
||||||
|
def createSubDir(): Path = {
|
||||||
|
Files.createTempDirectory(tempDir, "foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
def homeDirectory(): Path = Paths.get(System.getProperty("user.home"))
|
||||||
|
|
||||||
|
def setupTemp(): Unit = {
|
||||||
|
tempDir = Files.createTempDirectory("file-manager-test")
|
||||||
|
}
|
||||||
|
|
||||||
|
def cleanTemp(): Unit = {
|
||||||
|
FileUtils.deleteDirectory(tempDir.toFile)
|
||||||
|
tempDir = null
|
||||||
|
}
|
||||||
|
|
||||||
|
def withTemporaryDirectory[ret](f: Path => ret): ret = {
|
||||||
|
setupTemp()
|
||||||
|
try f(tempDir)
|
||||||
|
finally cleanTemp()
|
||||||
|
}
|
||||||
|
|
||||||
|
case class Subtree(
|
||||||
|
root: Path,
|
||||||
|
childrenFiles: Seq[Path],
|
||||||
|
childrenDirs: Seq[Path]) {
|
||||||
|
|
||||||
|
val elements: Seq[Path] =
|
||||||
|
(Seq(root) ++ childrenDirs ++ childrenFiles).map(root.resolve)
|
||||||
|
|
||||||
|
def rebase(otherRoot: Path): Subtree =
|
||||||
|
Subtree(otherRoot, childrenFiles, childrenDirs)
|
||||||
|
}
|
||||||
|
|
||||||
|
def createSubtree(): Subtree = {
|
||||||
|
val root = createSubDir()
|
||||||
|
val rootFile1 = Paths.get("file1")
|
||||||
|
val rootSubDir = Paths.get("dir")
|
||||||
|
val rootFile2 = Paths.get("dir/file2")
|
||||||
|
|
||||||
|
Files.write(root.resolve(rootFile1), contents)
|
||||||
|
Files.createDirectory(root.resolve(rootSubDir))
|
||||||
|
Files.write(root.resolve(rootFile2), contents)
|
||||||
|
Subtree(root, Seq(rootFile1, rootFile2), Seq(rootSubDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
def expectSubtree(subtree: Subtree): Unit = {
|
||||||
|
assert(Files.exists(subtree.root))
|
||||||
|
subtree.elements.foreach(
|
||||||
|
elem => expectExist(subtree.root.resolve(elem))
|
||||||
|
)
|
||||||
|
|
||||||
|
val listStream = Files.list(subtree.root)
|
||||||
|
try listStream.count() should be(2)
|
||||||
|
finally listStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
def expectExist(path: Path): Unit = {
|
||||||
|
assert(Files.exists(path), s"$path is expected to exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
def expectNotExist(path: Path): Unit = {
|
||||||
|
assert(!Files.exists(path), s"$path is expected to not exist")
|
||||||
|
}
|
||||||
|
}
|
223
FileManager/src/test/scala/org/enso/filemanager/WatchTests.scala
Normal file
223
FileManager/src/test/scala/org/enso/filemanager/WatchTests.scala
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
package org.enso.filemanager
|
||||||
|
|
||||||
|
import akka.actor.Scheduler
|
||||||
|
import akka.actor.testkit.typed.scaladsl.ActorTestKit
|
||||||
|
import akka.actor.testkit.typed.scaladsl.TestProbe
|
||||||
|
import akka.actor.typed.ActorRef
|
||||||
|
import akka.util.Timeout
|
||||||
|
import io.methvin.watcher.DirectoryChangeEvent
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.NotDirectoryException
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
import org.apache.commons.io.FileUtils
|
||||||
|
import org.enso.FileManager
|
||||||
|
import org.scalatest.BeforeAndAfterAll
|
||||||
|
import org.scalatest.FunSuite
|
||||||
|
import org.scalatest.Matchers
|
||||||
|
import org.scalatest.Outcome
|
||||||
|
|
||||||
|
import scala.concurrent.Await
|
||||||
|
import scala.concurrent.Future
|
||||||
|
import scala.concurrent.duration._
|
||||||
|
import scala.reflect.ClassTag
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
// needs to be separate because watcher message are asynchronous
|
||||||
|
class WatchTests
|
||||||
|
extends FunSuite
|
||||||
|
with BeforeAndAfterAll
|
||||||
|
with Matchers
|
||||||
|
with Helpers {
|
||||||
|
import FileManager.API._
|
||||||
|
|
||||||
|
var testKit: ActorTestKit = ActorTestKit()
|
||||||
|
implicit val timeout: Timeout = 3.seconds
|
||||||
|
implicit val scheduler: Scheduler = testKit.scheduler
|
||||||
|
|
||||||
|
var fileManager: ActorRef[InputMessage] = _
|
||||||
|
var testProbe: TestProbe[FileSystemEvent] = _
|
||||||
|
var watcherID: UUID = _
|
||||||
|
|
||||||
|
override def withFixture(test: NoArgTest): Outcome = {
|
||||||
|
withTemporaryDirectory(_ => {
|
||||||
|
fileManager = testKit.spawn(FileManager(tempDir))
|
||||||
|
testProbe = testKit.createTestProbe[FileSystemEvent]("file-observer")
|
||||||
|
watcherID = observe(tempDir)
|
||||||
|
|
||||||
|
try super.withFixture(test)
|
||||||
|
finally if (watcherID != null)
|
||||||
|
// Otherwise directory would stay blocked on Windows.
|
||||||
|
unobserve(watcherID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override def afterAll() {
|
||||||
|
testKit.shutdownTestKit()
|
||||||
|
}
|
||||||
|
|
||||||
|
def matchesEvent(
|
||||||
|
path: Path,
|
||||||
|
eventType: DirectoryChangeEvent.EventType
|
||||||
|
)(message: FileSystemEvent
|
||||||
|
): Boolean = {
|
||||||
|
message.path == path && message.eventType == eventType
|
||||||
|
}
|
||||||
|
|
||||||
|
def expectEventFor(
|
||||||
|
eventType: DirectoryChangeEvent.EventType,
|
||||||
|
events: Seq[FileSystemEvent]
|
||||||
|
)(path: Path
|
||||||
|
): Unit = {
|
||||||
|
assert(
|
||||||
|
events.exists(matchesEvent(path, eventType)),
|
||||||
|
s"not received message about $path"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def expectNextEvent(
|
||||||
|
path: Path,
|
||||||
|
eventType: DirectoryChangeEvent.EventType,
|
||||||
|
probe: TestProbe[FileSystemEvent] = testProbe
|
||||||
|
): Unit = {
|
||||||
|
val message = probe.receiveMessage()
|
||||||
|
assert(
|
||||||
|
matchesEvent(path, eventType)(message),
|
||||||
|
s"expected of type $eventType for $path, got $message"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def ask[response <: Response.Success: ClassTag](
|
||||||
|
requestPayload: Request.Payload[response]
|
||||||
|
): Future[Try[response]] = {
|
||||||
|
FileManager.ask(fileManager, requestPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
def observe(
|
||||||
|
path: Path,
|
||||||
|
replyTo: ActorRef[FileSystemEvent] = testProbe.ref
|
||||||
|
): UUID = {
|
||||||
|
val futureResponse = ask(Watch.Create.Request(path, replyTo))
|
||||||
|
Await.result(futureResponse, timeout.duration).get.id
|
||||||
|
}
|
||||||
|
|
||||||
|
def unobserve(id: UUID): Watch.Remove.Response = {
|
||||||
|
val futureResponse = ask(Watch.Remove.Request(id))
|
||||||
|
Await.result(futureResponse, timeout.duration).get
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Watcher: observe subtree creation and deletion") {
|
||||||
|
val subtree = createSubtree()
|
||||||
|
val events = testProbe.receiveMessages(subtree.elements.size)
|
||||||
|
subtree.elements.foreach(
|
||||||
|
expectEventFor(DirectoryChangeEvent.EventType.CREATE, events)
|
||||||
|
)
|
||||||
|
|
||||||
|
FileUtils.deleteDirectory(subtree.root.toFile)
|
||||||
|
|
||||||
|
val deletionEvents = testProbe.receiveMessages(subtree.elements.size)
|
||||||
|
subtree.elements.foreach(
|
||||||
|
expectEventFor(
|
||||||
|
DirectoryChangeEvent.EventType.DELETE,
|
||||||
|
deletionEvents
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
testProbe.expectNoMessage(50.millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Watcher: observe file modification") {
|
||||||
|
val dir10 = tempDir.resolve("dir10")
|
||||||
|
Files.createDirectory(dir10)
|
||||||
|
expectNextEvent(dir10, DirectoryChangeEvent.EventType.CREATE)
|
||||||
|
|
||||||
|
val dir20 = dir10.resolve("dir20")
|
||||||
|
Files.createDirectories(dir20)
|
||||||
|
expectNextEvent(dir20, DirectoryChangeEvent.EventType.CREATE)
|
||||||
|
|
||||||
|
val someFile = dir20.resolve("file.dat")
|
||||||
|
Files.createFile(someFile)
|
||||||
|
expectNextEvent(someFile, DirectoryChangeEvent.EventType.CREATE)
|
||||||
|
|
||||||
|
// Need to wait a moment, as change soon after creation might be missed
|
||||||
|
// otherwise by some subpar watch implementations.
|
||||||
|
Thread.sleep(2000)
|
||||||
|
Files.write(someFile, "blahblah".getBytes)
|
||||||
|
expectNextEvent(someFile, DirectoryChangeEvent.EventType.MODIFY)
|
||||||
|
|
||||||
|
Files.delete(someFile)
|
||||||
|
expectNextEvent(someFile, DirectoryChangeEvent.EventType.DELETE)
|
||||||
|
|
||||||
|
FileUtils.deleteDirectory(dir20.toFile)
|
||||||
|
expectNextEvent(dir20, DirectoryChangeEvent.EventType.DELETE)
|
||||||
|
testProbe.expectNoMessage(50.millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Watcher: disabling watch") {
|
||||||
|
val subtree = createSubtree()
|
||||||
|
testProbe.receiveMessages(subtree.elements.size)
|
||||||
|
testProbe.expectNoMessage(50.millis)
|
||||||
|
val stopResponse = unobserve(watcherID)
|
||||||
|
watcherID = null
|
||||||
|
stopResponse should be(Watch.Remove.Response())
|
||||||
|
|
||||||
|
// Watch has been disabled, no further messages should come
|
||||||
|
FileUtils.deleteDirectory(subtree.root.toFile)
|
||||||
|
testProbe.expectNoMessage(50.millis)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Watcher: cannot watch ordinary file") {
|
||||||
|
val file = createSubFile()
|
||||||
|
assertThrows[NotDirectoryException]({ observe(file, testProbe.ref) })
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Watcher: cannot watch symlink") {
|
||||||
|
val dir = createSubDir()
|
||||||
|
val dirLink = tempDir.resolve("mylink")
|
||||||
|
Files.createSymbolicLink(dirLink, dir)
|
||||||
|
assertThrows[NotDirectoryException]({ observe(dirLink, testProbe.ref) })
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Watcher: can watch under symlink") {
|
||||||
|
// The observed directory is not and does not contain symlink,
|
||||||
|
// however the path we observe it through contains symlink
|
||||||
|
val top = createSubDir()
|
||||||
|
val linkToTop = top.resolve("link")
|
||||||
|
Files.createSymbolicLink(linkToTop, top)
|
||||||
|
|
||||||
|
val realSub = top.resolve("sub")
|
||||||
|
val linkSub = linkToTop.resolve("sub")
|
||||||
|
|
||||||
|
Files.createDirectory(realSub)
|
||||||
|
|
||||||
|
val symlinkEventProbe =
|
||||||
|
testKit.createTestProbe[FileSystemEvent]("observe-symlink-dir")
|
||||||
|
|
||||||
|
val id = observe(linkSub, symlinkEventProbe.ref)
|
||||||
|
try {
|
||||||
|
// Create file using "real" path.
|
||||||
|
val filename = "testfile"
|
||||||
|
val realFilePath = realSub.resolve(filename)
|
||||||
|
val observedFilePath = linkSub.resolve(filename)
|
||||||
|
|
||||||
|
val expectedOfType = (eventType: DirectoryChangeEvent.EventType) =>
|
||||||
|
expectNextEvent(
|
||||||
|
observedFilePath,
|
||||||
|
eventType,
|
||||||
|
symlinkEventProbe
|
||||||
|
)
|
||||||
|
|
||||||
|
Files.createFile(realFilePath)
|
||||||
|
expectedOfType(DirectoryChangeEvent.EventType.CREATE)
|
||||||
|
|
||||||
|
Files.write(realFilePath, contents)
|
||||||
|
expectedOfType(DirectoryChangeEvent.EventType.MODIFY)
|
||||||
|
|
||||||
|
Files.delete(realFilePath)
|
||||||
|
expectedOfType(DirectoryChangeEvent.EventType.DELETE)
|
||||||
|
|
||||||
|
symlinkEventProbe.expectNoMessage(50.millis)
|
||||||
|
} finally unobserve(id)
|
||||||
|
}
|
||||||
|
}
|
21
build.sbt
21
build.sbt
@ -23,7 +23,8 @@ lazy val enso = (project in file("."))
|
|||||||
syntax,
|
syntax,
|
||||||
pkg,
|
pkg,
|
||||||
interpreter,
|
interpreter,
|
||||||
projectManager
|
projectManager,
|
||||||
|
fileManager
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sub-Projects
|
// Sub-Projects
|
||||||
@ -141,6 +142,9 @@ val akkaStream = "com.typesafe.akka" %% "akka-stream" % "2.5.23"
|
|||||||
val akkaHttp = "com.typesafe.akka" %% "akka-http" % "10.1.8"
|
val akkaHttp = "com.typesafe.akka" %% "akka-http" % "10.1.8"
|
||||||
val akkaSpray = "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.8"
|
val akkaSpray = "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.8"
|
||||||
val akkaTyped = "com.typesafe.akka" %% "akka-actor-typed" % "2.5.23"
|
val akkaTyped = "com.typesafe.akka" %% "akka-actor-typed" % "2.5.23"
|
||||||
|
val akkaTestkit = "com.typesafe.akka" %% "akka-testkit" % "2.5.23"
|
||||||
|
val akkaSLF4J = "com.typesafe.akka" %% "akka-slf4j" % "2.5.23"
|
||||||
|
val akkaTestkitTyped = "com.typesafe.akka" %% "akka-actor-testkit-typed" % "2.5.23" % Test
|
||||||
|
|
||||||
val akka = Seq(akkaActor, akkaStream, akkaHttp, akkaSpray, akkaTyped)
|
val akka = Seq(akkaActor, akkaStream, akkaHttp, akkaSpray, akkaTyped)
|
||||||
|
|
||||||
@ -148,6 +152,21 @@ val circe = Seq("circe-core", "circe-generic", "circe-yaml").map(
|
|||||||
"io.circe" %% _ % "0.10.0"
|
"io.circe" %% _ % "0.10.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
lazy val fileManager = (project in file("FileManager"))
|
||||||
|
.settings(
|
||||||
|
(Compile / mainClass) := Some("org.enso.filemanager.FileManager")
|
||||||
|
)
|
||||||
|
.settings(
|
||||||
|
libraryDependencies ++= akka,
|
||||||
|
libraryDependencies += akkaSLF4J,
|
||||||
|
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.3",
|
||||||
|
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.0-SNAP10" % Test,
|
||||||
|
libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.14.0" % Test,
|
||||||
|
libraryDependencies += akkaTestkitTyped,
|
||||||
|
libraryDependencies += "commons-io" % "commons-io" % "2.6",
|
||||||
|
libraryDependencies += "io.methvin" % "directory-watcher" % "0.9.6"
|
||||||
|
)
|
||||||
|
|
||||||
lazy val projectManager = (project in file("project-manager"))
|
lazy val projectManager = (project in file("project-manager"))
|
||||||
.settings(
|
.settings(
|
||||||
(Compile / mainClass) := Some("org.enso.projectmanager.Server")
|
(Compile / mainClass) := Some("org.enso.projectmanager.Server")
|
||||||
|
@ -1 +1,4 @@
|
|||||||
addSbtPlugin("de.sciss" % "sbt-jflex" % "0.4.0")
|
addSbtPlugin("de.sciss" % "sbt-jflex" % "0.4.0")
|
||||||
|
addSbtPlugin(
|
||||||
|
"com.thoughtworks.sbt-api-mappings" % "sbt-api-mappings" % "2.0.0"
|
||||||
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user