From 19d5bdb9dadfef90bf7629185b3b06b417278d11 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 28 Jun 2024 03:48:51 +1000 Subject: [PATCH] Fix importing `.enso-project` files (#10379) - Fix #10282 # Important Notes None --- app/ide-desktop/eslint.config.js | 2 +- app/ide-desktop/lib/client/src/bin/server.ts | 2 +- .../lib/client/src/desktop-environment.ts | 81 +++++++++++++ .../lib/client/src/project-management.ts | 87 ++++++++++---- .../src/pages/dashboard/Dashboard.tsx | 109 ++++++++++-------- .../src/projectManagement.ts | 49 ++++++-- 6 files changed, 252 insertions(+), 78 deletions(-) create mode 100644 app/ide-desktop/lib/client/src/desktop-environment.ts diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index e788dae5f6..0dec27700e 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -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$)/' diff --git a/app/ide-desktop/lib/client/src/bin/server.ts b/app/ide-desktop/lib/client/src/bin/server.ts index bebd8d22ce..ec8b0943d1 100644 --- a/app/ide-desktop/lib/client/src/bin/server.ts +++ b/app/ide-desktop/lib/client/src/bin/server.ts @@ -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' diff --git a/app/ide-desktop/lib/client/src/desktop-environment.ts b/app/ide-desktop/lib/client/src/desktop-environment.ts new file mode 100644 index 0000000000..8590a9d87d --- /dev/null +++ b/app/ide-desktop/lib/client/src/desktop-environment.ts @@ -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] + } +} diff --git a/app/ide-desktop/lib/client/src/project-management.ts b/app/ide-desktop/lib/client/src/project-management.ts index bda9166f2c..2033a54dcf 100644 --- a/app/ide-desktop/lib/client/src/project-management.ts +++ b/app/ide-desktop/lib/client/src/project-management.ts @@ -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(), diff --git a/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx b/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx index 235868cdac..cf1e04aaef 100644 --- a/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx +++ b/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx @@ -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() const [assetEvents, dispatchAssetEvent] = eventHooks.useEvent() - 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) } - })() + } } } } diff --git a/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts b/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts index 52f184f78f..660042c79d 100644 --- a/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts +++ b/app/ide-desktop/lib/project-manager-shim/src/projectManagement.ts @@ -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(),