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:
Sergei Garin 2024-10-15 14:32:52 +03:00 committed by GitHub
parent 4a2e522935
commit 3711b25fa7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 116 additions and 95 deletions

View File

@ -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)
}
/**

View File

@ -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')
}

View File

@ -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
}

View File

@ -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)
},

View File

@ -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,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)) {