Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
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
9 changes: 7 additions & 2 deletions .github/workflows/gui-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ jobs:
fail-on-error: false
fail-on-warning: false

- name: 📥 Download Enso Engine
continue-on-error: true
working-directory: app/project-manager-shim
run: pnpm run download-engine
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Comment on lines +92 to +98
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this a check if we're able to download engine, or we need to download engine for one of these checks?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need the enso executable to run the ProjectService.test.ts test suite

- name: 🧪 Unit Tests
id: unit-tests
continue-on-error: true
Expand Down Expand Up @@ -158,8 +165,6 @@ jobs:
- name: 🎭 Playwright Tests
working-directory: app/gui
run: pnpm run test:integration --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: ⬆️ Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
Expand Down
48 changes: 13 additions & 35 deletions app/gui/project-manager-shim-middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ import type { Readable } from 'node:stream'
import { finished } from 'node:stream/promises'
import { createGzip } from 'node:zlib'
import * as projectManagement from 'project-manager-shim'
import { handleFilesystemCommand, toJSONRPCError, toJSONRPCResult } from 'project-manager-shim'
import {
handleFilesystemCommand,
handleProjectServiceRequest,
isProjectServiceRequest,
} from 'project-manager-shim/handler'
import { ProjectService } from 'project-manager-shim/projectService'
import { tarFsPack, unzipEntries, zipWriteStream } from './archive'

