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.
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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')
|
||||
}
|
||||
|
@ -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<boolean>,
|
||||
@ -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
|
||||
}
|
||||
|
@ -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<projectManagement.ProjectInfo>(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)
|
||||
},
|
||||
|
@ -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<void>(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,17 +201,13 @@ 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 })
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================
|
||||
// === Metadata ===
|
||||
@ -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)) {
|
||||
|
Loading…
Reference in New Issue
Block a user