Fix importing .enso-project files (#10379)

- Fix #10282

# Important Notes
None
This commit is contained in:
somebody1234 2024-06-28 03:48:51 +10:00 committed by GitHub
parent dc7aa94348
commit 19d5bdb9da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 252 additions and 78 deletions

View File

@ -34,7 +34,7 @@ const DEFAULT_IMPORT_ONLY_MODULES =
'@vitejs\\u002Fplugin-react|node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|tiny-invariant|clsx|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener|tailwindcss.*|enso-assets.*|@modyfi\\u002Fvite-plugin-yaml|is-network-error|validator.+|.*[.]json$'
const OUR_MODULES = 'enso-.*'
const RELATIVE_MODULES =
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|detect|file-associations|index|ipc|log|naming|paths|preload|project-management|security|url-associations|#\\u002F.*'
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|desktop-environment|detect|file-associations|index|ipc|log|naming|paths|preload|project-management|security|url-associations|#\\u002F.*'
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|ajv\\u002Fdist\\u002F2020|${RELATIVE_MODULES}`
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
const NOT_CAMEL_CASE = '/^(?!_?[a-z][a-z0-9*]*([A-Z0-9][a-z0-9]*)*$)(?!React$)/'

View File

@ -16,7 +16,7 @@ import * as common from 'enso-common'
import GLOBAL_CONFIG from 'enso-common/src/config.json' assert { type: 'json' }
import * as contentConfig from 'enso-content-config'
import * as ydocServer from 'enso-gui2/ydoc-server'
import * as projectManagement from 'enso-project-manager-shim/src/projectManagement'
import * as projectManagement from 'project-management'
import * as paths from '../paths'

View File

@ -0,0 +1,81 @@
/**
* @file This module contains the logic for the detection of user-specific desktop environment attributes.
*/
import * as childProcess from 'node:child_process'
import * as os from 'node:os'
import * as path from 'node:path'
export const DOCUMENTS = getDocumentsPath()
const CHILD_PROCESS_TIMEOUT = 3000
/**
* Detects path of the user documents directory depending on the operating system.
*/
function getDocumentsPath(): string | undefined {
if (process.platform === 'linux') {
return getLinuxDocumentsPath()
} else if (process.platform === 'darwin') {
return getMacOsDocumentsPath()
} else if (process.platform === 'win32') {
return getWindowsDocumentsPath()
} else {
return
}
}
/**
* Returns the user documents path on Linux.
*/
function getLinuxDocumentsPath(): string {
const xdgDocumentsPath = getXdgDocumentsPath()
return xdgDocumentsPath ?? path.join(os.homedir(), 'enso')
}
/**
* Gets the documents directory from the XDG directory management system.
*/
function getXdgDocumentsPath(): string | undefined {
const out = childProcess.spawnSync('xdg-user-dir', ['DOCUMENTS'], {
timeout: CHILD_PROCESS_TIMEOUT,
})
if (out.error !== undefined) {
return
} else {
return out.stdout.toString().trim()
}
}
/**
* Get the user documents path. On macOS, `Documents` acts as a symlink pointing to the
* real locale-specific user documents directory.
*/
function getMacOsDocumentsPath(): string {
return path.join(os.homedir(), 'Documents')
}
/**
* Get the path to the `My Documents` Windows directory.
*/
function getWindowsDocumentsPath(): string | undefined {
const out = childProcess.spawnSync(
'reg',
[
'query',
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders',
'/v',
'personal',
],
{ timeout: CHILD_PROCESS_TIMEOUT }
)
if (out.error !== undefined) {
return
} else {
const stdoutString = out.stdout.toString()
return stdoutString.split(/\s\s+/)[4]
}
}

View File

@ -7,23 +7,28 @@
* - if the project is in a different location, we copy it to the Project Manager's location
* and open it.
* - if the project is a bundle, we extract it to the Project Manager's location and open it. */
import * as crypto from 'node:crypto'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as pathModule from 'node:path'
import type * as stream from 'node:stream'
import * as electron from 'electron'
import * as tar from 'tar'
import * as common from 'enso-common'
import * as buildUtils from 'enso-common/src/buildUtils'
import * as config from 'enso-content-config'
import * as desktopEnvironment from 'desktop-environment'
import * as paths from 'paths'
import * as fileAssociations from '../file-associations'
const logger = console
const logger = config.logger
// =================
// === Constants ===
// =================
export const PACKAGE_METADATA_RELATIVE_PATH = 'package.yaml'
export const PROJECT_METADATA_RELATIVE_PATH = '.enso/project.json'
/** The filename suffix for the project bundle, including the leading period character. */
const BUNDLED_PROJECT_SUFFIX = '.enso-project'
// ======================
// === Project Import ===
@ -40,7 +45,7 @@ export function importProjectFromPath(
name: string | null = null
): string {
directory ??= getProjectsDirectory()
if (pathModule.extname(openedPath).endsWith(fileAssociations.BUNDLED_PROJECT_SUFFIX)) {
if (pathModule.extname(openedPath).endsWith(BUNDLED_PROJECT_SUFFIX)) {
logger.log(`Path '${openedPath}' denotes a bundled project.`)
// The second part of condition is for the case when someone names a directory
// like `my-project.enso-project` and stores the project there.
@ -52,7 +57,7 @@ export function importProjectFromPath(
return importBundle(openedPath, directory, name)
}
} else {
logger.log(`Opening non-bundled file '${openedPath}'.`)
logger.log(`Opening non-bundled file: '${openedPath}'.`)
const rootPath = getProjectRoot(openedPath)
// Check if the project root is under the projects directory. If it is, we can open it.
// Otherwise, we need to install it first.
@ -123,7 +128,7 @@ export function importBundle(
sync: true,
strip: rootPieces.length,
})
return bumpMetadata(targetPath, name ?? null)
return bumpMetadata(targetPath, directory, name ?? null)
}
/** Upload the project from a bundle. */
@ -152,7 +157,7 @@ export async function uploadBundle(
fs.rmdirSync(temporaryDirectoryName)
}
}
return bumpMetadata(targetPath, name ?? null)
return bumpMetadata(targetPath, directory, name ?? null)
}
/** Import the project so it becomes visible to the Project Manager.
@ -185,7 +190,7 @@ export function importDirectory(
fs.cpSync(rootPath, targetPath, { recursive: true })
// Update the project ID, so we are certain that it is unique.
// This would be violated, if we imported the same project multiple times.
return bumpMetadata(targetPath, name ?? null)
return bumpMetadata(targetPath, directory, name ?? null)
}
}
}
@ -226,9 +231,17 @@ export function getProjectId(projectRoot: string): string | null {
return getMetadata(projectRoot)?.id ?? null
}
/** Get the package name. */
function getPackageName(projectRoot: string) {
const path = pathModule.join(projectRoot, PACKAGE_METADATA_RELATIVE_PATH)
const contents = fs.readFileSync(path, { encoding: 'utf-8' })
const [, name] = contents.match(/^name: (.*)/) ?? []
return name ?? null
}
/** Update the package name. */
export function updatePackageName(projectRoot: string, name: string) {
const path = pathModule.join(projectRoot, paths.PACKAGE_METADATA_RELATIVE)
const path = pathModule.join(projectRoot, PACKAGE_METADATA_RELATIVE_PATH)
const contents = fs.readFileSync(path, { encoding: 'utf-8' })
const newContents = contents.replace(/^name: .*/, `name: ${name}`)
fs.writeFileSync(path, newContents)
@ -246,7 +259,7 @@ export function createMetadata(): ProjectMetadata {
/** Retrieve the project's metadata. */
export function getMetadata(projectRoot: string): ProjectMetadata | null {
const metadataPath = pathModule.join(projectRoot, paths.PROJECT_METADATA_RELATIVE)
const metadataPath = pathModule.join(projectRoot, PROJECT_METADATA_RELATIVE_PATH)
try {
const jsonText = fs.readFileSync(metadataPath, 'utf8')
const metadata: unknown = JSON.parse(jsonText)
@ -258,7 +271,7 @@ export function getMetadata(projectRoot: string): ProjectMetadata | null {
/** Write the project's metadata. */
export function writeMetadata(projectRoot: string, metadata: ProjectMetadata): void {
const metadataPath = pathModule.join(projectRoot, paths.PROJECT_METADATA_RELATIVE)
const metadataPath = pathModule.join(projectRoot, PROJECT_METADATA_RELATIVE_PATH)
fs.mkdirSync(pathModule.dirname(metadataPath), { recursive: true })
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, buildUtils.INDENT_SIZE))
}
@ -284,7 +297,7 @@ export function updateMetadata(
/** Check if the given path represents the root of an Enso project.
* This is decided by the presence of the Project Manager's metadata. */
export function isProjectRoot(candidatePath: string): boolean {
const projectJsonPath = pathModule.join(candidatePath, paths.PROJECT_METADATA_RELATIVE)
const projectJsonPath = pathModule.join(candidatePath, PROJECT_METADATA_RELATIVE_PATH)
try {
fs.accessSync(projectJsonPath, fs.constants.R_OK)
return true
@ -365,7 +378,12 @@ export function getProjectRoot(subtreePath: string): string | null {
/** Get the directory that stores Enso projects. */
export function getProjectsDirectory(): string {
return pathModule.join(electron.app.getPath('home'), 'enso', 'projects')
const documentsPath = desktopEnvironment.DOCUMENTS
if (documentsPath === undefined) {
return pathModule.join(os.homedir(), 'enso', 'projects')
} else {
return pathModule.join(documentsPath, 'enso-projects')
}
}
/** Check if the given project is installed, i.e. can be opened with the Project Manager. */
@ -387,13 +405,38 @@ export function generateId(): string {
return crypto.randomUUID()
}
/** Update the project's ID to a new, unique value, and its last opened date to the current date.
* Return the new ID. */
export function bumpMetadata(projectRoot: string, name: string | null): string {
if (name != null) {
console.log('nom', name)
updatePackageName(projectRoot, name)
/** Update the project's ID to a new, unique value, and its last opened date to the current date. */
export function bumpMetadata(
projectRoot: string,
parentDirectory: string,
name: string | null
): string {
if (name == null) {
const currentName = getPackageName(projectRoot) ?? ''
let index: number | null = null
const prefix = `${currentName} `
for (const sibling of fs.readdirSync(parentDirectory, { withFileTypes: true })) {
if (sibling.isDirectory()) {
try {
const siblingPath = pathModule.join(parentDirectory, sibling.name)
const siblingName = getPackageName(siblingPath)
if (siblingName === currentName) {
index = index ?? 2
} else if (siblingName != null && siblingName.startsWith(prefix)) {
const suffix = siblingName.replace(prefix, '')
const [, numberString] = suffix.match(/^\((\d+)\)/) ?? []
if (numberString != null) {
index = Math.max(index ?? 2, Number(numberString) + 1)
}
}
} catch {
// Ignored - it is a directory but not a project.
}
}
}
name = index == null ? currentName : `${currentName} (${index})`
}
updatePackageName(projectRoot, name)
return updateMetadata(projectRoot, metadata => ({
...metadata,
id: generateId(),

View File

@ -2,6 +2,8 @@
* interactive components. */
import * as React from 'react'
import * as validator from 'validator'
import DriveIcon from 'enso-assets/drive.svg'
import EditorIcon from 'enso-assets/network.svg'
import SettingsIcon from 'enso-assets/settings.svg'
@ -35,7 +37,8 @@ import UserBar from '#/layouts/UserBar'
import Page from '#/components/Page'
import * as backendModule from '#/services/Backend'
import type * as projectManager from '#/services/ProjectManager'
import * as localBackendModule from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager'
import * as array from '#/utilities/array'
import LocalStorage from '#/utilities/LocalStorage'
@ -110,15 +113,12 @@ export interface DashboardProps {
readonly supportsLocalBackend: boolean
readonly appRunner: types.EditorRunner | null
readonly initialProjectName: string | null
readonly projectManagerUrl: string | null
readonly ydocUrl: string | null
readonly projectManagerRootDirectory: projectManager.Path | null
}
/** The component that contains the entire UI. */
export default function Dashboard(props: DashboardProps) {
const { appRunner, initialProjectName } = props
const { ydocUrl, projectManagerUrl, projectManagerRootDirectory } = props
const { appRunner, ydocUrl, initialProjectName: initialProjectNameRaw } = props
const session = authProvider.useNonPartialUserSession()
const remoteBackend = backendProvider.useRemoteBackend()
const localBackend = backendProvider.useLocalBackend()
@ -144,7 +144,13 @@ export default function Dashboard(props: DashboardProps) {
const [assetListEvents, dispatchAssetListEvent] =
eventHooks.useEvent<assetListEvent.AssetListEvent>()
const [assetEvents, dispatchAssetEvent] = eventHooks.useEvent<assetEvent.AssetEvent>()
const defaultCategory = remoteBackend != null ? Category.cloud : Category.local
const initialLocalProjectId =
initialProjectNameRaw != null && validator.isUUID(initialProjectNameRaw)
? localBackendModule.newProjectId(projectManager.UUID(initialProjectNameRaw))
: null
const initialProjectName = initialLocalProjectId ?? initialProjectNameRaw
const defaultCategory =
remoteBackend != null && initialLocalProjectId == null ? Category.cloud : Category.local
const [category, setCategory] = searchParamsState.useSearchParamsState(
'driveCategory',
() => defaultCategory,
@ -178,57 +184,66 @@ export default function Dashboard(props: DashboardProps) {
setPage(TabType.drive)
}
} else if (savedProjectStartupInfo != null) {
if (savedProjectStartupInfo.backendType === backendModule.BackendType.remote) {
if (remoteBackend != null) {
setPage(TabType.drive)
void (async () => {
const abortController = new AbortController()
openProjectAbortControllerRef.current = abortController
try {
const oldProject = await remoteBackend.getProjectDetails(
savedProjectStartupInfo.projectAsset.id,
savedProjectStartupInfo.projectAsset.parentId,
savedProjectStartupInfo.projectAsset.title
)
if (backendModule.IS_OPENING_OR_OPENED[oldProject.state.type]) {
const project = remoteBackend.waitUntilProjectIsReady(
switch (savedProjectStartupInfo.backendType) {
case backendModule.BackendType.remote: {
if (remoteBackend != null) {
setPage(TabType.drive)
void (async () => {
const abortController = new AbortController()
openProjectAbortControllerRef.current = abortController
try {
const oldProject = await remoteBackend.getProjectDetails(
savedProjectStartupInfo.projectAsset.id,
savedProjectStartupInfo.projectAsset.parentId,
savedProjectStartupInfo.projectAsset.title,
abortController.signal
savedProjectStartupInfo.projectAsset.title
)
setProjectStartupInfo({ ...savedProjectStartupInfo, project })
if (page === TabType.editor) {
setPage(page)
if (backendModule.IS_OPENING_OR_OPENED[oldProject.state.type]) {
const project = remoteBackend.waitUntilProjectIsReady(
savedProjectStartupInfo.projectAsset.id,
savedProjectStartupInfo.projectAsset.parentId,
savedProjectStartupInfo.projectAsset.title,
abortController.signal
)
setProjectStartupInfo({ ...savedProjectStartupInfo, project })
if (page === TabType.editor) {
setPage(page)
}
}
} catch {
setProjectStartupInfo(null)
}
} catch {
setProjectStartupInfo(null)
}
})()
})()
}
break
}
} else if (projectManagerUrl != null && projectManagerRootDirectory != null) {
if (localBackend != null) {
void (async () => {
await localBackend.openProject(
savedProjectStartupInfo.projectAsset.id,
{
executeAsync: false,
cognitoCredentials: null,
parentId: savedProjectStartupInfo.projectAsset.parentId,
},
savedProjectStartupInfo.projectAsset.title
)
const project = localBackend.getProjectDetails(
savedProjectStartupInfo.projectAsset.id,
savedProjectStartupInfo.projectAsset.parentId,
savedProjectStartupInfo.projectAsset.title
)
case backendModule.BackendType.local: {
if (localBackend != null) {
const project = localBackend
.openProject(
savedProjectStartupInfo.projectAsset.id,
{
executeAsync: false,
cognitoCredentials: null,
parentId: savedProjectStartupInfo.projectAsset.parentId,
},
savedProjectStartupInfo.projectAsset.title
)
.then(() =>
localBackend.getProjectDetails(
savedProjectStartupInfo.projectAsset.id,
savedProjectStartupInfo.projectAsset.parentId,
savedProjectStartupInfo.projectAsset.title
)
)
.catch(error => {
setProjectStartupInfo(null)
throw error
})
setProjectStartupInfo({ ...savedProjectStartupInfo, project })
if (page === TabType.editor) {
setPage(page)
}
})()
}
}
}
}

View File

@ -28,7 +28,7 @@ const logger = console
export const PACKAGE_METADATA_RELATIVE_PATH = 'package.yaml'
export const PROJECT_METADATA_RELATIVE_PATH = '.enso/project.json'
/** The filename suffix for the project bundle, including the leading period character. */
const BUNDLED_PROJECT_SUFFIX = `.enso-project`
const BUNDLED_PROJECT_SUFFIX = '.enso-project'
// ======================
// === Project Import ===
@ -128,7 +128,7 @@ export function importBundle(
sync: true,
strip: rootPieces.length,
})
return bumpMetadata(targetPath, name ?? null)
return bumpMetadata(targetPath, directory, name ?? null)
}
/** Upload the project from a bundle. */
@ -157,7 +157,7 @@ export async function uploadBundle(
fs.rmdirSync(temporaryDirectoryName)
}
}
return bumpMetadata(targetPath, name ?? null)
return bumpMetadata(targetPath, directory, name ?? null)
}
/** Import the project so it becomes visible to the Project Manager.
@ -190,7 +190,7 @@ export function importDirectory(
fs.cpSync(rootPath, targetPath, { recursive: true })
// Update the project ID, so we are certain that it is unique.
// This would be violated, if we imported the same project multiple times.
return bumpMetadata(targetPath, name ?? null)
return bumpMetadata(targetPath, directory, name ?? null)
}
}
}
@ -231,6 +231,14 @@ export function getProjectId(projectRoot: string): string | null {
return getMetadata(projectRoot)?.id ?? null
}
/** Get the package name. */
function getPackageName(projectRoot: string) {
const path = pathModule.join(projectRoot, PACKAGE_METADATA_RELATIVE_PATH)
const contents = fs.readFileSync(path, { encoding: 'utf-8' })
const [, name] = contents.match(/^name: (.*)/) ?? []
return name ?? null
}
/** Update the package name. */
export function updatePackageName(projectRoot: string, name: string) {
const path = pathModule.join(projectRoot, PACKAGE_METADATA_RELATIVE_PATH)
@ -398,10 +406,37 @@ export function generateId(): string {
}
/** Update the project's ID to a new, unique value, and its last opened date to the current date. */
export function bumpMetadata(projectRoot: string, name: string | null): string {
if (name != null) {
updatePackageName(projectRoot, name)
export function bumpMetadata(
projectRoot: string,
parentDirectory: string,
name: string | null
): string {
if (name == null) {
const currentName = getPackageName(projectRoot) ?? ''
let index: number | null = null
const prefix = `${currentName} `
for (const sibling of fs.readdirSync(parentDirectory, { withFileTypes: true })) {
if (sibling.isDirectory()) {
try {
const siblingPath = pathModule.join(parentDirectory, sibling.name)
const siblingName = getPackageName(siblingPath)
if (siblingName === currentName) {
index = index ?? 2
} else if (siblingName != null && siblingName.startsWith(prefix)) {
const suffix = siblingName.replace(prefix, '')
const [, numberString] = suffix.match(/^\((\d+)\)/) ?? []
if (numberString != null) {
index = Math.max(index ?? 2, Number(numberString) + 1)
}
}
} catch {
// Ignored - it is a directory but not a project.
}
}
}
name = index == null ? currentName : `${currentName} (${index})`
}
updatePackageName(projectRoot, name)
return updateMetadata(projectRoot, metadata => ({
...metadata,
id: generateId(),