mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 22:10:15 +03:00
Allow updating local assets (#11314)
* Allow updating local assets * Update shim * Fix duplicate upload * Manually invalidate once we upload file * Fix queryKey * upd prettier ignore * revert prettier ignore
This commit is contained in:
parent
4a2e522935
commit
3711b25fa7
@ -187,27 +187,14 @@ function generateDirectoryName(name: string, directory = getProjectsDirectory())
|
|||||||
|
|
||||||
// If the name already consists a suffix, reuse it.
|
// If the name already consists a suffix, reuse it.
|
||||||
const matches = name.match(/^(.*)_(\d+)$/)
|
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.
|
// Matches start with the whole match, so we need to skip it. Then come our two capture groups.
|
||||||
const [matchedName, matchedSuffix] = matches?.slice(1) ?? []
|
const [matchedName, matchedSuffix] = matches?.slice(1) ?? []
|
||||||
|
|
||||||
if (typeof matchedName !== 'undefined' && typeof matchedSuffix !== 'undefined') {
|
if (typeof matchedName !== 'undefined' && typeof matchedSuffix !== 'undefined') {
|
||||||
name = matchedName
|
name = matchedName
|
||||||
suffix = parseInt(matchedSuffix)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalPath: string
|
return pathModule.join(directory, name)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1782,6 +1782,10 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
deleteAsset(projectId)
|
deleteAsset(projectId)
|
||||||
toastAndLog('uploadProjectError', error)
|
toastAndLog('uploadProjectError', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [backend.type, 'listDirectory', asset.parentId],
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
uploadFileMutation
|
uploadFileMutation
|
||||||
.mutateAsync([
|
.mutateAsync([
|
||||||
@ -1896,7 +1900,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
|
|
||||||
fileMap.set(asset.id, conflict.file)
|
fileMap.set(asset.id, conflict.file)
|
||||||
|
|
||||||
insertAssets([asset], event.parentId)
|
|
||||||
void doUploadFile(asset, isUpdating ? 'update' : 'new')
|
void doUploadFile(asset, isUpdating ? 'update' : 'new')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -1934,8 +1937,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
|
|
||||||
const assets = [...newFiles, ...newProjects]
|
const assets = [...newFiles, ...newProjects]
|
||||||
|
|
||||||
insertAssets(assets, event.parentId)
|
|
||||||
|
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
void doUploadFile(asset, 'new')
|
void doUploadFile(asset, 'new')
|
||||||
}
|
}
|
||||||
|
@ -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
|
* 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
|
* 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
|
* 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
|
* 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
|
import './cjs-shim' // must be imported first
|
||||||
|
|
||||||
@ -54,8 +56,10 @@ function pathToURL(path: string): URL {
|
|||||||
// === App ===
|
// === 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 {
|
class App {
|
||||||
window: electron.BrowserWindow | null = null
|
window: electron.BrowserWindow | null = null
|
||||||
server: server.Server | null = null
|
server: server.Server | null = null
|
||||||
@ -155,12 +159,14 @@ class App {
|
|||||||
return { ...configParser.parseArgs(argsToParse), fileToOpen, urlToOpen }
|
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
|
* This method should be called before the application is ready, as it only
|
||||||
* modifies the startup options. If the application is already initialized,
|
* modifies the startup options. If the application is already initialized,
|
||||||
* an error will be logged, and the method will have no effect.
|
* 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) {
|
setProjectToOpenOnStartup(projectUrl: URL) {
|
||||||
// Make sure that we are not initialized yet, as this method should be called before the
|
// Make sure that we are not initialized yet, as this method should be called before the
|
||||||
// application is ready.
|
// 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) {
|
handleItemOpening(fileToOpen: string | null, urlToOpen: URL | null) {
|
||||||
logger.log('Opening file or URL.', { fileToOpen, urlToOpen })
|
logger.log('Opening file or URL.', { fileToOpen, urlToOpen })
|
||||||
try {
|
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[]) {
|
setChromeOptions(chromeOptions: configParser.ChromeOption[]) {
|
||||||
const addIf = (
|
const addIf = (
|
||||||
option: contentConfig.Option<boolean>,
|
option: contentConfig.Option<boolean>,
|
||||||
@ -256,10 +266,12 @@ class App {
|
|||||||
await this.createWindowIfEnabled(windowSize)
|
await this.createWindowIfEnabled(windowSize)
|
||||||
this.initIpc()
|
this.initIpc()
|
||||||
await this.loadWindowContent()
|
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
|
* 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
|
* 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
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
authentication.initAuthentication(() => this.window!)
|
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() {
|
initIpc() {
|
||||||
electron.ipcMain.on(ipc.Channel.error, (_event, data) => {
|
electron.ipcMain.on(ipc.Channel.error, (_event, data) => {
|
||||||
logger.error(`IPC error: ${JSON.stringify(data)}`)
|
logger.error(`IPC error: ${JSON.stringify(data)}`)
|
||||||
@ -475,9 +489,9 @@ class App {
|
|||||||
})
|
})
|
||||||
electron.ipcMain.on(
|
electron.ipcMain.on(
|
||||||
ipc.Channel.importProjectFromPath,
|
ipc.Channel.importProjectFromPath,
|
||||||
(event, path: string, directory: string | null) => {
|
(event, path: string, directory: string | null, title: string) => {
|
||||||
const directoryParams = directory == null ? [] : [directory]
|
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)
|
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
|
* is returned. This might be used to connect this application window to another, existing
|
||||||
* application server. */
|
* application server.
|
||||||
|
*/
|
||||||
serverPort(): number {
|
serverPort(): number {
|
||||||
return this.server?.config.port ?? this.args.groups.server.options.port.value
|
return this.server?.config.port ?? this.args.groups.server.options.port.value
|
||||||
}
|
}
|
||||||
|
@ -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
|
* 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:
|
* 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'
|
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
|
// 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).
|
// 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
|
// 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')
|
const electron = require('electron')
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
@ -52,8 +54,8 @@ const IMPORT_PROJECT_RESOLVE_FUNCTIONS = new Map<
|
|||||||
>()
|
>()
|
||||||
|
|
||||||
exposeInMainWorld(BACKEND_API_KEY, {
|
exposeInMainWorld(BACKEND_API_KEY, {
|
||||||
importProjectFromPath: (projectPath: string, directory: string | null = null) => {
|
importProjectFromPath: (projectPath: string, directory: string | null = null, title: string) => {
|
||||||
electron.ipcRenderer.send(ipc.Channel.importProjectFromPath, projectPath, directory)
|
electron.ipcRenderer.send(ipc.Channel.importProjectFromPath, projectPath, directory, title)
|
||||||
return new Promise<projectManagement.ProjectInfo>(resolve => {
|
return new Promise<projectManagement.ProjectInfo>(resolve => {
|
||||||
IMPORT_PROJECT_RESOLVE_FUNCTIONS.set(projectPath, 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
|
* - open OAuth flows in the system browser, and
|
||||||
* - handle deep links from the system browser or email client to the dashboard.
|
* - 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.
|
* The functions are exposed via this "API object", which is added to the main window.
|
||||||
*
|
*
|
||||||
* For more details, see:
|
* 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, {
|
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
|
* 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) => {
|
openUrlInSystemBrowser: (url: string) => {
|
||||||
electron.ipcRenderer.send(ipc.Channel.openUrlInSystemBrowser, url)
|
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
|
* The callback is intended to handle links like
|
||||||
* `enso://authentication/register?code=...&state=...` from external sources like the user's
|
* `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
|
* 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) => {
|
setDeepLinkHandler: (callback: (url: string) => void) => {
|
||||||
deepLinkHandler = callback
|
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
|
* 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) => {
|
saveAccessToken: (accessTokenPayload: accessToken.AccessToken | null) => {
|
||||||
electron.ipcRenderer.send(ipc.Channel.saveAccessToken, accessTokenPayload)
|
electron.ipcRenderer.send(ipc.Channel.saveAccessToken, accessTokenPayload)
|
||||||
},
|
},
|
||||||
|
@ -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
|
* Eventually this module should be replaced with a new Project Manager API that supports
|
||||||
* importing projects.
|
* 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 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
|
* - if the project is in a different location, we copy it to the Project Manager's location
|
||||||
* and open it.
|
* 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 crypto from 'node:crypto'
|
||||||
import * as fs from 'node:fs'
|
import * as fs from 'node:fs'
|
||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
@ -46,11 +48,13 @@ export interface ProjectInfo {
|
|||||||
// === Project Import ===
|
// === 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
|
* or the project bundle. If needed, the project will be imported into the Project Manager-enabled
|
||||||
* location.
|
* location.
|
||||||
* @returns Project ID (from Project Manager's metadata) identifying the imported project.
|
* @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(
|
export function importProjectFromPath(
|
||||||
openedPath: string,
|
openedPath: string,
|
||||||
directory?: string | null,
|
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(
|
export function importBundle(
|
||||||
bundlePath: string,
|
bundlePath: string,
|
||||||
directory?: string | null,
|
directory?: string | null,
|
||||||
@ -108,7 +114,7 @@ export function importBundle(
|
|||||||
normalizedBundlePrefix
|
normalizedBundlePrefix
|
||||||
: bundlePath
|
: bundlePath
|
||||||
logger.log(`Bundle normalized prefix: '${String(normalizedBundlePrefix)}'.`)
|
logger.log(`Bundle normalized prefix: '${String(normalizedBundlePrefix)}'.`)
|
||||||
const targetPath = generateDirectoryName(dirNameBase, directory)
|
const targetPath = generateDirectoryName(name ?? dirNameBase, directory)
|
||||||
logger.log(`Importing project as '${targetPath}'.`)
|
logger.log(`Importing project as '${targetPath}'.`)
|
||||||
fs.mkdirSync(targetPath, { recursive: true })
|
fs.mkdirSync(targetPath, { recursive: true })
|
||||||
// To be more resilient against different ways that user might attempt to create a bundle,
|
// 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()
|
directory ??= getProjectsDirectory()
|
||||||
logger.log(`Uploading project from bundle${name != null ? ` as '${name}'` : ''}.`)
|
logger.log(`Uploading project from bundle${name != null ? ` as '${name}'` : ''}.`)
|
||||||
|
|
||||||
const targetPath = generateDirectoryName(name ?? 'Project', directory)
|
const targetPath = generateDirectoryName(name ?? 'Project', directory)
|
||||||
fs.mkdirSync(targetPath, { recursive: true })
|
fs.mkdirSync(targetPath, { recursive: true })
|
||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
@ -171,9 +178,11 @@ export async function uploadBundle(
|
|||||||
return bumpMetadata(targetPath, directory, name ?? null)
|
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.
|
* @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(
|
export function importDirectory(
|
||||||
rootPath: string,
|
rootPath: string,
|
||||||
directory?: string | null,
|
directory?: string | null,
|
||||||
@ -192,15 +201,11 @@ export function importDirectory(
|
|||||||
} else {
|
} else {
|
||||||
logger.log(`Importing a project copy from '${rootPath}'${name != null ? ` as '${name}'` : ''}.`)
|
logger.log(`Importing a project copy from '${rootPath}'${name != null ? ` as '${name}'` : ''}.`)
|
||||||
const targetPath = generateDirectoryName(rootPath, directory)
|
const targetPath = generateDirectoryName(rootPath, directory)
|
||||||
if (fs.existsSync(targetPath)) {
|
logger.log(`Copying: '${rootPath}' -> '${targetPath}'.`)
|
||||||
throw new Error(`Project directory '${targetPath}' already exists.`)
|
fs.cpSync(rootPath, targetPath, { recursive: true, force: true })
|
||||||
} else {
|
// Update the project ID, so we are certain that it is unique.
|
||||||
logger.log(`Copying: '${rootPath}' -> '${targetPath}'.`)
|
// This would be violated, if we imported the same project multiple times.
|
||||||
fs.cpSync(rootPath, targetPath, { recursive: true })
|
return bumpMetadata(targetPath, directory, name ?? null)
|
||||||
// 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. */
|
/** The Project Manager's metadata associated with a project. */
|
||||||
interface ProjectMetadata {
|
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
|
readonly id: string
|
||||||
/** The project variant. This is currently always `UserProject`. */
|
/** The project variant. This is currently always `UserProject`. */
|
||||||
readonly kind: 'UserProject'
|
readonly kind: 'UserProject'
|
||||||
@ -221,14 +228,16 @@ interface ProjectMetadata {
|
|||||||
readonly lastOpened: string
|
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
|
* 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
|
* to match the {@link ProjectMetadata} interface. It can be used at runtime to validate that
|
||||||
* a given object has the expected shape.
|
* a given object has the expected shape.
|
||||||
* @param value - The object to check against the ProjectMetadata interface.
|
* @param value - The object to check against the ProjectMetadata interface.
|
||||||
* @returns A boolean value indicating whether the object matches
|
* @returns A boolean value indicating whether the object matches
|
||||||
* the {@link ProjectMetadata} interface. */
|
* the {@link ProjectMetadata} interface.
|
||||||
|
*/
|
||||||
function isProjectMetadata(value: unknown): value is ProjectMetadata {
|
function isProjectMetadata(value: unknown): value is ProjectMetadata {
|
||||||
return typeof value === 'object' && value != null && 'id' in value && typeof value.id === 'string'
|
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))
|
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.
|
* 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(
|
export function updateMetadata(
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
updater: (initialMetadata: ProjectMetadata) => ProjectMetadata,
|
updater: (initialMetadata: ProjectMetadata) => ProjectMetadata,
|
||||||
@ -301,8 +312,10 @@ export function updateMetadata(
|
|||||||
// === Project Directory ===
|
// === 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 {
|
export function isProjectRoot(candidatePath: string): boolean {
|
||||||
const projectJsonPath = pathModule.join(candidatePath, PROJECT_METADATA_RELATIVE_PATH)
|
const projectJsonPath = pathModule.join(candidatePath, PROJECT_METADATA_RELATIVE_PATH)
|
||||||
try {
|
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 {
|
export function prefixInBundle(bundlePath: string): string | null {
|
||||||
// We need to look up the root directory among the tarball entries.
|
// We need to look up the root directory among the tarball entries.
|
||||||
let commonPrefix: string | null = null
|
let commonPrefix: string | null = null
|
||||||
@ -332,43 +347,35 @@ export function prefixInBundle(bundlePath: string): string | null {
|
|||||||
return commonPrefix != null && commonPrefix !== '' ? commonPrefix : 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.
|
* collision.
|
||||||
*
|
*
|
||||||
* For example `Name` will become `Name_1` if there's already a directory named `Name`.
|
* 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
|
* 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
|
* `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 {
|
export function generateDirectoryName(name: string, directory = getProjectsDirectory()): string {
|
||||||
// Use only the last path component.
|
// Use only the last path component.
|
||||||
name = pathModule.parse(name).name
|
name = pathModule.parse(name).name
|
||||||
|
|
||||||
// If the name already consists a suffix, reuse it.
|
// If the name already consists a suffix, reuse it.
|
||||||
const matches = name.match(/^(.*)_(\d+)$/)
|
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.
|
// Matches start with the whole match, so we need to skip it. Then come our two capture groups.
|
||||||
const [matchedName, matchedSuffix] = matches?.slice(1) ?? []
|
const [matchedName, matchedSuffix] = matches?.slice(1) ?? []
|
||||||
|
|
||||||
if (typeof matchedName !== 'undefined' && typeof matchedSuffix !== 'undefined') {
|
if (typeof matchedName !== 'undefined' && typeof matchedSuffix !== 'undefined') {
|
||||||
name = matchedName
|
name = matchedName
|
||||||
suffix = parseInt(matchedSuffix)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalPath: string
|
return pathModule.join(directory, name)
|
||||||
while (true) {
|
|
||||||
suffix++
|
|
||||||
const newName = `${name}${suffix === 0 ? '' : `_${suffix}`}`
|
|
||||||
const candidatePath = pathModule.join(directory, newName)
|
|
||||||
if (!fs.existsSync(candidatePath)) {
|
|
||||||
finalPath = candidatePath
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return finalPath
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 {
|
export function getProjectRoot(subtreePath: string): string | null {
|
||||||
let currentPath = subtreePath
|
let currentPath = subtreePath
|
||||||
while (!isProjectRoot(currentPath)) {
|
while (!isProjectRoot(currentPath)) {
|
||||||
|
Loading…
Reference in New Issue
Block a user