Expand Down Expand Up @@ -212,30 +216,14 @@ export class ProjectManagerShimMiddleware {
break
}
}
} else if (requestPath.startsWith('/api/project-service/')) {
switch (`${request.method} ${requestPath}`) {
case 'POST /api/project-service/project/create': {
interface ResponseBody {
readonly name: string
readonly projectsDirectory: Path
}
bodyJson<ResponseBody>(request)
.then(async (body) => {
const projectService = await this.getProjectService()
return projectService.createProject(body.name, body.projectsDirectory)
})
.then((result) => {
response.writeHead(HTTP_STATUS_OK, COMMON_HEADERS).end(toJSONRPCResult(result))
})
.catch((err) => {
console.error(err)
response
.writeHead(HTTP_STATUS_OK, COMMON_HEADERS)
.end(toJSONRPCError('project/create failed', err))
})
break
}
}
} else if (isProjectServiceRequest(requestPath)) {
handleProjectServiceRequest(
request,
response,
requestPath,
() => this.getProjectService(),
COMMON_HEADERS,
)
} else if (requestPath.startsWith('/api/')) {
switch (`${request.method} ${requestPath}`) {
case `POST /api/${EXPORT_ARCHIVE_PATH}`: {
Expand Down Expand Up @@ -348,16 +336,6 @@ export class ProjectManagerShimMiddleware {
}
}

/** Read JSON from an HTTP request body. */
async function bodyJson<T>(request: http.IncomingMessage): Promise<T> {
const chunks: Buffer[] = []
for await (const chunk of request) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
}
const body = Buffer.concat(chunks).toString('utf-8')
return JSON.parse(body) as T
}

/** Return whether a file exists. */
async function fileExists(path: string) {
try {
Expand Down
70 changes: 57 additions & 13 deletions app/gui/src/dashboard/services/ProjectManager/ProjectManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,24 @@ export class ProjectManager {
private reconnecting = false
private resolvers = new Map<number, (value: never) => void>()
private rejecters = new Map<number, (reason?: JSONRPCError) => void>()
private socketPromise: Promise<WebSocket>
private socketPromise: Promise<WebSocket> | null = null

/** Create a {@link ProjectManager} */
constructor(
private readonly connectionUrl: string,
public readonly rootDirectory: Path,
) {
this.socketPromise = this.reconnect()
// This is a Vue function, not a React hook, so the React hooks rule doesn't apply
// eslint-disable-next-line react-hooks/rules-of-hooks
const enableProjectService = useFeatureFlag('enableProjectService')
if (!enableProjectService.value) {
this.socketPromise = this.reconnect()
}
Comment on lines +66 to +71
Copy link
Contributor

Choose a reason for hiding this comment

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

You don't make use of Vue reactivity here, so you may just add getFeatureFlag method there with body:

    return flagsStore.getState().featureFlags[key]

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, one thing looks strange: we reconnect only when ProjectService is not enabled? I thought it should be the other way round...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see, there's some confusion in the naming. The new logic is called ProjectService because there is a ProjectService.scala which I ported to TypeScript. In other words, the project service is the new logic in dashboard and the project manager is the old java process

}

/** Begin reconnecting the {@link WebSocket}. */
reconnect() {
if (this.reconnecting) {
if (this.reconnecting && this.socketPromise) {
return this.socketPromise
}
this.reconnecting = true
Expand Down Expand Up @@ -129,8 +134,10 @@ export class ProjectManager {

/** Dispose of the {@link ProjectManager}. */
async dispose() {
const socket = await this.socketPromise
socket.close()
if (this.socketPromise) {
const socket = await this.socketPromise
socket.close()
}
}

/** Get the state of a project given its path. */
Expand All @@ -157,7 +164,15 @@ export class ProjectManager {
if (cached) {
return cached.data
} else {
const promise = this.sendRequest<OpenProject>('project/open', fullParams)
// This is a Vue function, not a React hook, so the React hooks rule doesn't apply
// eslint-disable-next-line react-hooks/rules-of-hooks
const enableProjectService = useFeatureFlag('enableProjectService')
let promise: Promise<OpenProject>
if (enableProjectService.value) {
promise = this.runProjectServiceCommandJson('project/open', fullParams)
} else {
promise = this.sendRequest<OpenProject>('project/open', fullParams)
}
this.projects.set(fullParams.projectId, {
state: backend.ProjectState.openInProgress,
data: promise,
Expand Down Expand Up @@ -190,11 +205,19 @@ export class ProjectManager {
}
const fullParams: CloseProjectParams = this.paramsWithPathToWithId(params)
this.projects.delete(fullParams.projectId)
return this.sendRequest('project/close', fullParams)
// This is a Vue function, not a React hook, so the React hooks rule doesn't apply
// eslint-disable-next-line react-hooks/rules-of-hooks
const enableProjectService = useFeatureFlag('enableProjectService')
if (enableProjectService.value) {
return this.runProjectServiceCommandJson('project/close', fullParams)
} else {
return this.sendRequest('project/close', fullParams)
}
}

/** Create a new project. */
async createProject(params: CreateProjectParams): Promise<CreateProject> {
// This is a Vue function, not a React hook, so the React hooks rule doesn't apply
// eslint-disable-next-line react-hooks/rules-of-hooks
const enableProjectService = useFeatureFlag('enableProjectService')
let result: Omit<CreateProject, 'projectPath'>
Expand Down Expand Up @@ -232,7 +255,14 @@ export class ProjectManager {
/** Rename a project. */
async renameProject(params: WithProjectPath<RenameProjectParams>): Promise<void> {
const fullParams: RenameProjectParams = this.paramsWithPathToWithId(params)
await this.sendRequest('project/rename', fullParams)
// This is a Vue function, not a React hook, so the React hooks rule doesn't apply
// eslint-disable-next-line react-hooks/rules-of-hooks
const enableProjectService = useFeatureFlag('enableProjectService')
if (enableProjectService.value) {
await this.runProjectServiceCommandJson('project/rename', fullParams)
} else {
await this.sendRequest('project/rename', fullParams)
}
const state = this.projects.get(fullParams.projectId)
if (state?.state === backend.ProjectState.opened) {
this.projects.set(fullParams.projectId, {
Expand All @@ -255,10 +285,15 @@ export class ProjectManager {
params: WithProjectPath<DuplicateProjectParams>,
): Promise<DuplicatedProject> {
const fullParams: DuplicateProjectParams = this.paramsWithPathToWithId(params)
const result = await this.sendRequest<Omit<DuplicatedProject, 'projectPath'>>(
'project/duplicate',
fullParams,
)
// This is a Vue function, not a React hook, so the React hooks rule doesn't apply
// eslint-disable-next-line react-hooks/rules-of-hooks
const enableProjectService = useFeatureFlag('enableProjectService')
let result: Omit<DuplicatedProject, 'projectPath'>
if (enableProjectService.value) {
result = await this.runProjectServiceCommandJson('project/duplicate', fullParams)
} else {
result = await this.sendRequest('project/duplicate', fullParams)
}
// Update `internalDirectories` by listing the project's parent directory, because the
// directory name of the project is unknown. Deleting the directory is not an option because
// that will prevent ALL descendants of the parent directory from being updated.
Expand All @@ -279,7 +314,14 @@ export class ProjectManager {
if (cached && backend.IS_OPENING_OR_OPENED[cached.state]) {
await this.closeProject({ projectPath: params.projectPath })
}
await this.sendRequest('project/delete', fullParams)
// This is a Vue function, not a React hook, so the React hooks rule doesn't apply
// eslint-disable-next-line react-hooks/rules-of-hooks
const enableProjectService = useFeatureFlag('enableProjectService')
if (enableProjectService.value) {
await this.runProjectServiceCommandJson('project/delete', fullParams)
} else {
await this.sendRequest('project/delete', fullParams)
}
this.projectIds.delete(params.projectPath)
this.projects.delete(fullParams.projectId)
const siblings = this.directories.get(fullParams.projectsDirectory)
Expand Down Expand Up @@ -466,6 +508,8 @@ export class ProjectManager {

/** Send a JSON-RPC request to the project manager. */
private async sendRequest<T = void>(method: string, params: unknown): Promise<T> {
// Initialize socket lazily if not already initialized
this.socketPromise ??= this.reconnect()
const socket = await this.socketPromise
const id = this.id++
socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
Expand Down
1 change: 0 additions & 1 deletion app/gui/src/dashboard/services/ProjectManager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ export interface CreateProject {

/** The return value of the "open project" endpoint. */
export interface OpenProject {
readonly engineVersion: string
readonly languageServerJsonAddress: IpWithSocket
readonly languageServerBinaryAddress: IpWithSocket
readonly projectName: ProjectName
Expand Down
25 changes: 24 additions & 1 deletion app/ide-desktop/client/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ import type * as vite from 'vite'
import { COOP_COEP_CORP_HEADERS } from 'enso-common'
import GLOBAL_CONFIG from 'enso-common/src/config.json' with { type: 'json' }
import * as projectManagement from 'project-manager-shim'
import { handleFilesystemCommand } from 'project-manager-shim'
import {
handleFilesystemCommand,
handleProjectServiceRequest,
isProjectServiceRequest,
} from 'project-manager-shim/handler'
import { ProjectService } from 'project-manager-shim/projectService'
import * as ydocServer from 'ydoc-server'

import { tarFsPack, unzipEntries, zipWriteStream } from '@/archive'
Expand Down Expand Up @@ -196,12 +201,21 @@ async function findPort(port: number): Promise<number> {
export class Server {
private projectsRootDirectory: string
private devServer?: vite.ViteDevServer
private projectService?: ProjectService

/** Create a simple HTTP server. */
constructor(public config: Config) {
this.projectsRootDirectory = projectManagement.getProjectsDirectory().replace(/\\/g, '/')
}

/** Get the project service. */
getProjectService(): ProjectService {
if (!this.projectService) {
this.projectService = ProjectService.default()
}
return this.projectService
}

/** Server constructor. */
static async create(config: Config): Promise<Server> {
const localConfig = Object.assign({}, config)
Expand Down Expand Up @@ -316,6 +330,15 @@ export class Server {
),
{ end: true },
)
} else if (isProjectServiceRequest(requestUrl)) {
const headers = Object.fromEntries(COOP_COEP_CORP_HEADERS)
handleProjectServiceRequest(
request,
response,
requestUrl,
async () => this.getProjectService(),
headers,
)
} else if (request.url?.startsWith('/api/')) {
const route = new URL(`https://example.com${requestUrl.replace('/api/', '/')}`)
const params = route.searchParams
Expand Down
11 changes: 10 additions & 1 deletion app/project-manager-shim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./handler": {
"source": "./src/handler/index.ts",
"types": "./dist/handler/index.d.ts",
"import": "./dist/handler/index.js"
},
"./projectService": {
"source": "./src/projectService/index.ts",
"types": "./dist/projectService/index.d.ts",
Expand All @@ -19,17 +24,21 @@
"scripts": {
"test:unit": "vitest run",
"compile": "tsc",
"lint": "eslint . --cache --max-warnings=0"
"lint": "eslint . --cache --max-warnings=0",
"download-engine": "node scripts/download-engine.js"
},
"devDependencies": {
"@types/extract-zip": "^2.0.3",
"@types/node": "catalog:",
"@types/tar": "^6.1.13",
"extract-zip": "^2.0.1",
"typescript": "catalog:",
"vitest": "catalog:"
},
"dependencies": {
"enso-common": "workspace:*",
"tar": "^6.2.1",
"trash": "^9.0.0",
"yaml": "^2.7.0"
}
}
24 changes: 24 additions & 0 deletions app/project-manager-shim/scripts/download-engine.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env node
import { rmSync } from 'fs'
import { dirname, join } from 'path'
import process from 'process'
import { fileURLToPath } from 'url'
import { downloadEnsoEngine } from '../dist/projectService/ensoRunner.js'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const projectRoot = join(__dirname, '../../..')
const builtDistributionPath = join(projectRoot, 'built-distribution')

// Delete the built-distribution directory
rmSync(builtDistributionPath, { recursive: true, force: true })

// Download the engine
downloadEnsoEngine(projectRoot)
.then(() => {
process.exit(0)
})
.catch((error) => {
console.error('Failed to download engine:', error)
process.exit(1)
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import * as fs from 'node:fs/promises'
import type * as http from 'node:http'
import * as path from 'node:path'
import * as yaml from 'yaml'
import * as projectManagement from './projectManagement.js'
import * as projectManagement from '../projectManagement.js'
import { toJSONRPCError, toJSONRPCResult } from './jsonrpc.js'

// =======================
// === ProjectMetadata ===
Expand Down Expand Up @@ -84,20 +85,6 @@ export interface ProjectEntry {
// === Handlers ===
// ================

/** JSON-RPC result wrapper */
export function toJSONRPCResult(result: unknown): string {
return JSON.stringify({ jsonrpc: '2.0', id: 0, result })
}

/** JSON-RPC error wrapper */
export function toJSONRPCError(message: string, data?: unknown): string {
return JSON.stringify({
jsonrpc: '2.0',
id: 0,
error: { code: 0, message, ...(data != null ? { data } : {}) },
})
}

/**
* Return a {@link ProjectMetadata} if the metadata is a valid metadata object,
* else return `null`.
Expand Down
6 changes: 6 additions & 0 deletions app/project-manager-shim/src/handler/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @file Re-exports for handler module.
*/

export * from './filesystem.js'
export * from './projectService.js'
Loading
Loading