From ef7459d6676ec9df38736911a935104c3d36a891 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Mon, 20 Oct 2025 19:48:51 +1000 Subject: [PATCH 01/31] WIP: Decouple Electron from `electron-client` entrypoint --- app/electron-client/src/electron.ts | 123 ++++++++++++ app/electron-client/src/index.ts | 184 ++++-------------- app/electron-client/src/projectService.ts | 4 +- .../src/projectService/ensoRunner.ts | 8 +- .../src/projectService/index.ts | 4 +- 5 files changed, 169 insertions(+), 154 deletions(-) create mode 100644 app/electron-client/src/electron.ts diff --git a/app/electron-client/src/electron.ts b/app/electron-client/src/electron.ts new file mode 100644 index 000000000000..cefdf68417cb --- /dev/null +++ b/app/electron-client/src/electron.ts @@ -0,0 +1,123 @@ +import { Channel } from '@/ipc' +import { dialog, ipcMain, shell, type BrowserWindow } from 'electron' +import { download } from 'electron-dl' +import type { DownloadUrlOptions } from 'enso-gui/src/electronApi' +import { unlinkSync } from 'node:fs' +import { basename, dirname, extname } from 'node:path' +import { importProjectFromPath, isProjectBundle, isProjectRoot } from 'project-manager-shim' +import { toElectronFileFilter, type FileFilter } from './fileBrowser' + +/** + * Initialize Inter-Process Communication between the Electron application and the served + * website. + */ +export function initIpc(window?: BrowserWindow) { + ipcMain.on(Channel.error, (_event, data) => { + console.error(...data) + }) + ipcMain.on(Channel.warn, (_event, data) => { + console.warn(...data) + }) + ipcMain.on(Channel.log, (_event, data) => { + console.log(...data) + }) + ipcMain.on(Channel.info, (_event, data) => { + console.info(...data) + }) + ipcMain.on( + Channel.importProjectFromPath, + (event, path: string, directory: string | null, title: string) => { + const directoryParams = directory == null ? [] : [directory] + const info = importProjectFromPath(path, ...directoryParams, title) + event.reply(Channel.importProjectFromPath, path, info) + }, + ) + ipcMain.handle(Channel.downloadURL, async (_event, options: DownloadUrlOptions) => { + const { url, path, name, shouldUnpackProject, showFileDialog } = options + // This should never happen, but we'll check for it anyway. + if (!window) { + throw new Error('Window is not available.') + } + + await download(window, url, { + ...(path != null ? { directory: path } : {}), + ...(name != null ? { filename: name } : {}), + saveAs: showFileDialog != null ? showFileDialog : path == null, + onCompleted: (file) => { + const path = file.path + const filenameRaw = basename(path) + + try { + if (isProjectBundle(path) || isProjectRoot(path)) { + if (!shouldUnpackProject) { + return + } + // in case we're importing a project bundle, we need to remove the extension + // from the filename + const filename = filenameRaw.replace(extname(filenameRaw), '') + const directory = dirname(path) + + importProjectFromPath(path, directory, filename) + unlinkSync(path) + } + } catch (error) { + console.error('Error downloading URL', error) + } + }, + }) + + return + }) + ipcMain.on(Channel.showItemInFolder, (_event, fullPath: string) => { + shell.showItemInFolder(fullPath) + }) + ipcMain.handle( + Channel.openFileBrowser, + async ( + _event, + kind: 'default' | 'directory' | 'file' | 'filePath', + defaultPath?: string, + filters?: FileFilter[], + ) => { + console.log('Request for opening browser for ', kind, defaultPath, JSON.stringify(filters)) + let retval = null + if (kind === 'filePath') { + // "Accept", as the file won't be created immediately. + const { canceled, filePath } = await dialog.showSaveDialog({ + buttonLabel: 'Accept', + filters: filters?.map(toElectronFileFilter) ?? [], + ...(defaultPath != null ? { defaultPath } : {}), + }) + if (!canceled) { + retval = [filePath] + } + } else { + /** Helper for `showOpenDialog`, which has weird types by default. */ + type Properties = ('openDirectory' | 'openFile')[] + const properties: Properties = + kind === 'file' ? ['openFile'] + : kind === 'directory' ? ['openDirectory'] + : process.platform === 'darwin' ? ['openFile', 'openDirectory'] + : ['openFile'] + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties, + filters: filters?.map(toElectronFileFilter) ?? [], + ...(defaultPath != null ? { defaultPath } : {}), + }) + if (!canceled) { + retval = filePaths + } + } + return retval + }, + ) + + // Handling navigation events from renderer process + ipcMain.on(Channel.goBack, () => { + window?.webContents.navigationHistory.goBack() + }) + + ipcMain.on(Channel.goForward, () => { + window?.webContents.navigationHistory.goForward() + }) +} diff --git a/app/electron-client/src/index.ts b/app/electron-client/src/index.ts index 293adba03936..a99534046a7c 100644 --- a/app/electron-client/src/index.ts +++ b/app/electron-client/src/index.ts @@ -9,13 +9,11 @@ import './cjs-shim' // must be imported first -import * as fsSync from 'node:fs' import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as pathModule from 'node:path' import process from 'node:process' -import * as electron from 'electron' import * as common from 'enso-common' import { buildWebAppURLSearchParamsFromArgs, @@ -37,15 +35,37 @@ import * as security from '@/security' import * as server from '@/server' import * as urlAssociations from '@/urlAssociations' import * as projectManagement from 'project-manager-shim' -import { toElectronFileFilter, type FileFilter } from './fileBrowser' - -import * as download from 'electron-dl' -import type { DownloadUrlOptions } from 'enso-gui/src/electronApi' import { filterByRole, inheritMenuItem, makeMenuItem, replaceMenuItems } from './menuItems' const DEFAULT_WINDOW_WIDTH = 1380 const DEFAULT_WINDOW_HEIGHT = 900 +let electron: typeof import('electron') | undefined + +function exit(code = 0) { + if (electron) { + electron.app.exit(code) + } else { + process.exit(code) + } +} + +function quit() { + if (electron) { + electron.app.quit() + } else { + process.exit(0) + } +} + +function showErrorBox(title: string, content: string) { + if (electron) { + electron.dialog.showErrorBox(title, content) + } else { + console.error(`${title}\n\n${content}`) + } +} + /** Convert path to proper `file://` URL. */ function pathToURL(path: string): URL { if (process.platform === 'win32') { @@ -55,16 +75,12 @@ 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. */ class App { - window: electron.BrowserWindow | null = null + window: import('electron').BrowserWindow | null = null server: server.Server | null = null webOptions: Options = defaultOptions() isQuitting = false @@ -75,6 +91,7 @@ class App { urlAssociations.registerAssociations() // Register file associations for macOS. fileAssociations.setOpenFileEventHandler((path) => { + if (!electron) return if (electron.app.isReady()) { const project = fileAssociations.handleOpenFile(path) this.window?.webContents.send(ipc.Channel.openProject, project) @@ -85,13 +102,12 @@ class App { const { args, fileToOpen, urlToOpen } = this.processArguments() if (args.version) { await this.printVersion() - electron.app.quit() + quit() } else if (args.debug.info) { - await electron.app.whenReady().then(async () => { - await debug.printInfo() - electron.app.quit() - }) - } else { + await electron?.app.whenReady() + await debug.printInfo() + quit() + } else if (electron) { const isOriginalInstance = electron.app.requestSingleInstanceLock({ fileToOpen, urlToOpen, @@ -152,7 +168,7 @@ class App { this.registerShortcuts() } else { console.log('Another instance of the application is already running, exiting.') - electron.app.quit() + quit() } } } @@ -281,7 +297,7 @@ class App { authentication.initAuthentication(() => this.window!) } catch (err) { console.error('Failed to initialize the application, shutting down. Error: ', err) - electron.app.quit() + quit() } } @@ -410,127 +426,6 @@ class App { }) } - /** - * Initialize Inter-Process Communication between the Electron application and the served - * website. - */ - initIpc() { - electron.ipcMain.on(ipc.Channel.error, (_event, data) => { - console.error(...data) - }) - electron.ipcMain.on(ipc.Channel.warn, (_event, data) => { - console.warn(...data) - }) - electron.ipcMain.on(ipc.Channel.log, (_event, data) => { - console.log(...data) - }) - electron.ipcMain.on(ipc.Channel.info, (_event, data) => { - console.info(...data) - }) - electron.ipcMain.on( - ipc.Channel.importProjectFromPath, - (event, path: string, directory: string | null, title: string) => { - const directoryParams = directory == null ? [] : [directory] - const info = projectManagement.importProjectFromPath(path, ...directoryParams, title) - event.reply(ipc.Channel.importProjectFromPath, path, info) - }, - ) - electron.ipcMain.handle( - ipc.Channel.downloadURL, - async (_event, options: DownloadUrlOptions) => { - const { url, path, name, shouldUnpackProject, showFileDialog } = options - // This should never happen, but we'll check for it anyway. - if (!this.window) { - throw new Error('Window is not available.') - } - - await download.download(this.window, url, { - ...(path != null ? { directory: path } : {}), - ...(name != null ? { filename: name } : {}), - saveAs: showFileDialog != null ? showFileDialog : path == null, - onCompleted: (file) => { - const path = file.path - const filenameRaw = pathModule.basename(path) - - try { - if ( - projectManagement.isProjectBundle(path) || - projectManagement.isProjectRoot(path) - ) { - if (!shouldUnpackProject) { - return - } - // in case we're importing a project bundle, we need to remove the extension - // from the filename - const filename = filenameRaw.replace(pathModule.extname(filenameRaw), '') - const directory = pathModule.dirname(path) - - projectManagement.importProjectFromPath(path, directory, filename) - fsSync.unlinkSync(path) - } - } catch (error) { - console.error('Error downloading URL', error) - } - }, - }) - - return - }, - ) - electron.ipcMain.on(ipc.Channel.showItemInFolder, (_event, fullPath: string) => { - electron.shell.showItemInFolder(fullPath) - }) - electron.ipcMain.handle( - ipc.Channel.openFileBrowser, - async ( - _event, - kind: 'default' | 'directory' | 'file' | 'filePath', - defaultPath?: string, - filters?: FileFilter[], - ) => { - console.log('Request for opening browser for ', kind, defaultPath, JSON.stringify(filters)) - let retval = null - if (kind === 'filePath') { - // "Accept", as the file won't be created immediately. - const { canceled, filePath } = await electron.dialog.showSaveDialog({ - buttonLabel: 'Accept', - filters: filters?.map(toElectronFileFilter) ?? [], - ...(defaultPath != null ? { defaultPath } : {}), - }) - if (!canceled) { - retval = [filePath] - } - } else { - /** Helper for `showOpenDialog`, which has weird types by default. */ - type Properties = ('openDirectory' | 'openFile')[] - const properties: Properties = - kind === 'file' ? ['openFile'] - : kind === 'directory' ? ['openDirectory'] - : process.platform === 'darwin' ? ['openFile', 'openDirectory'] - : ['openFile'] - const { canceled, filePaths } = await electron.dialog.showOpenDialog({ - properties, - filters: filters?.map(toElectronFileFilter) ?? [], - ...(defaultPath != null ? { defaultPath } : {}), - }) - if (!canceled) { - retval = filePaths - } - } - return retval - }, - ) - - // Handling navigation events from renderer process - electron.ipcMain.on(ipc.Channel.goBack, () => { - this.window?.webContents.navigationHistory.goBack() - }) - - electron.ipcMain.on(ipc.Channel.goForward, () => { - this.window?.webContents.navigationHistory.goForward() - }) - } - /** * 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 @@ -602,6 +497,7 @@ class App { } registerShortcuts() { + if (!electron) return electron.app.on('web-contents-created', (_webContentsCreatedEvent, webContents) => { webContents.on('before-input-event', (_beforeInputEvent, input) => { const { code, alt, control, shift, meta, type } = input @@ -621,14 +517,10 @@ class App { } } -// =================== -// === App startup === -// =================== - process.on('uncaughtException', (err, origin) => { console.error(`Uncaught exception: ${err.toString()}\nException origin: ${origin}`) - electron.dialog.showErrorBox(common.PRODUCT_NAME, err.stack ?? err.toString()) - electron.app.exit(1) + showErrorBox(common.PRODUCT_NAME, err.stack ?? err.toString()) + exit(1) }) const APP = new App() diff --git a/app/electron-client/src/projectService.ts b/app/electron-client/src/projectService.ts index 96c8b2d602a5..63c0acfc415b 100644 --- a/app/electron-client/src/projectService.ts +++ b/app/electron-client/src/projectService.ts @@ -12,7 +12,7 @@ import { ProjectService } from 'project-manager-shim/projectService' // ======================= let projectService: ProjectService | null = null -let extraArgs: string[] = [] +let extraArgs: readonly string[] = [] /** Get the project service. */ function getProjectService(): ProjectService { @@ -23,7 +23,7 @@ function getProjectService(): ProjectService { } /** Setup the project service.*/ -export function setupProjectService(args: string[]) { +export function setupProjectService(args: readonly string[]) { extraArgs = args if (!projectService) { projectService = ProjectService.default(paths.RESOURCES_PATH, args) diff --git a/app/project-manager-shim/src/projectService/ensoRunner.ts b/app/project-manager-shim/src/projectService/ensoRunner.ts index 56b725b8c5d4..7df57b59cda0 100644 --- a/app/project-manager-shim/src/projectService/ensoRunner.ts +++ b/app/project-manager-shim/src/projectService/ensoRunner.ts @@ -14,8 +14,8 @@ export interface Runner { openProject( projectPath: Path, projectId: string, - extraArgs?: Array, - extraEnv?: Array<[string, string]>, + extraArgs?: readonly string[], + extraEnv?: readonly (readonly [string, string])[], ): Promise closeProject(projectId: string): Promise isProjectRunning(projectId: string): Promise @@ -106,8 +106,8 @@ export class EnsoRunner implements Runner { async openProject( projectPath: Path, projectId: string, - extraArgs?: Array, - extraEnv?: Array<[string, string]>, + extraArgs?: readonly string[], + extraEnv?: readonly (readonly [string, string])[], ): Promise { // Check if the project is already running const runningProject = this.runningProjects.get(projectId) diff --git a/app/project-manager-shim/src/projectService/index.ts b/app/project-manager-shim/src/projectService/index.ts index 33ffa54262b8..c535cd4820f4 100644 --- a/app/project-manager-shim/src/projectService/index.ts +++ b/app/project-manager-shim/src/projectService/index.ts @@ -84,12 +84,12 @@ export class ProjectService { /** Creates a new ProjectService with the specified runner. */ constructor( private readonly runner: Runner, - private readonly extraArgs: Array, + private readonly extraArgs: readonly string[], private readonly logger: Console = console, ) {} /** Creates a default ProjectService using the Enso executable found in the environment. */ - static default(workDir: string = '.', extraArgs: Array = []): ProjectService { + static default(workDir: string = '.', extraArgs: readonly string[] = []): ProjectService { const ensoPath = findEnsoExecutable(workDir) if (!ensoPath) { throw new Error('Enso executable not found') From 8d362a187dc0b196276044701ee33b7b8a307e77 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 21 Oct 2025 18:49:59 +1000 Subject: [PATCH 02/31] Decouple Electron from `electron-client` entrypoint --- app/electron-client/src/electron.ts | 2 +- app/electron-client/src/index.ts | 82 +++++++++++++++++------------ 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/app/electron-client/src/electron.ts b/app/electron-client/src/electron.ts index cefdf68417cb..d2ca401fee54 100644 --- a/app/electron-client/src/electron.ts +++ b/app/electron-client/src/electron.ts @@ -11,7 +11,7 @@ import { toElectronFileFilter, type FileFilter } from './fileBrowser' * Initialize Inter-Process Communication between the Electron application and the served * website. */ -export function initIpc(window?: BrowserWindow) { +export function initIpc(window: BrowserWindow | null) { ipcMain.on(Channel.error, (_event, data) => { console.error(...data) }) diff --git a/app/electron-client/src/index.ts b/app/electron-client/src/index.ts index a99534046a7c..596195378204 100644 --- a/app/electron-client/src/index.ts +++ b/app/electron-client/src/index.ts @@ -25,6 +25,7 @@ import * as authentication from '@/authentication' import * as configParser from '@/configParser' import * as contentConfig from '@/contentConfig' import * as debug from '@/debug' +import { initIpc } from '@/electron' import * as fileAssociations from '@/fileAssociations' import * as ipc from '@/ipc' import * as log from '@/log' @@ -34,6 +35,7 @@ import * as projectService from '@/projectService' import * as security from '@/security' import * as server from '@/server' import * as urlAssociations from '@/urlAssociations' +import type { BrowserWindowConstructorOptions, WebPreferences } from 'electron' import * as projectManagement from 'project-manager-shim' import { filterByRole, inheritMenuItem, makeMenuItem, replaceMenuItems } from './menuItems' @@ -41,6 +43,7 @@ const DEFAULT_WINDOW_WIDTH = 1380 const DEFAULT_WINDOW_HEIGHT = 900 let electron: typeof import('electron') | undefined +type Electron = typeof import('electron') function exit(code = 0) { if (electron) { @@ -114,7 +117,7 @@ class App { }) if (isOriginalInstance) { this.handleItemOpening(fileToOpen, urlToOpen) - this.setChromeOptions() + this.setChromeOptions(electron) security.enableAll() this.onStart().catch((err) => { @@ -175,26 +178,29 @@ class App { /** Background tasks scheduled on the application startup. */ async onStart() { - const userData = electron.app.getPath('userData') - const versionInfoPath = pathModule.join(userData, 'version_info.json') - const versionInfoPathExists = await fs - .access(versionInfoPath, fs.constants.F_OK) - .then(() => true) - .catch(() => false) - - if (versionInfoPathExists) { - const versionInfoText = await fs.readFile(versionInfoPath, 'utf8') - const versionInfoJson = JSON.parse(versionInfoText) - - if (debug.VERSION_INFO.version === versionInfoJson.version && !contentConfig.VERSION.isDev()) - return - } + const writeVersionInfoPromise = (async () => { + if (!electron) return + const userData = electron.app.getPath('userData') + const versionInfoPath = pathModule.join(userData, 'version_info.json') + const versionInfoPathExists = await fs + .access(versionInfoPath, fs.constants.F_OK) + .then(() => true) + .catch(() => false) + + if (versionInfoPathExists) { + const versionInfoText = await fs.readFile(versionInfoPath, 'utf8') + const versionInfoJson = JSON.parse(versionInfoText) + + if ( + debug.VERSION_INFO.version === versionInfoJson.version && + !contentConfig.VERSION.isDev() + ) + return + } + + return fs.writeFile(versionInfoPath, JSON.stringify(debug.VERSION_INFO), 'utf8') + })() - const writeVersionInfoPromise = fs.writeFile( - versionInfoPath, - JSON.stringify(debug.VERSION_INFO), - 'utf8', - ) const downloadSamplesPromise = projectManagement.downloadSamples() return Promise.allSettled([writeVersionInfoPromise, downloadSamplesPromise]) @@ -222,17 +228,21 @@ class App { * @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. - if (!electron.app.isReady()) { - console.log(`Setting the project to open on startup to '${projectUrl.toString()}'.`) - this.webOptions.startup.project = projectUrl.toString() + if (electron) { + // Make sure that we are not initialized yet, as this method should be called before the + // application is ready. + if (!electron.app.isReady()) { + console.log(`Setting the project to open on startup to '${projectUrl.toString()}'.`) + this.webOptions.startup.project = projectUrl.toString() + } else { + console.error( + "Cannot set the project to open on startup to '" + + projectUrl.toString() + + "', as the application is already initialized.", + ) + } } else { - console.error( - "Cannot set the project to open on startup to '" + - projectUrl.toString() + - "', as the application is already initialized.", - ) + // } } @@ -262,7 +272,7 @@ 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. */ - setChromeOptions() { + setChromeOptions(electron: Electron) { // Needed to accept localhost self-signed cert electron.app.commandLine.appendSwitch('ignore-certificate-errors') // Enable native CPU-mappable GPU memory buffer support on Linux. @@ -286,7 +296,7 @@ class App { // appears, it serves the website immediately. await this.startContentServerIfEnabled(args) await this.createWindowIfEnabled(args) - this.initIpc() + initIpc(this.window) await this.loadWindowContent(args) /** * The non-null assertion on the following line is safe because the window @@ -337,14 +347,18 @@ class App { /** Create the Electron window and display it on the screen. */ async createWindowIfEnabled(args: Options) { await this.runIfEnabled(args.displayWindow, () => { + if (!electron) { + console.error('Running in headless mode, window will not be created.') + return + } console.log('Creating the window.') - const webPreferences: electron.WebPreferences = { + const webPreferences: WebPreferences = { preload: pathModule.join(paths.APP_PATH, 'preload.mjs'), sandbox: true, spellcheck: false, ...(process.env.ENSO_TEST ? { partition: 'test' } : {}), } - const windowPreferences: electron.BrowserWindowConstructorOptions = { + const windowPreferences: BrowserWindowConstructorOptions = { webPreferences, width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT, From 3be6b66c8bfd40906d7ccfb26edf6fdcebdd4d3c Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 22 Oct 2025 19:33:36 +1000 Subject: [PATCH 03/31] Move `LocalBackend` and `RemoteBackend` to `common` --- app/common/package.json | 4 +- app/common/src/backendQuery.ts | 2 +- app/common/src/services/Backend.ts | 19 +- .../src/services/Backend}/Category.ts | 17 +- .../src/services/Backend}/utilities.ts | 18 +- .../src}/services/LocalBackend.ts | 128 +++++++------ .../services/ProjectManager/ProjectManager.ts | 26 +-- .../src}/services/ProjectManager/types.ts | 14 +- .../src}/services/RemoteBackend.ts | 49 ++--- .../src}/services/RemoteBackend/ids.ts | 13 +- .../src}/services/__test__/Backend.test.ts | 4 +- app/common/src/text.ts | 16 +- .../__test__}/nameValidation.test.ts | 0 .../src}/utilities/async.ts | 0 .../util => common/src/utilities}/data/opt.ts | 0 .../src/utilities}/data/result.ts | 3 +- app/common/src/utilities/download.ts | 16 ++ app/common/src/utilities/errors.ts | 156 ++++++++++++++++ .../src/utilities}/nameValidation.ts | 20 +- app/common/src/utilities/permissions.ts | 107 +++++++++-- app/common/src/utilities/uniqueString.ts | 4 - app/gui/integration-test/base.ts | 2 +- .../dashboard/assetsTableFeatures.spec.ts | 2 +- .../dashboard/rightPanel.spec.ts | 4 +- app/gui/integration-test/mock/cloudApi.ts | 6 +- app/gui/integration-test/mock/localApi.ts | 4 +- app/gui/package.json | 2 +- app/gui/src/components/AppContainer.vue | 4 +- .../AppContainer/AppContainerInner.vue | 2 +- .../components/AppContainer/RightPanel.vue | 2 +- app/gui/src/components/AppContainerLayout.vue | 4 +- app/gui/src/components/CommandPalette.vue | 2 +- app/gui/src/components/RegistrationPage.vue | 2 +- app/gui/src/components/WithCurrentProject.vue | 4 +- app/gui/src/composables/appTitle.ts | 2 +- app/gui/src/dashboard/App.tsx | 2 +- .../components/Checkbox/CheckboxGroup.tsx | 2 +- .../src/dashboard/components/ColorPicker.tsx | 2 +- .../components/Devtools/EnsoDevtoolsImpl.tsx | 4 +- .../Devtools/EnsoDevtoolsProvider.tsx | 2 +- .../dashboard/components/ErrorBoundary.tsx | 2 +- .../components/Form/components/useForm.ts | 2 +- .../dashboard/components/JSONSchemaInput.tsx | 2 +- .../dashboard/components/Radio/RadioGroup.tsx | 2 +- .../UserWithPopover/UserWithPopover.tsx | 2 +- .../src/dashboard/data/datalinkValidator.ts | 2 +- .../data/serviceCredentials/google.ts | 2 +- .../data/serviceCredentials/logic.ts | 2 +- .../data/serviceCredentials/snowflake.ts | 2 +- .../data/serviceCredentials/strava.ts | 2 +- .../data/serviceCredentials/utilities.ts | 2 +- .../dashboard/hooks/backendBatchedHooks.ts | 8 +- app/gui/src/dashboard/hooks/backendHooks.ts | 24 +-- .../hooks/backendUploadFilesHooks.tsx | 4 +- .../hooks/billing/FeaturesConfiguration.ts | 2 +- .../dashboard/hooks/billing/paywallHooks.ts | 2 +- .../src/dashboard/hooks/cutAndPasteHooks.tsx | 2 +- app/gui/src/dashboard/hooks/measureHooks.ts | 34 +--- app/gui/src/dashboard/hooks/menuHooks.ts | 2 +- app/gui/src/dashboard/hooks/projectHooks.ts | 6 +- .../src/dashboard/hooks/toastAndLogHooks.ts | 2 +- .../dashboard/layouts/AssetContextMenu.tsx | 4 +- .../AssetPanel/components/AssetDiffView.tsx | 4 +- .../AssetPanel/components/AssetProperties.tsx | 24 +-- .../AssetPanel/components/AssetVersion.tsx | 4 +- .../AssetPanel/components/AssetVersions.tsx | 11 +- .../components/ProjectExecution.tsx | 4 +- .../components/ProjectExecutionsCalendar.tsx | 14 +- .../AssetPanel/components/ProjectSession.tsx | 7 +- .../AssetPanel/components/ProjectSessions.tsx | 4 +- .../layouts/AssetPanel/components/queries.ts | 10 +- .../src/dashboard/layouts/AssetSearchBar.tsx | 3 +- app/gui/src/dashboard/layouts/AssetsTable.tsx | 37 ++-- .../AssetsTableCombinedContextMenu.tsx | 2 +- .../layouts/AssetsTableContextMenu.tsx | 2 +- .../dashboard/layouts/CategorySwitcher.tsx | 2 +- .../layouts/CategorySwitcher/Category.ts | 2 +- app/gui/src/dashboard/layouts/Drive.tsx | 4 +- .../Drive/Categories/categoriesHooks.tsx | 18 +- .../layouts/Drive/Categories/index.ts | 2 +- .../Drive/Categories/persistentState.ts | 2 +- .../transferBetweenCategoriesHooks.tsx | 12 +- .../layouts/Drive/assetsTableItemsHooks.ts | 2 +- .../layouts/Drive/persistentState.ts | 2 +- .../layouts/Drive/useDownloadDirectory.ts | 2 +- app/gui/src/dashboard/layouts/Editor.tsx | 2 +- .../layouts/NewProjectExecutionModal.tsx | 12 +- .../Settings/ActivityLogSettingsSection.tsx | 3 +- .../KeyboardShortcutsSettingsSection.tsx | 2 +- .../Settings/MembersSettingsSection.tsx | 4 +- .../OrganizationProfilePictureInput.tsx | 2 +- .../layouts/Settings/ProfilePictureInput.tsx | 2 +- .../Settings/UserGroupsSettingsSection.tsx | 2 +- .../src/dashboard/layouts/Settings/data.tsx | 22 +-- .../layouts/useGlobalContextMenuEntries.tsx | 4 +- .../modals/AcceptInvitationModal.tsx | 2 +- .../modals/CreateCredentialModal.tsx | 2 +- .../dashboard/modals/DuplicateAssetsModal.tsx | 6 +- .../InviteUsersModal/InviteUsersForm.tsx | 2 +- .../InviteUsersModal/InviteUsersModal.tsx | 2 +- .../dashboard/modals/ManageLabelsModal.tsx | 12 +- .../src/dashboard/modals/ProjectLogsModal.tsx | 3 +- .../src/dashboard/modals/TrialEndedModal.tsx | 2 +- .../dashboard/modals/UpsertSecretModal.tsx | 2 +- .../components/PlanSelector/PlanSelector.tsx | 2 +- .../PlanSelector/components/Card.tsx | 2 +- .../components/PlanSelectorDialog.tsx | 2 +- .../dashboard/modules/payments/constants.ts | 2 +- .../modules/payments/useSubscriptionPrice.ts | 2 +- .../src/dashboard/pages/PaymentsSuccess.tsx | 2 +- .../dashboard/pages/dashboard/Dashboard.tsx | 4 +- .../Drive/DriveBar/DriveBarNavigation.tsx | 13 +- .../Drive/DriveBar/DriveBarToolbar.tsx | 9 +- .../pages/dashboard/UserBar/UserBar.tsx | 2 +- .../pages/dashboard/UserBar/UserMenu.tsx | 2 +- .../pages/dashboard/components/AssetIcon.tsx | 2 +- .../pages/dashboard/components/AssetRow.tsx | 14 +- .../dashboard/components/AssetSummary.tsx | 2 +- .../dashboard/components/DatalinkInput.tsx | 2 +- .../pages/dashboard/components/Label.tsx | 4 +- .../components/PermissionDisplay.tsx | 2 +- .../dashboard/components/ProjectIcon.tsx | 18 +- .../components/column/DatalinkNameColumn.tsx | 2 +- .../components/column/DirectoryNameColumn.tsx | 2 +- .../components/column/FileNameColumn.tsx | 2 +- .../components/column/PathColumn.tsx | 4 +- .../components/column/ProjectNameColumn.tsx | 7 +- .../components/column/SecretNameColumn.tsx | 2 +- .../dashboard/components/column/column.ts | 6 +- .../components/column/columnUtils.ts | 2 +- .../components/column/components.tsx | 10 +- .../dashboard/pages/subscribe/Subscribe.tsx | 2 +- .../src/dashboard/pages/useExportArchive.ts | 4 +- .../providers/InputBindingsProvider.tsx | 2 +- app/gui/src/dashboard/services/Backend.ts | 3 - .../services/ProjectManager/index.ts | 3 - app/gui/src/dashboard/utilities/AssetQuery.ts | 2 +- .../src/dashboard/utilities/LocalStorage.ts | 2 +- .../utilities/__tests__/error.test.ts | 2 +- app/gui/src/dashboard/utilities/download.ts | 1 - app/gui/src/dashboard/utilities/drag.ts | 4 +- app/gui/src/dashboard/utilities/error.ts | 172 ------------------ app/gui/src/dashboard/utilities/image.ts | 2 +- .../src/dashboard/utilities/inputBindings.ts | 2 +- app/gui/src/dashboard/utilities/jsonSchema.ts | 2 +- app/gui/src/dashboard/utilities/newtype.ts | 2 - app/gui/src/dashboard/utilities/object.ts | 3 - app/gui/src/dashboard/utilities/path.ts | 2 - .../src/dashboard/utilities/permissions.ts | 98 ---------- app/gui/src/dashboard/utilities/sorting.ts | 2 +- app/gui/src/electronApi.ts | 3 +- app/gui/src/project-view/ProjectView.vue | 2 +- .../components/CodeEditor/sync.ts | 2 +- .../components/ComponentBrowser.vue | 2 +- .../components/ComponentBrowser/ai.ts | 2 +- .../components/ComponentBrowser/input.ts | 2 +- .../components/ComponentHelp/metadata.ts | 2 +- .../components/ComponentHelpPanel.vue | 2 +- .../components/DescriptionEditor.vue | 2 +- .../ClosedProjectDocumentationEditor.vue | 2 +- .../OpenedProjectDocumentationEditor.vue | 2 +- .../components/FunctionSignatureEditor.vue | 2 +- .../project-view/components/GraphEditor.vue | 2 +- .../GraphEditor/CodeMirrorWidgetBase.vue | 2 +- .../GraphEditor/ComponentWidgetTree.vue | 2 +- .../components/GraphEditor/GraphEdges.vue | 2 +- .../components/GraphEditor/GraphNode.vue | 2 +- .../GraphEditor/GraphNodeMessage.vue | 2 +- .../GraphVisualization/visualizationData.ts | 4 +- .../GraphEditor/__tests__/collapsing.test.ts | 2 +- .../components/GraphEditor/collapsing.ts | 2 +- .../GraphEditor/widgets/WidgetApplication.vue | 2 +- .../GraphEditor/widgets/WidgetFunction.vue | 2 +- .../widgets/WidgetFunctionDef/ArgumentRow.vue | 2 +- .../widgets/WidgetFunctionName.vue | 2 +- .../GraphEditor/widgets/WidgetTableEditor.vue | 2 +- .../WidgetTableEditor/tableInputArgument.ts | 8 +- .../GraphEditor/widgets/WidgetText.vue | 2 +- .../__tests__/ensoPath.test.ts | 2 +- .../__tests__/fileExtensionFilter.test.ts | 2 +- .../FileBrowserWidget/__tests__/mockData.ts | 6 +- .../__tests__/pathBrowsing.test.ts | 2 +- .../__tests__/useAcceptCurrentFile.test.ts | 4 +- .../__tests__/useFileBrowserSync.test.ts | 4 +- .../widgets/FileBrowserWidget/ensoPath.ts | 3 +- .../widgets/FileBrowserWidget/fileBrowser.ts | 2 +- .../FileBrowserWidget/fileExtensionFilter.ts | 2 +- .../FileBrowserWidget/fileExtensions.ts | 2 +- .../widgets/FileBrowserWidget/pathBrowsing.ts | 2 +- .../FileBrowserWidget/useAcceptCurrentFile.ts | 2 +- .../FileBrowserWidget/useFileBrowserSync.ts | 2 +- .../src/project-view/composables/backend.ts | 2 +- .../project-view/composables/nodeCreation.ts | 2 +- .../composables/stackNavigator.ts | 6 +- .../project-view/providers/asyncResources.ts | 2 +- .../providers/asyncResources/AsyncResource.ts | 2 +- .../asyncResources/__tests__/parse.test.ts | 4 +- .../providers/asyncResources/context.ts | 2 +- .../providers/asyncResources/parse.ts | 4 +- .../providers/asyncResources/resolve.ts | 14 +- .../providers/asyncResources/upload.ts | 4 +- .../project-view/providers/projectBackend.ts | 2 +- app/gui/src/project-view/stores/persisted.ts | 2 +- .../util/__tests__/projectPath.test.ts | 2 +- .../util/__tests__/qualifiedName.test.ts | 2 +- .../project-view/util/__tests__/url.test.ts | 2 +- app/gui/src/project-view/util/data/result.ts | 1 - .../src/project-view/util/methodPointer.ts | 2 +- app/gui/src/project-view/util/projectPath.ts | 2 +- .../src/project-view/util/qualifiedName.ts | 2 +- app/gui/src/project-view/util/toast.ts | 2 +- app/gui/src/project-view/util/url.ts | 2 +- app/gui/src/providers/auth.ts | 4 +- app/gui/src/providers/backends.ts | 23 ++- app/gui/src/providers/container.ts | 8 +- app/gui/src/providers/featureFlags.ts | 11 +- app/gui/src/providers/openedProjects.ts | 2 +- .../graph/__tests__/imports.test.ts | 2 +- .../providers/openedProjects/graph/graph.ts | 2 +- .../openedProjects/graph/graphDatabase.ts | 2 +- .../providers/openedProjects/module/module.ts | 2 +- .../project/computedValueRegistry.ts | 2 +- .../project/executionContext.ts | 2 +- .../openedProjects/project/project.ts | 4 +- .../project/visualizationDataRegistry.ts | 2 +- .../providers/openedProjects/projectNames.ts | 8 +- .../__tests__/documentation.test.ts | 2 +- .../__tests__/lsUpdate.test.ts | 2 +- .../suggestionDatabase/index.ts | 2 +- .../suggestionDatabase/lsUpdate.ts | 2 +- .../widgetRegistry/widgetRegistry.ts | 2 +- app/gui/src/providers/react/container.tsx | 2 +- app/gui/src/providers/rightPanel.ts | 4 +- app/gui/src/providers/session.ts | 6 +- app/gui/src/providers/text.ts | 4 +- .../src/router/__tests__/dataLoader.test.ts | 2 +- .../router/__tests__/initialProject.test.ts | 6 +- app/gui/src/router/dataLoader.ts | 2 +- app/gui/src/router/initialProject.ts | 6 +- app/gui/tsconfig.node.json | 11 -- app/ydoc-shared/src/ast/parserSupport.ts | 4 +- app/ydoc-shared/src/ast/tree.ts | 4 +- app/ydoc-shared/src/languageServer.ts | 2 +- app/ydoc-shared/src/languageServer/files.ts | 2 +- .../src/util/__tests__/net.test.ts | 2 +- app/ydoc-shared/src/util/net.ts | 2 +- pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 1 + 248 files changed, 883 insertions(+), 908 deletions(-) rename app/{gui/src/dashboard/layouts/Drive/Categories => common/src/services/Backend}/Category.ts (93%) rename app/{gui/src/dashboard/services => common/src/services/Backend}/utilities.ts (87%) rename app/{gui/src/dashboard => common/src}/services/LocalBackend.ts (91%) rename app/{gui/src/dashboard => common/src}/services/ProjectManager/ProjectManager.ts (94%) rename app/{gui/src/dashboard => common/src}/services/ProjectManager/types.ts (94%) rename app/{gui/src/dashboard => common/src}/services/RemoteBackend.ts (97%) rename app/{gui/src/dashboard => common/src}/services/RemoteBackend/ids.ts (92%) rename app/{gui/src/dashboard => common/src}/services/__test__/Backend.test.ts (99%) rename app/{gui/src/project-view/util/__tests__ => common/src/utilities/__test__}/nameValidation.test.ts (100%) rename app/{gui/src/dashboard => common/src}/utilities/async.ts (100%) rename app/{ydoc-shared/src/util => common/src/utilities}/data/opt.ts (100%) rename app/{ydoc-shared/src/util => common/src/utilities}/data/result.ts (99%) create mode 100644 app/common/src/utilities/download.ts rename app/{gui/src/project-view/util => common/src/utilities}/nameValidation.ts (54%) delete mode 100644 app/gui/src/dashboard/services/Backend.ts delete mode 100644 app/gui/src/dashboard/services/ProjectManager/index.ts delete mode 100644 app/gui/src/dashboard/utilities/error.ts delete mode 100644 app/gui/src/dashboard/utilities/newtype.ts delete mode 100644 app/gui/src/dashboard/utilities/object.ts delete mode 100644 app/gui/src/dashboard/utilities/path.ts delete mode 100644 app/gui/src/dashboard/utilities/permissions.ts delete mode 100644 app/gui/src/project-view/util/data/result.ts diff --git a/app/common/package.json b/app/common/package.json index a749347039a1..fa067eed5cfc 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -32,10 +32,10 @@ "@tanstack/vue-query": "5.59.20", "@types/node": "catalog:", "is-network-error": "^1.1.0", - "vue": "*" + "vue": "catalog:" }, "devDependencies": { "@fast-check/vitest": "catalog:", "vitest": "catalog:" } -} +} \ No newline at end of file diff --git a/app/common/src/backendQuery.ts b/app/common/src/backendQuery.ts index 7917124c4a5c..a95e5f643aff 100644 --- a/app/common/src/backendQuery.ts +++ b/app/common/src/backendQuery.ts @@ -1,6 +1,6 @@ /** @file Framework-independent helpers for constructing backend Tanstack queries. */ import type * as queryCore from '@tanstack/query-core' -import type Backend from './services/Backend.js' +import type { Backend } from './services/Backend.js' import * as backendModule from './services/Backend.js' import { omit, type ExtractKeys, type MethodOf } from './utilities/data/object.js' diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 648e741d11d9..8e99abce34b6 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -4,6 +4,7 @@ import { getText, resolveDictionary, type Replacements, type TextId } from '../t import * as array from '../utilities/data/array.js' import * as dateTime from '../utilities/data/dateTime.js' import * as newtype from '../utilities/data/newtype.js' +import type { DownloadOptions } from '../utilities/download.js' import * as permissions from '../utilities/permissions.js' import * as uniqueString from '../utilities/uniqueString.js' import { getFileDetailsPath } from './Backend/remoteBackendPaths.js' @@ -98,7 +99,7 @@ export interface Logger { readonly error: (message: unknown, ...optionalParams: unknown[]) => void } -type GetText = (key: K, ...replacements: Replacements[K]) => string +export type GetText = (key: K, ...replacements: Replacements[K]) => string /** The {@link Backend} variant. If a new variant is created, it should be added to this enum. */ export enum BackendType { @@ -1744,15 +1745,23 @@ export class NetworkError extends Error { export class NotAuthorizedError extends NetworkError {} /** Interface for sending requests to a backend that manages assets and runs projects. */ -export default abstract class Backend { +export abstract class Backend { abstract readonly type: BackendType abstract readonly baseUrl: URL + protected getText: GetText + private readonly client: HttpClient + protected readonly downloader: (options: DownloadOptions) => void | Promise /** Create a {@link LocalBackend}. */ constructor( - protected getText: GetText, - private readonly client: HttpClient, - ) {} + getText: GetText, + client: HttpClient, + downloader: (options: DownloadOptions) => void | Promise, + ) { + this.getText = getText + this.client = client + this.downloader = downloader + } /** * Set `this.getText`. This function is exposed rather than the property itself to make it clear diff --git a/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts b/app/common/src/services/Backend/Category.ts similarity index 93% rename from app/gui/src/dashboard/layouts/Drive/Categories/Category.ts rename to app/common/src/services/Backend/Category.ts index b1958cea18d1..4478351bda0e 100644 --- a/app/gui/src/dashboard/layouts/Drive/Categories/Category.ts +++ b/app/common/src/services/Backend/Category.ts @@ -1,6 +1,6 @@ /** @file The categories available in the category switcher. */ -import type { SvgUseIcon } from '#/components/types' -import type { UserId } from '#/services/Backend' +import * as z from 'zod' +import type { UserId } from '../Backend.js' import { BackendType, FilterBy, @@ -9,11 +9,7 @@ import { type User, type UserGroup, type UserGroupId, -} from '#/services/Backend' -import { isUrlString } from '@/util/data/urlString' -import { isIconName } from '@/util/iconMetadata/iconName' -import type { DropOperation } from '@react-types/shared' -import * as z from 'zod' +} from '../Backend.js' // oxlint-disable-next-line no-unused-vars const PATH_SCHEMA = z.string().refine((s): s is Path => true) @@ -22,9 +18,7 @@ const DIRECTORY_ID_SCHEMA = z.string().refine((s): s is DirectoryId => true) const EACH_CATEGORY_SCHEMA = z.object({ label: z.string(), - icon: z.custom( - (icon) => typeof icon === 'string' && (isIconName(icon) || isUrlString(icon)), - ), + icon: z.string(), canUploadHere: z.boolean(), /** * Internal type discriminator. @@ -170,7 +164,6 @@ export const CATEGORY_TO_FILTER_BY: Readonly AnyCategory | null @@ -22,14 +18,12 @@ export interface PathItem { readonly icon: AnyCategory['icon'] } -/** - * Parse the parents path and virtual parents path into a list of {@link PathItem}. - */ +/** Parse the parents path and virtual parents path into a list of {@link PathItem}. */ export function parseDirectoriesPath(options: ParsedDirectoriesPathOptions) { const { getCategoryByDirectoryId, parentsPath, rootDirectoryId, virtualParentsPath } = options // e.g: parentsPath = 'directory-id1adsf/directory-id2adsf/directory-id3adsf' - // eslint-disable-next-line no-restricted-syntax + const splitPath = parentsPath.split('/') as DirectoryId[] const rootDirectoryInPath = splitPath[0] || rootDirectoryId diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/common/src/services/LocalBackend.ts similarity index 91% rename from app/gui/src/dashboard/services/LocalBackend.ts rename to app/common/src/services/LocalBackend.ts index c2ef93947e22..69b7f6a43b7b 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/common/src/services/LocalBackend.ts @@ -5,35 +5,35 @@ * The functions are asynchronous and return a {@link Promise} that resolves to the response from * the API. */ -import { localRootDirectoryStore } from '#/layouts/Drive/persistentState' -import Backend, * as backend from '#/services/Backend' -import * as projectManager from '#/services/ProjectManager' -import type { ProjectManager } from '#/services/ProjectManager/ProjectManager' -import { download } from '#/utilities/download' -import { tryGetMessage } from '#/utilities/error' -import { getDirectoryAndName, joinPath } from '#/utilities/path' -import type { GetText } from '$/providers/text' -import { PRODUCT_NAME } from 'enso-common' -import { - downloadProjectPath, - EXPORT_ARCHIVE_PATH, -} from 'enso-common/src/services/Backend/remoteBackendPaths' -import { HttpClient } from 'enso-common/src/services/HttpClient' -import { toReadableIsoString } from 'enso-common/src/utilities/data/dateTime' +import { markRaw } from 'vue' +import { PRODUCT_NAME } from '../index.js' +import { toReadableIsoString } from '../utilities/data/dateTime.js' +import type { DownloadOptions } from '../utilities/download.js' +import { tryGetMessage } from '../utilities/errors.js' import { fileExtension, + getDirectoryAndName, getFileName, getFolderPath, + joinPath, normalizePath, -} from 'enso-common/src/utilities/file' -import { uniqueString } from 'enso-common/src/utilities/uniqueString' -import invariant from 'tiny-invariant' -import { markRaw } from 'vue' +} from '../utilities/file.js' +import { uniqueString } from '../utilities/uniqueString.js' +import * as backend from './Backend.js' +import { downloadProjectPath, EXPORT_ARCHIVE_PATH } from './Backend/remoteBackendPaths.js' +import { HttpClient } from './HttpClient.js' +import type { ProjectManager } from './ProjectManager/ProjectManager.js' +import { + MissingComponentAction, + Path, + ProjectName, + type IpWithSocket, +} from './ProjectManager/types.js' const LOCAL_API_URL = '/api/' /** Convert a {@link projectManager.IpWithSocket} to a {@link backend.Address}. */ -function ipWithSocketToAddress(ipWithSocket: projectManager.IpWithSocket) { +function ipWithSocketToAddress(ipWithSocket: IpWithSocket) { return backend.Address(`ws://${ipWithSocket.host}:${ipWithSocket.port}`) } @@ -42,12 +42,12 @@ export const PROJECT_ID_PREFIX = `${backend.AssetType.project}-` export const FILE_ID_PREFIX = `${backend.AssetType.file}-` /** Create a {@link backend.DirectoryId} from a path. */ -export function newDirectoryId(path: projectManager.Path) { +export function newDirectoryId(path: Path) { return backend.DirectoryId(`${DIRECTORY_ID_PREFIX}${encodeURIComponent(path)}` as const) } /** Create a {@link backend.ProjectId} from a path. */ -export function newProjectId(path: projectManager.Path) { +export function newProjectId(path: Path) { return backend.ProjectId(`${PROJECT_ID_PREFIX}${encodeURIComponent(path)}`) } @@ -66,7 +66,7 @@ export function isLocalProjectId(projectId: backend.ProjectId): boolean { } /** Create a {@link backend.FileId} from a path. */ -export function newFileId(path: projectManager.Path) { +export function newFileId(path: Path) { return backend.FileId(`${FILE_ID_PREFIX}${encodeURIComponent(path)}`) } @@ -74,26 +74,37 @@ export function newFileId(path: projectManager.Path) { * Class for sending requests to the Project Manager API endpoints. * This is used instead of the cloud backend API when managing local projects from the dashboard. */ -export default class LocalBackend extends Backend { +export class LocalBackend extends backend.Backend { static readonly type = backend.BackendType.local override readonly type = LocalBackend.type override readonly baseUrl = new URL(LOCAL_API_URL, location.href) /** All files that have been uploaded to the Project Manager. */ uploadedFiles: Map = new Map() private readonly projectManager: ProjectManager + private readonly getLocalRootDirectory: () => Path | null + private readonly getFilePath: ((item: File) => string) | undefined /** Create a {@link LocalBackend}. */ - constructor(getText: GetText, projectManagerInstance: ProjectManager, client = new HttpClient()) { - super(getText, client) - + constructor( + getText: backend.GetText, + projectManagerInstance: ProjectManager, + client = new HttpClient(), + downloader: (options: DownloadOptions) => void | Promise, + getLocalRootDirectory: () => Path | null, + getFilePath: ((item: File) => string) | undefined, + ) { + super(getText, client, downloader) this.projectManager = projectManagerInstance + this.getLocalRootDirectory = getLocalRootDirectory + this.getFilePath = getFilePath } /** The root directory of this backend. */ rootPath() { - return ( - localRootDirectoryStore.getState().localRootDirectory ?? this.projectManager.rootDirectory - ) + // TODO: We have settings in Electron for this, but not in the node CLI. + // We need to figure out where to store this setting for the node CLI as well, + // so that it is synced between both. + return this.getLocalRootDirectory() ?? this.projectManager.rootDirectory } /** Return the ID of the root directory. */ @@ -206,8 +217,6 @@ export default class LocalBackend extends Backend { } } result.sort((a, b) => backend.compareAssets(a, b, query.sortExpression, query.sortDirection)) - // This is SAFE as the only `PaginationToken`s returned from this class are created from `AssetId`s. - // eslint-disable-next-line no-restricted-syntax const from = query.from as backend.AssetId | null const index = from == null ? 0 : result.findIndex((asset) => asset.id === from) + 1 const assets = result.slice(index, query.pageSize != null ? index + query.pageSize : undefined) @@ -234,8 +243,6 @@ export default class LocalBackend extends Backend { recursive: true, }) const fullAssetList = result.assets.filter(backend.doesAssetMatchQuery(query)) - // This is SAFE as the only `PaginationToken`s returned from this class are created from `AssetId`s. - // eslint-disable-next-line no-restricted-syntax const from = query.from as backend.AssetId | null const index = from == null ? 0 : fullAssetList.findIndex((asset) => asset.id === from) + 1 const assets = fullAssetList.slice( @@ -261,8 +268,8 @@ export default class LocalBackend extends Backend { this.projectManager.rootDirectory : backend.extractTypeAndPath(body.parentDirectoryId).path const project = await this.projectManager.createProject({ - name: projectManager.ProjectName(body.projectName), - missingComponentAction: projectManager.MissingComponentAction.install, + name: ProjectName(body.projectName), + missingComponentAction: MissingComponentAction.install, projectsDirectory, }) return { @@ -321,7 +328,6 @@ export default class LocalBackend extends Backend { } throw new backend.AssetDoesNotExistError() } - // eslint-disable-next-line no-restricted-syntax return entry as unknown as backend.AssetDetailsResponse } @@ -400,7 +406,7 @@ export default class LocalBackend extends Backend { try { await this.projectManager.openProject({ projectPath: path, - missingComponentAction: projectManager.MissingComponentAction.install, + missingComponentAction: MissingComponentAction.install, ...(body?.openHybridProjectParameters != null ? { cloud: body.openHybridProjectParameters } : {}), @@ -427,7 +433,7 @@ export default class LocalBackend extends Backend { if (body.projectName != null) { await this.projectManager.renameProject({ projectPath: path, - name: projectManager.ProjectName(body.projectName), + name: ProjectName(body.projectName), }) } const parentPath = getDirectoryAndName(path).directoryPath @@ -648,7 +654,7 @@ export default class LocalBackend extends Backend { if (parentDirectoryId == null) { return joinPath( - projectManager.Path(currentParentDirectoryPath.split('/').slice(0, -1).join('/')), + Path(currentParentDirectoryPath.split('/').slice(0, -1).join('/')), fileName, ) } @@ -663,7 +669,7 @@ export default class LocalBackend extends Backend { if (type === backend.AssetType.project && title != null) { await this.projectManager.renameProject({ projectPath: path, - name: projectManager.ProjectName(title), + name: ProjectName(title), }) } } @@ -679,7 +685,7 @@ export default class LocalBackend extends Backend { : backend.extractTypeAndPath(body.parentDirectoryId).path const filePath = joinPath(parentPath, body.fileName) const uploadId = uniqueString() - const sourcePath = body.filePath ?? window.api?.system?.getFilePath(file) + const sourcePath = body.filePath ?? this.getFilePath?.(file) const searchParams = new URLSearchParams([ ['directory', newDirectoryId(parentPath)], ['file_name', body.fileName], @@ -720,7 +726,9 @@ export default class LocalBackend extends Backend { override uploadFileEnd(body: backend.UploadFileEndRequestBody): Promise { // Do nothing, the entire file has already been uploaded in `uploadFileStart`. const file = this.uploadedFiles.get(body.uploadId) - invariant(file, 'Uploaded file not found') + if (!file) { + throw new Error('Uploaded file not found') + } return Promise.resolve(file) } @@ -732,7 +740,7 @@ export default class LocalBackend extends Backend { const typeAndId = backend.extractTypeAndPath(fileId) const from = typeAndId.path const folderPath = getFolderPath(from) - const to = joinPath(projectManager.Path(folderPath), body.title) + const to = joinPath(Path(folderPath), body.title) await this.projectManager.moveFile(from, to) } @@ -747,7 +755,7 @@ export default class LocalBackend extends Backend { body: backend.UpdateDirectoryRequestBody, ): Promise { const from = backend.extractTypeAndPath(directoryId).path - const folderPath = projectManager.Path(getFolderPath(from)) + const folderPath = Path(getFolderPath(from)) const to = joinPath(folderPath, body.title) await this.projectManager.moveFile(from, to) return { @@ -759,8 +767,7 @@ export default class LocalBackend extends Backend { /** Resolve path to asset. In case of LocalBackend, this is just the filesystem path. */ override resolveEnsoPath(path: backend.EnsoPath): Promise { - // eslint-disable-next-line no-restricted-syntax - const { directoryPath } = getDirectoryAndName(projectManager.Path(path as string)) + const { directoryPath } = getDirectoryAndName(Path(path as string)) return this.findAsset(directoryPath, 'ensoPath', path) } @@ -788,8 +795,10 @@ export default class LocalBackend extends Backend { switch (asset.type) { case backend.AssetType.project: { const details = await this.getProjectDetails(asset.id, true) - invariant(details.url != null, 'The download URL of the project must be present.') - await download({ + if (details.url == null) { + throw new Error('The download URL of the project must be present.') + } + await this.downloader({ url: details.url, name: `${title}.enso-project`, electronOptions: { @@ -802,8 +811,10 @@ export default class LocalBackend extends Backend { } case backend.AssetType.file: { const details = await this.getFileDetails(asset.id, title, true) - invariant(details.url != null, 'The download URL of the file must be present.') - await download({ + if (details.url == null) { + throw new Error('The download URL of the file must be present.') + } + await this.downloader({ url: details.url, name: details.file.fileName ?? '', electronOptions: { @@ -817,8 +828,7 @@ export default class LocalBackend extends Backend { case backend.AssetType.secret: case backend.AssetType.directory: case backend.AssetType.specialUp: { - invariant(`'${asset.type}' assets cannot be downloaded.`) - break + throw new Error(`'${asset.type}' assets cannot be downloaded.`) } } } @@ -841,7 +851,10 @@ export default class LocalBackend extends Backend { // Download files as HTTP stream const secondsString = new Date().getSeconds().toString().padStart(2, '0') const dateString = `${toReadableIsoString(new Date()).replace(/[:]/g, ' ')} ${secondsString}` - await download({ url: this.resolvePath(path), name: `${PRODUCT_NAME} ${dateString}.zip` }) + await this.downloader({ + url: this.resolvePath(path), + name: `${PRODUCT_NAME} ${dateString}.zip`, + }) return { filePath: null } } } @@ -1026,7 +1039,7 @@ export default class LocalBackend extends Backend { /** Find asset details using directory listing. */ private async findAsset( - directory: projectManager.Path, + directory: Path, key: Key, value: backend.AnyAsset[Key], ) { @@ -1041,18 +1054,13 @@ export default class LocalBackend extends Backend { from: null, pageSize: null, }) - const entry = directoryContents.assets.find((content) => content[key] === value) - if (entry == null) { if (backend.isDirectoryId(value)) { throw new backend.DirectoryDoesNotExistError() } - throw new backend.AssetDoesNotExistError() } - - // eslint-disable-next-line no-restricted-syntax return entry as never } } diff --git a/app/gui/src/dashboard/services/ProjectManager/ProjectManager.ts b/app/common/src/services/ProjectManager/ProjectManager.ts similarity index 94% rename from app/gui/src/dashboard/services/ProjectManager/ProjectManager.ts rename to app/common/src/services/ProjectManager/ProjectManager.ts index c598dcf2f562..b7430e53cfba 100644 --- a/app/gui/src/dashboard/services/ProjectManager/ProjectManager.ts +++ b/app/common/src/services/ProjectManager/ProjectManager.ts @@ -3,13 +3,16 @@ * @see * https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-project-manager.md */ -import * as backend from '#/services/Backend' -import { getFileName, getFolderPath } from '#/utilities/fileInfo' -import { omit } from '#/utilities/object' -import { getDirectoryAndName, normalizeSlashes } from '#/utilities/path' -import { normalizeName } from '@/util/nameValidation' -import * as dateTime from 'enso-common/src/utilities/data/dateTime' -import invariant from 'tiny-invariant' +import * as dateTime from '../../utilities/data/dateTime.js' +import { omit } from '../../utilities/data/object.js' +import { + getDirectoryAndName, + getFileName, + getFolderPath, + normalizeSlashes, +} from '../../utilities/file.js' +import { normalizeName } from '../../utilities/nameValidation.js' +import * as backend from '../Backend.js' import { MissingComponentAction, Path, @@ -61,7 +64,9 @@ export class ProjectManager { } await this.listDirectory(Path(getFolderPath(projectPath))) const projectId = this.projectIds.get(projectPath) - invariant(projectId, `Unknown project id for project '${projectPath}'.`) + if (!projectId) { + throw new Error(`Unknown project id for project '${projectPath}'.`) + } return this.projects.get(projectId) } @@ -376,7 +381,6 @@ export class ProjectManager { ...cliArguments: string[] ): Promise { const searchParams = new URLSearchParams({ - // eslint-disable-next-line @typescript-eslint/naming-convention 'cli-arguments': JSON.stringify([`--${name}`, ...cliArguments]), }) return await fetch(`/api/run-project-manager-command?${searchParams}`, { method: 'POST', body }) @@ -389,8 +393,6 @@ export class ProjectManager { ...cliArguments: string[] ): Promise { const response = await this.runStandaloneCommand(body, name, ...cliArguments) - // There is no way to avoid this as `JSON.parse` returns `any`. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const json: JSONRPCResponse = await response.json() if ('result' in json) { return json.result @@ -405,8 +407,6 @@ export class ProjectManager { method: 'POST', body: body && JSON.stringify(body), }) - // There is no way to avoid this as `JSON.parse` returns `any`. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const json: JSONRPCResponse = await response.json() if ('result' in json) { return json.result diff --git a/app/gui/src/dashboard/services/ProjectManager/types.ts b/app/common/src/services/ProjectManager/types.ts similarity index 94% rename from app/gui/src/dashboard/services/ProjectManager/types.ts rename to app/common/src/services/ProjectManager/types.ts index 01b04a5a34c7..edb2c68c986f 100644 --- a/app/gui/src/dashboard/services/ProjectManager/types.ts +++ b/app/common/src/services/ProjectManager/types.ts @@ -3,9 +3,9 @@ * @see * https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-project-manager.md */ -import type * as backend from '#/services/Backend' -import * as newtype from '#/utilities/newtype' -import type * as dateTime from 'enso-common/src/utilities/data/dateTime' +import type * as dateTime from '../../utilities/data/dateTime.js' +import * as newtype from '../../utilities/data/newtype.js' +import type * as backend from '../Backend.js' /** Possible actions to take when a component is missing. */ export enum MissingComponentAction { @@ -40,21 +40,21 @@ interface JSONRPCErrorResponse extends JSONRPCBaseResponse { /** The return value of a JSON-RPC call. */ export type JSONRPCResponse = JSONRPCErrorResponse | JSONRPCSuccessResponse -// These are constructor functions that construct values of the type they are named after. -/* eslint-disable @typescript-eslint/no-redeclare */ - /** A UUID. */ export type UUID = newtype.Newtype /** Create a {@link UUID}. */ export const UUID = newtype.newtypeConstructor() + /** A filesystem path. */ export type Path = newtype.Newtype /** Create a {@link Path}. */ export const Path = newtype.newtypeConstructor() + /** An ID of a directory. */ export type DirectoryId = newtype.Newtype /** Create a {@link DirectoryId}. */ export const DirectoryId = newtype.newtypeConstructor() + /** A name of a project. */ export type ProjectName = newtype.Newtype /** Create a {@link ProjectName}. */ @@ -67,8 +67,6 @@ export type UTCDateTime = dateTime.Rfc3339DateTime /** Create a {@link UTCDateTime}. */ export const UTCDateTime = newtype.newtypeConstructor() -/* eslint-enable @typescript-eslint/no-redeclare */ - /** Details of a project. */ export interface ProjectMetadata { /** The name of the project. */ diff --git a/app/gui/src/dashboard/services/RemoteBackend.ts b/app/common/src/services/RemoteBackend.ts similarity index 97% rename from app/gui/src/dashboard/services/RemoteBackend.ts rename to app/common/src/services/RemoteBackend.ts index 96b8f6077b50..7559648b1fe6 100644 --- a/app/gui/src/dashboard/services/RemoteBackend.ts +++ b/app/common/src/services/RemoteBackend.ts @@ -5,17 +5,15 @@ * an API endpoint. The functions are asynchronous and return a {@link Promise} that resolves to * the response from the API. */ -import Backend, * as backend from '#/services/Backend' -import { extractIdFromDirectoryId, organizationIdToDirectoryId } from '#/services/RemoteBackend/ids' -import { delay } from '#/utilities/async' -import * as download from '#/utilities/download' -import * as objects from '#/utilities/object' -import { getFileName, getFolderPath } from '#/utilities/path' -import * as detect from 'enso-common/src/detect' -import * as remoteBackendPaths from 'enso-common/src/services/Backend/remoteBackendPaths' -import invariant from 'tiny-invariant' import { markRaw } from 'vue' import { z } from 'zod' +import * as detect from '../detect.js' +import { delay } from '../utilities/async.js' +import * as objects from '../utilities/data/object.js' +import { getFileName, getFolderPath } from '../utilities/file.js' +import * as backend from './Backend.js' +import * as remoteBackendPaths from './Backend/remoteBackendPaths.js' +import { extractIdFromDirectoryId, organizationIdToDirectoryId } from './RemoteBackend/ids.js' /** HTTP status indicating that the resource does not exist. */ const STATUS_NOT_FOUND = 404 @@ -27,10 +25,11 @@ const EXPORT_STATUS_INTERVAL_MS = 5_000 const IMPORT_STATUS_INTERVAL_MS = 5_000 /** Class for sending requests to the Cloud backend API endpoints. */ -export default class RemoteBackend extends Backend { +export class RemoteBackend extends backend.Backend { static readonly type = backend.BackendType.remote override readonly type = RemoteBackend.type - override readonly baseUrl = new URL($config.API_URL ?? '', location.href) + // TODO: $config.API_URL + override readonly baseUrl = new URL(process.env.ENSO_IDE_API_URL ?? '', location.href) private user: objects.Mutable | null = null /** The path to the root directory of this {@link Backend}. */ @@ -198,7 +197,7 @@ export default class RemoteBackend extends Backend { file: Blob, ): Promise { const paramsString = new URLSearchParams({ - // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase + // eslint-disable-next-line camelcase ...(params.fileName != null ? { file_name: params.fileName } : {}), }).toString() const path = `${remoteBackendPaths.UPLOAD_USER_PICTURE_PATH}?${paramsString}` @@ -269,7 +268,7 @@ export default class RemoteBackend extends Backend { file: Blob, ): Promise { const paramsString = new URLSearchParams({ - // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase + // eslint-disable-next-line camelcase ...(params.fileName != null ? { file_name: params.fileName } : {}), }).toString() const path = `${remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH}?${paramsString}` @@ -313,7 +312,6 @@ export default class RemoteBackend extends Backend { const plan = user.plan - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (plan == null) { // @ts-expect-error The property is declared as read-only, but it's not enforced. // We assume it's read-only for external use. @@ -1218,14 +1216,14 @@ export default class RemoteBackend extends Backend { } const paramsString = new URLSearchParams({ - /* eslint-disable @typescript-eslint/naming-convention, camelcase */ + /* eslint-disable camelcase */ ...(params.userEmail != null ? { user_email: params.userEmail } : {}), ...(params.lambdaKind != null ? { lambda_kind: params.lambdaKind } : {}), ...(params.startDate != null ? { start_date: params.startDate } : {}), ...(params.endDate != null ? { end_date: params.endDate } : {}), ...(params.from != null ? { from: String(params.from) } : {}), ...(params.pageSize != null ? { page_size: String(params.pageSize) } : {}), - /* eslint-enable @typescript-eslint/naming-convention, camelcase */ + /* eslint-enable camelcase */ }).toString() const path = `${remoteBackendPaths.GET_LOG_EVENTS_PATH}?${paramsString}` const response = await this.get(path) @@ -1278,8 +1276,10 @@ export default class RemoteBackend extends Backend { switch (asset.type) { case backend.AssetType.project: { const details = await this.getProjectDetails(asset.id, true) - invariant(details.url != null, 'The download URL of the project must be present.') - await download.download({ + if (details.url == null) { + throw new Error('The download URL of the project must be present.') + } + await this.downloader({ url: details.url, name: `${title}.enso-project`, electronOptions: { shouldUnpackProject, path: targetPath }, @@ -1288,8 +1288,10 @@ export default class RemoteBackend extends Backend { } case backend.AssetType.file: { const details = await this.getFileDetails(asset.id, title, true) - invariant(details.url != null, 'The download URL of the file must be present.') - await download.download({ + if (details.url == null) { + throw new Error('The download URL of the file must be present.') + } + await this.downloader({ url: details.url, name: details.file.fileName ?? '', electronOptions: { path: targetPath }, @@ -1305,7 +1307,7 @@ export default class RemoteBackend extends Backend { }), ) try { - await download.download({ + await this.downloader({ url: fileObjectUrl, name: fileName, electronOptions: { path: targetPath }, @@ -1319,8 +1321,7 @@ export default class RemoteBackend extends Backend { case backend.AssetType.directory: case backend.AssetType.specialUp: default: { - invariant(`'${asset.type}' assets cannot be downloaded.`) - break + throw new Error(`'${asset.type}' assets cannot be downloaded.`) } } } @@ -1479,7 +1480,7 @@ export default class RemoteBackend extends Backend { await delay(EXPORT_STATUS_INTERVAL_MS) continue } - await download.download({ + await this.downloader({ url, name: filePath != null ? getFileName(filePath) : undefined, electronOptions: { diff --git a/app/gui/src/dashboard/services/RemoteBackend/ids.ts b/app/common/src/services/RemoteBackend/ids.ts similarity index 92% rename from app/gui/src/dashboard/services/RemoteBackend/ids.ts rename to app/common/src/services/RemoteBackend/ids.ts index e5563606369a..2126f22f98bf 100644 --- a/app/gui/src/dashboard/services/RemoteBackend/ids.ts +++ b/app/common/src/services/RemoteBackend/ids.ts @@ -1,15 +1,6 @@ /** @file ID encoding and decoding that is specific to cloud backend. */ -import { - DirectoryId, - UserGroupId, - UserId, - type AssetId, - type OrganizationId, -} from '#/services/Backend' -import { - TEAMS_DIRECTORY_ID, - USERS_DIRECTORY_ID, -} from 'enso-common/src/services/Backend/remoteBackendPaths' +import { DirectoryId, UserGroupId, UserId, type AssetId, type OrganizationId } from '../Backend.js' +import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '../Backend/remoteBackendPaths.js' /** Whether the given directory is a special directory that cannot be written to. */ export function isSpecialReadonlyDirectoryId(id: AssetId) { diff --git a/app/gui/src/dashboard/services/__test__/Backend.test.ts b/app/common/src/services/__test__/Backend.test.ts similarity index 99% rename from app/gui/src/dashboard/services/__test__/Backend.test.ts rename to app/common/src/services/__test__/Backend.test.ts index c21c6acf1ae7..4c73b3ec8e81 100644 --- a/app/gui/src/dashboard/services/__test__/Backend.test.ts +++ b/app/common/src/services/__test__/Backend.test.ts @@ -1,11 +1,11 @@ -import { Rfc3339DateTime } from 'enso-common/src/utilities/data/dateTime' import { describe, expect, it, test } from 'vitest' +import { Rfc3339DateTime } from '../../utilities/data/dateTime.js' import { AssetType, compareAssets, doesTitleContainInvalidCharacters, type AnyAsset, -} from '../Backend' +} from '../Backend.js' describe('Backend', () => { it('sorts assets by modified date descending', () => { diff --git a/app/common/src/text.ts b/app/common/src/text.ts index b3adc6e7bd3f..078d720e61db 100644 --- a/app/common/src/text.ts +++ b/app/common/src/text.ts @@ -3,12 +3,10 @@ import ENGLISH from './text/english.json' with { type: 'json' } import { unsafeKeys } from './utilities/data/object.js' /** Possible languages in which to display text. */ -export enum Language { - english = 'english', -} +export type Language = 'english' export const LANGUAGE_TO_LOCALE: Record = { - [Language.english]: 'en-US', + english: 'en-US', } /** An object containing the corresponding localized text for each text ID. */ @@ -200,8 +198,9 @@ export interface Replacements Record, []> {} export const TEXTS: Readonly> = { - [Language.english]: ENGLISH, + english: ENGLISH, } + /** * A function that gets localized text for a given key, with optional replacements. * @param key - The key of the text to get. @@ -218,11 +217,10 @@ export type GetText = ( /** Resolves the language texts based on the user's preferred language. */ export function resolveUserLanguage() { const locale = navigator.language - const language = + return ( unsafeKeys(LANGUAGE_TO_LOCALE).find((language) => locale === LANGUAGE_TO_LOCALE[language]) ?? - Language.english - - return language + 'english' + ) } /** diff --git a/app/gui/src/project-view/util/__tests__/nameValidation.test.ts b/app/common/src/utilities/__test__/nameValidation.test.ts similarity index 100% rename from app/gui/src/project-view/util/__tests__/nameValidation.test.ts rename to app/common/src/utilities/__test__/nameValidation.test.ts diff --git a/app/gui/src/dashboard/utilities/async.ts b/app/common/src/utilities/async.ts similarity index 100% rename from app/gui/src/dashboard/utilities/async.ts rename to app/common/src/utilities/async.ts diff --git a/app/ydoc-shared/src/util/data/opt.ts b/app/common/src/utilities/data/opt.ts similarity index 100% rename from app/ydoc-shared/src/util/data/opt.ts rename to app/common/src/utilities/data/opt.ts diff --git a/app/ydoc-shared/src/util/data/result.ts b/app/common/src/utilities/data/result.ts similarity index 99% rename from app/ydoc-shared/src/util/data/result.ts rename to app/common/src/utilities/data/result.ts index 93264c1ddb63..e600fe0fb571 100644 --- a/app/ydoc-shared/src/util/data/result.ts +++ b/app/common/src/utilities/data/result.ts @@ -2,8 +2,7 @@ * @file A generic type that can either hold a value representing a successful result, * or an error. */ - -import { isSome, type Opt } from './opt' +import { isSome, type Opt } from './opt.js' /** * A type representing result of a function where errors are expected and recoverable. diff --git a/app/common/src/utilities/download.ts b/app/common/src/utilities/download.ts new file mode 100644 index 000000000000..a32c8ada34f8 --- /dev/null +++ b/app/common/src/utilities/download.ts @@ -0,0 +1,16 @@ +import type { Path } from './file.js' + +export interface DownloadUrlOptions { + readonly url: string + readonly path?: Path | null | undefined + readonly name?: string | null | undefined + readonly shouldUnpackProject?: boolean + readonly showFileDialog?: boolean +} + +/** Options for `download` function. */ +export interface DownloadOptions { + readonly url: string + readonly name?: string | null | undefined + readonly electronOptions?: Omit +} diff --git a/app/common/src/utilities/errors.ts b/app/common/src/utilities/errors.ts index 53408788d8c0..8475322445ef 100644 --- a/app/common/src/utilities/errors.ts +++ b/app/common/src/utilities/errors.ts @@ -73,3 +73,159 @@ export function isNetworkError(error: unknown): error is TypeError { return false } } + +/** Evaluates the given type only if it the exact same type as `Expected`. */ +type MustBe = + (() => U extends T ? 1 : 2) extends () => U extends Expected ? 1 : 2 ? T : never + +/** + * Used to enforce a parameter must be `any`. This is useful to verify that the value comes + * from an API that returns `any`. + */ +type MustBeAny = + never extends T ? + 0 extends T & 1 ? + T + : never + : never + +/** + * Enforces that a parameter must not have a known type. This means the only types allowed are + * `{}`, `object`, `unknown` and `any`. + */ +export type MustNotBeKnown = + | MustBe> + | MustBe + | MustBe + | MustBeAny + +/** + * Extracts the `message` property of a value if it is a string. Intended to be used on + * {@link Error}s. + */ +export function tryGetMessage( + error: MustNotBeKnown, + defaultMessage: DefaultMessage = null as DefaultMessage, +): DefaultMessage | string { + const unknownError: unknown = error + return ( + unknownError != null && + typeof unknownError === 'object' && + 'message' in unknownError && + typeof unknownError.message === 'string' + ) ? + unknownError.message + : defaultMessage +} + +/** Extracts the `error` property of a value if it is a string. */ +export function tryGetError(error: MustNotBeKnown): string | null { + const unknownError: unknown = error + return ( + unknownError != null && + typeof unknownError === 'object' && + 'error' in unknownError && + typeof unknownError.error === 'string' + ) ? + unknownError.error + : null +} + +/** Extracts the `stack` property of a value if it is a string. Intended to be used on {@link Error}s. */ +export function tryGetStack( + error: MustNotBeKnown, + defaultMessage: DefaultMessage = null as DefaultMessage, +): DefaultMessage | string { + const unknownError: unknown = error + return ( + unknownError != null && + typeof unknownError === 'object' && + 'stack' in unknownError && + typeof unknownError.stack === 'string' + ) ? + unknownError.stack + : defaultMessage +} + +/** + * Like {@link tryGetMessage} but return the string representation of the value if it is not an + * {@link Error}. + */ +export function getMessageOrToString(error: MustNotBeKnown) { + return tryGetMessage(error) ?? String(error) +} + +/** + * Extracts the display message from an error. + * This is the message that should be displayed to the user. + */ +export function extractDisplayMessage(error: MustNotBeKnown) { + if (error instanceof ErrorWithDisplayMessage) { + return error.displayMessage + } else { + return null + } +} + +/** + * An error used to indicate when an unreachable case is hit in a `switch` or `if` statement. + * + * TypeScript is sometimes unable to determine if we're exhaustively matching in a `switch` or `if` + * statement, so we introduce this error in the `default` case (or equivalent) to ensure that we at + * least find out at runtime if we've missed a case, or forgotten to update the code when we add a + * new case. + */ +export class UnreachableCaseError extends Error { + /** + * Creates an `UnreachableCaseError`. + * The parameter should be `never` since it is unreachable assuming all logic is sound. + */ + constructor(value: never) { + super(`Unreachable case: ${JSON.stringify(value)}`) + } +} + +/** + * A function that throws an {@link UnreachableCaseError} so that it can be used + * in an expression. + * @throws {UnreachableCaseError} Always. + */ +export function unreachable(value: never): never { + throw new UnreachableCaseError(value) +} + +/** + * Assert that a value is truthy. + * @throws {Error} when the value is not truthy. + */ +export function assert(makeValue: () => T | '' | 0 | 0n | false | null | undefined): T { + const result = makeValue() + if (!result) { + throw new Error( + 'Assertion failed: `' + + makeValue.toString().replace(/^\s*[(].*?[)]\s*=>\s*/, '') + + '` should not be `null`.', + ) + } else { + return result + } +} + +/** Checks if the given error is a JavaScript execution error. */ +export function isJSError(error: unknown): boolean { + if (error instanceof TypeError) { + return true + } else if (error instanceof ReferenceError) { + return true + } else if (error instanceof SyntaxError) { + return true + } else if (error instanceof RangeError) { + return true + } else if (error instanceof URIError) { + return true + } else if (error instanceof EvalError) { + return true + } else { + return false + } +} diff --git a/app/gui/src/project-view/util/nameValidation.ts b/app/common/src/utilities/nameValidation.ts similarity index 54% rename from app/gui/src/project-view/util/nameValidation.ts rename to app/common/src/utilities/nameValidation.ts index 2b90eb18820e..dc4d25f37a27 100644 --- a/app/gui/src/project-view/util/nameValidation.ts +++ b/app/common/src/utilities/nameValidation.ts @@ -4,12 +4,8 @@ * This module copies implementation from NameValidation.scala module in the backend. */ -import { isIdentifier, type Identifier } from '@/util/qualifiedName' - -/** - * Transforms the given string into a valid package name. - */ -export function normalizeName(name: string): Identifier { +/** Transform the given string into a valid package name. */ +export function normalizeName(name: string): string { const starting = ( name.length === 0 || @@ -21,19 +17,11 @@ export function normalizeName(name: string): Identifier { 'Project' : !name[0]?.match(/[a-zA-Z]/) ? 'Project_' + name : name - const startingWithUppercase = starting.charAt(0).toUpperCase() + starting.slice(1) - const onlyAlphanumeric = startingWithUppercase.split('').filter(isAllowedNameCharacter).join('') - if (!isIdentifier(onlyAlphanumeric)) { - throw new Error(`Project name normalization failed: ${name}`) - } - - return onlyAlphanumeric + return startingWithUppercase.split('').filter(isAllowedNameCharacter).join('') } -/** - * Checks if a character is allowed in a project name. - */ +/** Check whether a character is allowed in a project name. */ function isAllowedNameCharacter(char: string): boolean { return /[a-zA-Z0-9_]/.test(char) } diff --git a/app/common/src/utilities/permissions.ts b/app/common/src/utilities/permissions.ts index 9b3d6ce9267b..b04a366bd14c 100644 --- a/app/common/src/utilities/permissions.ts +++ b/app/common/src/utilities/permissions.ts @@ -1,10 +1,13 @@ /** @file Utilities for working with permissions. */ +import { + compareAssetPermissions, + type AnyAsset, + type AssetPermission, + type User, + type UserGroup, +} from '../services/Backend.js' import type * as text from '../text.js' -// ======================== -// === PermissionAction === -// ======================== - /** Backend representation of user permission types. */ export enum PermissionAction { own = 'Own', @@ -31,10 +34,6 @@ export const PERMISSION_ACTION_CAN_EXECUTE: Readonly { readonly type: T @@ -212,3 +207,91 @@ export const DEFAULT_PERMISSIONS: Permissions = Object.freeze({ docs: false, execute: false, }) + +/** CSS classes for each permission. */ +export const PERMISSION_CLASS_NAME: Readonly> = { + [Permission.owner]: 'text-tag-text bg-permission-owner', + [Permission.admin]: 'text-tag-text bg-permission-admin', + [Permission.edit]: 'text-tag-text bg-permission-edit', + [Permission.read]: 'text-tag-text bg-permission-read', + [Permission.view]: 'text-tag-text-2 bg-permission-view', + [Permission.delete]: 'text-tag-text bg-delete', +} + +/** CSS classes for the docs permission. */ +export const DOCS_CLASS_NAME = 'text-tag-text bg-permission-docs' +/** CSS classes for the execute permission. */ +export const EXEC_CLASS_NAME = 'text-tag-text bg-permission-exec' + +/** Try to find a permission belonging to the user. */ +export function tryFindSelfPermission( + self: User, + otherPermissions: readonly AssetPermission[] | null | undefined, +) { + let selfPermission: AssetPermission | null = null + for (const permission of otherPermissions ?? []) { + // `a >= b` means that `a` does not have more permissions than `b`. + if (selfPermission && compareAssetPermissions(selfPermission, permission) >= 0) { + continue + } + if ('user' in permission && permission.user.userId !== self.userId) { + continue + } + if ( + 'userGroup' in permission && + (self.userGroups ?? []).every((groupId) => groupId !== permission.userGroup.id) + ) { + continue + } + selfPermission = permission + } + return selfPermission +} + +/** Whether the given permission means the user can edit the list of assets of the directory. */ +export function canPermissionModifyDirectoryContents(permission: PermissionAction) { + return ( + permission === PermissionAction.own || + permission === PermissionAction.admin || + permission === PermissionAction.edit + ) +} + +/** Replace the first owner permission with the permission of a new user or team. */ +export function tryGetOwnerPermission(asset: AnyAsset) { + return asset.permissions?.find((permission) => permission.permission === PermissionAction.own) +} + +const USER_PATH_REGEX = /^enso:[/][/][/]Users[/]([^/]+)/ +const TEAM_PATH_REGEX = /^enso:[/][/][/]Teams[/]([^/]+)/ + +/** Whether a path is inside a user's home directory. */ +export function isUserPath(path: string) { + return USER_PATH_REGEX.test(path) +} + +/** Whether a path is inside a team's home directory. */ +export function isTeamPath(path: string) { + return TEAM_PATH_REGEX.test(path) +} + +/** Find the new owner of an asset based on the path of its new parent directory. */ +export function newOwnerFromPath( + path: string, + users: readonly User[], + userGroups: readonly UserGroup[], +) { + const [, userName] = path.match(USER_PATH_REGEX) ?? [] + if (userName != null) { + const userNameLowercase = userName.toLowerCase() + return users.find((user) => user.name.toLowerCase() === userNameLowercase) + } else { + const [, teamName] = path.match(TEAM_PATH_REGEX) ?? [] + if (teamName != null) { + const teamNameLowercase = teamName.toLowerCase() + return userGroups.find((userGroup) => userGroup.name === teamNameLowercase) + } else { + return + } + } +} diff --git a/app/common/src/utilities/uniqueString.ts b/app/common/src/utilities/uniqueString.ts index 3d4a9bf77e01..f5c8af869df6 100644 --- a/app/common/src/utilities/uniqueString.ts +++ b/app/common/src/utilities/uniqueString.ts @@ -1,9 +1,5 @@ /** @file A function that generates a unique string. */ -// ==================== -// === uniqueString === -// ==================== - // This is initialized to an unusual number, to minimize the chances of collision. let counter = Number(new Date()) >>> 2 diff --git a/app/gui/integration-test/base.ts b/app/gui/integration-test/base.ts index de50db408620..e4a8d22ea251 100644 --- a/app/gui/integration-test/base.ts +++ b/app/gui/integration-test/base.ts @@ -1,5 +1,5 @@ -import { UUID } from '#/services/Backend' import type { FeatureFlags } from '$/providers/featureFlags' +import { UUID } from 'enso-common/src/services/Backend' import { toRfc3339 } from 'enso-common/src/utilities/data/dateTime' import { test as base, expect as baseExpect, type Locator } from 'playwright/test' import type DrivePageActions from './actions/DrivePageActions' diff --git a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts index 3e3355daf43f..d96252b8e6ed 100644 --- a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts +++ b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts @@ -1,5 +1,5 @@ /** @file Test the drive view. */ -import { EmailAddress, ProjectState } from '#/services/Backend' +import { EmailAddress, ProjectState } from 'enso-common/src/services/Backend' import { expect, test, type Page } from 'integration-test/base' import { getText, TEXT } from '../actions' diff --git a/app/gui/integration-test/dashboard/rightPanel.spec.ts b/app/gui/integration-test/dashboard/rightPanel.spec.ts index 7d91a4763c70..e427d19efaa3 100644 --- a/app/gui/integration-test/dashboard/rightPanel.spec.ts +++ b/app/gui/integration-test/dashboard/rightPanel.spec.ts @@ -1,9 +1,9 @@ /** @file Tests for the asset panel. */ import { expect, test, type Locator, type Page } from 'integration-test/base' -import { EmailAddress, UserId } from '#/services/Backend' +import { EmailAddress, UserId } from 'enso-common/src/services/Backend' -import { PermissionAction } from '#/utilities/permissions' +import { PermissionAction } from 'enso-common/src/utilities/permissions' import { TEXT } from '../actions' diff --git a/app/gui/integration-test/mock/cloudApi.ts b/app/gui/integration-test/mock/cloudApi.ts index 2bb2ea58af48..1d133ba77f32 100644 --- a/app/gui/integration-test/mock/cloudApi.ts +++ b/app/gui/integration-test/mock/cloudApi.ts @@ -1,14 +1,14 @@ /** @file The mock API. */ -import * as backend from '#/services/Backend' import { organizationIdToDirectoryId, userGroupIdToDirectoryId, userIdToDirectoryId, } from '#/services/RemoteBackend/ids' -import * as object from '#/utilities/object' -import * as permissions from '#/utilities/permissions' +import * as backend from 'enso-common/src/services/Backend' import * as paths from 'enso-common/src/services/Backend/remoteBackendPaths' import * as dateTime from 'enso-common/src/utilities/data/dateTime' +import * as object from 'enso-common/src/utilities/data/object' +import * as permissions from 'enso-common/src/utilities/permissions' import * as uniqueString from 'enso-common/src/utilities/uniqueString' import { test, type Page, type Request, type Route } from 'integration-test/base' import { readFileSync } from 'node:fs' diff --git a/app/gui/integration-test/mock/localApi.ts b/app/gui/integration-test/mock/localApi.ts index 32e3a93ba401..970f7f18fd4b 100644 --- a/app/gui/integration-test/mock/localApi.ts +++ b/app/gui/integration-test/mock/localApi.ts @@ -1,4 +1,3 @@ -import * as backend from '#/services/Backend' import { Path, ProjectName, @@ -17,10 +16,11 @@ import { type ProjectMetadata, type ProjectState, } from '#/services/ProjectManager/types' -import { unsafeMutable } from '#/utilities/object' import { getDirectoryAndName } from '#/utilities/path' import { capitalizeFirst } from '#/utilities/string' +import * as backend from 'enso-common/src/services/Backend' import { toRfc3339 } from 'enso-common/src/utilities/data/dateTime' +import { unsafeMutable } from 'enso-common/src/utilities/data/object' import { uniqueString } from 'enso-common/src/utilities/uniqueString' import { test } from 'integration-test/base' import { uuidv4 } from 'lib0/random.js' diff --git a/app/gui/package.json b/app/gui/package.json index b7fe906ef793..9c8061e60fe6 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -116,7 +116,7 @@ "ts-results": "^3.3.0", "validator": "^13.12.0", "veaury": "=2.4.4", - "vue": "^3.5.13", + "vue": "catalog:", "vue-component-type-helpers": "^2.2.0", "vue-router": "^4.5.0", "y-protocols": "^1.0.6", diff --git a/app/gui/src/components/AppContainer.vue b/app/gui/src/components/AppContainer.vue index 12da118514a7..dca4703551ce 100644 --- a/app/gui/src/components/AppContainer.vue +++ b/app/gui/src/components/AppContainer.vue @@ -1,6 +1,5 @@ diff --git a/app/gui/src/App.vue b/app/gui/src/App.vue index 4575251d972d..a34592b39577 100644 --- a/app/gui/src/App.vue +++ b/app/gui/src/App.vue @@ -5,7 +5,6 @@ import { useAuth } from '$/providers/auth' import { ContextsForReactProvider } from '$/providers/react/globalProvider' import ReactRoot from '$/ReactRoot' import { appOpenCloseCallback } from '$/utils/analytics' -import { Platform, platform } from '$/utils/detect' import '@/assets/base.css' import { appBindings } from '@/bindings' import TooltipDisplayer from '@/components/TooltipDisplayer.vue' @@ -20,6 +19,7 @@ import { registerAutoBlurHandler, registerGlobalBlurHandler } from '@/util/autoB import { reactComponent } from '@/util/react' import { useQueryClient } from '@tanstack/vue-query' import * as objects from 'enso-common/src/utilities/data/object' +import { Platform, platform } from 'enso-common/src/utilities/detect' import { computed } from 'vue' const LoadingScreen = reactComponent(LoadingScreenReact) diff --git a/app/gui/src/authentication/cognito.ts b/app/gui/src/authentication/cognito.ts index 30710a41cf31..f620db47080a 100644 --- a/app/gui/src/authentication/cognito.ts +++ b/app/gui/src/authentication/cognito.ts @@ -35,7 +35,7 @@ import * as amplify from '@aws-amplify/auth' import * as cognito from 'amazon-cognito-identity-js' import * as results from 'ts-results' -import * as detect from '$/utils/detect' +import * as detect from 'enso-common/src/utilities/detect' import type * as loggerProvider from '#/providers/LoggerProvider' diff --git a/app/gui/src/authentication/service.ts b/app/gui/src/authentication/service.ts index 5ceab7da22fd..ec2bd666a951 100644 --- a/app/gui/src/authentication/service.ts +++ b/app/gui/src/authentication/service.ts @@ -9,11 +9,11 @@ import * as cognitoModule from '$/authentication/cognito' import * as listen from '$/authentication/listen' import { useFeatureFlag } from '$/providers/featureFlags' import { useText } from '$/providers/text' -import * as detect from '$/utils/detect' import { parseEnsoDeeplink } from '@/util/url' import * as amplify from '@aws-amplify/auth' import * as common from 'enso-common' import type * as saveAccessTokenModule from 'enso-common/src/accessToken' +import * as detect from 'enso-common/src/utilities/detect' import * as toastify from 'react-toastify' import { useRouter } from 'vue-router' diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index 14c7d08b9ffe..dad46be1eab4 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -15,7 +15,7 @@ import * as reactQuery from '@tanstack/react-query' import * as toastify from 'react-toastify' import * as z from 'zod' -import * as detect from '$/utils/detect' +import * as detect from 'enso-common/src/utilities/detect' import InputBindingsProvider from '#/providers/InputBindingsProvider' import ModalProvider from '#/providers/ModalProvider' diff --git a/app/gui/src/dashboard/components/Button/ButtonGroup.tsx b/app/gui/src/dashboard/components/Button/ButtonGroup.tsx index 74e68988d7f0..00f04a1239cf 100644 --- a/app/gui/src/dashboard/components/Button/ButtonGroup.tsx +++ b/app/gui/src/dashboard/components/Button/ButtonGroup.tsx @@ -3,7 +3,7 @@ import { forwardRef, Fragment, type PropsWithChildren, type ReactElement } from import flattenChildren from 'react-keyed-flatten-children' import { tv, type VariantProps } from '#/utilities/tailwindVariants' -import { IS_DEV_MODE } from '$/utils/detect' +import { IS_DEV_MODE } from 'enso-common/src/utilities/detect' import invariant from 'tiny-invariant' import type { TestIdProps } from '../types' import { diff --git a/app/gui/src/dashboard/components/Button/CloseButton.tsx b/app/gui/src/dashboard/components/Button/CloseButton.tsx index 82042319c25a..c355ab68d0c5 100644 --- a/app/gui/src/dashboard/components/Button/CloseButton.tsx +++ b/app/gui/src/dashboard/components/Button/CloseButton.tsx @@ -2,7 +2,7 @@ import DismissIcon from '#/assets/dismiss.svg' import { twMerge } from '#/utilities/tailwindMerge' import { useText } from '$/providers/react' -import { isOnMacOS } from '$/utils/detect' +import { isOnMacOS } from 'enso-common/src/utilities/detect' import { memo } from 'react' import { Button } from './Button' import type { ButtonProps } from './types' diff --git a/app/gui/src/dashboard/components/ContextMenu.tsx b/app/gui/src/dashboard/components/ContextMenu.tsx index 52bdf41362ed..3d7b3401607e 100644 --- a/app/gui/src/dashboard/components/ContextMenu.tsx +++ b/app/gui/src/dashboard/components/ContextMenu.tsx @@ -7,7 +7,7 @@ import { usePortalContext } from '#/components/Portal' import { useEventListener } from '#/hooks/eventListenerHooks' import { useInputBindings } from '#/providers/InputBindingsProvider' import { twMerge } from '#/utilities/tailwindMerge' -import { isOnMacOS } from '$/utils/detect' +import { isOnMacOS } from 'enso-common/src/utilities/detect' import { forwardRef, useEffect, diff --git a/app/gui/src/dashboard/components/MenuEntry.tsx b/app/gui/src/dashboard/components/MenuEntry.tsx index b4b97ecce7ee..a1257ce5e927 100644 --- a/app/gui/src/dashboard/components/MenuEntry.tsx +++ b/app/gui/src/dashboard/components/MenuEntry.tsx @@ -15,8 +15,8 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import { setModal, unsetModal } from '#/providers/ModalProvider' import * as tailwindVariants from '#/utilities/tailwindVariants' import { useText } from '$/providers/react' -import * as detect from '$/utils/detect' import type * as text from 'enso-common/src/text' +import * as detect from 'enso-common/src/utilities/detect' import * as React from 'react' const MENU_ENTRY_VARIANTS = tailwindVariants.tv({ diff --git a/app/gui/src/dashboard/configurations/inputBindings.ts b/app/gui/src/dashboard/configurations/inputBindings.ts index ffac1c660439..e68dc081a91c 100644 --- a/app/gui/src/dashboard/configurations/inputBindings.ts +++ b/app/gui/src/dashboard/configurations/inputBindings.ts @@ -1,7 +1,7 @@ /** @file Shortcuts for the dashboard application. */ import { SETTINGS_TAB_DATA } from '#/layouts/Settings/data' import * as inputBindings from '#/utilities/inputBindings' -import * as detect from '$/utils/detect' +import * as detect from 'enso-common/src/utilities/detect' /** The type of the keybind and mousebind namespace for the dashboard. */ export type DashboardBindingNamespace = ReturnType diff --git a/app/gui/src/dashboard/hooks/eventListenerHooks.ts b/app/gui/src/dashboard/hooks/eventListenerHooks.ts index 732368a97933..2f68a7cf3613 100644 --- a/app/gui/src/dashboard/hooks/eventListenerHooks.ts +++ b/app/gui/src/dashboard/hooks/eventListenerHooks.ts @@ -3,7 +3,7 @@ * * Set of hooks to work with native event listeners. */ -import { IS_DEV_MODE } from '$/utils/detect' +import { IS_DEV_MODE } from 'enso-common/src/utilities/detect' import type { RefObject } from 'react' import { useEffect, useRef } from 'react' diff --git a/app/gui/src/dashboard/layouts/AssetPanel/components/AssetVersions.tsx b/app/gui/src/dashboard/layouts/AssetPanel/components/AssetVersions.tsx index 149baa0876ad..83227d6bc0ea 100644 --- a/app/gui/src/dashboard/layouts/AssetPanel/components/AssetVersions.tsx +++ b/app/gui/src/dashboard/layouts/AssetPanel/components/AssetVersions.tsx @@ -10,6 +10,7 @@ import { useRightPanelContextCategory, useRightPanelFocusedAsset, } from '$/providers/react/container' +import { includes } from '$/utils/data/array' import { useMutation, useSuspenseQuery } from '@tanstack/react-query' import type { AnyAsset, @@ -19,7 +20,6 @@ import type { } from 'enso-common/src/services/Backend' import { AssetType, BackendType, S3ObjectVersionId } from 'enso-common/src/services/Backend' import type { RemoteBackend } from 'enso-common/src/services/RemoteBackend' -import { includes } from 'enso-common/src/utilities/data/array' import { uniqueString } from 'enso-common/src/utilities/uniqueString' import { AssetVersion, type DuplicateOptions, type Version } from './AssetVersion' import { assetVersionsQueryOptions } from './queries' @@ -152,9 +152,7 @@ function AssetVersionsInternal(props: AssetVersionsInternalProps) { ) } -/** - * Check if the asset is allowed to have versions. - */ +/** Check if the asset is allowed to have versions. */ function isAllowedAssetType(asset: AnyAsset): asset is DatalinkAsset | FileAsset | ProjectAsset { return includes([AssetType.project, AssetType.datalink, AssetType.file], asset.type) } diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 243b87a499b8..079d9f9d99bb 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -63,7 +63,6 @@ import { setModal, unsetModal } from '#/providers/ModalProvider' import AssetQuery from '#/utilities/AssetQuery' import { ASSET_ROWS, setDragImageToBlank, type AssetRowsDragPayload } from '#/utilities/drag' import { isElementTextInput, isTextInputEvent } from '#/utilities/event' -import { fileExtension } from '#/utilities/fileInfo' import { DEFAULT_HANDLER } from '#/utilities/inputBindings' import LocalStorage from '#/utilities/LocalStorage' import { withPresence } from '#/utilities/set' @@ -94,6 +93,7 @@ import { userGroupIdToDirectoryId, userIdToDirectoryId, } from 'enso-common/src/services/RemoteBackend/ids' +import { fileExtension } from 'enso-common/src/utilities/file' import { Children, cloneElement, diff --git a/app/gui/src/dashboard/layouts/VersionChecker.tsx b/app/gui/src/dashboard/layouts/VersionChecker.tsx index 2963c58f79e1..a4b04b04dd1c 100644 --- a/app/gui/src/dashboard/layouts/VersionChecker.tsx +++ b/app/gui/src/dashboard/layouts/VersionChecker.tsx @@ -11,8 +11,8 @@ import { useToastAndLog } from '#/hooks/toastAndLogHooks' import { download } from '#/utilities/download' import { getDownloadUrl, getLatestRelease } from '#/utilities/github' import { useBackends, useText } from '$/providers/react' -import { IS_DEV_MODE } from '$/utils/detect' import { useQuery, useQueryClient } from '@tanstack/react-query' +import { IS_DEV_MODE } from 'enso-common/src/utilities/detect' import { startTransition, useState } from 'react' // eslint-disable-next-line @typescript-eslint/no-magic-numbers diff --git a/app/gui/src/dashboard/modals/CaptureKeyboardShortcutModal.tsx b/app/gui/src/dashboard/modals/CaptureKeyboardShortcutModal.tsx index 148754b154c6..192998ba92fc 100644 --- a/app/gui/src/dashboard/modals/CaptureKeyboardShortcutModal.tsx +++ b/app/gui/src/dashboard/modals/CaptureKeyboardShortcutModal.tsx @@ -7,12 +7,12 @@ import KeyboardShortcut from '#/pages/dashboard/components/KeyboardShortcut' import { unsetModal } from '#/providers/ModalProvider' import { twMerge } from '#/utilities/tailwindMerge' import { useText } from '$/providers/react' -import { isOnMacOS } from '$/utils/detect' import { modifierFlagsForEvent, modifiersForModifierFlags, normalizedKeyboardSegmentLookup, } from '@/util/shortcuts' +import { isOnMacOS } from 'enso-common/src/utilities/detect' import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' const DISALLOWED_KEYS = new Set(['Control', 'Alt', 'Shift', 'Meta']) diff --git a/app/gui/src/dashboard/pages/authentication/Login.tsx b/app/gui/src/dashboard/pages/authentication/Login.tsx index 9c457c84771a..5da27eec87e1 100644 --- a/app/gui/src/dashboard/pages/authentication/Login.tsx +++ b/app/gui/src/dashboard/pages/authentication/Login.tsx @@ -17,7 +17,7 @@ import { DASHBOARD_PATH, FORGOT_PASSWORD_PATH, REGISTRATION_PATH } from '$/appUt import type { CognitoUser } from '$/authentication/cognito' import { useRouter, useSession, useText } from '$/providers/react' import { useQueryParam } from '$/providers/react/queryParams' -import { isOnElectron } from '$/utils/detect' +import { isOnElectron } from 'enso-common/src/utilities/detect' import { useState } from 'react' /** A form for users to log in. */ diff --git a/app/gui/src/dashboard/pages/dashboard/components/KeyboardShortcut.tsx b/app/gui/src/dashboard/pages/dashboard/components/KeyboardShortcut.tsx index e60acb725093..2414783d8e85 100644 --- a/app/gui/src/dashboard/pages/dashboard/components/KeyboardShortcut.tsx +++ b/app/gui/src/dashboard/pages/dashboard/components/KeyboardShortcut.tsx @@ -12,9 +12,9 @@ import { toModifierKey } from '#/utilities/inputBindings' import { twMerge } from '#/utilities/tailwindMerge' import { useText } from '$/providers/react' import type { GetText } from '$/providers/text' -import * as detect from '$/utils/detect' import { type ModifierKey, parseKeybindString } from '@/util/shortcuts' import type * as text from 'enso-common/src/text' +import * as detect from 'enso-common/src/utilities/detect' import * as React from 'react' /** The size (both width and height) of key icons. */ diff --git a/app/gui/src/dashboard/utilities/__tests__/fileInfo.test.ts b/app/gui/src/dashboard/utilities/__tests__/fileInfo.test.ts index 352156a8a43a..735679616202 100644 --- a/app/gui/src/dashboard/utilities/__tests__/fileInfo.test.ts +++ b/app/gui/src/dashboard/utilities/__tests__/fileInfo.test.ts @@ -1,10 +1,9 @@ /** @file Tests for `fileInfo.ts`. */ +import { fileExtension } from 'enso-common/src/utilities/file' import * as v from 'vitest' -import * as fileInfo from '$/utils/file' - v.test('fileExtension', () => { - v.expect(fileInfo.fileExtension('image.png')).toBe('png') - v.expect(fileInfo.fileExtension('.gif')).toBe('gif') - v.expect(fileInfo.fileExtension('fileInfo.spec.js')).toBe('js') + v.expect(fileExtension('image.png')).toBe('png') + v.expect(fileExtension('.gif')).toBe('gif') + v.expect(fileExtension('fileInfo.spec.js')).toBe('js') }) diff --git a/app/gui/src/dashboard/utilities/__tests__/shortcuts.test.ts b/app/gui/src/dashboard/utilities/__tests__/shortcuts.test.ts index 721f59509423..c9408574bfd9 100644 --- a/app/gui/src/dashboard/utilities/__tests__/shortcuts.test.ts +++ b/app/gui/src/dashboard/utilities/__tests__/shortcuts.test.ts @@ -1,7 +1,7 @@ /** @file Tests for `dateTime.ts`. */ import * as v from 'vitest' -import * as detect from '$/utils/detect' +import * as detect from 'enso-common/src/utilities/detect' import { decomposeKeybindString } from '@/util/shortcuts' diff --git a/app/gui/src/dashboard/utilities/event.ts b/app/gui/src/dashboard/utilities/event.ts index 44b6b4b9a408..b4beb0a23a9c 100644 --- a/app/gui/src/dashboard/utilities/event.ts +++ b/app/gui/src/dashboard/utilities/event.ts @@ -1,7 +1,7 @@ /** @file Utility functions related to event handling. */ import type * as React from 'react' -import * as detect from '$/utils/detect' +import * as detect from 'enso-common/src/utilities/detect' /** Returns `true` if and only if the event is a single click event. */ export function isSingleClick(event: React.MouseEvent) { diff --git a/app/gui/src/dashboard/utilities/fileIcon.ts b/app/gui/src/dashboard/utilities/fileIcon.ts index 7bcbe26753e8..003908631032 100644 --- a/app/gui/src/dashboard/utilities/fileIcon.ts +++ b/app/gui/src/dashboard/utilities/fileIcon.ts @@ -1,6 +1,6 @@ /** @file Return the appropriate file icon given the file name. */ import type { SvgUseIcon } from '#/components/types' -import { basenameAndExtension } from '$/utils/file' +import { basenameAndExtension } from 'enso-common/src/utilities/file' /** Return the appropriate icon given the file name. */ export function fileIcon(fileName: string): SvgUseIcon { diff --git a/app/gui/src/dashboard/utilities/fileInfo.ts b/app/gui/src/dashboard/utilities/fileInfo.ts deleted file mode 100644 index 45b204d45675..000000000000 --- a/app/gui/src/dashboard/utilities/fileInfo.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** @file Utility functions for extracting and manipulating file information. */ -export * from '$/utils/file' diff --git a/app/gui/src/dashboard/utilities/github.ts b/app/gui/src/dashboard/utilities/github.ts index d1d17e0b475f..5314064f41e9 100644 --- a/app/gui/src/dashboard/utilities/github.ts +++ b/app/gui/src/dashboard/utilities/github.ts @@ -1,6 +1,6 @@ /** @file Utilities getting various metadata about the app. */ -import * as detect from '$/utils/detect' import * as common from 'enso-common' +import * as detect from 'enso-common/src/utilities/detect' /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/app/gui/src/dashboard/utilities/preventNavigation.tsx b/app/gui/src/dashboard/utilities/preventNavigation.tsx index 73617d4b656b..93bb3b0749af 100644 --- a/app/gui/src/dashboard/utilities/preventNavigation.tsx +++ b/app/gui/src/dashboard/utilities/preventNavigation.tsx @@ -5,7 +5,7 @@ import { Text } from '#/components/Text' import { useSyncRef } from '#/hooks/syncRefHooks' import { setModal, unsetModal } from '#/providers/ModalProvider' import { useText } from '$/providers/react' -import { isOnElectron } from '$/utils/detect' +import { isOnElectron } from 'enso-common/src/utilities/detect' import { useEffect } from 'react' let shouldClose = false diff --git a/app/gui/src/entrypoint.ts b/app/gui/src/entrypoint.ts index ca638c5fcd0e..92d34f7c17f1 100644 --- a/app/gui/src/entrypoint.ts +++ b/app/gui/src/entrypoint.ts @@ -6,12 +6,12 @@ import App from '$/App.vue' import { setupLogger } from '$/log' import { widgetDevtools } from '$/providers/openedProjects/widgetRegistry/devtools' import router from '$/router' -import * as detect from '$/utils/detect' import { createQueryClient } from '$/utils/queryClient' import * as sentry from '@sentry/vue' import type { Vue } from '@sentry/vue/types/types' import { VueQueryPlugin } from '@tanstack/vue-query' import { HttpClient } from 'enso-common/src/services/HttpClient' +import * as detect from 'enso-common/src/utilities/detect' import * as idbKeyval from 'idb-keyval' import { createApp, markRaw } from 'vue' diff --git a/app/gui/src/project-view/util/shortcuts.ts b/app/gui/src/project-view/util/shortcuts.ts index 2c75514b31bd..7041a2cf6871 100644 --- a/app/gui/src/project-view/util/shortcuts.ts +++ b/app/gui/src/project-view/util/shortcuts.ts @@ -1,6 +1,6 @@ -import { isOnMacOS } from '$/utils/detect' import { assert } from '@/util/assert' import { unsafeKeys } from 'enso-common/src/utilities/data/object' +import { isOnMacOS } from 'enso-common/src/utilities/detect' /** All possible modifier keys. */ export type ModifierKey = keyof typeof RAW_MODIFIER_FLAG diff --git a/app/gui/src/providers/openedProjects/project/computedValueRegistry.ts b/app/gui/src/providers/openedProjects/project/computedValueRegistry.ts index 202121bddbeb..74953f4b89e2 100644 --- a/app/gui/src/providers/openedProjects/project/computedValueRegistry.ts +++ b/app/gui/src/providers/openedProjects/project/computedValueRegistry.ts @@ -3,11 +3,11 @@ import { mockProjectNameStore, type ProjectNameStore, } from '$/providers/openedProjects/projectNames' +import { clamp } from '$/utils/data/math' import { ReactiveDb, ReactiveIndex } from '@/util/database/reactiveDb' import { arrayEquals } from '@/util/equals' import { parseMethodPointer, type MethodCall } from '@/util/methodPointer' import type { ProjectPath } from '@/util/projectPath' -import { clamp } from 'enso-common/src/utilities/data/math' import { isSome } from 'enso-common/src/utilities/data/opt' import { Ok, type Result } from 'enso-common/src/utilities/data/result' import { markRaw } from 'vue' diff --git a/app/gui/src/providers/upload.ts b/app/gui/src/providers/upload.ts index 2ac93f886e48..d40f4e382ced 100644 --- a/app/gui/src/providers/upload.ts +++ b/app/gui/src/providers/upload.ts @@ -1,8 +1,8 @@ +import { ConditionVariable } from '$/utils/ConditionVariable' import { backendMutationOptions } from '@/composables/backend' import * as vueQuery from '@tanstack/vue-query' import { createGlobalState } from '@vueuse/core' import type { Backend, HttpsUrl, UploadFileRequestParams } from 'enso-common/src/services/Backend' -import { ConditionVariable } from 'enso-common/src/utilities/ConditionVariable' import { reactive } from 'vue' import { useBackends } from './backends' import { useFeatureFlag } from './featureFlags' diff --git a/app/gui/src/utils/analytics/index.ts b/app/gui/src/utils/analytics/index.ts index bc4f2a39c8f3..6e615642b3b9 100644 --- a/app/gui/src/utils/analytics/index.ts +++ b/app/gui/src/utils/analytics/index.ts @@ -1,5 +1,5 @@ import * as gtag from '$/utils/analytics/gtag' -import * as detect from '$/utils/detect' +import * as detect from 'enso-common/src/utilities/detect' export const createUser = { /** Log successful user creation. */ From 84b1055480f462212e1c48760aef164805e805ec Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 28 Oct 2025 20:04:09 +1000 Subject: [PATCH 19/31] Fix type error --- app/gui/src/dashboard/data/serviceCredentials/ms365.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/gui/src/dashboard/data/serviceCredentials/ms365.ts b/app/gui/src/dashboard/data/serviceCredentials/ms365.ts index ad93c63e64cf..536464bdd825 100644 --- a/app/gui/src/dashboard/data/serviceCredentials/ms365.ts +++ b/app/gui/src/dashboard/data/serviceCredentials/ms365.ts @@ -1,10 +1,7 @@ -/** - * @file Definitions for the MS365 credentials integration. - */ -import invariant from 'tiny-invariant' - -import type { MS365CredentialInput, SecretId } from '#/services/Backend' +/** @file Definitions for the MS365 credentials integration. */ +import type { MS365CredentialInput, SecretId } from 'enso-common/src/services/Backend' import * as i18n from 'enso-common/src/text' +import invariant from 'tiny-invariant' import { z } from 'zod' import type { CredentialRecipe } from './types' import { getOauthRedirectUri } from './utilities' From bcf55e2d4caac1f781d4446faacc513927337f54 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 28 Oct 2025 21:25:15 +1000 Subject: [PATCH 20/31] Fix lint error --- app/gui/src/project-view/util/react.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/gui/src/project-view/util/react.tsx b/app/gui/src/project-view/util/react.tsx index bbcff69db6f9..6f6fa107f6a8 100644 --- a/app/gui/src/project-view/util/react.tsx +++ b/app/gui/src/project-view/util/react.tsx @@ -1,10 +1,10 @@ import { Dialog as DialogReact } from '#/components/Dialog' import { Result as ResultReact } from '#/components/Result' import { Suspense } from '#/components/Suspense' +import type { Opt } from 'enso-common/src/utilities/data/opt' import type { ReactNode } from 'react' // Imported here to implement the safer wrapper. // eslint-disable-next-line no-restricted-imports -import type { Opt } from 'enso-common/src/utilities/data/opt' import { applyPureReactInVue, type magicOptions } from 'veaury' import type { DefineComponent } from 'vue' From a0f01116120714515916d7a16f45a954a8bb24a4 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 29 Oct 2025 19:53:55 +1000 Subject: [PATCH 21/31] Revert changes to `electron-client` --- app/electron-client/src/authentication.ts | 125 +-- app/electron-client/src/electron.ts | 163 ---- app/electron-client/src/index.ts | 930 ++++++++++++--------- app/electron-client/src/projectService.ts | 4 +- app/electron-client/src/urlAssociations.ts | 11 +- app/project-manager-shim/src/hybrid.ts | 48 -- 6 files changed, 609 insertions(+), 672 deletions(-) delete mode 100644 app/electron-client/src/electron.ts delete mode 100644 app/project-manager-shim/src/hybrid.ts diff --git a/app/electron-client/src/authentication.ts b/app/electron-client/src/authentication.ts index 6c65b4d36f2a..47f25f5ebefa 100644 --- a/app/electron-client/src/authentication.ts +++ b/app/electron-client/src/authentication.ts @@ -40,8 +40,8 @@ * credentials. * * To redirect the user from the IDE to an external source: - * 1. Register a listener for {@link Channel.openUrlInSystemBrowser} IPC events. - * 2. Emit an {@link Channel.openUrlInSystemBrowser} event. The listener registered in step + * 1. Register a listener for {@link ipc.Channel.openUrlInSystemBrowser} IPC events. + * 2. Emit an {@link ipc.Channel.openUrlInSystemBrowser} event. The listener registered in step * 1 will use the {@link opener} library to open the event's {@link URL} * argument in the system web browser, in a cross-platform way. * @@ -58,7 +58,7 @@ * To prepare the application to handle deep links: * - Register a custom URL protocol scheme with the OS (c.f., `electron-builder-config.ts`). * - Define a listener for Electron `OPEN_URL_EVENT`s. - * - Define a listener for {@link Channel.openDeepLink} events (c.f., `preload.ts`). + * - Define a listener for {@link ipc.Channel.openDeepLink} events (c.f., `preload.ts`). * * Then when the user clicks on a deep link from an external source to the IDE: * - The OS redirects the user to the application. @@ -66,22 +66,28 @@ * - The `OPEN_URL_EVENT` listener checks if the {@link URL} is a deep link. * - If the {@link URL} is a deep link, the `OPEN_URL_EVENT` listener prevents Electron from * handling the event. - * - The `OPEN_URL_EVENT` listener then emits an {@link Channel.openDeepLink} event. - * - The {@link Channel.openDeepLink} listener registered by the dashboard receives the event. + * - The `OPEN_URL_EVENT` listener then emits an {@link ipc.Channel.openDeepLink} event. + * - The {@link ipc.Channel.openDeepLink} listener registered by the dashboard receives the event. * Then it parses the {@link URL} from the event's {@link URL} argument. Then it uses the * {@link URL} to redirect the user to the dashboard, to the page specified in the {@link URL}'s * `pathname`. */ -import type { BrowserWindow } from 'electron' -import { DEEP_LINK_SCHEME, PRODUCT_NAME } from 'enso-common' -import type { AccessToken } from 'enso-common/src/accessToken' -import { mkdir, unlinkSync, writeFile } from 'node:fs' -import { homedir } from 'node:os' -import { join as joinPath } from 'node:path' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import * as electron from 'electron' import opener from 'opener' -import type { Electron } from './electron.js' -import { Channel } from './ipc.js' -import { registerUrlCallback } from './urlAssociations.js' + +import * as common from 'enso-common' +import type * as accessToken from 'enso-common/src/accessToken' + +import * as ipc from '@/ipc' +import * as urlAssociations from '@/urlAssociations' + +// ======================================== +// === Initialize Authentication Module === +// ======================================== /** * Configure all the functionality that must be set up in the Electron app to support @@ -91,67 +97,70 @@ import { registerUrlCallback } from './urlAssociations.js' * does not use the `window` until after it is initialized, so while the lambda may return `null` in * theory, it never will in practice. */ -export function initAuthentication(electron: Electron, window: () => BrowserWindow) { +export function initAuthentication(window: () => electron.BrowserWindow) { // Listen for events to open a URL externally in a browser the user trusts. This is used for // OAuth authentication, both for trustworthiness and for convenience (the ability to use the // browser's saved passwords). - electron.ipcMain.on(Channel.openUrlInSystemBrowser, (_event, url: string) => { + electron.ipcMain.on(ipc.Channel.openUrlInSystemBrowser, (_event, url: string) => { console.log(`Opening URL '${url}' in the default browser.`) opener(url) }) // Listen for events to handle deep links. - registerUrlCallback(electron, (url) => { + urlAssociations.registerUrlCallback((url) => { console.log(`Received 'open-url' event for '${url.toString()}'.`) - if (url.protocol !== `${DEEP_LINK_SCHEME}:`) { + if (url.protocol !== `${common.DEEP_LINK_SCHEME}:`) { console.error(`'${url.toString()}' is not a deep link, ignoring.`) } else { console.log(`'${url.toString()}' is a deep link, sending to renderer.`) - window().webContents.send(Channel.openDeepLink, url.toString()) + window().webContents.send(ipc.Channel.openDeepLink, url.toString()) } }) // Listen for events to save the given user credentials to `~/.enso/credentials`. - electron.ipcMain.on(Channel.saveAccessToken, (event, accessTokenPayload: AccessToken | null) => { - event.preventDefault() + electron.ipcMain.on( + ipc.Channel.saveAccessToken, + (event, accessTokenPayload: accessToken.AccessToken | null) => { + event.preventDefault() - /** Home directory for the credentials file. */ - const credentialsDirectoryName = `.${PRODUCT_NAME.toLowerCase()}` - /** File name of the credentials file. */ - const credentialsFileName = 'credentials' - /** System agnostic credentials directory home path. */ - const credentialsHomePath = joinPath(homedir(), credentialsDirectoryName) + /** Home directory for the credentials file. */ + const credentialsDirectoryName = `.${common.PRODUCT_NAME.toLowerCase()}` + /** File name of the credentials file. */ + const credentialsFileName = 'credentials' + /** System agnostic credentials directory home path. */ + const credentialsHomePath = path.join(os.homedir(), credentialsDirectoryName) - if (accessTokenPayload == null) { - try { - unlinkSync(joinPath(credentialsHomePath, credentialsFileName)) - } catch { - // Ignored, most likely the path does not exist. - } - } else { - mkdir(credentialsHomePath, { recursive: true }, (error) => { - if (error) { - console.error(`Could not create '${credentialsDirectoryName}' directory.`) - } else { - writeFile( - joinPath(credentialsHomePath, credentialsFileName), - JSON.stringify({ - /* eslint-disable camelcase */ - client_id: accessTokenPayload.clientId, - access_token: accessTokenPayload.accessToken, - refresh_token: accessTokenPayload.refreshToken, - refresh_url: accessTokenPayload.refreshUrl, - expire_at: accessTokenPayload.expireAt, - /* eslint-enable camelcase */ - }), - (innerError) => { - if (innerError) { - console.error(`Could not write to '${credentialsFileName}' file.`) - } - }, - ) + if (accessTokenPayload == null) { + try { + fs.unlinkSync(path.join(credentialsHomePath, credentialsFileName)) + } catch { + // Ignored, most likely the path does not exist. } - }) - } - }) + } else { + fs.mkdir(credentialsHomePath, { recursive: true }, (error) => { + if (error) { + console.error(`Could not create '${credentialsDirectoryName}' directory.`) + } else { + fs.writeFile( + path.join(credentialsHomePath, credentialsFileName), + JSON.stringify({ + /* eslint-disable camelcase */ + client_id: accessTokenPayload.clientId, + access_token: accessTokenPayload.accessToken, + refresh_token: accessTokenPayload.refreshToken, + refresh_url: accessTokenPayload.refreshUrl, + expire_at: accessTokenPayload.expireAt, + /* eslint-enable camelcase */ + }), + (innerError) => { + if (innerError) { + console.error(`Could not write to '${credentialsFileName}' file.`) + } + }, + ) + } + }) + } + }, + ) } diff --git a/app/electron-client/src/electron.ts b/app/electron-client/src/electron.ts deleted file mode 100644 index 2389c487c546..000000000000 --- a/app/electron-client/src/electron.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { Channel } from '@/ipc' -import { dialog, ipcMain, shell, type BrowserWindow } from 'electron' -import { download } from 'electron-dl' -import type { DownloadUrlOptions } from 'enso-gui/src/electronApi' -import { unlinkSync } from 'node:fs' -import { basename, dirname, extname } from 'node:path' -import { importProjectFromPath, isProjectBundle, isProjectRoot } from 'project-manager-shim' -import { toElectronFileFilter, type FileFilter } from './fileBrowser' - -export type Electron = typeof import('electron') - -/** - * 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. - */ -export function setChromeOptions(electron: Electron) { - // Needed to accept localhost self-signed cert - electron.app.commandLine.appendSwitch('ignore-certificate-errors') - // Enable native CPU-mappable GPU memory buffer support on Linux. - electron.app.commandLine.appendSwitch('enable-native-gpu-memory-buffers') - // Override the list of blocked GPU hardware, allowing for GPU acceleration on system configurations - // that do not inherently support it. It should be noted that some hardware configurations may have - // driver issues that could result in rendering discrepancies. Despite this, the utilization of GPU - // acceleration has the potential to significantly enhance the performance of the application in our - // specific use cases. This behavior can be observed in the following example: - // https://groups.google.com/a/chromium.org/g/chromium-dev/c/09NnO6jYT6o. - electron.app.commandLine.appendSwitch('ignore-gpu-blocklist') -} - -/** Register keyboard shortcuts that should be handled by Electron. */ -export function registerShortcuts(electron: Electron) { - electron.app.on('web-contents-created', (_webContentsCreatedEvent, webContents) => { - webContents.on('before-input-event', (_beforeInputEvent, input) => { - const { code, alt, control, shift, meta, type } = input - if (type === 'keyDown') { - const focusedWindow = electron.BrowserWindow.getFocusedWindow() - if (focusedWindow) { - if (control && alt && shift && !meta && code === 'KeyI') { - focusedWindow.webContents.toggleDevTools() - } - if (control && alt && shift && !meta && code === 'KeyR') { - focusedWindow.reload() - } - } - } - }) - }) -} - -/** - * Initialize Inter-Process Communication between the Electron application and the served - * website. - */ -export function initIpc(window: BrowserWindow | null) { - ipcMain.on(Channel.error, (_event, data) => { - console.error(...data) - }) - ipcMain.on(Channel.warn, (_event, data) => { - console.warn(...data) - }) - ipcMain.on(Channel.log, (_event, data) => { - console.log(...data) - }) - ipcMain.on(Channel.info, (_event, data) => { - console.info(...data) - }) - ipcMain.on( - Channel.importProjectFromPath, - (event, path: string, directory: string | null, title: string) => { - const directoryParams = directory == null ? [] : [directory] - const info = importProjectFromPath(path, ...directoryParams, title) - event.reply(Channel.importProjectFromPath, path, info) - }, - ) - ipcMain.handle(Channel.downloadURL, async (_event, options: DownloadUrlOptions) => { - const { url, path, name, shouldUnpackProject, showFileDialog } = options - // This should never happen, but we'll check for it anyway. - if (!window) { - throw new Error('Window is not available.') - } - - await download(window, url, { - ...(path != null ? { directory: path } : {}), - ...(name != null ? { filename: name } : {}), - saveAs: showFileDialog != null ? showFileDialog : path == null, - onCompleted: (file) => { - const path = file.path - const filenameRaw = basename(path) - - try { - if (isProjectBundle(path) || isProjectRoot(path)) { - if (!shouldUnpackProject) { - return - } - // in case we're importing a project bundle, we need to remove the extension - // from the filename - const filename = filenameRaw.replace(extname(filenameRaw), '') - const directory = dirname(path) - - importProjectFromPath(path, directory, filename) - unlinkSync(path) - } - } catch (error) { - console.error('Error downloading URL', error) - } - }, - }) - - return - }) - ipcMain.on(Channel.showItemInFolder, (_event, fullPath: string) => { - shell.showItemInFolder(fullPath) - }) - ipcMain.handle( - Channel.openFileBrowser, - async ( - _event, - kind: 'default' | 'directory' | 'file' | 'filePath', - defaultPath?: string, - filters?: FileFilter[], - ) => { - console.log('Request for opening browser for ', kind, defaultPath, JSON.stringify(filters)) - let retval = null - if (kind === 'filePath') { - // "Accept", as the file won't be created immediately. - const { canceled, filePath } = await dialog.showSaveDialog({ - buttonLabel: 'Accept', - filters: filters?.map(toElectronFileFilter) ?? [], - ...(defaultPath != null ? { defaultPath } : {}), - }) - if (!canceled) { - retval = [filePath] - } - } else { - /** Helper for `showOpenDialog`, which has weird types by default. */ - type Properties = ('openDirectory' | 'openFile')[] - const properties: Properties = - kind === 'file' ? ['openFile'] - : kind === 'directory' ? ['openDirectory'] - : process.platform === 'darwin' ? ['openFile', 'openDirectory'] - : ['openFile'] - const { canceled, filePaths } = await dialog.showOpenDialog({ - properties, - filters: filters?.map(toElectronFileFilter) ?? [], - ...(defaultPath != null ? { defaultPath } : {}), - }) - if (!canceled) { - retval = filePaths - } - } - return retval - }, - ) - - // Handling navigation events from renderer process - ipcMain.on(Channel.goBack, () => { - window?.webContents.navigationHistory.goBack() - }) - - ipcMain.on(Channel.goForward, () => { - window?.webContents.navigationHistory.goForward() - }) -} diff --git a/app/electron-client/src/index.ts b/app/electron-client/src/index.ts index 3c7be3082567..293adba03936 100644 --- a/app/electron-client/src/index.ts +++ b/app/electron-client/src/index.ts @@ -9,68 +9,43 @@ import './cjs-shim' // must be imported first -import type { BrowserWindowConstructorOptions, WebPreferences } from 'electron' -import { DEEP_LINK_SCHEME, PRODUCT_NAME } from 'enso-common' +import * as fsSync from 'node:fs' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as pathModule from 'node:path' +import process from 'node:process' + +import * as electron from 'electron' +import * as common from 'enso-common' import { buildWebAppURLSearchParamsFromArgs, defaultOptions, type Options, } from 'enso-common/src/options' -import { access, constants, readFile, writeFile } from 'node:fs/promises' -import { platform } from 'node:os' -import { join as joinPath } from 'node:path' -import process from 'node:process' -import { downloadSamples } from 'project-manager-shim' -import { initAuthentication } from './authentication.js' -import { parseArgs } from './configParser.js' -import { VERSION } from './contentConfig.js' -import { printInfo, VERSION_INFO } from './debug.js' -import { initIpc, registerShortcuts, setChromeOptions } from './electron.js' -import { - argsDenoteFileOpenAttempt, - CLIENT_ARGUMENTS, - handleOpenFile, - setOpenFileEventHandler, -} from './fileAssociations.js' -import { Channel } from './ipc.js' -import { setupLogger } from './log.js' -import { filterByRole, inheritMenuItem, makeMenuItem, replaceMenuItems } from './menuItems.js' -import { capitalizeFirstLetter } from './naming.js' -import { APP_PATH, ASSETS_PATH } from './paths.js' -import { handleProjectProtocol, setupProjectService, version } from './projectService.js' -import { enableAll } from './security.js' -import { Config, Server } from './server.js' -import { argsDenoteUrlOpenAttempt, handleOpenUrl, registerAssociations } from './urlAssociations.js' - -type Electron = typeof import('electron') + +import * as authentication from '@/authentication' +import * as configParser from '@/configParser' +import * as contentConfig from '@/contentConfig' +import * as debug from '@/debug' +import * as fileAssociations from '@/fileAssociations' +import * as ipc from '@/ipc' +import * as log from '@/log' +import * as naming from '@/naming' +import * as paths from '@/paths' +import * as projectService from '@/projectService' +import * as security from '@/security' +import * as server from '@/server' +import * as urlAssociations from '@/urlAssociations' +import * as projectManagement from 'project-manager-shim' +import { toElectronFileFilter, type FileFilter } from './fileBrowser' + +import * as download from 'electron-dl' +import type { DownloadUrlOptions } from 'enso-gui/src/electronApi' +import { filterByRole, inheritMenuItem, makeMenuItem, replaceMenuItems } from './menuItems' const DEFAULT_WINDOW_WIDTH = 1380 const DEFAULT_WINDOW_HEIGHT = 900 -function exit(code: number, electron: Electron | undefined) { - if (electron) { - electron.app.exit(code) - } else { - process.exit(code) - } -} - -function quit(electron: Electron | undefined) { - if (electron) { - electron.app.quit() - } else { - process.exit(0) - } -} - -function showErrorBox(title: string, content: string, electron: Electron | undefined) { - if (electron) { - electron.dialog.showErrorBox(title, content) - } else { - console.error(`${title}\n\n${content}`) - } -} - /** Convert path to proper `file://` URL. */ function pathToURL(path: string): URL { if (process.platform === 'win32') { @@ -80,171 +55,162 @@ 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. */ -interface App { - window: import('electron').BrowserWindow | null - server: Server | null - webOptions: Options - isQuitting: boolean -} - -const createApp = (): App => ({ - window: null, - server: null, - webOptions: defaultOptions(), - isQuitting: false, -}) - -/** Initialize and run the Electron application. */ -async function runApp(app: App, electron: Electron | undefined) { - process.on('uncaughtException', (err, origin) => { - console.error(`Uncaught exception: ${err.toString()}\nException origin: ${origin}`) - showErrorBox(PRODUCT_NAME, err.stack ?? err.toString(), electron) - exit(1, electron) - }) - setupLogger() - if (electron) { - registerAssociations(electron) - } - // Register file associations for macOS. - setOpenFileEventHandler((path) => { - if (!electron) return - if (electron.app.isReady()) { - const project = handleOpenFile(path) - app.window?.webContents.send(Channel.openProject, project) - } else { - setProjectToOpenOnStartup(app, pathToURL(path), electron) - } - }) - const { args, fileToOpen, urlToOpen } = processArguments() - if (args.version) { - await printVersion() - return quit(electron) - } else if (args.debug.info) { - await electron?.app.whenReady() - await printInfo() - return quit(electron) - } else if (!electron) { - return - } - const isOriginalInstance = electron.app.requestSingleInstanceLock({ - fileToOpen, - urlToOpen, - }) - if (isOriginalInstance) { - handleItemOpening(app, fileToOpen, urlToOpen, electron) - setChromeOptions(electron) - enableAll() - - onStart(electron).catch((err) => { - console.error(err) +class App { + window: electron.BrowserWindow | null = null + server: server.Server | null = null + webOptions: Options = defaultOptions() + isQuitting = false + + /** Initialize and run the Electron application. */ + async run() { + log.setupLogger() + urlAssociations.registerAssociations() + // Register file associations for macOS. + fileAssociations.setOpenFileEventHandler((path) => { + if (electron.app.isReady()) { + const project = fileAssociations.handleOpenFile(path) + this.window?.webContents.send(ipc.Channel.openProject, project) + } else { + this.setProjectToOpenOnStartup(pathToURL(path)) + } }) + const { args, fileToOpen, urlToOpen } = this.processArguments() + if (args.version) { + await this.printVersion() + electron.app.quit() + } else if (args.debug.info) { + await electron.app.whenReady().then(async () => { + await debug.printInfo() + electron.app.quit() + }) + } else { + const isOriginalInstance = electron.app.requestSingleInstanceLock({ + fileToOpen, + urlToOpen, + }) + if (isOriginalInstance) { + this.handleItemOpening(fileToOpen, urlToOpen) + this.setChromeOptions() + security.enableAll() + + this.onStart().catch((err) => { + console.error(err) + }) - electron.app.on('before-quit', () => { - app.isQuitting = true - }) + electron.app.on('before-quit', () => { + this.isQuitting = true + }) - electron.app.on('second-instance', (_event, argv) => { - console.error(`Got data from 'second-instance' event: '${argv.toString()}'.`) + electron.app.on('second-instance', (_event, argv) => { + console.error(`Got data from 'second-instance' event: '${argv.toString()}'.`) - const isWin = platform() === 'win32' + const isWin = os.platform() === 'win32' - if (isWin) { - const ensoLinkInArgs = argv.find((arg) => arg.startsWith(DEEP_LINK_SCHEME)) + if (isWin) { + const ensoLinkInArgs = argv.find((arg) => arg.startsWith(common.DEEP_LINK_SCHEME)) - if (ensoLinkInArgs != null) { - electron.app.emit('open-url', new CustomEvent('open-url'), ensoLinkInArgs) - } - } + if (ensoLinkInArgs != null) { + electron.app.emit('open-url', new CustomEvent('open-url'), ensoLinkInArgs) + } + } - // The second instances will close themselves, but our window likely is not in the - // foreground - the focus went to the "second instance" of the application. - if (app.window) { - if (app.window.isMinimized()) { - app.window.restore() - } - app.window.focus() + // The second instances will close themselves, but our window likely is not in the + // foreground - the focus went to the "second instance" of the application. + if (this.window) { + if (this.window.isMinimized()) { + this.window.restore() + } + this.window.focus() + } else { + console.error('No window found after receiving URL from second instance.') + } + }) + electron.app.whenReady().then( + async () => { + console.log('Electron application is ready.') + + electron.protocol.handle('enso', (request) => + projectService.handleProjectProtocol( + decodeURIComponent(request.url.replace('enso://', '')), + ), + ) + + await this.main(args) + }, + (error) => { + console.error('Failed to initialize Electron.', error) + }, + ) + this.registerShortcuts() } else { - console.error('No window found after receiving URL from second instance.') + console.log('Another instance of the application is already running, exiting.') + electron.app.quit() } - }) - electron.app.whenReady().then( - async () => { - console.log('Electron application is ready.') - - electron.protocol.handle('enso', (request) => - handleProjectProtocol(decodeURIComponent(request.url.replace('enso://', ''))), - ) - - await main(app, args, electron) - }, - (error) => { - console.error('Failed to initialize Electron.', error) - }, - ) - registerShortcuts(electron) - } else { - console.log('Another instance of the application is already running, exiting.') - quit(electron) + } } -} -/** Background tasks scheduled on the application startup. */ -async function onStart(electron: Electron | undefined) { - const writeVersionInfoPromise = (async () => { - if (!electron) return + /** Background tasks scheduled on the application startup. */ + async onStart() { const userData = electron.app.getPath('userData') - const versionInfoPath = joinPath(userData, 'version_info.json') - const versionInfoPathExists = await access(versionInfoPath, constants.F_OK) + const versionInfoPath = pathModule.join(userData, 'version_info.json') + const versionInfoPathExists = await fs + .access(versionInfoPath, fs.constants.F_OK) .then(() => true) .catch(() => false) if (versionInfoPathExists) { - const versionInfoText = await readFile(versionInfoPath, 'utf8') + const versionInfoText = await fs.readFile(versionInfoPath, 'utf8') const versionInfoJson = JSON.parse(versionInfoText) - if (VERSION_INFO.version === versionInfoJson.version && !VERSION.isDev()) return + if (debug.VERSION_INFO.version === versionInfoJson.version && !contentConfig.VERSION.isDev()) + return } - return writeFile(versionInfoPath, JSON.stringify(VERSION_INFO), 'utf8') - })() - - const downloadSamplesPromise = downloadSamples() + const writeVersionInfoPromise = fs.writeFile( + versionInfoPath, + JSON.stringify(debug.VERSION_INFO), + 'utf8', + ) + const downloadSamplesPromise = projectManagement.downloadSamples() - return Promise.allSettled([writeVersionInfoPromise, downloadSamplesPromise]) -} + return Promise.allSettled([writeVersionInfoPromise, downloadSamplesPromise]) + } -/** Process the command line arguments. */ -function processArguments(args = CLIENT_ARGUMENTS) { - // We parse only "client arguments", so we don't have to worry about the Electron-Dev vs - // Electron-Proper distinction. - const fileToOpen = argsDenoteFileOpenAttempt(args) - const urlToOpen = argsDenoteUrlOpenAttempt(args) - // If we are opening a file (i.e. we were spawned with just a path of the file to open as - // the argument) or URL, it means that effectively we don't have any non-standard arguments. - // We just need to let caller know that we are opening a file. - const argsToParse = fileToOpen != null || urlToOpen != null ? [] : args - return { args: parseArgs(argsToParse), fileToOpen, urlToOpen } -} + /** Process the command line arguments. */ + processArguments(args = fileAssociations.CLIENT_ARGUMENTS) { + // We parse only "client arguments", so we don't have to worry about the Electron-Dev vs + // Electron-Proper distinction. + const fileToOpen = fileAssociations.argsDenoteFileOpenAttempt(args) + const urlToOpen = urlAssociations.argsDenoteUrlOpenAttempt(args) + // If we are opening a file (i.e. we were spawned with just a path of the file to open as + // the argument) or URL, it means that effectively we don't have any non-standard arguments. + // We just need to let caller know that we are opening a file. + const argsToParse = fileToOpen != null || urlToOpen != null ? [] : args + return { args: configParser.parseArgs(argsToParse), fileToOpen, urlToOpen } + } -/** - * 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. - */ -function setProjectToOpenOnStartup(app: App, projectUrl: URL, electron: Electron | undefined) { - if (electron) { + /** + * 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. + */ + setProjectToOpenOnStartup(projectUrl: URL) { // Make sure that we are not initialized yet, as this method should be called before the // application is ready. if (!electron.app.isReady()) { console.log(`Setting the project to open on startup to '${projectUrl.toString()}'.`) - app.webOptions.startup.project = projectUrl.toString() + this.webOptions.startup.project = projectUrl.toString() } else { console.error( "Cannot set the project to open on startup to '" + @@ -252,250 +218,418 @@ function setProjectToOpenOnStartup(app: App, projectUrl: URL, electron: Electron "', as the application is already initialized.", ) } - } else { - // FIXME: } -} -/** - * This method is invoked when the application was spawned due to being a default application - * for a URL protocol or file extension. - */ -function handleItemOpening( - app: App, - fileToOpen: string | null, - urlToOpen: URL | null, - electron: Electron | undefined, -) { - console.log('Opening file or URL.', { fileToOpen, urlToOpen }) - try { - if (fileToOpen != null) { - // The IDE must receive the project path, otherwise if the IDE has a custom root directory - // set then it is added to the (incorrect) default root directory. - setProjectToOpenOnStartup(app, pathToURL(fileToOpen), electron) - } - if (urlToOpen != null) { - handleOpenUrl(urlToOpen) + /** + * 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) { + console.log('Opening file or URL.', { fileToOpen, urlToOpen }) + try { + if (fileToOpen != null) { + // The IDE must receive the project path, otherwise if the IDE has a custom root directory + // set then it is added to the (incorrect) default root directory. + this.setProjectToOpenOnStartup(pathToURL(fileToOpen)) + } + + if (urlToOpen != null) { + urlAssociations.handleOpenUrl(urlToOpen) + } + } catch { + // If we failed to open the file, we should enter the usual welcome screen. + // The `handleOpenFile` function will have already displayed an error message. } - } catch { - // If we failed to open the file, we should enter the usual welcome screen. - // The `handleOpenFile` function will have already displayed an error message. } -} -/** Main app entry point. */ -async function main(app: App, args: Options, electron: Electron | undefined) { - // We catch all errors here. Otherwise, it might be possible that the app will run partially - // and enter a "zombie mode", where user is not aware of the app still running. - try { - console.log('Starting the application') - // Note that we want to do all the actions synchronously, so when the window - // appears, it serves the website immediately. - await startContentServerIfEnabled(app, args) - await createWindowIfEnabled(app, args, electron) - initIpc(app.window) - await loadWindowContent(app, args) - if (electron) { + /** + * 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() { + // Needed to accept localhost self-signed cert + electron.app.commandLine.appendSwitch('ignore-certificate-errors') + // Enable native CPU-mappable GPU memory buffer support on Linux. + electron.app.commandLine.appendSwitch('enable-native-gpu-memory-buffers') + // Override the list of blocked GPU hardware, allowing for GPU acceleration on system configurations + // that do not inherently support it. It should be noted that some hardware configurations may have + // driver issues that could result in rendering discrepancies. Despite this, the utilization of GPU + // acceleration has the potential to significantly enhance the performance of the application in our + // specific use cases. This behavior can be observed in the following example: + // https://groups.google.com/a/chromium.org/g/chromium-dev/c/09NnO6jYT6o. + electron.app.commandLine.appendSwitch('ignore-gpu-blocklist') + } + + /** Main app entry point. */ + async main(args: Options) { + // We catch all errors here. Otherwise, it might be possible that the app will run partially + // and enter a "zombie mode", where user is not aware of the app still running. + try { + console.log('Starting the application') + // Note that we want to do all the actions synchronously, so when the window + // appears, it serves the website immediately. + await this.startContentServerIfEnabled(args) + await this.createWindowIfEnabled(args) + this.initIpc() + await this.loadWindowContent(args) /** * 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. */ - initAuthentication(electron, () => app.window!) + authentication.initAuthentication(() => this.window!) + } catch (err) { + console.error('Failed to initialize the application, shutting down. Error: ', err) + electron.app.quit() } - } catch (err) { - console.error('Failed to initialize the application, shutting down. Error: ', err) - quit(electron) } -} - -/** Setup the project service. */ -function createProjectService(args: Options) { - const backendVerboseOpts = args.debug.verbose ? ['--log-level', 'trace'] : [] - const backendProfileTime = ['--profiling-time', String(args.debug.profileTime)] - const backendProfileOpts = - args.debug.profile ? ['--profiling-path', 'profiling.npss', ...backendProfileTime] : [] - const backendJvmOpts = args.useJvm ? ['--jvm'] : [] - const backendOpts = [...backendVerboseOpts, ...backendProfileOpts, ...backendJvmOpts] - return setupProjectService(backendOpts) -} + /** Run the provided function if the provided option was enabled. Log a message otherwise. */ + async runIfEnabled(option: boolean, fn: () => Promise | void) { + if (option) { + await fn() + } + } -/** Start the content server, which will serve the application content (HTML) to the window. */ -async function startContentServerIfEnabled(app: App, args: Options) { - if (!args.useServer) return - console.log('Starting the content server.') - const serverCfg = new Config({ - dir: ASSETS_PATH, - port: args.server.port, - }) - const projectService = createProjectService(args) - app.server = await Server.create(serverCfg, projectService) - console.log('Content server started.') -} + /** Setup the project service. */ + private createProjectService(args: Options) { + const backendVerboseOpts = args.debug.verbose ? ['--log-level', 'trace'] : [] + const backendProfileTime = ['--profiling-time', String(args.debug.profileTime)] + const backendProfileOpts = + args.debug.profile ? ['--profiling-path', 'profiling.npss', ...backendProfileTime] : [] + const backendJvmOpts = args.useJvm ? ['--jvm'] : [] + const backendOpts = [...backendVerboseOpts, ...backendProfileOpts, ...backendJvmOpts] -/** Create the Electron window and display it on the screen. */ -async function createWindowIfEnabled(app: App, args: Options, electron: Electron | undefined) { - if (!args.displayWindow) return - if (!electron) { - console.error('Running in headless mode, window will not be created.') - return + return projectService.setupProjectService(backendOpts) } - console.log('Creating the window.') - const webPreferences: WebPreferences = { - preload: joinPath(APP_PATH, 'preload.mjs'), - sandbox: true, - spellcheck: false, - ...(process.env.ENSO_TEST ? { partition: 'test' } : {}), + + /** Start the content server, which will serve the application content (HTML) to the window. */ + async startContentServerIfEnabled(args: Options) { + await this.runIfEnabled(args.useServer, async () => { + console.log('Starting the content server.') + const serverCfg = new server.Config({ + dir: paths.ASSETS_PATH, + port: args.server.port, + }) + const projectService = this.createProjectService(args) + this.server = await server.Server.create(serverCfg, projectService) + console.log('Content server started.') + }) } - const windowPreferences: BrowserWindowConstructorOptions = { - webPreferences, - width: DEFAULT_WINDOW_WIDTH, - height: DEFAULT_WINDOW_HEIGHT, - frame: true, - titleBarStyle: 'default', - ...(process.env.DEV_DARK_BACKGROUND ? { backgroundColor: '#36312c' } : {}), + + /** Create the Electron window and display it on the screen. */ + async createWindowIfEnabled(args: Options) { + await this.runIfEnabled(args.displayWindow, () => { + console.log('Creating the window.') + const webPreferences: electron.WebPreferences = { + preload: pathModule.join(paths.APP_PATH, 'preload.mjs'), + sandbox: true, + spellcheck: false, + ...(process.env.ENSO_TEST ? { partition: 'test' } : {}), + } + const windowPreferences: electron.BrowserWindowConstructorOptions = { + webPreferences, + width: DEFAULT_WINDOW_WIDTH, + height: DEFAULT_WINDOW_HEIGHT, + frame: true, + titleBarStyle: 'default', + ...(process.env.DEV_DARK_BACKGROUND ? { backgroundColor: '#36312c' } : {}), + } + const window = new electron.BrowserWindow(windowPreferences) + + const oldMenu = electron.Menu.getApplicationMenu() + if (oldMenu != null) { + const newMenu = replaceMenuItems(oldMenu.items, [ + { + filter: [filterByRole('help')], + replacement: (item) => + inheritMenuItem(item, undefined, [ + makeMenuItem(window, `About ${common.PRODUCT_NAME}`, 'about'), + ]), + }, + { + filter: [filterByRole('fileMenu'), filterByRole('close')], + replacement: () => makeMenuItem(window, 'Close Tab', 'closeTab', 'CmdOrCtrl+W'), + }, + { + filter: [filterByRole('appMenu'), filterByRole('about')], + replacement: () => undefined, + }, + { + filter: [filterByRole('appMenu'), filterByRole('hide')], + replacement: (item) => inheritMenuItem(item, `Hide ${common.PRODUCT_NAME}`), + }, + { + filter: [filterByRole('appMenu'), filterByRole('quit')], + replacement: (item) => inheritMenuItem(item, `Quit ${common.PRODUCT_NAME}`), + }, + ]) + electron.Menu.setApplicationMenu(newMenu) + } + window.setMenuBarVisibility(false) + + if (args.debug.devTools) { + window.webContents.openDevTools() + } + + const allowedPermissions = ['clipboard-read', 'clipboard-sanitized-write'] + window.webContents.session.setPermissionRequestHandler( + (_webContents, permission, callback) => { + if (allowedPermissions.includes(permission)) { + callback(true) + } else { + console.error(`Denied permission check '${permission}'.`) + callback(false) + } + }, + ) + + // Quit application on window close on all platforms except Mac (it is default behavior on Mac). + const closeToQuit = process.platform !== 'darwin' + + window.on('close', (event) => { + if (!this.isQuitting && !closeToQuit) { + event.preventDefault() + window.hide() + } + }) + + electron.app.on('activate', () => { + if (!closeToQuit) { + window.show() + } + }) + + window.webContents.on('render-process-gone', (_event, details) => { + console.error('Error, the render process crashed.', details) + }) + + this.window = window + console.log('Window created.') + }) } - const window = new electron.BrowserWindow(windowPreferences) - - const oldMenu = electron.Menu.getApplicationMenu() - if (oldMenu != null) { - const newMenu = replaceMenuItems(oldMenu.items, [ - { - filter: [filterByRole('help')], - replacement: (item) => - inheritMenuItem(item, undefined, [ - makeMenuItem(window, `About ${PRODUCT_NAME}`, 'about'), - ]), - }, - { - filter: [filterByRole('fileMenu'), filterByRole('close')], - replacement: () => makeMenuItem(window, 'Close Tab', 'closeTab', 'CmdOrCtrl+W'), - }, - { - filter: [filterByRole('appMenu'), filterByRole('about')], - replacement: () => undefined, + + /** + * Initialize Inter-Process Communication between the Electron application and the served + * website. + */ + initIpc() { + electron.ipcMain.on(ipc.Channel.error, (_event, data) => { + console.error(...data) + }) + electron.ipcMain.on(ipc.Channel.warn, (_event, data) => { + console.warn(...data) + }) + electron.ipcMain.on(ipc.Channel.log, (_event, data) => { + console.log(...data) + }) + electron.ipcMain.on(ipc.Channel.info, (_event, data) => { + console.info(...data) + }) + electron.ipcMain.on( + ipc.Channel.importProjectFromPath, + (event, path: string, directory: string | null, title: string) => { + const directoryParams = directory == null ? [] : [directory] + const info = projectManagement.importProjectFromPath(path, ...directoryParams, title) + event.reply(ipc.Channel.importProjectFromPath, path, info) }, - { - filter: [filterByRole('appMenu'), filterByRole('hide')], - replacement: (item) => inheritMenuItem(item, `Hide ${PRODUCT_NAME}`), + ) + electron.ipcMain.handle( + ipc.Channel.downloadURL, + async (_event, options: DownloadUrlOptions) => { + const { url, path, name, shouldUnpackProject, showFileDialog } = options + // This should never happen, but we'll check for it anyway. + if (!this.window) { + throw new Error('Window is not available.') + } + + await download.download(this.window, url, { + ...(path != null ? { directory: path } : {}), + ...(name != null ? { filename: name } : {}), + saveAs: showFileDialog != null ? showFileDialog : path == null, + onCompleted: (file) => { + const path = file.path + const filenameRaw = pathModule.basename(path) + + try { + if ( + projectManagement.isProjectBundle(path) || + projectManagement.isProjectRoot(path) + ) { + if (!shouldUnpackProject) { + return + } + // in case we're importing a project bundle, we need to remove the extension + // from the filename + const filename = filenameRaw.replace(pathModule.extname(filenameRaw), '') + const directory = pathModule.dirname(path) + + projectManagement.importProjectFromPath(path, directory, filename) + fsSync.unlinkSync(path) + } + } catch (error) { + console.error('Error downloading URL', error) + } + }, + }) + + return }, - { - filter: [filterByRole('appMenu'), filterByRole('quit')], - replacement: (item) => inheritMenuItem(item, `Quit ${PRODUCT_NAME}`), + ) + electron.ipcMain.on(ipc.Channel.showItemInFolder, (_event, fullPath: string) => { + electron.shell.showItemInFolder(fullPath) + }) + electron.ipcMain.handle( + ipc.Channel.openFileBrowser, + async ( + _event, + kind: 'default' | 'directory' | 'file' | 'filePath', + defaultPath?: string, + filters?: FileFilter[], + ) => { + console.log('Request for opening browser for ', kind, defaultPath, JSON.stringify(filters)) + let retval = null + if (kind === 'filePath') { + // "Accept", as the file won't be created immediately. + const { canceled, filePath } = await electron.dialog.showSaveDialog({ + buttonLabel: 'Accept', + filters: filters?.map(toElectronFileFilter) ?? [], + ...(defaultPath != null ? { defaultPath } : {}), + }) + if (!canceled) { + retval = [filePath] + } + } else { + /** Helper for `showOpenDialog`, which has weird types by default. */ + type Properties = ('openDirectory' | 'openFile')[] + const properties: Properties = + kind === 'file' ? ['openFile'] + : kind === 'directory' ? ['openDirectory'] + : process.platform === 'darwin' ? ['openFile', 'openDirectory'] + : ['openFile'] + const { canceled, filePaths } = await electron.dialog.showOpenDialog({ + properties, + filters: filters?.map(toElectronFileFilter) ?? [], + ...(defaultPath != null ? { defaultPath } : {}), + }) + if (!canceled) { + retval = filePaths + } + } + return retval }, - ]) - electron.Menu.setApplicationMenu(newMenu) + ) + + // Handling navigation events from renderer process + electron.ipcMain.on(ipc.Channel.goBack, () => { + this.window?.webContents.navigationHistory.goBack() + }) + + electron.ipcMain.on(ipc.Channel.goForward, () => { + this.window?.webContents.navigationHistory.goForward() + }) } - window.setMenuBarVisibility(false) - if (args.debug.devTools) { - window.webContents.openDevTools() + /** + * 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. + */ + serverPort(args: Options): number { + return this.server?.config.port ?? args.server.port } - const allowedPermissions = ['clipboard-read', 'clipboard-sanitized-write'] - window.webContents.session.setPermissionRequestHandler((_webContents, permission, callback) => { - if (allowedPermissions.includes(permission)) { - callback(true) - } else { - console.error(`Denied permission check '${permission}'.`) - callback(false) + /** Redirect the web view to `localhost:` to see the served website. */ + async loadWindowContent(args: Options) { + if (this.window != null) { + const searchParams = buildWebAppURLSearchParamsFromArgs({ + ...this.webOptions, + ...args, + }) + const address = new URL('https://localhost') + address.port = this.serverPort(args).toString() + address.search = searchParams.toString() + console.log(`Loading the window address '${address.toString()}'.`) + if (process.env.ELECTRON_DEV_MODE === 'true') { + // Vite takes a while to be `import`ed, so the first load almost always fails. + // Reload every second until Vite is ready + // (i.e. when `index.html` has a non-empty body). + const window = this.window + const onLoad = () => { + void window.webContents.mainFrame + // Get the HTML contents of `document.body`. + .executeJavaScript('document.body.innerHTML') + .then((html) => { + // If `document.body` is empty, then `index.html` failed to load. + if (html === '') { + console.warn('Loading failed, reloading...') + window.webContents.once('did-finish-load', onLoad) + setTimeout(() => { + void window.loadURL(address.toString()) + }, 1_000) + } + }) + } + // Wait for page to load before checking content, because of course the content is + // empty if the page isn't loaded. + window.webContents.once('did-finish-load', onLoad) + } + await this.window.loadURL(address.toString()) } - }) - - // Quit application on window close on all platforms except Mac (it is default behavior on Mac). - const closeToQuit = process.platform !== 'darwin' + } - window.on('close', (event) => { - if (!app.isQuitting && !closeToQuit) { - event.preventDefault() - window.hide() + /** Print the version of the frontend and the backend. */ + async printVersion(): Promise { + const indent = ' ' + let maxNameLen = 0 + for (const name in debug.VERSION_INFO) { + maxNameLen = Math.max(maxNameLen, name.length) } - }) - - electron.app.on('activate', () => { - if (!closeToQuit) { - window.show() + process.stdout.write('Frontend:\n') + for (const [name, value] of Object.entries(debug.VERSION_INFO)) { + const label = naming.capitalizeFirstLetter(name) + const spacing = ' '.repeat(maxNameLen - name.length) + process.stdout.write(`${indent}${label}:${spacing} ${value}\n`) } - }) - - window.webContents.on('render-process-gone', (_event, details) => { - console.error('Error, the render process crashed.', details) - }) - - app.window = window - console.log('Window created.') -} - -/** - * 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. - */ -function serverPort(app: App, args: Options): number { - return app.server?.config.port ?? args.server.port -} - -/** Redirect the web view to `localhost:` to see the served website. */ -async function loadWindowContent(app: App, args: Options) { - if (!app.window) return - const searchParams = buildWebAppURLSearchParamsFromArgs({ - ...app.webOptions, - ...args, - }) - const address = new URL('https://localhost') - address.port = serverPort(app, args).toString() - address.search = searchParams.toString() - console.log(`Loading the window address '${address.toString()}'.`) - if (process.env.ELECTRON_DEV_MODE === 'true') { - // Vite takes a while to be `import`ed, so the first load almost always fails. - // Reload every second until Vite is ready - // (i.e. when `index.html` has a non-empty body). - const window = app.window - const onLoad = () => { - void window.webContents.mainFrame - // Get the HTML contents of `document.body`. - .executeJavaScript('document.body.innerHTML') - .then((html) => { - // If `document.body` is empty, then `index.html` failed to load. - if (html === '') { - console.warn('Loading failed, reloading...') - window.webContents.once('did-finish-load', onLoad) - setTimeout(() => { - void window.loadURL(address.toString()) - }, 1_000) - } - }) + process.stdout.write('\n') + process.stdout.write('Backend:\n') + const backend = await projectService.version() + const lines = backend.split(/\r?\n/).filter((line) => line.length > 0) + for (const line of lines) { + process.stdout.write(`${indent}${line}\n`) } - // Wait for page to load before checking content, because of course the content is - // empty if the page isn't loaded. - window.webContents.once('did-finish-load', onLoad) } - await app.window.loadURL(address.toString()) -} -/** Print the version of the frontend and the backend. */ -async function printVersion(): Promise { - const indent = ' ' - let maxNameLen = 0 - for (const name in VERSION_INFO) { - maxNameLen = Math.max(maxNameLen, name.length) - } - process.stdout.write('Frontend:\n') - for (const [name, value] of Object.entries(VERSION_INFO)) { - const label = capitalizeFirstLetter(name) - const spacing = ' '.repeat(maxNameLen - name.length) - process.stdout.write(`${indent}${label}:${spacing} ${value}\n`) - } - process.stdout.write('\n') - process.stdout.write('Backend:\n') - const backend = await version() - const lines = backend.split(/\r?\n/).filter((line) => line.length > 0) - for (const line of lines) { - process.stdout.write(`${indent}${line}\n`) + registerShortcuts() { + electron.app.on('web-contents-created', (_webContentsCreatedEvent, webContents) => { + webContents.on('before-input-event', (_beforeInputEvent, input) => { + const { code, alt, control, shift, meta, type } = input + if (type === 'keyDown') { + const focusedWindow = electron.BrowserWindow.getFocusedWindow() + if (focusedWindow) { + if (control && alt && shift && !meta && code === 'KeyI') { + focusedWindow.webContents.toggleDevTools() + } + if (control && alt && shift && !meta && code === 'KeyR') { + focusedWindow.reload() + } + } + } + }) + }) } } -// FIXME: Conditionally load `electron` -void runApp(createApp(), await import('electron')) +// =================== +// === App startup === +// =================== + +process.on('uncaughtException', (err, origin) => { + console.error(`Uncaught exception: ${err.toString()}\nException origin: ${origin}`) + electron.dialog.showErrorBox(common.PRODUCT_NAME, err.stack ?? err.toString()) + electron.app.exit(1) +}) + +const APP = new App() +void APP.run() diff --git a/app/electron-client/src/projectService.ts b/app/electron-client/src/projectService.ts index 63c0acfc415b..96c8b2d602a5 100644 --- a/app/electron-client/src/projectService.ts +++ b/app/electron-client/src/projectService.ts @@ -12,7 +12,7 @@ import { ProjectService } from 'project-manager-shim/projectService' // ======================= let projectService: ProjectService | null = null -let extraArgs: readonly string[] = [] +let extraArgs: string[] = [] /** Get the project service. */ function getProjectService(): ProjectService { @@ -23,7 +23,7 @@ function getProjectService(): ProjectService { } /** Setup the project service.*/ -export function setupProjectService(args: readonly string[]) { +export function setupProjectService(args: string[]) { extraArgs = args if (!projectService) { projectService = ProjectService.default(paths.RESOURCES_PATH, args) diff --git a/app/electron-client/src/urlAssociations.ts b/app/electron-client/src/urlAssociations.ts index cf8bc5b27898..0f906c5243b5 100644 --- a/app/electron-client/src/urlAssociations.ts +++ b/app/electron-client/src/urlAssociations.ts @@ -1,8 +1,13 @@ /** @file URL associations for the IDE. */ + +import * as electron from 'electron' import electronIsDev from 'electron-is-dev' + import * as common from 'enso-common' -type Electron = typeof import('electron') +// ============================ +// === Protocol Association === +// ============================ /** * Register the application as a handler for our [deep link scheme]{@link common.DEEP_LINK_SCHEME}. @@ -13,7 +18,7 @@ type Electron = typeof import('electron') * It is also no-op on macOS, as the OS handles the URL opening by passing the `open-url` event to * the application, thanks to the information baked in our application by `electron-builder`. */ -export function registerAssociations(electron: Electron) { +export function registerAssociations() { if (!electron.app.isDefaultProtocolClient(common.DEEP_LINK_SCHEME)) { if (process.platform === 'darwin') { // Registration is handled automatically there thanks to electron-builder. @@ -75,7 +80,7 @@ export function handleOpenUrl(openedUrl: URL) { * new instance of the application is started and the URL is passed as a command line argument. * @param callback - The callback to call when the application is requested to open a URL. */ -export function registerUrlCallback(electron: Electron, callback: (url: URL) => void) { +export function registerUrlCallback(callback: (url: URL) => void) { if (initialUrl != null) { callback(initialUrl) } diff --git a/app/project-manager-shim/src/hybrid.ts b/app/project-manager-shim/src/hybrid.ts deleted file mode 100644 index 82bd9edd9fcf..000000000000 --- a/app/project-manager-shim/src/hybrid.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - AssetType, - extractTypeAndPath, - extractTypeFromId, - type PathResolveResponse, -} from 'enso-common/src/services/Backend' -import type { EnsoPath } from 'enso-common/src/services/Backend/types' -import type { ProjectManager } from 'enso-common/src/services/ProjectManager/ProjectManager' -import type { Path, ProjectEntry, UUID } from 'enso-common/src/services/ProjectManager/types' -import type { RemoteBackend } from 'enso-common/src/services/RemoteBackend' -import { ProjectService } from './projectService/index.js' - -/** Start a hybrid project. */ -export async function startHybridProject( - path: EnsoPath, - projectManager: ProjectManager, - remoteBackend: RemoteBackend, - projectService = ProjectService.default(), -) { - let project: ProjectEntry | undefined - let asset: PathResolveResponse | undefined - let projectId: UUID | undefined - try { - asset = await remoteBackend.resolveEnsoPath(path) - const typeAndId = extractTypeFromId(asset.id) - if (typeAndId.type !== AssetType.project) { - throw new Error(`The path '${path}' does not point to a project.`) - } - const localProject = await remoteBackend.downloadProject(typeAndId.id) - let parentPath: Path | undefined - for (const parentId of [localProject.parentId, localProject.projectRootId]) { - parentPath = extractTypeAndPath(parentId).path - const entries = await projectManager.listDirectory(parentPath) - project = entries.filter((entry) => entry.type === 'ProjectEntry')[0] - if (project) break - } - - if (!project || !parentPath) { - throw new Error('Downloaded cloud project does not exist in Local Backend.') - } - projectService.openProject(project.metadata.id, parentPath) - } catch (error) { - console.error(`Error starting hybrid project '${asset?.title ?? '(unknown)'}':`, error) - if (projectId) { - await projectService.closeProject(projectId) - } - } -} From 8e579c5d7fe88dbe30449b1d8579b00eb672c099 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 29 Oct 2025 20:05:26 +1000 Subject: [PATCH 22/31] Address CR --- app/common/src/utilities/permissions.ts | 15 --------------- app/electron-client/src/index.ts | 2 +- .../dashboard/components/PermissionDisplay.tsx | 5 +++-- app/gui/src/dashboard/utilities/download.ts | 16 ++++------------ .../dashboard/utilities/permissionsClasses.ts | 12 ++++++++++++ app/gui/src/electronApi.ts | 9 +-------- app/project-manager-shim/src/index.ts | 1 - 7 files changed, 21 insertions(+), 39 deletions(-) create mode 100644 app/gui/src/dashboard/utilities/permissionsClasses.ts diff --git a/app/common/src/utilities/permissions.ts b/app/common/src/utilities/permissions.ts index b04a366bd14c..4c97629a52dc 100644 --- a/app/common/src/utilities/permissions.ts +++ b/app/common/src/utilities/permissions.ts @@ -208,21 +208,6 @@ export const DEFAULT_PERMISSIONS: Permissions = Object.freeze({ execute: false, }) -/** CSS classes for each permission. */ -export const PERMISSION_CLASS_NAME: Readonly> = { - [Permission.owner]: 'text-tag-text bg-permission-owner', - [Permission.admin]: 'text-tag-text bg-permission-admin', - [Permission.edit]: 'text-tag-text bg-permission-edit', - [Permission.read]: 'text-tag-text bg-permission-read', - [Permission.view]: 'text-tag-text-2 bg-permission-view', - [Permission.delete]: 'text-tag-text bg-delete', -} - -/** CSS classes for the docs permission. */ -export const DOCS_CLASS_NAME = 'text-tag-text bg-permission-docs' -/** CSS classes for the execute permission. */ -export const EXEC_CLASS_NAME = 'text-tag-text bg-permission-exec' - /** Try to find a permission belonging to the user. */ export function tryFindSelfPermission( self: User, diff --git a/app/electron-client/src/index.ts b/app/electron-client/src/index.ts index 293adba03936..47db1eee6cb8 100644 --- a/app/electron-client/src/index.ts +++ b/app/electron-client/src/index.ts @@ -40,7 +40,7 @@ import * as projectManagement from 'project-manager-shim' import { toElectronFileFilter, type FileFilter } from './fileBrowser' import * as download from 'electron-dl' -import type { DownloadUrlOptions } from 'enso-gui/src/electronApi' +import type { DownloadUrlOptions } from 'enso-common/src/utilities/download' import { filterByRole, inheritMenuItem, makeMenuItem, replaceMenuItems } from './menuItems' const DEFAULT_WINDOW_WIDTH = 1380 diff --git a/app/gui/src/dashboard/pages/dashboard/components/PermissionDisplay.tsx b/app/gui/src/dashboard/pages/dashboard/components/PermissionDisplay.tsx index 9cfa07bbae69..63edc24fecb4 100644 --- a/app/gui/src/dashboard/pages/dashboard/components/PermissionDisplay.tsx +++ b/app/gui/src/dashboard/pages/dashboard/components/PermissionDisplay.tsx @@ -2,6 +2,7 @@ import type * as aria from '#/components/aria' import { Button } from '#/components/Button' import { Text } from '#/components/Text' +import { PERMISSION_CLASS_NAME } from '#/utilities/permissionsClasses' import * as tailwindMerge from '#/utilities/tailwindMerge' import * as permissionsModule from 'enso-common/src/utilities/permissions' import * as React from 'react' @@ -36,7 +37,7 @@ export default function PermissionDisplay(props: PermissionDisplayProps) { isDisabled={!onPress} className={tailwindMerge.twMerge( 'inline-block h-6 whitespace-nowrap rounded-full px-[7px]', - permissionsModule.PERMISSION_CLASS_NAME[permission.type], + PERMISSION_CLASS_NAME[permission.type], className, )} onPress={onPress} @@ -67,7 +68,7 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
diff --git a/app/gui/src/dashboard/utilities/download.ts b/app/gui/src/dashboard/utilities/download.ts index 9b2af8434691..9d1106c3a8d9 100644 --- a/app/gui/src/dashboard/utilities/download.ts +++ b/app/gui/src/dashboard/utilities/download.ts @@ -1,19 +1,11 @@ /** @file Functions to initiate a download. */ -import type { DownloadUrlOptions, SystemApi } from '$/electronApi' - -/** Options for `download` function. */ -export interface DownloadOptions { - readonly url: string - readonly name?: string | null | undefined - readonly electronOptions?: Omit -} +import type { SystemApi } from '$/electronApi' +import type { DownloadOptions, DownloadUrlOptions } from 'enso-common/src/utilities/download' /** Initiate a download for the specified url. */ export async function download(options: DownloadOptions) { - let { url } = options - const { name, electronOptions } = options - - url = new URL(url, location.toString()).toString() + const { url: urlRaw, name, electronOptions } = options + const url = new URL(urlRaw, location.toString()).toString() const systemApi = window.api?.system if (systemApi != null) { diff --git a/app/gui/src/dashboard/utilities/permissionsClasses.ts b/app/gui/src/dashboard/utilities/permissionsClasses.ts new file mode 100644 index 000000000000..fb496e596904 --- /dev/null +++ b/app/gui/src/dashboard/utilities/permissionsClasses.ts @@ -0,0 +1,12 @@ +/** @file CSS classes related to permissions. */ +import { Permission } from 'enso-common/src/utilities/permissions' + +/** CSS classes for each permission. */ +export const PERMISSION_CLASS_NAME: Readonly> = { + [Permission.owner]: 'text-tag-text bg-permission-owner', + [Permission.admin]: 'text-tag-text bg-permission-admin', + [Permission.edit]: 'text-tag-text bg-permission-edit', + [Permission.read]: 'text-tag-text bg-permission-read', + [Permission.view]: 'text-tag-text-2 bg-permission-view', + [Permission.delete]: 'text-tag-text bg-delete', +} diff --git a/app/gui/src/electronApi.ts b/app/gui/src/electronApi.ts index 3bfa71c56424..6019f169dfce 100644 --- a/app/gui/src/electronApi.ts +++ b/app/gui/src/electronApi.ts @@ -1,6 +1,7 @@ /** @file Shared API types exposed on `window.api` for both GUI and Electron. */ import type * as saveAccessToken from 'enso-common/src/accessToken' import type { Path } from 'enso-common/src/services/Backend' +import type { DownloadUrlOptions } from 'enso-common/src/utilities/download' import type { FileFilter } from './project-view/util/fileFilter' import type { MenuItem, MenuItemHandler } from './project-view/util/menuItems' @@ -19,14 +20,6 @@ export interface MenuApi { readonly setMenuItemHandler: (name: MenuItem, callback: MenuItemHandler) => void } -export interface DownloadUrlOptions { - readonly url: string - readonly path?: Path | null | undefined - readonly name?: string | null | undefined - readonly shouldUnpackProject?: boolean - readonly showFileDialog?: boolean -} - export interface SystemApi { readonly downloadURL: (options: DownloadUrlOptions) => Promise readonly showItemInFolder: (fullPath: string) => void diff --git a/app/project-manager-shim/src/index.ts b/app/project-manager-shim/src/index.ts index d2c739d09177..344ae1844a35 100644 --- a/app/project-manager-shim/src/index.ts +++ b/app/project-manager-shim/src/index.ts @@ -1,3 +1,2 @@ -export * from './hybrid.js' export * from './projectManagement.js' export { downloadEnsoEngine, findEnsoExecutable } from './projectService/ensoRunner.js' From 413d4af6d72c79bc0b4bcf48aa9059db7e44008f Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 29 Oct 2025 20:39:24 +1000 Subject: [PATCH 23/31] Fix unit tests --- app/gui/src/providers/__tests__/session.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/gui/src/providers/__tests__/session.test.ts b/app/gui/src/providers/__tests__/session.test.ts index ec0ee1cd9dc5..51fd886b3cb2 100644 --- a/app/gui/src/providers/__tests__/session.test.ts +++ b/app/gui/src/providers/__tests__/session.test.ts @@ -7,10 +7,10 @@ import type { SignUpError, UserSession, } from '$/authentication/cognito' -import { uniqueString } from '$/utils/uniqueString' import { withSetup } from '@/util/testing' import { HttpClient } from 'enso-common/src/services/HttpClient' import { Rfc3339DateTime } from 'enso-common/src/utilities/data/dateTime' +import { uniqueString } from 'enso-common/src/utilities/uniqueString' import { Result } from 'ts-results' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' From 26fd722b52ba967830f986ae9d9c7a07e61a5c80 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 29 Oct 2025 20:41:24 +1000 Subject: [PATCH 24/31] Fix more unit tests --- app/gui/src/providers/__tests__/upload.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/gui/src/providers/__tests__/upload.test.ts b/app/gui/src/providers/__tests__/upload.test.ts index 416dcd109110..a72d157e6755 100644 --- a/app/gui/src/providers/__tests__/upload.test.ts +++ b/app/gui/src/providers/__tests__/upload.test.ts @@ -1,3 +1,5 @@ +import { withSetup } from '@/util/testing' +import { flushPromises } from '@vue/test-utils' import { DirectoryId, HttpsUrl, @@ -7,10 +9,7 @@ import { type UploadFileEndRequestBody, type UploadFileRequestParams, type UploadLargeFileMetadata, -} from '#/services/Backend' -import {} from '@/util/assert' -import { withSetup } from '@/util/testing' -import { flushPromises } from '@vue/test-utils' +} from 'enso-common/src/services/Backend' import { assert, expect, test, vi } from 'vitest' import { setFeatureFlag } from '../featureFlags' import { createUploadsStore } from '../upload' From 051d5451085499993b8d6b54b44d2a74c2a65361 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 31 Oct 2025 20:48:36 +1000 Subject: [PATCH 25/31] Move `download.ts` in `common` --- app/common/src/{utilities => }/download.ts | 2 +- app/common/src/services/Backend.ts | 2 +- app/common/src/services/LocalBackend.ts | 2 +- app/common/src/services/RemoteBackend.ts | 2 +- app/electron-client/src/index.ts | 2 +- app/gui/src/dashboard/utilities/download.ts | 2 +- app/gui/src/electronApi.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename app/common/src/{utilities => }/download.ts (90%) diff --git a/app/common/src/utilities/download.ts b/app/common/src/download.ts similarity index 90% rename from app/common/src/utilities/download.ts rename to app/common/src/download.ts index a32c8ada34f8..6be8f2db8646 100644 --- a/app/common/src/utilities/download.ts +++ b/app/common/src/download.ts @@ -1,4 +1,4 @@ -import type { Path } from './file.js' +import type { Path } from './utilities/file.js' export interface DownloadUrlOptions { readonly url: string diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index bcb406ef872f..191f32b19ed8 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -1,9 +1,9 @@ /** @file Type definitions common between all backends. */ import { z } from 'zod' +import type { DownloadOptions } from '../download.js' import { getText, resolveDictionary, type Replacements, type TextId } from '../text.js' import * as dateTime from '../utilities/data/dateTime.js' import * as newtype from '../utilities/data/newtype.js' -import type { DownloadOptions } from '../utilities/download.js' import * as permissions from '../utilities/permissions.js' import { getFileDetailsPath } from './Backend/remoteBackendPaths.js' import { diff --git a/app/common/src/services/LocalBackend.ts b/app/common/src/services/LocalBackend.ts index 1302ecae85d3..9195f3a9723e 100644 --- a/app/common/src/services/LocalBackend.ts +++ b/app/common/src/services/LocalBackend.ts @@ -6,9 +6,9 @@ * the API. */ import { markRaw } from 'vue' +import type { DownloadOptions } from '../download.js' import { PRODUCT_NAME } from '../index.js' import { toReadableIsoString } from '../utilities/data/dateTime.js' -import type { DownloadOptions } from '../utilities/download.js' import { tryGetMessage } from '../utilities/errors.js' import { fileExtension, diff --git a/app/common/src/services/RemoteBackend.ts b/app/common/src/services/RemoteBackend.ts index 5f48fe08305e..749ceb76694b 100644 --- a/app/common/src/services/RemoteBackend.ts +++ b/app/common/src/services/RemoteBackend.ts @@ -7,10 +7,10 @@ */ import { markRaw } from 'vue' import { z } from 'zod' +import type { DownloadOptions } from '../download.js' import { delay } from '../utilities/async.js' import * as objects from '../utilities/data/object.js' import * as detect from '../utilities/detect.js' -import type { DownloadOptions } from '../utilities/download' import { getFileName, getFolderPath } from '../utilities/file.js' import * as backend from './Backend.js' import * as remoteBackendPaths from './Backend/remoteBackendPaths.js' diff --git a/app/electron-client/src/index.ts b/app/electron-client/src/index.ts index 47db1eee6cb8..121380fccfb5 100644 --- a/app/electron-client/src/index.ts +++ b/app/electron-client/src/index.ts @@ -40,7 +40,7 @@ import * as projectManagement from 'project-manager-shim' import { toElectronFileFilter, type FileFilter } from './fileBrowser' import * as download from 'electron-dl' -import type { DownloadUrlOptions } from 'enso-common/src/utilities/download' +import type { DownloadUrlOptions } from 'enso-common/src/download' import { filterByRole, inheritMenuItem, makeMenuItem, replaceMenuItems } from './menuItems' const DEFAULT_WINDOW_WIDTH = 1380 diff --git a/app/gui/src/dashboard/utilities/download.ts b/app/gui/src/dashboard/utilities/download.ts index 9d1106c3a8d9..37deb5b5029c 100644 --- a/app/gui/src/dashboard/utilities/download.ts +++ b/app/gui/src/dashboard/utilities/download.ts @@ -1,6 +1,6 @@ /** @file Functions to initiate a download. */ import type { SystemApi } from '$/electronApi' -import type { DownloadOptions, DownloadUrlOptions } from 'enso-common/src/utilities/download' +import type { DownloadOptions, DownloadUrlOptions } from 'enso-common/src/download' /** Initiate a download for the specified url. */ export async function download(options: DownloadOptions) { diff --git a/app/gui/src/electronApi.ts b/app/gui/src/electronApi.ts index 6019f169dfce..2a05c3e23c02 100644 --- a/app/gui/src/electronApi.ts +++ b/app/gui/src/electronApi.ts @@ -1,7 +1,7 @@ /** @file Shared API types exposed on `window.api` for both GUI and Electron. */ import type * as saveAccessToken from 'enso-common/src/accessToken' +import type { DownloadUrlOptions } from 'enso-common/src/download' import type { Path } from 'enso-common/src/services/Backend' -import type { DownloadUrlOptions } from 'enso-common/src/utilities/download' import type { FileFilter } from './project-view/util/fileFilter' import type { MenuItem, MenuItemHandler } from './project-view/util/menuItems' From bf8ed25d0108a2fe12978b7b6a690985a75f982b Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 31 Oct 2025 20:49:49 +1000 Subject: [PATCH 26/31] Remove `.js` extension from files in `common` --- app/common/src/download.ts | 2 +- app/common/src/services/Backend.ts | 18 +++++++-------- app/common/src/services/Backend/Category.ts | 4 ++-- .../src/services/Backend/projectExecution.ts | 4 ++-- .../services/Backend/remoteBackendPaths.ts | 2 +- app/common/src/services/Backend/types.ts | 4 ++-- app/common/src/services/Backend/utilities.ts | 4 ++-- app/common/src/services/HttpClient.ts | 2 +- app/common/src/services/LocalBackend.ts | 22 +++++++++---------- .../services/ProjectManager/ProjectManager.ts | 10 ++++----- .../src/services/ProjectManager/types.ts | 6 ++--- app/common/src/services/RemoteBackend.ts | 16 +++++++------- app/common/src/services/RemoteBackend/ids.ts | 4 ++-- .../src/services/__test__/Backend.test.ts | 4 ++-- app/common/src/utilities/data/dateTime.ts | 4 ++-- app/common/src/utilities/data/result.ts | 2 +- app/common/src/utilities/file.ts | 4 ++-- app/common/src/utilities/permissions.ts | 4 ++-- 18 files changed, 58 insertions(+), 58 deletions(-) diff --git a/app/common/src/download.ts b/app/common/src/download.ts index 6be8f2db8646..c36cd117dde2 100644 --- a/app/common/src/download.ts +++ b/app/common/src/download.ts @@ -1,4 +1,4 @@ -import type { Path } from './utilities/file.js' +import type { Path } from './utilities/file' export interface DownloadUrlOptions { readonly url: string diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 191f32b19ed8..b160742753c5 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -1,11 +1,11 @@ /** @file Type definitions common between all backends. */ import { z } from 'zod' -import type { DownloadOptions } from '../download.js' -import { getText, resolveDictionary, type Replacements, type TextId } from '../text.js' -import * as dateTime from '../utilities/data/dateTime.js' -import * as newtype from '../utilities/data/newtype.js' -import * as permissions from '../utilities/permissions.js' -import { getFileDetailsPath } from './Backend/remoteBackendPaths.js' +import type { DownloadOptions } from '../download' +import { getText, resolveDictionary, type Replacements, type TextId } from '../text' +import * as dateTime from '../utilities/data/dateTime' +import * as newtype from '../utilities/data/newtype' +import * as permissions from '../utilities/permissions' +import { getFileDetailsPath } from './Backend/remoteBackendPaths' import { DatalinkId, DirectoryId, @@ -35,11 +35,11 @@ import { type UserGroupId, type UserId, type UserPermissionIdentifier, -} from './Backend/types.js' -import { HttpClient, type HttpClientPostOptions, type ResponseWithTypedJson } from './HttpClient.js' +} from './Backend/types' +import { HttpClient, type HttpClientPostOptions, type ResponseWithTypedJson } from './HttpClient' export { prettifyError } from 'zod/v4' -export * from './Backend/types.js' +export * from './Backend/types' /** HTTP status indicating that the request was successful, but the user is not authorized to access. */ const STATUS_NOT_AUTHORIZED = 401 diff --git a/app/common/src/services/Backend/Category.ts b/app/common/src/services/Backend/Category.ts index 4478351bda0e..a44f7535de83 100644 --- a/app/common/src/services/Backend/Category.ts +++ b/app/common/src/services/Backend/Category.ts @@ -1,6 +1,6 @@ /** @file The categories available in the category switcher. */ import * as z from 'zod' -import type { UserId } from '../Backend.js' +import type { UserId } from '../Backend' import { BackendType, FilterBy, @@ -9,7 +9,7 @@ import { type User, type UserGroup, type UserGroupId, -} from '../Backend.js' +} from '../Backend' // oxlint-disable-next-line no-unused-vars const PATH_SCHEMA = z.string().refine((s): s is Path => true) diff --git a/app/common/src/services/Backend/projectExecution.ts b/app/common/src/services/Backend/projectExecution.ts index cf24bbbf1323..cc6e74c594ab 100644 --- a/app/common/src/services/Backend/projectExecution.ts +++ b/app/common/src/services/Backend/projectExecution.ts @@ -1,6 +1,6 @@ import { ZonedDateTime, parseAbsolute } from '@internationalized/date' -import { DAYS_PER_WEEK, MONTHS_PER_YEAR, getDay } from '../../utilities/data/dateTime.js' -import type { ProjectExecutionInfo } from '../Backend.js' +import { DAYS_PER_WEEK, MONTHS_PER_YEAR, getDay } from '../../utilities/data/dateTime' +import type { ProjectExecutionInfo } from '../Backend' /** Positive modulo of the number with respect to the base. */ function remainder(n: number, mod: number) { diff --git a/app/common/src/services/Backend/remoteBackendPaths.ts b/app/common/src/services/Backend/remoteBackendPaths.ts index f4107f19001e..8066a49c3350 100644 --- a/app/common/src/services/Backend/remoteBackendPaths.ts +++ b/app/common/src/services/Backend/remoteBackendPaths.ts @@ -16,7 +16,7 @@ import { type UserGroupId, type UserId, type ZipAssetsJobId, -} from './types.js' +} from './types' /** Relative HTTP path to the "list users" endpoint of the Cloud backend API. */ export const LIST_USERS_PATH = 'users' diff --git a/app/common/src/services/Backend/types.ts b/app/common/src/services/Backend/types.ts index 0e034a82a20f..c8f991749c58 100644 --- a/app/common/src/services/Backend/types.ts +++ b/app/common/src/services/Backend/types.ts @@ -1,5 +1,5 @@ -import { newtypeConstructor, type Newtype } from '../../utilities/data/newtype.js' -import type { IdType } from '../Backend.js' +import { newtypeConstructor, type Newtype } from '../../utilities/data/newtype' +import type { IdType } from '../Backend' /** A KSUID. */ export type KSUID = Newtype diff --git a/app/common/src/services/Backend/utilities.ts b/app/common/src/services/Backend/utilities.ts index 70f201740b23..9061e67344f0 100644 --- a/app/common/src/services/Backend/utilities.ts +++ b/app/common/src/services/Backend/utilities.ts @@ -1,6 +1,6 @@ /** @file Backend agnostic utility functions. */ -import type { DirectoryId } from '../Backend.js' -import type { AnyCategory } from './Category.js' +import type { DirectoryId } from '../Backend' +import type { AnyCategory } from './Category' /** Options for {@link parseDirectoriesPath}. */ export interface ParsedDirectoriesPathOptions { diff --git a/app/common/src/services/HttpClient.ts b/app/common/src/services/HttpClient.ts index 28a4c9ac41e6..709b19ceff55 100644 --- a/app/common/src/services/HttpClient.ts +++ b/app/common/src/services/HttpClient.ts @@ -1,5 +1,5 @@ /** @file HTTP client definition that includes default HTTP headers for all sent requests. */ -import { NetworkError, OfflineError, isNetworkError } from '../utilities/errors.js' +import { NetworkError, OfflineError, isNetworkError } from '../utilities/errors' export const FETCH_SUCCESS_EVENT_NAME = 'fetch-success' export const FETCH_ERROR_EVENT_NAME = 'fetch-error' diff --git a/app/common/src/services/LocalBackend.ts b/app/common/src/services/LocalBackend.ts index 9195f3a9723e..f5caa68db5a2 100644 --- a/app/common/src/services/LocalBackend.ts +++ b/app/common/src/services/LocalBackend.ts @@ -6,10 +6,10 @@ * the API. */ import { markRaw } from 'vue' -import type { DownloadOptions } from '../download.js' -import { PRODUCT_NAME } from '../index.js' -import { toReadableIsoString } from '../utilities/data/dateTime.js' -import { tryGetMessage } from '../utilities/errors.js' +import type { DownloadOptions } from '../download' +import { PRODUCT_NAME } from '../index' +import { toReadableIsoString } from '../utilities/data/dateTime' +import { tryGetMessage } from '../utilities/errors' import { fileExtension, getDirectoryAndName, @@ -17,18 +17,18 @@ import { getFolderPath, joinPath, normalizePath, -} from '../utilities/file.js' -import { uniqueString } from '../utilities/uniqueString.js' -import * as backend from './Backend.js' -import { downloadProjectPath, EXPORT_ARCHIVE_PATH } from './Backend/remoteBackendPaths.js' -import { HttpClient } from './HttpClient.js' -import type { ProjectManager } from './ProjectManager/ProjectManager.js' +} from '../utilities/file' +import { uniqueString } from '../utilities/uniqueString' +import * as backend from './Backend' +import { downloadProjectPath, EXPORT_ARCHIVE_PATH } from './Backend/remoteBackendPaths' +import { HttpClient } from './HttpClient' +import type { ProjectManager } from './ProjectManager/ProjectManager' import { MissingComponentAction, Path, ProjectName, type IpWithSocket, -} from './ProjectManager/types.js' +} from './ProjectManager/types' const LOCAL_API_URL = '/api/' diff --git a/app/common/src/services/ProjectManager/ProjectManager.ts b/app/common/src/services/ProjectManager/ProjectManager.ts index b7430e53cfba..9bcb00a86e46 100644 --- a/app/common/src/services/ProjectManager/ProjectManager.ts +++ b/app/common/src/services/ProjectManager/ProjectManager.ts @@ -3,16 +3,16 @@ * @see * https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-project-manager.md */ -import * as dateTime from '../../utilities/data/dateTime.js' -import { omit } from '../../utilities/data/object.js' +import * as dateTime from '../../utilities/data/dateTime' +import { omit } from '../../utilities/data/object' import { getDirectoryAndName, getFileName, getFolderPath, normalizeSlashes, -} from '../../utilities/file.js' -import { normalizeName } from '../../utilities/nameValidation.js' -import * as backend from '../Backend.js' +} from '../../utilities/file' +import { normalizeName } from '../../utilities/nameValidation' +import * as backend from '../Backend' import { MissingComponentAction, Path, diff --git a/app/common/src/services/ProjectManager/types.ts b/app/common/src/services/ProjectManager/types.ts index edb2c68c986f..c334a0c1f0c4 100644 --- a/app/common/src/services/ProjectManager/types.ts +++ b/app/common/src/services/ProjectManager/types.ts @@ -3,9 +3,9 @@ * @see * https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-project-manager.md */ -import type * as dateTime from '../../utilities/data/dateTime.js' -import * as newtype from '../../utilities/data/newtype.js' -import type * as backend from '../Backend.js' +import type * as dateTime from '../../utilities/data/dateTime' +import * as newtype from '../../utilities/data/newtype' +import type * as backend from '../Backend' /** Possible actions to take when a component is missing. */ export enum MissingComponentAction { diff --git a/app/common/src/services/RemoteBackend.ts b/app/common/src/services/RemoteBackend.ts index 749ceb76694b..aa14b5325856 100644 --- a/app/common/src/services/RemoteBackend.ts +++ b/app/common/src/services/RemoteBackend.ts @@ -7,15 +7,15 @@ */ import { markRaw } from 'vue' import { z } from 'zod' -import type { DownloadOptions } from '../download.js' -import { delay } from '../utilities/async.js' -import * as objects from '../utilities/data/object.js' -import * as detect from '../utilities/detect.js' -import { getFileName, getFolderPath } from '../utilities/file.js' -import * as backend from './Backend.js' -import * as remoteBackendPaths from './Backend/remoteBackendPaths.js' +import type { DownloadOptions } from '../download' +import { delay } from '../utilities/async' +import * as objects from '../utilities/data/object' +import * as detect from '../utilities/detect' +import { getFileName, getFolderPath } from '../utilities/file' +import * as backend from './Backend' +import * as remoteBackendPaths from './Backend/remoteBackendPaths' import type { HttpClient } from './HttpClient' -import { extractIdFromDirectoryId, organizationIdToDirectoryId } from './RemoteBackend/ids.js' +import { extractIdFromDirectoryId, organizationIdToDirectoryId } from './RemoteBackend/ids' /** HTTP status indicating that the resource does not exist. */ const STATUS_NOT_FOUND = 404 diff --git a/app/common/src/services/RemoteBackend/ids.ts b/app/common/src/services/RemoteBackend/ids.ts index 2126f22f98bf..dba9bcf47d1a 100644 --- a/app/common/src/services/RemoteBackend/ids.ts +++ b/app/common/src/services/RemoteBackend/ids.ts @@ -1,6 +1,6 @@ /** @file ID encoding and decoding that is specific to cloud backend. */ -import { DirectoryId, UserGroupId, UserId, type AssetId, type OrganizationId } from '../Backend.js' -import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '../Backend/remoteBackendPaths.js' +import { DirectoryId, UserGroupId, UserId, type AssetId, type OrganizationId } from '../Backend' +import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '../Backend/remoteBackendPaths' /** Whether the given directory is a special directory that cannot be written to. */ export function isSpecialReadonlyDirectoryId(id: AssetId) { diff --git a/app/common/src/services/__test__/Backend.test.ts b/app/common/src/services/__test__/Backend.test.ts index 4c73b3ec8e81..1b1389998e7d 100644 --- a/app/common/src/services/__test__/Backend.test.ts +++ b/app/common/src/services/__test__/Backend.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, test } from 'vitest' -import { Rfc3339DateTime } from '../../utilities/data/dateTime.js' +import { Rfc3339DateTime } from '../../utilities/data/dateTime' import { AssetType, compareAssets, doesTitleContainInvalidCharacters, type AnyAsset, -} from '../Backend.js' +} from '../Backend' describe('Backend', () => { it('sorts assets by modified date descending', () => { diff --git a/app/common/src/utilities/data/dateTime.ts b/app/common/src/utilities/data/dateTime.ts index 078f5af61c74..51148d44cee3 100644 --- a/app/common/src/utilities/data/dateTime.ts +++ b/app/common/src/utilities/data/dateTime.ts @@ -1,7 +1,7 @@ /** @file Utilities for manipulating and displaying dates and times. */ import { ZonedDateTime, getDayOfWeek } from '@internationalized/date' -import type { TextId } from '../../text.js' -import { newtypeConstructor, type Newtype } from './newtype.js' +import type { TextId } from '../../text' +import { newtypeConstructor, type Newtype } from './newtype' // 0 = Monday. Use `en-US` for 0 = Sunday. const DAY_OF_WEEK_LOCALE = 'en-GB' diff --git a/app/common/src/utilities/data/result.ts b/app/common/src/utilities/data/result.ts index e600fe0fb571..b44a24cb406d 100644 --- a/app/common/src/utilities/data/result.ts +++ b/app/common/src/utilities/data/result.ts @@ -2,7 +2,7 @@ * @file A generic type that can either hold a value representing a successful result, * or an error. */ -import { isSome, type Opt } from './opt.js' +import { isSome, type Opt } from './opt' /** * A type representing result of a function where errors are expected and recoverable. diff --git a/app/common/src/utilities/file.ts b/app/common/src/utilities/file.ts index cddbdf177c0e..5fece5a46c0c 100644 --- a/app/common/src/utilities/file.ts +++ b/app/common/src/utilities/file.ts @@ -1,5 +1,5 @@ -import { newtypeConstructor, type Newtype } from './data/newtype.js' -import { isOnWindows } from './detect.js' +import { newtypeConstructor, type Newtype } from './data/newtype' +import { isOnWindows } from './detect' /** A filesystem path. */ export type Path = Newtype diff --git a/app/common/src/utilities/permissions.ts b/app/common/src/utilities/permissions.ts index 4c97629a52dc..c28acf849794 100644 --- a/app/common/src/utilities/permissions.ts +++ b/app/common/src/utilities/permissions.ts @@ -5,8 +5,8 @@ import { type AssetPermission, type User, type UserGroup, -} from '../services/Backend.js' -import type * as text from '../text.js' +} from '../services/Backend' +import type * as text from '../text' /** Backend representation of user permission types. */ export enum PermissionAction { From 58c46e9398bc2ab5cbee9151fb3b08fb0bd9c80d Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 6 Nov 2025 23:00:57 +1000 Subject: [PATCH 27/31] Fix import errors --- app/common/src/services/Backend.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 6ce4fc81a15a..9697f522fc84 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -1,11 +1,11 @@ /** @file Type definitions common between all backends. */ import { z } from 'zod' -import type { DownloadOptions } from '../download' -import { getText, resolveDictionary, type Replacements, type TextId } from '../text' -import * as dateTime from '../utilities/data/dateTime' -import * as newtype from '../utilities/data/newtype' -import * as permissions from '../utilities/permissions' -import { getFileDetailsPath } from './Backend/remoteBackendPaths' +import type { DownloadOptions } from '../download.js' +import { getText, resolveDictionary, type Replacements, type TextId } from '../text.js' +import * as dateTime from '../utilities/data/dateTime.js' +import * as newtype from '../utilities/data/newtype.js' +import * as permissions from '../utilities/permissions.js' +import { getFileDetailsPath } from './Backend/remoteBackendPaths.js' import { DatalinkId, DirectoryId, @@ -35,8 +35,8 @@ import { type UserGroupId, type UserId, type UserPermissionIdentifier, -} from './Backend/types' -import { HttpClient, type HttpClientPostOptions, type ResponseWithTypedJson } from './HttpClient' +} from './Backend/types.js' +import { HttpClient, type HttpClientPostOptions, type ResponseWithTypedJson } from './HttpClient.js' export { prettifyError } from 'zod/v4' export * from './Backend/types' From 5e5a3639a8f288e4aa881d4fe0452272b9b3cc70 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 6 Nov 2025 23:06:14 +1000 Subject: [PATCH 28/31] Fix import errors --- app/common/src/services/Backend.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 9697f522fc84..a0ef93795b4e 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -39,7 +39,7 @@ import { import { HttpClient, type HttpClientPostOptions, type ResponseWithTypedJson } from './HttpClient.js' export { prettifyError } from 'zod/v4' -export * from './Backend/types' +export * from './Backend/types.js' /** HTTP status indicating that the request was successful, but the user is not authorized to access. */ const STATUS_NOT_AUTHORIZED = 401 From 70154993cb654c38327558ac5904d156b5b14a55 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 6 Nov 2025 23:43:07 +1000 Subject: [PATCH 29/31] Fix import errors --- app/common/src/services/Backend/remoteBackendPaths.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/common/src/services/Backend/remoteBackendPaths.ts b/app/common/src/services/Backend/remoteBackendPaths.ts index 8066a49c3350..f4107f19001e 100644 --- a/app/common/src/services/Backend/remoteBackendPaths.ts +++ b/app/common/src/services/Backend/remoteBackendPaths.ts @@ -16,7 +16,7 @@ import { type UserGroupId, type UserId, type ZipAssetsJobId, -} from './types' +} from './types.js' /** Relative HTTP path to the "list users" endpoint of the Cloud backend API. */ export const LIST_USERS_PATH = 'users' From e081372546f611fc8be3538b69d0a868e17dd42e Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Thu, 6 Nov 2025 23:50:02 +1000 Subject: [PATCH 30/31] I guess we do need `.js` suffix now... --- app/common/src/download.ts | 2 +- app/common/src/options.ts | 1 - app/common/src/services/Backend/Category.ts | 4 ++-- .../src/services/Backend/projectExecution.ts | 4 ++-- app/common/src/services/Backend/types.ts | 4 ++-- app/common/src/services/Backend/utilities.ts | 4 ++-- app/common/src/services/HttpClient.ts | 2 +- app/common/src/services/LocalBackend.ts | 22 +++++++++---------- .../services/ProjectManager/ProjectManager.ts | 12 +++++----- .../src/services/ProjectManager/types.ts | 6 ++--- app/common/src/services/RemoteBackend.ts | 18 +++++++-------- app/common/src/services/RemoteBackend/ids.ts | 4 ++-- app/common/src/utilities/data/dateTime.ts | 4 ++-- app/common/src/utilities/data/newtype.ts | 4 ---- app/common/src/utilities/data/result.ts | 3 ++- app/common/src/utilities/file.ts | 4 ++-- app/common/src/utilities/permissions.ts | 4 ++-- 17 files changed, 49 insertions(+), 53 deletions(-) diff --git a/app/common/src/download.ts b/app/common/src/download.ts index c36cd117dde2..6be8f2db8646 100644 --- a/app/common/src/download.ts +++ b/app/common/src/download.ts @@ -1,4 +1,4 @@ -import type { Path } from './utilities/file' +import type { Path } from './utilities/file.js' export interface DownloadUrlOptions { readonly url: string diff --git a/app/common/src/options.ts b/app/common/src/options.ts index a16d68227536..dee348b4ba53 100644 --- a/app/common/src/options.ts +++ b/app/common/src/options.ts @@ -1,5 +1,4 @@ /** @file Shared application options schema and helpers. */ - import { z } from 'zod' const DEFAULT_PROFILING_TIME = 120 diff --git a/app/common/src/services/Backend/Category.ts b/app/common/src/services/Backend/Category.ts index a44f7535de83..881eff19a530 100644 --- a/app/common/src/services/Backend/Category.ts +++ b/app/common/src/services/Backend/Category.ts @@ -1,6 +1,5 @@ /** @file The categories available in the category switcher. */ import * as z from 'zod' -import type { UserId } from '../Backend' import { BackendType, FilterBy, @@ -9,7 +8,8 @@ import { type User, type UserGroup, type UserGroupId, -} from '../Backend' + type UserId, +} from '../Backend.js' // oxlint-disable-next-line no-unused-vars const PATH_SCHEMA = z.string().refine((s): s is Path => true) diff --git a/app/common/src/services/Backend/projectExecution.ts b/app/common/src/services/Backend/projectExecution.ts index cc6e74c594ab..cf24bbbf1323 100644 --- a/app/common/src/services/Backend/projectExecution.ts +++ b/app/common/src/services/Backend/projectExecution.ts @@ -1,6 +1,6 @@ import { ZonedDateTime, parseAbsolute } from '@internationalized/date' -import { DAYS_PER_WEEK, MONTHS_PER_YEAR, getDay } from '../../utilities/data/dateTime' -import type { ProjectExecutionInfo } from '../Backend' +import { DAYS_PER_WEEK, MONTHS_PER_YEAR, getDay } from '../../utilities/data/dateTime.js' +import type { ProjectExecutionInfo } from '../Backend.js' /** Positive modulo of the number with respect to the base. */ function remainder(n: number, mod: number) { diff --git a/app/common/src/services/Backend/types.ts b/app/common/src/services/Backend/types.ts index c8f991749c58..0e034a82a20f 100644 --- a/app/common/src/services/Backend/types.ts +++ b/app/common/src/services/Backend/types.ts @@ -1,5 +1,5 @@ -import { newtypeConstructor, type Newtype } from '../../utilities/data/newtype' -import type { IdType } from '../Backend' +import { newtypeConstructor, type Newtype } from '../../utilities/data/newtype.js' +import type { IdType } from '../Backend.js' /** A KSUID. */ export type KSUID = Newtype diff --git a/app/common/src/services/Backend/utilities.ts b/app/common/src/services/Backend/utilities.ts index 9061e67344f0..70f201740b23 100644 --- a/app/common/src/services/Backend/utilities.ts +++ b/app/common/src/services/Backend/utilities.ts @@ -1,6 +1,6 @@ /** @file Backend agnostic utility functions. */ -import type { DirectoryId } from '../Backend' -import type { AnyCategory } from './Category' +import type { DirectoryId } from '../Backend.js' +import type { AnyCategory } from './Category.js' /** Options for {@link parseDirectoriesPath}. */ export interface ParsedDirectoriesPathOptions { diff --git a/app/common/src/services/HttpClient.ts b/app/common/src/services/HttpClient.ts index 709b19ceff55..28a4c9ac41e6 100644 --- a/app/common/src/services/HttpClient.ts +++ b/app/common/src/services/HttpClient.ts @@ -1,5 +1,5 @@ /** @file HTTP client definition that includes default HTTP headers for all sent requests. */ -import { NetworkError, OfflineError, isNetworkError } from '../utilities/errors' +import { NetworkError, OfflineError, isNetworkError } from '../utilities/errors.js' export const FETCH_SUCCESS_EVENT_NAME = 'fetch-success' export const FETCH_ERROR_EVENT_NAME = 'fetch-error' diff --git a/app/common/src/services/LocalBackend.ts b/app/common/src/services/LocalBackend.ts index 25ea74c64844..b41787edc98d 100644 --- a/app/common/src/services/LocalBackend.ts +++ b/app/common/src/services/LocalBackend.ts @@ -6,10 +6,10 @@ * the API. */ import { markRaw } from 'vue' -import type { DownloadOptions } from '../download' -import { PRODUCT_NAME } from '../index' -import { toReadableIsoString } from '../utilities/data/dateTime' -import { tryGetMessage } from '../utilities/errors' +import type { DownloadOptions } from '../download.js' +import { PRODUCT_NAME } from '../index.js' +import { toReadableIsoString } from '../utilities/data/dateTime.js' +import { tryGetMessage } from '../utilities/errors.js' import { fileExtension, getDirectoryAndName, @@ -18,18 +18,18 @@ import { joinPath, normalizePath, normalizeSlashes, -} from '../utilities/file' -import { uniqueString } from '../utilities/uniqueString' -import * as backend from './Backend' -import { downloadProjectPath, EXPORT_ARCHIVE_PATH } from './Backend/remoteBackendPaths' -import { HttpClient } from './HttpClient' -import type { ProjectManager } from './ProjectManager/ProjectManager' +} from '../utilities/file.js' +import { uniqueString } from '../utilities/uniqueString.js' +import * as backend from './Backend.js' +import { downloadProjectPath, EXPORT_ARCHIVE_PATH } from './Backend/remoteBackendPaths.js' +import { HttpClient } from './HttpClient.js' +import type { ProjectManager } from './ProjectManager/ProjectManager.js' import { MissingComponentAction, Path, ProjectName, type IpWithSocket, -} from './ProjectManager/types' +} from './ProjectManager/types.js' const LOCAL_API_URL = '/api/' diff --git a/app/common/src/services/ProjectManager/ProjectManager.ts b/app/common/src/services/ProjectManager/ProjectManager.ts index 833589f86b3f..7b443d648a58 100644 --- a/app/common/src/services/ProjectManager/ProjectManager.ts +++ b/app/common/src/services/ProjectManager/ProjectManager.ts @@ -3,16 +3,16 @@ * @see * https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-project-manager.md */ -import * as dateTime from '../../utilities/data/dateTime' -import { omit } from '../../utilities/data/object' +import * as dateTime from '../../utilities/data/dateTime.js' +import { omit } from '../../utilities/data/object.js' import { getDirectoryAndName, getFileName, getFolderPath, normalizeSlashes, -} from '../../utilities/file' -import { normalizeName } from '../../utilities/nameValidation' -import * as backend from '../Backend' +} from '../../utilities/file.js' +import { normalizeName } from '../../utilities/nameValidation.js' +import * as backend from '../Backend.js' import { MissingComponentAction, Path, @@ -29,7 +29,7 @@ import { type ProjectState, type RenameProjectParams, type UUID, -} from './types' +} from './types.js' /** A project with its path provided instead of its id. */ type WithProjectPath = Omit & { diff --git a/app/common/src/services/ProjectManager/types.ts b/app/common/src/services/ProjectManager/types.ts index c334a0c1f0c4..edb2c68c986f 100644 --- a/app/common/src/services/ProjectManager/types.ts +++ b/app/common/src/services/ProjectManager/types.ts @@ -3,9 +3,9 @@ * @see * https://github.com/enso-org/enso/blob/develop/docs/language-server/protocol-project-manager.md */ -import type * as dateTime from '../../utilities/data/dateTime' -import * as newtype from '../../utilities/data/newtype' -import type * as backend from '../Backend' +import type * as dateTime from '../../utilities/data/dateTime.js' +import * as newtype from '../../utilities/data/newtype.js' +import type * as backend from '../Backend.js' /** Possible actions to take when a component is missing. */ export enum MissingComponentAction { diff --git a/app/common/src/services/RemoteBackend.ts b/app/common/src/services/RemoteBackend.ts index aa14b5325856..35ecbdcbf907 100644 --- a/app/common/src/services/RemoteBackend.ts +++ b/app/common/src/services/RemoteBackend.ts @@ -7,15 +7,15 @@ */ import { markRaw } from 'vue' import { z } from 'zod' -import type { DownloadOptions } from '../download' -import { delay } from '../utilities/async' -import * as objects from '../utilities/data/object' -import * as detect from '../utilities/detect' -import { getFileName, getFolderPath } from '../utilities/file' -import * as backend from './Backend' -import * as remoteBackendPaths from './Backend/remoteBackendPaths' -import type { HttpClient } from './HttpClient' -import { extractIdFromDirectoryId, organizationIdToDirectoryId } from './RemoteBackend/ids' +import type { DownloadOptions } from '../download.js' +import { delay } from '../utilities/async.js' +import * as objects from '../utilities/data/object.js' +import * as detect from '../utilities/detect.js' +import { getFileName, getFolderPath } from '../utilities/file.js' +import * as backend from './Backend.js' +import * as remoteBackendPaths from './Backend/remoteBackendPaths.js' +import type { HttpClient } from './HttpClient.js' +import { extractIdFromDirectoryId, organizationIdToDirectoryId } from './RemoteBackend/ids.js' /** HTTP status indicating that the resource does not exist. */ const STATUS_NOT_FOUND = 404 diff --git a/app/common/src/services/RemoteBackend/ids.ts b/app/common/src/services/RemoteBackend/ids.ts index dba9bcf47d1a..2126f22f98bf 100644 --- a/app/common/src/services/RemoteBackend/ids.ts +++ b/app/common/src/services/RemoteBackend/ids.ts @@ -1,6 +1,6 @@ /** @file ID encoding and decoding that is specific to cloud backend. */ -import { DirectoryId, UserGroupId, UserId, type AssetId, type OrganizationId } from '../Backend' -import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '../Backend/remoteBackendPaths' +import { DirectoryId, UserGroupId, UserId, type AssetId, type OrganizationId } from '../Backend.js' +import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '../Backend/remoteBackendPaths.js' /** Whether the given directory is a special directory that cannot be written to. */ export function isSpecialReadonlyDirectoryId(id: AssetId) { diff --git a/app/common/src/utilities/data/dateTime.ts b/app/common/src/utilities/data/dateTime.ts index 51148d44cee3..078f5af61c74 100644 --- a/app/common/src/utilities/data/dateTime.ts +++ b/app/common/src/utilities/data/dateTime.ts @@ -1,7 +1,7 @@ /** @file Utilities for manipulating and displaying dates and times. */ import { ZonedDateTime, getDayOfWeek } from '@internationalized/date' -import type { TextId } from '../../text' -import { newtypeConstructor, type Newtype } from './newtype' +import type { TextId } from '../../text.js' +import { newtypeConstructor, type Newtype } from './newtype.js' // 0 = Monday. Use `en-US` for 0 = Sunday. const DAY_OF_WEEK_LOCALE = 'en-GB' diff --git a/app/common/src/utilities/data/newtype.ts b/app/common/src/utilities/data/newtype.ts index 39c827edbc4d..50f993c831b0 100644 --- a/app/common/src/utilities/data/newtype.ts +++ b/app/common/src/utilities/data/newtype.ts @@ -1,9 +1,5 @@ /** @file Emulates `newtype`s in TypeScript. */ -// =============== -// === Newtype === -// =============== - /** An interface specifying the variant of a newtype. */ type NewtypeVariant = { readonly _$type: TypeName diff --git a/app/common/src/utilities/data/result.ts b/app/common/src/utilities/data/result.ts index 454c73177e78..3aec6c06a998 100644 --- a/app/common/src/utilities/data/result.ts +++ b/app/common/src/utilities/data/result.ts @@ -2,7 +2,7 @@ * @file A generic type that can either hold a value representing a successful result, * or an error. */ -import { isSome, type Opt } from './opt' +import { isSome, type Opt } from './opt.js' /** * A type representing result of a function where errors are expected and recoverable. @@ -201,6 +201,7 @@ export class ResultError { return `${preamble}${preamble ? ': ' : ''}${payload}${ctx}` } + /** A readable string representation of the error. */ toString() { return this.message() } diff --git a/app/common/src/utilities/file.ts b/app/common/src/utilities/file.ts index 5fece5a46c0c..cddbdf177c0e 100644 --- a/app/common/src/utilities/file.ts +++ b/app/common/src/utilities/file.ts @@ -1,5 +1,5 @@ -import { newtypeConstructor, type Newtype } from './data/newtype' -import { isOnWindows } from './detect' +import { newtypeConstructor, type Newtype } from './data/newtype.js' +import { isOnWindows } from './detect.js' /** A filesystem path. */ export type Path = Newtype diff --git a/app/common/src/utilities/permissions.ts b/app/common/src/utilities/permissions.ts index c28acf849794..4c97629a52dc 100644 --- a/app/common/src/utilities/permissions.ts +++ b/app/common/src/utilities/permissions.ts @@ -5,8 +5,8 @@ import { type AssetPermission, type User, type UserGroup, -} from '../services/Backend' -import type * as text from '../text' +} from '../services/Backend.js' +import type * as text from '../text.js' /** Backend representation of user permission types. */ export enum PermissionAction { From ddc0da0b1a62c393fda9f92f70a60f3dd63d1e6b Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 7 Nov 2025 01:23:39 +1000 Subject: [PATCH 31/31] Fix type errors --- .../components/AppContainer/AppContainer.vue | 6 +++--- app/gui/src/providers/openedProjects.ts | 2 +- .../providers/openedProjects/projectInfo.ts | 7 ++++++- .../providers/openedProjects/projectStates.ts | 20 +++++++++---------- app/gui/src/providers/react/openedProjects.ts | 2 +- 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/app/gui/src/components/AppContainer/AppContainer.vue b/app/gui/src/components/AppContainer/AppContainer.vue index 932c43faa378..7299008f4d0f 100644 --- a/app/gui/src/components/AppContainer/AppContainer.vue +++ b/app/gui/src/components/AppContainer/AppContainer.vue @@ -1,14 +1,11 @@