Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add project/duplicate endpoint #10407

Merged
merged 7 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/language-server/protocol-project-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ transport formats, please look [here](./protocol-architecture.md).
- [`project/delete`](#projectdelete)
- [`project/listSample`](#projectlistsample)
- [`project/status`](#projectstatus)
- [`project/duplicate`](#projectduplicate)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be called duplicate? Why not copy or fork?

- [Action Progress Reporting](#action-progress-reporting)
- [`task/started`](#taskstarted)
- [`task/progress-update`](#taskprogress-update)
Expand Down Expand Up @@ -750,6 +751,50 @@ interface ProjectStatusResponse {
}
```

### `project/duplicate`

This message requests to make a copy of the project.

- **Type:** Request
- **Direction:** Client -> Server
- **Connection:** Protocol
- **Visibility:** Public

#### Parameters

```typescript
interface ProjectDuplicateRequest {
/**
* The project to duplicate.
*/
projectId: UUID;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the message support (optional) suggested name for the newly created project?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the cloud, the project with the (copy) suffix is created, and then the user can rename it.


/**
* Custom directory with the user projects.
*/
projectsDirectory?: string;
}
```

#### Result

```typescript
interface ProjectDuplicateResponse {
projectId: UUID;
projectName: string;
projectNormalizedName: string;
}
```

#### Errors

- [`ProjectDataStoreError`](#projectdatastoreerror) to signal problems with
underlying data store.
- [`ProjectNotFoundError`](#projectnotfounderror) to signal that the project
doesn't exist.
- [`ServiceError`](./protocol-common.md#serviceerror) to signal that the the
operation timed out.

## Action Progress Reporting

Some actions, especially those related to installation of new components may
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,15 @@ abstract class JsonRpcServerTestKit

def clientControllerFactory(): ClientControllerFactory

var _clientControllerFactory: ClientControllerFactory = _
private var _clientControllerFactory: ClientControllerFactory = _

override def beforeEach(): Unit = {
super.beforeEach()
val factory = protocolFactory
factory.init()
_clientControllerFactory = clientControllerFactory()
server = new JsonRpcServer(factory, _clientControllerFactory)
binding = Await.result(server.bind(interface, port = 0), 3.seconds)
binding = Await.result(server.bind(interface, port = 0), 5.seconds.dilated)
address = s"ws://$interface:${binding.localAddress.getPort}"
}

Expand Down Expand Up @@ -174,10 +174,12 @@ abstract class JsonRpcServerTestKit
def fuzzyExpectJson(
json: Json,
timeout: FiniteDuration = 5.seconds.dilated
)(implicit pos: Position): Assertion = {
)(implicit pos: Position): Json = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we leave Either here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the JSON does not match the expected json value, the assertion will fail and the function will fail with the assertion exception

val parsed = parse(expectMessage(timeout))

parsed should fuzzyMatchJson(json)

inside(parsed) { case Right(json) => json }
}

def expectNoMessage(): Unit = outActor.expectNoMessage()
Expand All @@ -191,9 +193,10 @@ abstract class JsonRpcServerTestKit
trait FuzzyJsonMatchers { self: Matchers =>
class JsonEquals(expected: Json)
extends Matcher[Either[io.circe.ParsingFailure, Json]] {
val patch = inferPatch(expected)

def apply(left: Either[io.circe.ParsingFailure, Json]) = {
private val patch = inferPatch(expected)

def apply(left: Either[io.circe.ParsingFailure, Json]): MatchResult = {
val leftFormatted = patch[scala.util.Try](left.getOrElse(Json.Null))
val expectedFormatted = patch[scala.util.Try](expected)
MatchResult(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ class BlockingFileSystem[F[+_, +_]: Sync: ErrorChannel](
}
.mapError(toFsFailure)

/** @inheritdoc */
override def copy(from: File, to: File): F[FileSystemFailure, Unit] =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See! It is called copy here ;-)

Sync[F]
.blockingOp {
if (to.isDirectory) {
FileUtils.copyToDirectory(from, to)
} else if (from.isDirectory) {
FileUtils.copyDirectory(from, to)
} else {
FileUtils.copyFile(from, to)
}
}
.mapError(toFsFailure)

/** @inheritdoc */
override def exists(file: File): F[FileSystemFailure, Boolean] =
Sync[F]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,22 @@ trait FileSystem[F[+_, +_]] {
*/
def remove(path: File): F[FileSystemFailure, Unit]

/** Move a file or directory recursively
/** Move a file or directory recursively.
*
* @param from a path to the source
* @param to a path to the destination
* @return either [[FileSystemFailure]] or Unit
*/
def move(from: File, to: File): F[FileSystemFailure, Unit]

/** Copy a file or directory recursively.
*
* @param from a path to the source
* @param to a path to the destination
* @return either [[FileSystemFailure]] or Unit
*/
def copy(from: File, to: File): F[FileSystemFailure, Unit]

/** Tests if a file exists.
*
* @param file the file to check
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import java.nio.file.Path
import java.nio.file.attribute.FileTime
import java.util.UUID
import org.enso.pkg.{Package, PackageManager}
import org.enso.pkg.validation.NameValidation
import org.enso.projectmanager.boot.configuration.MetadataStorageConfig
import org.enso.projectmanager.control.core.{
Applicative,
Expand Down Expand Up @@ -207,14 +208,7 @@ class ProjectFileRepository[
/** @inheritdoc */
def update(project: Project): F[ProjectRepositoryFailure, Unit] =
metadataStorage(project.path)
.persist(
ProjectMetadata(
id = project.id,
kind = project.kind,
created = project.created,
lastOpened = project.lastOpened
)
)
.persist(ProjectMetadata(project))
.mapError(th => StorageFailure(th.toString))

/** @inheritdoc */
Expand All @@ -231,46 +225,55 @@ class ProjectFileRepository[
}

/** @inheritdoc */
override def moveProjectToTargetDir(
override def moveProject(
projectId: UUID,
newName: String
): F[ProjectRepositoryFailure, File] = {
def move(project: Project) =
for {
targetPath <- findTargetPath(newName)
_ <- moveProjectDir(project, targetPath)
targetPath <- findTargetPath(NameValidation.normalizeName(newName))
_ <- moveProjectDir(project.path, targetPath)
} yield targetPath

for {
project <- getProject(projectId)
primaryPath = new File(projectsPath, newName)
finalPath <-
if (isLocationOk(project.path, primaryPath)) {
CovariantFlatMap[F].pure(primaryPath)
} else {
move(project)
}
} yield finalPath
project <- getProject(projectId)
projectPath <- move(project)
} yield projectPath
}

private def isLocationOk(
currentFile: File,
primaryFile: File
): Boolean = {
val currentPath = currentFile.toString
val primaryPath = primaryFile.toString
if (currentPath.startsWith(primaryPath)) {
val suffixPattern = "_\\d+"
val suffix = currentPath.substring(primaryPath.length, currentPath.length)
suffix.matches(suffixPattern)
} else {
false
}
/** @inheritdoc */
override def copyProject(
project: Project,
newName: String,
newMetadata: ProjectMetadata
): F[ProjectRepositoryFailure, Project] = {
def copy(project: Project) =
for {
targetPath <- findTargetPath(NameValidation.normalizeName(newName))
_ <- copyProjectDir(project.path, targetPath)
} yield targetPath

for {
newProjectPath <- copy(project)
_ <- metadataStorage(newProjectPath)
.persist(newMetadata)
.mapError(th => StorageFailure(th.toString))
_ <- renamePackage(newProjectPath, newName)
newProject <- getProject(newMetadata.id)
} yield newProject
}

private def moveProjectDir(projectPath: File, targetPath: File) = {
fileSystem
.move(projectPath, targetPath)
.mapError[ProjectRepositoryFailure](failure =>
StorageFailure(failure.toString)
)
}

private def moveProjectDir(project: Project, targetPath: File) = {
private def copyProjectDir(projectPath: File, targetPath: File) = {
fileSystem
.move(project.path, targetPath)
.copy(projectPath, targetPath)
.mapError[ProjectRepositoryFailure](failure =>
StorageFailure(failure.toString)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ package org.enso.projectmanager.infrastructure.repository
import java.io.File
import java.nio.file.Path
import java.util.UUID

import org.enso.projectmanager.model.Project
import org.enso.projectmanager.model.{Project, ProjectMetadata}

/** An abstraction for accessing project domain objects from durable storage.
*
Expand Down Expand Up @@ -82,11 +81,23 @@ trait ProjectRepository[F[+_, +_]] {
* @param projectId the project id
* @param newName the new project name
*/
def moveProjectToTargetDir(
def moveProject(
projectId: UUID,
newName: String
): F[ProjectRepositoryFailure, File]

/** Create a copy of the project.
*
* @param project the project to copy
* @param newName the new project name
* @param newMetadata the new project metadata
*/
def copyProject(
project: Project,
newName: String,
newMetadata: ProjectMetadata
): F[ProjectRepositoryFailure, Project]

/** Gets a package name for the specified project.
*
* @param projectId the project id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ class ClientController[F[+_, +_]: Exec: CovariantFlatMap: ErrorChannel: Sync](
),
ProjectRename -> ProjectRenameHandler
.props[F](projectService, timeoutConfig.requestTimeout),
ProjectDuplicate -> ProjectDuplicateHandler.props[F](
projectService,
timeoutConfig.requestTimeout,
timeoutConfig.retries
),
EngineListInstalled -> EngineListInstalledHandler.props(
runtimeVersionManagementService,
timeoutConfig.requestTimeout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ object JsonRpc {
.registerRequest(ProjectClose)
.registerRequest(ProjectRename)
.registerRequest(ProjectList)
.registerRequest(ProjectDuplicate)
.registerNotification(TaskStarted)
.registerNotification(TaskProgressUpdate)
.registerNotification(TaskFinished)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,27 @@ object ProjectManagementApi {
}
}

case object ProjectDuplicate extends Method("project/duplicate") {

case class Params(projectId: UUID, projectsDirectory: Option[String])

case class Result(
projectId: UUID,
projectName: String,
projectNormalizedName: String
)

implicit val hasParams: HasParams.Aux[this.type, ProjectDuplicate.Params] =
new HasParams[this.type] {
type Params = ProjectDuplicate.Params
}

implicit val hasResult: HasResult.Aux[this.type, ProjectDuplicate.Result] =
new HasResult[this.type] {
type Result = ProjectDuplicate.Result
}
}

case object ProjectOpen extends Method("project/open") {

case class Params(
Expand Down
Loading