diff --git a/app/gui/project-manager-shim-middleware/projectManagement.ts b/app/gui/project-manager-shim-middleware/projectManagement.ts index b2f8094062..15587fc566 100644 --- a/app/gui/project-manager-shim-middleware/projectManagement.ts +++ b/app/gui/project-manager-shim-middleware/projectManagement.ts @@ -187,27 +187,14 @@ function generateDirectoryName(name: string, directory = getProjectsDirectory()) // If the name already consists a suffix, reuse it. const matches = name.match(/^(.*)_(\d+)$/) - const initialSuffix = -1 - let suffix = initialSuffix // Matches start with the whole match, so we need to skip it. Then come our two capture groups. const [matchedName, matchedSuffix] = matches?.slice(1) ?? [] + if (typeof matchedName !== 'undefined' && typeof matchedSuffix !== 'undefined') { name = matchedName - suffix = parseInt(matchedSuffix) } - let finalPath: string - // eslint-disable-next-line no-constant-condition - while (true) { - suffix++ - const newName = `${name}${suffix === 0 ? '' : `_${suffix}`}` - const candidatePath = pathModule.join(directory, newName) - if (!fs.existsSync(candidatePath)) { - finalPath = candidatePath - break - } - } - return finalPath + return pathModule.join(directory, name) } /** diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index a395ce5387..a76bf1ae9d 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -1782,6 +1782,10 @@ export default function AssetsTable(props: AssetsTableProps) { deleteAsset(projectId) toastAndLog('uploadProjectError', error) }) + + void queryClient.invalidateQueries({ + queryKey: [backend.type, 'listDirectory', asset.parentId], + }) } else { uploadFileMutation .mutateAsync([ @@ -1896,7 +1900,6 @@ export default function AssetsTable(props: AssetsTableProps) { fileMap.set(asset.id, conflict.file) - insertAssets([asset], event.parentId) void doUploadFile(asset, isUpdating ? 'update' : 'new') } }} @@ -1934,8 +1937,6 @@ export default function AssetsTable(props: AssetsTableProps) { const assets = [...newFiles, ...newProjects] - insertAssets(assets, event.parentId) - for (const asset of assets) { void doUploadFile(asset, 'new') } diff --git a/app/ide-desktop/client/src/index.ts b/app/ide-desktop/client/src/index.ts index d934490f61..6bec836eb5 100644 --- a/app/ide-desktop/client/src/index.ts +++ b/app/ide-desktop/client/src/index.ts @@ -1,9 +1,11 @@ -/** @file Definition of an Electron application, which entails the creation of a rudimentary HTTP +/** + * @file Definition of an Electron application, which entails the creation of a rudimentary HTTP * server and the presentation of a Chrome web view, designed for optimal performance and * compatibility across a wide range of hardware configurations. The application's web component * is then served and showcased within the web view, complemented by the establishment of an * Inter-Process Communication channel, which enables seamless communication between the served web - * application and the Electron process. */ + * application and the Electron process. + */ import './cjs-shim' // must be imported first @@ -54,8 +56,10 @@ function pathToURL(path: string): URL { // === App === // =========== -/** The Electron application. It is responsible for starting all the required services, and - * displaying and managing the app window. */ +/** + * The Electron application. It is responsible for starting all the required services, and + * displaying and managing the app window. + */ class App { window: electron.BrowserWindow | null = null server: server.Server | null = null @@ -155,12 +159,14 @@ class App { return { ...configParser.parseArgs(argsToParse), fileToOpen, urlToOpen } } - /** Set the project to be opened on application startup. + /** + * Set the project to be opened on application startup. * * This method should be called before the application is ready, as it only * modifies the startup options. If the application is already initialized, * an error will be logged, and the method will have no effect. - * @param projectUrl - The `file://` url of project to be opened on startup. */ + * @param projectUrl - The `file://` url of project to be opened on startup. + */ setProjectToOpenOnStartup(projectUrl: URL) { // Make sure that we are not initialized yet, as this method should be called before the // application is ready. @@ -176,8 +182,10 @@ class App { } } - /** This method is invoked when the application was spawned due to being a default application - * for a URL protocol or file extension. */ + /** + * This method is invoked when the application was spawned due to being a default application + * for a URL protocol or file extension. + */ handleItemOpening(fileToOpen: string | null, urlToOpen: URL | null) { logger.log('Opening file or URL.', { fileToOpen, urlToOpen }) try { @@ -196,8 +204,10 @@ class App { } } - /** Set Chrome options based on the app configuration. For comprehensive list of available - * Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. */ + /** + * Set Chrome options based on the app configuration. For comprehensive list of available + * Chrome options refer to: https://peter.sh/experiments/chromium-command-line-switches. + */ setChromeOptions(chromeOptions: configParser.ChromeOption[]) { const addIf = ( option: contentConfig.Option, @@ -256,10 +266,12 @@ class App { await this.createWindowIfEnabled(windowSize) this.initIpc() await this.loadWindowContent() - /** The non-null assertion on the following line is safe because the window + /** + * The non-null assertion on the following line is safe because the window * initialization is guarded by the `createWindowIfEnabled` method. The window is * not yet created at this point, but it will be created by the time the - * authentication module uses the lambda providing the window. */ + * authentication module uses the lambda providing the window. + */ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion authentication.initAuthentication(() => this.window!) }) @@ -443,8 +455,10 @@ class App { }) } - /** Initialize Inter-Process Communication between the Electron application and the served - * website. */ + /** + * Initialize Inter-Process Communication between the Electron application and the served + * website. + */ initIpc() { electron.ipcMain.on(ipc.Channel.error, (_event, data) => { logger.error(`IPC error: ${JSON.stringify(data)}`) @@ -475,9 +489,9 @@ class App { }) electron.ipcMain.on( ipc.Channel.importProjectFromPath, - (event, path: string, directory: string | null) => { + (event, path: string, directory: string | null, title: string) => { const directoryParams = directory == null ? [] : [directory] - const info = projectManagement.importProjectFromPath(path, ...directoryParams) + const info = projectManagement.importProjectFromPath(path, ...directoryParams, title) event.reply(ipc.Channel.importProjectFromPath, path, info) }, ) @@ -537,9 +551,11 @@ class App { }) } - /** The server port. In case the server was not started, the port specified in the configuration + /** + * The server port. In case the server was not started, the port specified in the configuration * is returned. This might be used to connect this application window to another, existing - * application server. */ + * application server. + */ serverPort(): number { return this.server?.config.port ?? this.args.groups.server.options.port.value } diff --git a/app/ide-desktop/client/src/preload.ts b/app/ide-desktop/client/src/preload.ts index f98aa0c6f6..7ddd05ae95 100644 --- a/app/ide-desktop/client/src/preload.ts +++ b/app/ide-desktop/client/src/preload.ts @@ -1,7 +1,9 @@ -/** @file Preload script containing code that runs before web page is loaded into the browser +/** + * @file Preload script containing code that runs before web page is loaded into the browser * window. It has access to both DOM APIs and Node environment, and is used to expose privileged * APIs to the renderer via the contextBridge API. To learn more, visit: - * https://www.electronjs.org/docs/latest/tutorial/tutorial-preload. */ + * https://www.electronjs.org/docs/latest/tutorial/tutorial-preload. + */ import type * as accessToken from 'enso-common/src/accessToken' @@ -14,7 +16,7 @@ import type * as projectManagement from '@/projectManagement' // esbuild, we have to manually use "require". Switch this to an import once new electron version // actually honours ".mjs" files for sandboxed preloading (this will likely become an error at that time). // https://www.electronjs.org/fr/docs/latest/tutorial/esm#sandboxed-preload-scripts-cant-use-esm-imports -// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-var-requires +// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-var-requires,@typescript-eslint/no-require-imports const electron = require('electron') // ================= @@ -52,8 +54,8 @@ const IMPORT_PROJECT_RESOLVE_FUNCTIONS = new Map< >() exposeInMainWorld(BACKEND_API_KEY, { - importProjectFromPath: (projectPath: string, directory: string | null = null) => { - electron.ipcRenderer.send(ipc.Channel.importProjectFromPath, projectPath, directory) + importProjectFromPath: (projectPath: string, directory: string | null = null, title: string) => { + electron.ipcRenderer.send(ipc.Channel.importProjectFromPath, projectPath, directory, title) return new Promise(resolve => { IMPORT_PROJECT_RESOLVE_FUNCTIONS.set(projectPath, resolve) }) @@ -94,7 +96,8 @@ electron.ipcRenderer.on( }, ) -/** Object exposed on the Electron main window; provides proxy functions to: +/** + * Object exposed on the Electron main window; provides proxy functions to: * - open OAuth flows in the system browser, and * - handle deep links from the system browser or email client to the dashboard. * @@ -104,28 +107,35 @@ electron.ipcRenderer.on( * The functions are exposed via this "API object", which is added to the main window. * * For more details, see: - * https://www.electronjs.org/docs/latest/api/context-bridge#api-functions. */ + * https://www.electronjs.org/docs/latest/api/context-bridge#api-functions. + */ exposeInMainWorld(AUTHENTICATION_API_KEY, { - /** Open a URL in the system browser (rather than in the app). + /** + * Open a URL in the system browser (rather than in the app). * * OAuth URLs must be opened this way because the dashboard application is sandboxed and thus - * not privileged to do so unless we explicitly expose this functionality. */ + * not privileged to do so unless we explicitly expose this functionality. + */ openUrlInSystemBrowser: (url: string) => { electron.ipcRenderer.send(ipc.Channel.openUrlInSystemBrowser, url) }, - /** Set the callback that will be called when a deep link to the application is opened. + /** + * Set the callback that will be called when a deep link to the application is opened. * * The callback is intended to handle links like * `enso://authentication/register?code=...&state=...` from external sources like the user's * system browser or email client. Handling the links involves resuming whatever flow was in - * progress when the link was opened (e.g., an OAuth registration flow). */ + * progress when the link was opened (e.g., an OAuth registration flow). + */ setDeepLinkHandler: (callback: (url: string) => void) => { deepLinkHandler = callback }, - /** Save the access token to a credentials file. + /** + * Save the access token to a credentials file. * * The backend doesn't have access to Electron's `localStorage` so we need to save access token - * to a file. Then the token will be used to sign cloud API requests. */ + * to a file. Then the token will be used to sign cloud API requests. + */ saveAccessToken: (accessTokenPayload: accessToken.AccessToken | null) => { electron.ipcRenderer.send(ipc.Channel.saveAccessToken, accessTokenPayload) }, diff --git a/app/ide-desktop/client/src/projectManagement.ts b/app/ide-desktop/client/src/projectManagement.ts index d08fe0ef0a..352ad59da5 100644 --- a/app/ide-desktop/client/src/projectManagement.ts +++ b/app/ide-desktop/client/src/projectManagement.ts @@ -1,4 +1,5 @@ -/** @file This module contains functions for importing projects into the Project Manager. +/** + * @file This module contains functions for importing projects into the Project Manager. * * Eventually this module should be replaced with a new Project Manager API that supports * importing projects. @@ -6,7 +7,8 @@ * - if the project is already in the Project Manager's location, we just open it; * - 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. */ + * - 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' @@ -46,11 +48,13 @@ export interface ProjectInfo { // === Project Import === // ====================== -/** Open a project from the given path. Path can be either a source file under the project root, +/** + * Open a project from the given path. Path can be either a source file under the project root, * or the project bundle. If needed, the project will be imported into the Project Manager-enabled * location. * @returns Project ID (from Project Manager's metadata) identifying the imported project. - * @throws {Error} if the path does not belong to a valid project. */ + * @throws {Error} if the path does not belong to a valid project. + */ export function importProjectFromPath( openedPath: string, directory?: string | null, @@ -83,8 +87,10 @@ export function importProjectFromPath( } } -/** Import the project from a bundle. - * @returns Project ID (from Project Manager's metadata) identifying the imported project. */ +/** + * Import the project from a bundle. + * @returns Project ID (from Project Manager's metadata) identifying the imported project. + */ export function importBundle( bundlePath: string, directory?: string | null, @@ -108,7 +114,7 @@ export function importBundle( normalizedBundlePrefix : bundlePath logger.log(`Bundle normalized prefix: '${String(normalizedBundlePrefix)}'.`) - const targetPath = generateDirectoryName(dirNameBase, directory) + const targetPath = generateDirectoryName(name ?? dirNameBase, directory) logger.log(`Importing project as '${targetPath}'.`) fs.mkdirSync(targetPath, { recursive: true }) // To be more resilient against different ways that user might attempt to create a bundle, @@ -151,6 +157,7 @@ export async function uploadBundle( ) { directory ??= getProjectsDirectory() logger.log(`Uploading project from bundle${name != null ? ` as '${name}'` : ''}.`) + const targetPath = generateDirectoryName(name ?? 'Project', directory) fs.mkdirSync(targetPath, { recursive: true }) await new Promise(resolve => { @@ -171,9 +178,11 @@ export async function uploadBundle( return bumpMetadata(targetPath, directory, name ?? null) } -/** Import the project so it becomes visible to the Project Manager. +/** + * Import the project so it becomes visible to the Project Manager. * @returns The project ID (from the Project Manager's metadata) identifying the imported project. - * @throws {Error} if a race condition occurs when generating a unique project directory name. */ + * @throws {Error} if a race condition occurs when generating a unique project directory name. + */ export function importDirectory( rootPath: string, directory?: string | null, @@ -192,15 +201,11 @@ export function importDirectory( } else { logger.log(`Importing a project copy from '${rootPath}'${name != null ? ` as '${name}'` : ''}.`) const targetPath = generateDirectoryName(rootPath, directory) - if (fs.existsSync(targetPath)) { - throw new Error(`Project directory '${targetPath}' already exists.`) - } else { - logger.log(`Copying: '${rootPath}' -> '${targetPath}'.`) - 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, directory, name ?? null) - } + logger.log(`Copying: '${rootPath}' -> '${targetPath}'.`) + fs.cpSync(rootPath, targetPath, { recursive: true, force: 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, directory, name ?? null) } } @@ -210,8 +215,10 @@ export function importDirectory( /** The Project Manager's metadata associated with a project. */ interface ProjectMetadata { - /** The ID of the project. It is only used in communication with project manager; - * it has no semantic meaning. */ + /** + * The ID of the project. It is only used in communication with project manager; + * it has no semantic meaning. + */ readonly id: string /** The project variant. This is currently always `UserProject`. */ readonly kind: 'UserProject' @@ -221,14 +228,16 @@ interface ProjectMetadata { readonly lastOpened: string } -/** A type guard function to check if an object conforms to the {@link ProjectMetadata} interface. +/** + * A type guard function to check if an object conforms to the {@link ProjectMetadata} interface. * * This function checks if the input object has the required properties and correct types * to match the {@link ProjectMetadata} interface. It can be used at runtime to validate that * a given object has the expected shape. * @param value - The object to check against the ProjectMetadata interface. * @returns A boolean value indicating whether the object matches - * the {@link ProjectMetadata} interface. */ + * the {@link ProjectMetadata} interface. + */ function isProjectMetadata(value: unknown): value is ProjectMetadata { return typeof value === 'object' && value != null && 'id' in value && typeof value.id === 'string' } @@ -283,10 +292,12 @@ export function writeMetadata(projectRoot: string, metadata: ProjectMetadata): v fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, buildUtils.INDENT_SIZE)) } -/** Update the project's metadata. +/** + * Update the project's metadata. * If the provided updater does not return anything, the metadata file is left intact. * - * Returns the metadata returned from the updater function. */ + * Returns the metadata returned from the updater function. + */ export function updateMetadata( projectRoot: string, updater: (initialMetadata: ProjectMetadata) => ProjectMetadata, @@ -301,8 +312,10 @@ export function updateMetadata( // === Project Directory === // ========================= -/** Check if the given path represents the root of an Enso project. - * This is decided by the presence of the Project Manager's metadata. */ +/** + * 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, PROJECT_METADATA_RELATIVE_PATH) try { @@ -313,8 +326,10 @@ export function isProjectRoot(candidatePath: string): boolean { } } -/** Check if this bundle is a compressed directory (rather than directly containing the project - * files). If it is, we return the path to the directory. Otherwise, we return `null`. */ +/** + * Check if this bundle is a compressed directory (rather than directly containing the project + * files). If it is, we return the path to the directory. Otherwise, we return `null`. + */ export function prefixInBundle(bundlePath: string): string | null { // We need to look up the root directory among the tarball entries. let commonPrefix: string | null = null @@ -332,43 +347,35 @@ export function prefixInBundle(bundlePath: string): string | null { return commonPrefix != null && commonPrefix !== '' ? commonPrefix : null } -/** Generate a name for a project using given base string. A suffix is added if there is a +/** + * Generate a name for a project using given base string. A suffix is added if there is a * collision. * * For example `Name` will become `Name_1` if there's already a directory named `Name`. * If given a name like `Name_1` it will become `Name_2` if there is already a directory named * `Name_1`. If a path containing multiple components is given, only the last component is used - * for the name. */ + * for the name. + */ export function generateDirectoryName(name: string, directory = getProjectsDirectory()): string { // Use only the last path component. name = pathModule.parse(name).name // If the name already consists a suffix, reuse it. const matches = name.match(/^(.*)_(\d+)$/) - const initialSuffix = -1 - let suffix = initialSuffix // Matches start with the whole match, so we need to skip it. Then come our two capture groups. const [matchedName, matchedSuffix] = matches?.slice(1) ?? [] + if (typeof matchedName !== 'undefined' && typeof matchedSuffix !== 'undefined') { name = matchedName - suffix = parseInt(matchedSuffix) } - let finalPath: string - while (true) { - suffix++ - const newName = `${name}${suffix === 0 ? '' : `_${suffix}`}` - const candidatePath = pathModule.join(directory, newName) - if (!fs.existsSync(candidatePath)) { - finalPath = candidatePath - break - } - } - return finalPath + return pathModule.join(directory, name) } -/** Take a path to a file, presumably located in a project's subtree.Returns the path - * to the project's root directory or `null` if the file is not located in a project. */ +/** + * Take a path to a file, presumably located in a project's subtree.Returns the path + * to the project's root directory or `null` if the file is not located in a project. + */ export function getProjectRoot(subtreePath: string): string | null { let currentPath = subtreePath while (!isProjectRoot(currentPath)) {