diff --git a/.gitignore b/.gitignore index 444645571d9b..d860250369e9 100644 --- a/.gitignore +++ b/.gitignore @@ -191,3 +191,9 @@ test-results/ test-traces/ playwright-report/ playwright/.cache/ + +######### +## Git ## +######### + +/.mailmap diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c054bb201f5..626ec6f1edad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ [11902]: https://github.com/enso-org/enso/pull/11902 [11908]: https://github.com/enso-org/enso/pull/11908 +#### Enso Standard Library + +- [Allow using `/` to access files inside a directory reached through a data + link.][11926] + +[11926]: https://github.com/enso-org/enso/pull/11926 + #### Enso Language & Runtime - [Promote broken values instead of ignoring them][11777]. diff --git a/app/common/src/backendQuery.ts b/app/common/src/backendQuery.ts index 1c1ae7fafcaf..75f5054bfab2 100644 --- a/app/common/src/backendQuery.ts +++ b/app/common/src/backendQuery.ts @@ -11,7 +11,9 @@ export type BackendMethods = object.ExtractKeys) => queryCore.QueryKey + [Method in BackendMethods]?: ( + ...args: Readonly> + ) => queryCore.QueryKey } const NORMALIZE_METHOD_QUERY: BackendQueryNormalizers = { @@ -22,7 +24,7 @@ const NORMALIZE_METHOD_QUERY: BackendQueryNormalizers = { /** Creates a partial query key representing the given method and arguments. */ function normalizeMethodQuery( method: Method, - args: Parameters, + args: Readonly>, ) { return NORMALIZE_METHOD_QUERY[method]?.(...args) ?? args } @@ -31,7 +33,7 @@ function normalizeMethodQuery( export function backendQueryOptions( backend: Backend | null, method: Method, - args: Parameters, + args: Readonly>, keyExtra?: queryCore.QueryKey | undefined, ): { queryKey: queryCore.QueryKey @@ -47,7 +49,7 @@ export function backendQueryOptions( export function backendQueryKey( backend: Backend | null, method: Method, - args: Parameters, + args: Readonly>, keyExtra?: queryCore.QueryKey | undefined, ): queryCore.QueryKey { return [backend?.type, method, ...normalizeMethodQuery(method, args), ...(keyExtra ?? [])] diff --git a/app/common/src/queryClient.ts b/app/common/src/queryClient.ts index 140caf24287e..90a1d1c3c775 100644 --- a/app/common/src/queryClient.ts +++ b/app/common/src/queryClient.ts @@ -39,6 +39,7 @@ declare module '@tanstack/query-core' { * @default false */ readonly awaitInvalidates?: queryCore.QueryKey[] | boolean + readonly refetchType?: queryCore.InvalidateQueryFilters['refetchType'] } readonly queryMeta: { @@ -98,6 +99,7 @@ export function createQueryClient( mutationCache: new queryCore.MutationCache({ onSuccess: (_data, _variables, _context, mutation) => { const shouldAwaitInvalidates = mutation.meta?.awaitInvalidates ?? false + const refetchType = mutation.meta?.refetchType ?? 'active' const invalidates = mutation.meta?.invalidates ?? [] const invalidatesToAwait = (() => { if (Array.isArray(shouldAwaitInvalidates)) { @@ -113,6 +115,7 @@ export function createQueryClient( for (const queryKey of invalidatesToIgnore) { void queryClient.invalidateQueries({ predicate: query => queryCore.matchQuery({ queryKey }, query), + refetchType, }) } @@ -121,6 +124,7 @@ export function createQueryClient( invalidatesToAwait.map(queryKey => queryClient.invalidateQueries({ predicate: query => queryCore.matchQuery({ queryKey }, query), + refetchType, }), ), ) diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 452d08ef633e..7a77545c63f4 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -1070,6 +1070,18 @@ export function assetIsType(type: Type) { return (asset: AnyAsset): asset is Extract> => asset.type === type } +/** Extract the type of an id and return a discriminated union containing both id and type. */ +export function extractTypeFromId(id: AssetId): AnyAsset extends infer T ? + T extends T ? + Pick + : never +: never { + return { + type: id.match(/^(.+?)-/)?.[1], + id, + } as never +} + /** Creates a new placeholder asset id for the given asset type. */ export function createPlaceholderAssetId( type: Type, @@ -1674,11 +1686,7 @@ export default abstract class Backend { title: string, ): Promise /** Return project details. */ - abstract getProjectDetails( - projectId: ProjectId, - directoryId: DirectoryId | null, - getPresignedUrl?: boolean, - ): Promise + abstract getProjectDetails(projectId: ProjectId, getPresignedUrl?: boolean): Promise /** Return Language Server logs for a project session. */ abstract getProjectSessionLogs( projectSessionId: ProjectSessionId, @@ -1767,8 +1775,8 @@ export default abstract class Backend { projectId?: string | null, metadata?: object | null, ): Promise - /** Download from an arbitrary URL that is assumed to originate from this backend. */ - abstract download(url: string, name?: string): Promise + /** Download an asset. */ + abstract download(assetId: AssetId, title: string): Promise /** * Get the URL for the customer portal. diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 2a8daff68ada..41d132f95426 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -39,7 +39,6 @@ "editDescriptionError": "Could not edit description", "canOnlyDownloadFilesError": "You currently can only download files.", "noProjectSelectedError": "First select a project to download.", - "downloadInvalidTypeError": "You can only download files, projects, and Datalinks", "downloadProjectError": "Could not download project '$0'", "downloadFileError": "Could not download file '$0'", "downloadDatalinkError": "Could not download Datalink '$0'", @@ -64,9 +63,6 @@ "nameShouldNotContainInvalidCharacters": "Name should not contain invalid characters", "invalidEmailValidationError": "Please enter a valid email address", - "projectHasNoSourceFilesPhrase": "project has no source files", - "fileNotFoundPhrase": "file not found", - "noNewProfilePictureError": "Could not upload a new profile picture because no image was found", "registrationError": "Something went wrong! Please try again or contact the administrators.", diff --git a/app/common/src/utilities/data/array.ts b/app/common/src/utilities/data/array.ts index 032937f468de..37d1f335db56 100644 --- a/app/common/src/utilities/data/array.ts +++ b/app/common/src/utilities/data/array.ts @@ -1,6 +1,6 @@ /** @file Utilities for manipulating arrays. */ -export const EMPTY_ARRAY: readonly never[] = [] +export const EMPTY_ARRAY: readonly [] = [] // ==================== // === shallowEqual === diff --git a/app/common/src/utilities/data/object.ts b/app/common/src/utilities/data/object.ts index 963cafed7b4e..cd6b01233b35 100644 --- a/app/common/src/utilities/data/object.ts +++ b/app/common/src/utilities/data/object.ts @@ -219,3 +219,12 @@ export function useObjectId() { * can be used to splice together objects without the risk of collisions. */ export type DisjointKeysUnion = keyof A & keyof B extends never ? A & B : never + +/** + * Merge types of values of an object union. Useful to return an object that UNSAFELY + * (at runtime) conforms to the shape of a discriminated union. + * Especially useful for things like Tanstack Query results. + */ +export type MergeValuesOfObjectUnion = { + [K in `${keyof T & string}`]: T[K & keyof T] +} diff --git a/app/gui/.storybook/preview.tsx b/app/gui/.storybook/preview.tsx index a984a548119e..fbcc5e609e85 100644 --- a/app/gui/.storybook/preview.tsx +++ b/app/gui/.storybook/preview.tsx @@ -66,7 +66,7 @@ const reactPreview: ReactPreview = { (Story, context) => ( <> -
+
diff --git a/app/gui/index.html b/app/gui/index.html index 2fd49ff74630..c6225a07d42e 100644 --- a/app/gui/index.html +++ b/app/gui/index.html @@ -50,7 +50,7 @@
-
+
diff --git a/app/gui/integration-test/dashboard/createAsset.spec.ts b/app/gui/integration-test/dashboard/createAsset.spec.ts index a9ae229e685f..79688a9e721b 100644 --- a/app/gui/integration-test/dashboard/createAsset.spec.ts +++ b/app/gui/integration-test/dashboard/createAsset.spec.ts @@ -1,5 +1,5 @@ /** @file Test copying, moving, cutting and pasting. */ -import { expect, test, type Page } from '@playwright/test' +import { expect, test } from '@playwright/test' import { mockAllAndLogin } from './actions' @@ -12,13 +12,6 @@ const SECRET_NAME = 'a secret name' /** The value of the created secret. */ const SECRET_VALUE = 'a secret value' -/** Find an editor container. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function locateEditor(page: Page) { - // Test ID of a placeholder editor component used during testing. - return page.locator('.App') -} - test('create folder', ({ page }) => mockAllAndLogin({ page }) .createFolder() diff --git a/app/gui/integration-test/dashboard/delete.spec.ts b/app/gui/integration-test/dashboard/delete.spec.ts index 360bc8f69c9d..f1b68c344370 100644 --- a/app/gui/integration-test/dashboard/delete.spec.ts +++ b/app/gui/integration-test/dashboard/delete.spec.ts @@ -21,11 +21,6 @@ test('delete and restore', ({ page }) => .contextMenu.restoreFromTrash() .driveTable.expectTrashPlaceholderRow() .goToCategory.cloud() - .expectStartModal() - .withStartModal(async (startModal) => { - await expect(startModal).toBeVisible() - }) - .close() .driveTable.withRows(async (rows) => { await expect(rows).toHaveCount(1) })) @@ -50,8 +45,6 @@ test('delete and restore (keyboard)', ({ page }) => .press('Mod+R') .driveTable.expectTrashPlaceholderRow() .goToCategory.cloud() - .expectStartModal() - .close() .driveTable.withRows(async (rows) => { await expect(rows).toHaveCount(1) })) diff --git a/app/gui/integration-test/dashboard/driveView.spec.ts b/app/gui/integration-test/dashboard/driveView.spec.ts index 883f54ba9425..607b039042c3 100644 --- a/app/gui/integration-test/dashboard/driveView.spec.ts +++ b/app/gui/integration-test/dashboard/driveView.spec.ts @@ -1,15 +1,8 @@ /** @file Test the drive view. */ -import { expect, test, type Locator, type Page } from '@playwright/test' +import { expect, test, type Locator } from '@playwright/test' import { TEXT, mockAllAndLogin } from './actions' -/** Find an editor container. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function locateEditor(page: Page) { - // Test ID of a placeholder editor component used during testing. - return page.locator('.App') -} - /** Find a button to close the project. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function locateStopProjectButton(page: Locator) { diff --git a/app/gui/integration-test/dashboard/labelsPanel.spec.ts b/app/gui/integration-test/dashboard/labelsPanel.spec.ts index f38f2204974a..dfcd6781f650 100644 --- a/app/gui/integration-test/dashboard/labelsPanel.spec.ts +++ b/app/gui/integration-test/dashboard/labelsPanel.spec.ts @@ -84,6 +84,7 @@ test('labels', ({ page }) => // Labels panel with one entry await locateCreateButton(locateNewLabelModal(page)).click() await expect(locateLabelsPanel(page)).toBeVisible() + expect(await locateLabelsPanelLabels(page).count()).toBe(1) // Empty labels panel again, after deleting the only entry await locateLabelsPanelLabels(page).first().hover() @@ -91,5 +92,5 @@ test('labels', ({ page }) => const labelsPanel = locateLabelsPanel(page) await labelsPanel.getByRole('button').and(labelsPanel.getByLabel(TEXT.delete)).click() await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() - expect(await locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1) + expect(await locateLabelsPanelLabels(page).count()).toBe(0) })) diff --git a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts index de26fe3bded7..5efac7ffbf8e 100644 --- a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts +++ b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts @@ -6,7 +6,7 @@ import { mockAllAndLogin } from './actions' /** Find an editor container. */ function locateEditor(page: Page) { // Test ID of a placeholder editor component used during testing. - return page.locator('.App') + return page.locator('.ProjectView') } /** Find a drive view. */ diff --git a/app/gui/integration-test/dashboard/startModal.spec.ts b/app/gui/integration-test/dashboard/startModal.spec.ts index 3c1ef1b6b8cb..26c46abda262 100644 --- a/app/gui/integration-test/dashboard/startModal.spec.ts +++ b/app/gui/integration-test/dashboard/startModal.spec.ts @@ -6,7 +6,7 @@ import { mockAllAndLogin } from './actions' /** Find an editor container. */ function locateEditor(page: Page) { // Test ID of a placeholder editor component used during testing. - return page.locator('.App') + return page.locator('.ProjectView') } /** Find a samples list. */ diff --git a/app/gui/integration-test/project-view/graphNodeVisualization.spec.ts b/app/gui/integration-test/project-view/graphNodeVisualization.spec.ts index af27ca9159c9..6b8d9ddd0861 100644 --- a/app/gui/integration-test/project-view/graphNodeVisualization.spec.ts +++ b/app/gui/integration-test/project-view/graphNodeVisualization.spec.ts @@ -3,6 +3,7 @@ import assert from 'assert' import * as actions from './actions' import { computedContent } from './css' import { expect } from './customExpect' +import { CONTROL_KEY } from './keyboard' import * as locate from './locate' test('Node can open and load visualization', async ({ page }) => { @@ -50,10 +51,12 @@ test('Previewing visualization', async ({ page }) => { test('Warnings visualization', async ({ page }) => { await actions.goToGraph(page) - + // Without centering the graph, menu sometimes goes out of the view. + await page.keyboard.press(`${CONTROL_KEY}+Shift+A`) // Create a node, attach a warning, open the warnings-visualization. await locate.addNewNodeButton(page).click() const input = locate.componentBrowserInput(page).locator('input') + await input.fill('Warning.attach "Uh oh" 42') await page.keyboard.press('Enter') await expect(locate.componentBrowser(page)).toBeHidden() diff --git a/app/gui/src/App.vue b/app/gui/src/App.vue new file mode 100644 index 000000000000..c81288cfaf3e --- /dev/null +++ b/app/gui/src/App.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/app/gui/src/ReactRoot.tsx b/app/gui/src/ReactRoot.tsx new file mode 100644 index 000000000000..000ed9ae4372 --- /dev/null +++ b/app/gui/src/ReactRoot.tsx @@ -0,0 +1,76 @@ +/** @file A file containing setup for React part of application. */ + +import App from '#/App.tsx' +import { ReactQueryDevtools } from '#/components/Devtools' +import { ErrorBoundary } from '#/components/ErrorBoundary' +import { OfflineNotificationManager } from '#/components/OfflineNotificationManager' +import { Suspense } from '#/components/Suspense' +import UIProviders from '#/components/UIProviders' +import LoadingScreen from '#/pages/authentication/LoadingScreen' +import { HttpClientProvider } from '#/providers/HttpClientProvider' +import LoggerProvider from '#/providers/LoggerProvider' +import HttpClient from '#/utilities/HttpClient' +import { ApplicationConfigValue } from '@/util/config' +import { QueryClientProvider } from '@tanstack/react-query' +import { QueryClient } from '@tanstack/vue-query' +import { IS_DEV_MODE, isOnElectron, isOnLinux } from 'enso-common/src/detect' +import { StrictMode } from 'react' +import invariant from 'tiny-invariant' + +interface ReactRootProps { + config: ApplicationConfigValue + queryClient: QueryClient + classSet: Map + onAuthenticated: (accessToken: string | null) => void +} + +function resolveEnvUrl(url: string | undefined) { + return url?.replace('__HOSTNAME__', window.location.hostname) +} + +/** + * A component gathering all views written currently in React with necessary contexts. + */ +export default function ReactRoot(props: ReactRootProps) { + const { config, queryClient, onAuthenticated } = props + + const httpClient = new HttpClient() + const supportsDeepLinks = !IS_DEV_MODE && !isOnLinux() && isOnElectron() + const portalRoot = document.querySelector('#enso-portal-root') + const shouldUseAuthentication = config.authentication.enabled + const projectManagerUrl = + (config.engine.projectManagerUrl || resolveEnvUrl(PROJECT_MANAGER_URL)) ?? null + const ydocUrl = (config.engine.ydocUrl || resolveEnvUrl(YDOC_SERVER_URL)) ?? null + const initialProjectName = config.startup.project || null + invariant(portalRoot, 'PortalRoot element not found') + + return ( + + + + + }> + + + + + + + + + + + + + + + ) +} diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index 6d04f6c9c6b6..0e786cae8728 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -70,7 +70,6 @@ import Dashboard from '#/pages/dashboard/Dashboard' import * as subscribe from '#/pages/subscribe/Subscribe' import * as subscribeSuccess from '#/pages/subscribe/SubscribeSuccess' -import type * as editor from '#/layouts/Editor' import * as openAppWatcher from '#/layouts/OpenAppWatcher' import VersionChecker from '#/layouts/VersionChecker' @@ -143,7 +142,6 @@ function getMainPageUrl() { /** Global configuration for the `App` component. */ export interface AppProps { - readonly vibrancy: boolean /** Whether the application may have the local backend running. */ readonly supportsLocalBackend: boolean /** If true, the app can only be used in offline mode. */ @@ -153,15 +151,11 @@ export interface AppProps { * the installed app on macOS and Windows. */ readonly supportsDeepLinks: boolean - /** Whether the dashboard should be rendered. */ - readonly shouldShowDashboard: boolean /** The name of the project to open on startup, if any. */ readonly initialProjectName: string | null readonly onAuthenticated: (accessToken: string | null) => void readonly projectManagerUrl: string | null readonly ydocUrl: string | null - readonly appRunner: editor.GraphEditorRunner | null - readonly queryClient: reactQuery.QueryClient } /** @@ -217,8 +211,7 @@ export default function App(props: AppProps) { const { isOffline } = useOffline() const { getText } = textProvider.useText() - - const queryClient = props.queryClient + const queryClient = reactQuery.useQueryClient() // Force all queries to be stale // We don't use the `staleTime` option because it's not performant @@ -304,8 +297,7 @@ export interface AppRouterProps extends AppProps { * component as the component that defines the provider. */ function AppRouter(props: AppRouterProps) { - const { isAuthenticationDisabled, shouldShowDashboard } = props - const { onAuthenticated, projectManagerInstance } = props + const { isAuthenticationDisabled, onAuthenticated, projectManagerInstance } = props const httpClient = useHttpClientStrict() const logger = useLogger() const navigate = router.useNavigate() @@ -483,10 +475,7 @@ function AppRouter(props: AppRouterProps) { }> }> }> - } - /> + } /> , item: backendModule.AnyAsset, ) => void - readonly onDragOver?: ( - event: React.DragEvent, - item: backendModule.AnyAsset, - ) => void readonly onDragLeave?: ( event: React.DragEvent, item: backendModule.AnyAsset, @@ -148,18 +131,14 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) { } }) -/** - * Props for a {@link AssetSpecialRow}. - */ +/** Props for a {@link AssetSpecialRow}. */ export interface AssetSpecialRowProps { readonly type: backendModule.AssetType readonly columnsLength: number readonly depth: number } -/** - * Renders a special asset row. - */ +/** Renders a special asset row. */ // eslint-disable-next-line no-restricted-syntax const AssetSpecialRow = React.memo(function AssetSpecialRow(props: AssetSpecialRowProps) { const { type, columnsLength, depth } = props @@ -230,14 +209,10 @@ const AssetSpecialRow = React.memo(function AssetSpecialRow(props: AssetSpecialR } }) -/** - * Props for a {@link RealAssetRow}. - */ +/** Props for a {@link RealAssetRow}. */ type RealAssetRowProps = AssetRowProps & { readonly id: backendModule.RealAssetId } -/** - * Renders a real asset row. - */ +/** Renders a real asset row. */ // eslint-disable-next-line no-restricted-syntax const RealAssetRow = React.memo(function RealAssetRow(props: RealAssetRowProps) { const { id } = props @@ -252,16 +227,12 @@ const RealAssetRow = React.memo(function RealAssetRow(props: RealAssetRowProps) return }) -/** - * Internal props for a {@link RealAssetRow}. - */ +/** Internal props for a {@link RealAssetRow}. */ export interface RealAssetRowInternalProps extends AssetRowProps { readonly asset: backendModule.AnyAsset } -/** - * Internal implementation of a {@link RealAssetRow}. - */ +/** Internal implementation of a {@link RealAssetRow}. */ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { const { id, @@ -278,14 +249,12 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { asset, } = props const { path, hidden: hiddenRaw, grabKeyboardFocus, visibility: visibilityRaw, depth } = props - const { initialAssetEvents } = props - const { nodeMap, doCopy, doCut, doPaste, doDelete: doDeleteRaw } = state - const { doRestore, doMove, category, rootDirectoryId, backend } = state + const { nodeMap, doCopy, doCut, doPaste } = state + const { category, rootDirectoryId, backend } = state const driveStore = useDriveStore() - const queryClient = useQueryClient() const { user } = useFullUserSession() - const setSelectedKeys = useSetSelectedKeys() + const setSelectedAssets = useSetSelectedAssets() const selected = useStore(driveStore, ({ visuallySelectedKeys, selectedKeys }) => (visuallySelectedKeys ?? selectedKeys).has(id), ) @@ -299,17 +268,18 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { ) const draggableProps = dragAndDropHooks.useDraggable({ isDisabled: !selected }) const { setModal, unsetModal } = modalProvider.useSetModal() - const { getText } = textProvider.useText() - const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const [isDraggedOver, setIsDraggedOver] = React.useState(false) + const setIsDraggingOverSelectedRow = useSetIsDraggingOverSelectedRow() + const setDragTargetAssetId = useSetDragTargetAssetId() const rootRef = React.useRef(null) const dragOverTimeoutHandle = React.useRef(null) const grabKeyboardFocusRef = useSyncRef(grabKeyboardFocus) const [innerRowState, setRowState] = React.useState( assetRowUtils.INITIAL_ROW_STATE, ) - const cutAndPaste = useCutAndPaste(category) + const cutAndPaste = useCutAndPaste(backend, category) const toggleDirectoryExpansion = useToggleDirectoryExpansion() + const setLabelsDragPayload = useSetLabelsDragPayload() const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id) const isEditingName = innerRowState.isEditingName || isNewlyCreated @@ -323,16 +293,29 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { readonly parentKeys: Map } | null>(null) - const isDeleting = + const isDeletingSingleAsset = useBackendMutationState(backend, 'deleteAsset', { predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id, + select: () => null, }).length !== 0 - const isRestoring = + const isDeletingMultipleAssets = + useDeleteAssetsMutationState(backend, { + predicate: ({ state: { variables: [assetIds = EMPTY_ARRAY] = EMPTY_ARRAY } }) => + assetIds.includes(asset.id), + select: () => null, + }).length !== 0 + const isDeleting = isDeletingSingleAsset || isDeletingMultipleAssets + const isRestoringSingleAsset = useBackendMutationState(backend, 'undoDeleteAsset', { - predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id, + predicate: ({ state: { variables: [assetId] = EMPTY_ARRAY } }) => assetId === asset.id, + select: () => null, }).length !== 0 - - const isCloud = isCloudCategory(category) + const isRestoringMultipleAssets = + useRestoreAssetsMutationState(backend, { + predicate: ({ state: { variables: assetIds = EMPTY_ARRAY } }) => assetIds.includes(asset.id), + select: () => null, + }).length !== 0 + const isRestoring = isRestoringSingleAsset || isRestoringMultipleAssets const { data: projectState } = useQuery({ ...createGetProjectDetailsQuery({ @@ -340,18 +323,21 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { // see `enabled` property below. // eslint-disable-next-line no-restricted-syntax assetId: asset.id as backendModule.ProjectId, - parentId: asset.parentId, backend, }), select: (data) => data.state.type, enabled: asset.type === backendModule.AssetType.project && !isPlaceholder && isOpened, }) - const toastAndLog = useToastAndLog() - const uploadFiles = useUploadFiles(backend, category) - const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission')) - const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag')) + const createPermissionMutation = useMutation( + backendMutationOptions(backend, 'createPermission', { + meta: { + invalidates: [[backend.type, 'listDirectory', asset.parentId]], + awaitInvalidates: true, + }, + }), + ) const insertionVisibility = useStore(driveStore, (driveState) => driveState.pasteData?.type === 'move' && driveState.pasteData.data.ids.has(id) ? @@ -360,19 +346,22 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { ) const createPermissionVariables = createPermissionMutation.variables?.[0] const isRemovingSelf = - createPermissionVariables != null && - createPermissionVariables.action == null && - createPermissionVariables.actorsIds[0] === user.userId + createPermissionVariables?.actorsIds[0] === user.userId && + createPermissionVariables.action == null const visibility = - isRemovingSelf ? Visibility.hidden + isDeleting || isRestoring ? Visibility.faded + : isRemovingSelf ? Visibility.hidden : visibilityRaw === Visibility.visible ? insertionVisibility : visibilityRaw ?? insertionVisibility - const hidden = isDeleting || isRestoring || hiddenRaw || visibility === Visibility.hidden + const hidden = hiddenRaw || visibility === Visibility.hidden const setSelected = useEventCallback((newSelected: boolean) => { - const { selectedKeys } = driveStore.getState() - - setSelectedKeys(set.withPresence(selectedKeys, id, newSelected)) + const { selectedAssets } = driveStore.getState() + setSelectedAssets( + newSelected ? + [...selectedAssets, asset] + : selectedAssets.filter((otherAsset) => otherAsset.id !== asset.id), + ) }) React.useEffect(() => { @@ -388,27 +377,22 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { } }, [grabKeyboardFocusRef, isKeyboardSelected, asset]) - const doDelete = React.useCallback( - (forever = false) => { - void doDeleteRaw(asset, forever) - }, - [doDeleteRaw, asset], - ) - - const clearDragState = React.useCallback(() => { - setIsDraggedOver(false) - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - }, []) - const onDragOver = (event: React.DragEvent) => { - const directoryKey = asset.type === backendModule.AssetType.directory ? id : parentId + const directoryId = asset.type === backendModule.AssetType.directory ? id : parentId + const labelsPayload = drag.LABELS.lookup(event) + if (labelsPayload) { + event.preventDefault() + event.stopPropagation() + setDragTargetAssetId(asset.id) + const { isDraggingOverSelectedRow } = driveStore.getState() + if (selected !== isDraggingOverSelectedRow) { + setIsDraggingOverSelectedRow(selected) + } + return + } const payload = drag.ASSET_ROWS.lookup(event) const isPayloadMatch = - payload != null && payload.every((innerItem) => innerItem.key !== directoryKey) + payload != null && payload.every((innerItem) => innerItem.key !== directoryId) const canPaste = (() => { if (!isPayloadMatch) { return false @@ -417,7 +401,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { const parentKeys = new Map( Array.from(nodeMap.current.entries()).map(([otherId, otherAsset]) => [ otherId, - otherAsset.directoryKey, + otherAsset.item.parentId, ]), ) nodeParentKeysRef.current = { nodeMap: new WeakRef(nodeMap.current), parentKeys } @@ -454,212 +438,6 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { } } - eventListProvider.useAssetEventListener(async (event) => { - switch (event.type) { - case AssetEventType.move: { - if (event.ids.has(id)) { - await doMove(event.newParentKey, asset) - } - break - } - case AssetEventType.delete: { - if (event.ids.has(id)) { - doDelete(false) - } - break - } - case AssetEventType.deleteForever: { - if (event.ids.has(id)) { - doDelete(true) - } - break - } - case AssetEventType.restore: { - if (event.ids.has(id)) { - await doRestore(asset) - } - break - } - case AssetEventType.download: - case AssetEventType.downloadSelected: { - if (event.type === AssetEventType.downloadSelected ? selected : event.ids.has(asset.id)) { - if (isCloud) { - switch (asset.type) { - case backendModule.AssetType.project: { - try { - const details = await queryClient.fetchQuery( - backendQueryOptions( - backend, - 'getProjectDetails', - [asset.id, asset.parentId, true], - { staleTime: 0 }, - ), - ) - if (details.url != null) { - await backend.download(details.url, `${asset.title}.enso-project`) - } else { - const error: unknown = getText('projectHasNoSourceFilesPhrase') - toastAndLog('downloadProjectError', error, asset.title) - } - } catch (error) { - toastAndLog('downloadProjectError', error, asset.title) - } - break - } - case backendModule.AssetType.file: { - try { - const details = await queryClient.fetchQuery( - backendQueryOptions(backend, 'getFileDetails', [asset.id, asset.title, true], { - staleTime: 0, - }), - ) - if (details.url != null) { - await backend.download(details.url, asset.title) - } else { - const error: unknown = getText('fileNotFoundPhrase') - toastAndLog('downloadFileError', error, asset.title) - } - } catch (error) { - toastAndLog('downloadFileError', error, asset.title) - } - break - } - case backendModule.AssetType.datalink: { - try { - const value = await queryClient.fetchQuery( - backendQueryOptions(backend, 'getDatalink', [asset.id, asset.title]), - ) - const fileName = `${asset.title}.datalink` - download( - URL.createObjectURL( - new File([JSON.stringify(value)], fileName, { - type: 'application/json+x-enso-data-link', - }), - ), - fileName, - ) - } catch (error) { - toastAndLog('downloadDatalinkError', error, asset.title) - } - break - } - default: { - toastAndLog('downloadInvalidTypeError') - break - } - } - } else { - if (asset.type === backendModule.AssetType.project) { - const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id - const uuid = localBackend.extractTypeAndId(asset.id).id - const queryString = new URLSearchParams({ projectsDirectory }).toString() - await backend.download( - `./api/project-manager/projects/${uuid}/enso-project?${queryString}`, - `${asset.title}.enso-project`, - ) - } - } - } - break - } - case AssetEventType.removeSelf: { - // This is not triggered from the asset list, so it uses `item.id` instead of `key`. - if (event.id === asset.id && user.isEnabled) { - try { - await createPermissionMutation.mutateAsync([ - { - action: null, - resourceId: asset.id, - actorsIds: [user.userId], - }, - ]) - dispatchAssetListEvent({ type: AssetListEventType.delete, key: id }) - } catch (error) { - toastAndLog(null, error) - } - } - break - } - case AssetEventType.temporarilyAddLabels: { - const labels = event.ids.has(id) ? event.labelNames : set.EMPTY_SET - setRowState((oldRowState) => - ( - oldRowState.temporarilyAddedLabels === labels && - oldRowState.temporarilyRemovedLabels === set.EMPTY_SET - ) ? - oldRowState - : object.merge(oldRowState, { - temporarilyAddedLabels: labels, - temporarilyRemovedLabels: set.EMPTY_SET, - }), - ) - break - } - case AssetEventType.temporarilyRemoveLabels: { - const labels = event.ids.has(id) ? event.labelNames : set.EMPTY_SET - setRowState((oldRowState) => - ( - oldRowState.temporarilyAddedLabels === set.EMPTY_SET && - oldRowState.temporarilyRemovedLabels === labels - ) ? - oldRowState - : object.merge(oldRowState, { - temporarilyAddedLabels: set.EMPTY_SET, - temporarilyRemovedLabels: labels, - }), - ) - break - } - case AssetEventType.addLabels: { - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - const labels = asset.labels - if ( - event.ids.has(id) && - (labels == null || [...event.labelNames].some((label) => !labels.includes(label))) - ) { - const newLabels = [ - ...(labels ?? []), - ...[...event.labelNames].filter((label) => labels?.includes(label) !== true), - ] - try { - await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) - } catch (error) { - toastAndLog(null, error) - } - } - break - } - case AssetEventType.removeLabels: { - setRowState((oldRowState) => - oldRowState.temporarilyAddedLabels === set.EMPTY_SET ? - oldRowState - : object.merge(oldRowState, { temporarilyAddedLabels: set.EMPTY_SET }), - ) - const labels = asset.labels - if ( - event.ids.has(id) && - labels != null && - [...event.labelNames].some((label) => labels.includes(label)) - ) { - const newLabels = labels.filter((label) => !event.labelNames.has(label)) - try { - await associateTagMutation.mutateAsync([asset.id, newLabels, asset.title]) - } catch (error) { - toastAndLog(null, error) - } - } - break - } - default: { - return - } - } - }, initialAssetEvents) - switch (type) { case backendModule.AssetType.directory: case backendModule.AssetType.project: @@ -734,7 +512,6 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { doCopy={doCopy} doCut={doCut} doPaste={doPaste} - doDelete={doDelete} />, ) } @@ -761,18 +538,17 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { }, DRAG_EXPAND_DELAY_MS) } // Required because `dragover` does not fire on `mouseenter`. - props.onDragOver?.(event, asset) onDragOver(event) }} onDragOver={(event) => { if (state.category.type === 'trash') { event.dataTransfer.dropEffect = 'none' } - props.onDragOver?.(event, asset) onDragOver(event) }} onDragEnd={(event) => { - clearDragState() + setIsDraggedOver(false) + setLabelsDragPayload(null) props.onDragEnd?.(event, asset) }} onDragLeave={(event) => { @@ -787,14 +563,14 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget) ) { - clearDragState() + setIsDraggedOver(false) } props.onDragLeave?.(event, asset) }} onDrop={(event) => { if (state.category.type !== 'trash') { props.onDrop?.(event, asset) - clearDragState() + setIsDraggedOver(false) const directoryId = asset.type === backendModule.AssetType.directory ? asset.id : parentId const payload = drag.ASSET_ROWS.lookup(event) @@ -831,7 +607,6 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) { )} diff --git a/app/gui/src/dashboard/components/dashboard/Label.tsx b/app/gui/src/dashboard/components/dashboard/Label.tsx index 7420a08b7ec0..4597d982cb19 100644 --- a/app/gui/src/dashboard/components/dashboard/Label.tsx +++ b/app/gui/src/dashboard/components/dashboard/Label.tsx @@ -65,7 +65,7 @@ export default function Label(props: InternalLabelProps) { title={title} disabled={isDisabled} className={twMerge( - 'focus-child relative flex h-6 items-center whitespace-nowrap rounded-inherit px-[7px] opacity-75 transition-all after:pointer-events-none after:absolute after:inset after:rounded-full hover:opacity-100 focus:opacity-100', + 'focus-child relative flex h-6 items-center whitespace-nowrap rounded-inherit px-[7px] opacity-50 transition-all after:pointer-events-none after:absolute after:inset after:rounded-full hover:opacity-100 focus:opacity-100', active && 'active', negated && 'after:border-2 after:border-delete', )} diff --git a/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx b/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx index 8d580e96c13c..fbb05d81f6b9 100644 --- a/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx +++ b/app/gui/src/dashboard/components/dashboard/ProjectIcon.tsx @@ -93,7 +93,6 @@ export default function ProjectIcon(props: ProjectIconProps) { const { data: projectState, isError } = reactQuery.useQuery({ ...projectHooks.createGetProjectDetailsQuery({ assetId: item.id, - parentId: item.parentId, backend, }), select: (data) => data.state, diff --git a/app/gui/src/dashboard/components/dashboard/column.ts b/app/gui/src/dashboard/components/dashboard/column.ts index 9ea88144c16f..d021d0045f12 100644 --- a/app/gui/src/dashboard/components/dashboard/column.ts +++ b/app/gui/src/dashboard/components/dashboard/column.ts @@ -3,7 +3,7 @@ import { memo, type Dispatch, type JSX, type SetStateAction } from 'react' import type { AssetRowState, AssetsTableState } from '#/layouts/AssetsTable' import type { Category } from '#/layouts/CategorySwitcher/Category' -import type { AnyAsset, Asset, AssetId, BackendType } from '#/services/Backend' +import type { AnyAsset, Asset, BackendType } from '#/services/Backend' import type { SortInfo } from '#/utilities/sorting' import type { SortableColumn } from './column/columnUtils' import { Column } from './column/columnUtils' @@ -15,13 +15,8 @@ import PathColumn from './column/PathColumn' import PlaceholderColumn from './column/PlaceholderColumn' import SharedWithColumn from './column/SharedWithColumn' -// =================== -// === AssetColumn === -// =================== - /** Props for an arbitrary variant of {@link Asset}. */ export interface AssetColumnProps { - readonly keyProp: AssetId readonly isOpened: boolean readonly item: AnyAsset readonly depth: number diff --git a/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx b/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx index bdb1959cd322..1884b436a3b2 100644 --- a/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/column/LabelsColumn.tsx @@ -19,7 +19,10 @@ import ManageLabelsModal from '#/modals/ManageLabelsModal' import * as backendModule from '#/services/Backend' +import { useStore } from '#/hooks/storeHooks' +import { useDriveStore } from '#/providers/DriveProvider' import * as permissions from '#/utilities/permissions' +import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array' // ==================== // === LabelsColumn === @@ -27,13 +30,18 @@ import * as permissions from '#/utilities/permissions' /** A column listing the labels on this asset. */ export default function LabelsColumn(props: column.AssetColumnProps) { - const { item, state, rowState } = props + const { item, state } = props const { backend, category, setQuery } = state - const { temporarilyAddedLabels, temporarilyRemovedLabels } = rowState const { user } = authProvider.useFullUserSession() const { setModal, unsetModal } = modalProvider.useSetModal() const { getText } = textProvider.useText() const { data: labels } = backendHooks.useBackendQuery(backend, 'listTags', []) + const driveStore = useDriveStore() + const showDraggedLabelsFallback = useStore( + driveStore, + ({ selectedKeys, isDraggingOverSelectedRow }) => + isDraggingOverSelectedRow && selectedKeys.has(item.id), + ) const labelsByName = React.useMemo(() => { return new Map(labels?.map((label) => [label.value, label])) }, [labels]) @@ -42,6 +50,38 @@ export default function LabelsColumn(props: column.AssetColumnProps) { category.type !== 'trash' && (self?.permission === permissions.PermissionAction.own || self?.permission === permissions.PermissionAction.admin) + const temporarilyAddedLabels = useStore( + driveStore, + ({ labelsDragPayload, dragTargetAssetId }) => { + const areTemporaryLabelsRelevant = (() => { + if (showDraggedLabelsFallback) { + return labelsDragPayload?.typeWhenAppliedToSelection === 'add' + } else { + return item.id === dragTargetAssetId + } + })() + if (areTemporaryLabelsRelevant) { + return labelsDragPayload?.labels ?? EMPTY_ARRAY + } + return EMPTY_ARRAY + }, + ) + const temporarilyRemovedLabels = useStore( + driveStore, + ({ labelsDragPayload, dragTargetAssetId }) => { + const areTemporaryLabelsRelevant = (() => { + if (showDraggedLabelsFallback) { + return labelsDragPayload?.typeWhenAppliedToSelection === 'remove' + } else { + return item.id === dragTargetAssetId + } + })() + if (areTemporaryLabelsRelevant) { + return labelsDragPayload?.labels ?? EMPTY_ARRAY + } + return EMPTY_ARRAY + }, + ) return (
@@ -53,9 +93,9 @@ export default function LabelsColumn(props: column.AssetColumnProps) { data-testid="asset-label" title={getText('rightClickToRemoveLabel')} color={labelsByName.get(label)?.color ?? backendModule.COLORS[0]} - active={!temporarilyRemovedLabels.has(label)} - isDisabled={temporarilyRemovedLabels.has(label)} - negated={temporarilyRemovedLabels.has(label)} + active={!temporarilyRemovedLabels.includes(label)} + isDisabled={temporarilyRemovedLabels.includes(label)} + negated={temporarilyRemovedLabels.includes(label)} onContextMenu={(event) => { event.preventDefault() event.stopPropagation() @@ -83,7 +123,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) { {label} ))} - {...[...temporarilyAddedLabels] + {temporarilyAddedLabels .filter((label) => item.labels?.includes(label) !== true) .map((label) => (
diff --git a/app/gui/src/dashboard/layouts/Editor.tsx b/app/gui/src/dashboard/layouts/Editor.tsx index 073a28951a89..72746fae0547 100644 --- a/app/gui/src/dashboard/layouts/Editor.tsx +++ b/app/gui/src/dashboard/layouts/Editor.tsx @@ -1,71 +1,34 @@ /** @file The container that launches the IDE. */ -import * as React from 'react' - -import * as reactQuery from '@tanstack/react-query' - -import * as appUtils from '#/appUtils' - +import * as errorBoundary from '#/components/ErrorBoundary' +import * as suspense from '#/components/Suspense' +import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as gtagHooks from '#/hooks/gtagHooks' import * as projectHooks from '#/hooks/projectHooks' - import * as backendProvider from '#/providers/BackendProvider' import type { LaunchedProject } from '#/providers/ProjectsProvider' import * as textProvider from '#/providers/TextProvider' - -import * as errorBoundary from '#/components/ErrorBoundary' -import * as suspense from '#/components/Suspense' - -import type Backend from '#/services/Backend' import * as backendModule from '#/services/Backend' - -import { useEventCallback } from '#/hooks/eventCallbackHooks' import * as twMerge from '#/utilities/tailwindMerge' +import * as reactQuery from '@tanstack/react-query' +import * as React from 'react' import { useTimeoutCallback } from '../hooks/timeoutHooks' - -// ==================== -// === StringConfig === -// ==================== - -/** A configuration in which values may be strings or nested configurations. */ -interface StringConfig { - readonly [key: string]: StringConfig | string -} - -// ======================== -// === GraphEditorProps === -// ======================== +// eslint-disable-next-line no-restricted-syntax +import ProjectViewTabVue from '@/ProjectViewTab.vue' +import { applyPureVueInReact } from 'veaury' +import type { AllowedComponentProps, VNodeProps } from 'vue' +import type { ComponentProps } from 'vue-component-type-helpers' /** Props for the GUI editor root component. */ -export interface GraphEditorProps { - readonly config: StringConfig | null - readonly projectId: string - readonly hidden: boolean - readonly ignoreParamsRegex?: RegExp - readonly logEvent: (message: string, projectId?: string | null, metadata?: object | null) => void - readonly renameProject: (newName: string) => void - readonly projectBackend: Backend | null - readonly remoteBackend: Backend | null -} - -// ========================= -// === GraphEditorRunner === -// ========================= +export type ProjectViewTabProps = Omit< + ComponentProps, + keyof AllowedComponentProps | keyof VNodeProps +> -/** - * The value passed from the entrypoint to the dashboard, which enables the dashboard to - * open a new IDE instance. - */ -export type GraphEditorRunner = React.ComponentType - -// ================= -// === Constants === -// ================= - -const IGNORE_PARAMS_REGEX = new RegExp(`^${appUtils.SEARCH_PARAMS_PREFIX}(.+)$`) - -// ============== -// === Editor === -// ============== +// applyPureVuewInReact returns Function, but this is not enough to satisfy TSX. +// eslint-disable-next-line no-restricted-syntax +const ProjectViewTab = applyPureVueInReact(ProjectViewTabVue) as ( + props: ProjectViewTabProps, +) => JSX.Element /** Props for an {@link Editor}. */ export interface EditorProps { @@ -75,7 +38,6 @@ export interface EditorProps { readonly project: LaunchedProject readonly hidden: boolean readonly ydocUrl: string | null - readonly appRunner: GraphEditorRunner | null readonly renameProject: (newName: string, projectId: backendModule.ProjectId) => void readonly projectId: backendModule.ProjectId } @@ -88,7 +50,6 @@ function Editor(props: EditorProps) { const projectStatusQuery = projectHooks.createGetProjectDetailsQuery({ assetId: project.id, - parentId: project.parentId, backend, }) @@ -187,7 +148,7 @@ interface EditorInternalProps extends Omit { /** An internal editor. */ function EditorInternal(props: EditorInternalProps) { - const { hidden, ydocUrl, appRunner: AppRunner, renameProject, openedProject, backendType } = props + const { hidden, ydocUrl, renameProject, openedProject, backendType } = props const { getText } = textProvider.useText() const gtagEvent = gtagHooks.useGtagEvent() @@ -195,13 +156,6 @@ function EditorInternal(props: EditorInternalProps) { const localBackend = backendProvider.useLocalBackend() const remoteBackend = backendProvider.useRemoteBackend() - const logEvent = React.useCallback( - (message: string, projectId?: string | null, metadata?: object | null) => { - void remoteBackend.logEvent(message, projectId, metadata) - }, - [remoteBackend], - ) - React.useEffect(() => { if (!hidden) { return gtagHooks.gtagOpenCloseCallback(gtagEvent, 'open_workflow', 'close_workflow') @@ -212,7 +166,7 @@ function EditorInternal(props: EditorInternalProps) { renameProject(newName, openedProject.projectId) }) - const appProps = React.useMemo(() => { + const appProps = React.useMemo(() => { const jsonAddress = openedProject.jsonAddress const binaryAddress = openedProject.binaryAddress const ydocAddress = openedProject.ydocAddress ?? ydocUrl ?? '' @@ -225,18 +179,16 @@ function EditorInternal(props: EditorInternalProps) { throw new Error(getText('noBinaryEndpointError')) } else { return { - config: { + hidden, + projectViewProps: { + projectId: openedProject.projectId, + projectName: openedProject.packageName, + projectDisplayedName: openedProject.name, engine: { rpcUrl: jsonAddress, dataUrl: binaryAddress, ydocUrl: ydocAddress }, - startup: { project: openedProject.packageName, displayedProjectName: openedProject.name }, - window: { topBarOffset: '0' }, + renameProject: onRenameProject, + projectBackend, + remoteBackend, }, - projectId: openedProject.projectId, - hidden, - ignoreParamsRegex: IGNORE_PARAMS_REGEX, - logEvent, - renameProject: onRenameProject, - projectBackend, - remoteBackend, } } }, [ @@ -244,16 +196,18 @@ function EditorInternal(props: EditorInternalProps) { ydocUrl, getText, hidden, - logEvent, onRenameProject, backendType, localBackend, remoteBackend, ]) + // EsLint does not handle types imported from vue files and their dependences. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const key: string = appProps.projectViewProps.projectId // Currently the GUI component needs to be fully rerendered whenever the project is changed. Once // this is no longer necessary, the `key` could be removed. - return AppRunner == null ? null : + return } export default React.memo(Editor) diff --git a/app/gui/src/dashboard/layouts/GlobalContextMenu.tsx b/app/gui/src/dashboard/layouts/GlobalContextMenu.tsx index 67c3eb8ae9ac..a3878da25ff9 100644 --- a/app/gui/src/dashboard/layouts/GlobalContextMenu.tsx +++ b/app/gui/src/dashboard/layouts/GlobalContextMenu.tsx @@ -7,13 +7,8 @@ import ContextMenuEntry from '#/components/ContextMenuEntry' import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal' import UpsertSecretModal from '#/modals/UpsertSecretModal' -import { - useNewDatalink, - useNewFolder, - useNewProject, - useNewSecret, - useUploadFiles, -} from '#/hooks/backendHooks' +import { useNewDatalink, useNewFolder, useNewProject, useNewSecret } from '#/hooks/backendHooks' +import { useUploadFiles } from '#/hooks/backendUploadFilesHooks' import { useEventCallback } from '#/hooks/eventCallbackHooks' import type { Category } from '#/layouts/CategorySwitcher/Category' import { useDriveStore } from '#/providers/DriveProvider' @@ -31,7 +26,6 @@ export interface GlobalContextMenuProps { readonly backend: Backend readonly category: Category readonly rootDirectoryId: DirectoryId - readonly directoryKey: DirectoryId | null readonly directoryId: DirectoryId | null readonly path: string | null readonly doPaste: (newParentKey: DirectoryId, newParentId: DirectoryId) => void @@ -49,7 +43,6 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext hidden = false, backend, category, - directoryKey = null, directoryId = null, path, rootDirectoryId, @@ -148,7 +141,7 @@ export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContext }} /> )} - {isCloud && directoryKey == null && hasPasteData && ( + {isCloud && directoryId == null && hasPasteData && (
| null @@ -25,7 +23,7 @@ export interface DashboardTabPanelsProps { /** The tab panels for the dashboard page. */ export function DashboardTabPanels(props: DashboardTabPanelsProps) { - const { appRunner, initialProjectName, ydocUrl, assetManagementApiRef } = props + const { initialProjectName, ydocUrl, assetManagementApiRef } = props const page = usePage() @@ -68,7 +66,6 @@ export function DashboardTabPanels(props: DashboardTabPanelsProps) { ydocUrl={ydocUrl} project={project} projectId={project.id} - appRunner={appRunner} isOpeningFailed={openProjectMutation.isError} openingError={openProjectMutation.error} startProject={openProjectMutation.mutate} diff --git a/app/gui/src/dashboard/providers/DriveProvider.tsx b/app/gui/src/dashboard/providers/DriveProvider.tsx index db4ad93eb21b..fe872a0fab39 100644 --- a/app/gui/src/dashboard/providers/DriveProvider.tsx +++ b/app/gui/src/dashboard/providers/DriveProvider.tsx @@ -7,15 +7,19 @@ import invariant from 'tiny-invariant' import { useEventCallback } from '#/hooks/eventCallbackHooks' import type { Category } from '#/layouts/CategorySwitcher/Category' import type AssetTreeNode from '#/utilities/AssetTreeNode' +import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode' import type { PasteData } from '#/utilities/pasteData' import { EMPTY_SET } from '#/utilities/set' import type { + AnyAsset, AssetId, BackendType, DirectoryAsset, DirectoryId, + LabelName, } from 'enso-common/src/services/Backend' import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array' +import { unsafeMutable } from 'enso-common/src/utilities/data/object' // ================== // === DriveStore === @@ -28,6 +32,20 @@ export interface DrivePastePayload { readonly ids: ReadonlySet } +/** The subset of asset information required for selections. */ +export type SelectedAssetInfo = + AnyAsset extends infer T ? + T extends T ? + Pick + : never + : never + +/** Payload for labels being dragged. */ +export interface LabelsDragPayload { + readonly typeWhenAppliedToSelection: 'add' | 'remove' + readonly labels: readonly LabelName[] +} + /** The state of this zustand store. */ interface DriveStore { readonly resetAssetTableState: () => void @@ -44,9 +62,18 @@ interface DriveStore { readonly expandedDirectoryIds: readonly DirectoryId[] readonly setExpandedDirectoryIds: (selectedKeys: readonly DirectoryId[]) => void readonly selectedKeys: ReadonlySet - readonly setSelectedKeys: (selectedKeys: ReadonlySet) => void + readonly selectedAssets: readonly SelectedAssetInfo[] + readonly setSelectedAssets: (selectedAssets: readonly SelectedAssetInfo[]) => void readonly visuallySelectedKeys: ReadonlySet | null readonly setVisuallySelectedKeys: (visuallySelectedKeys: ReadonlySet | null) => void + readonly labelsDragPayload: LabelsDragPayload | null + readonly setLabelsDragPayload: (labelsDragPayload: LabelsDragPayload | null) => void + readonly isDraggingOverSelectedRow: boolean + readonly setIsDraggingOverSelectedRow: (isDraggingOverSelectedRow: boolean) => void + readonly dragTargetAssetId: AssetId | null + readonly setDragTargetAssetId: (dragTargetAssetId: AssetId | null) => void + readonly nodeMap: { readonly current: ReadonlyMap } + readonly setNodeMap: (nodeMap: ReadonlyMap) => void } // ======================= @@ -72,10 +99,7 @@ export interface ProjectsProviderProps { // === ProjectsProvider === // ======================== -/** - * A React provider (and associated hooks) for determining whether the current area - * containing the current element is focused. - */ +/** A React provider for Drive-specific metadata. */ export default function DriveProvider(props: ProjectsProviderProps) { const { children } = props @@ -126,13 +150,50 @@ export default function DriveProvider(props: ProjectsProviderProps) { } }, selectedKeys: EMPTY_SET, - setSelectedKeys: (selectedKeys) => { - set({ selectedKeys }) + selectedAssets: EMPTY_ARRAY, + setSelectedAssets: (selectedAssets) => { + if (selectedAssets.length === 0) { + selectedAssets = EMPTY_ARRAY + } + if (get().selectedAssets !== selectedAssets) { + set({ + selectedAssets, + selectedKeys: + selectedAssets.length === 0 ? + EMPTY_SET + : new Set(selectedAssets.map((asset) => asset.id)), + }) + } }, visuallySelectedKeys: null, setVisuallySelectedKeys: (visuallySelectedKeys) => { set({ visuallySelectedKeys }) }, + labelsDragPayload: null, + setLabelsDragPayload: (labelsDragPayload) => { + if (get().labelsDragPayload !== labelsDragPayload) { + set({ labelsDragPayload }) + } + }, + isDraggingOverSelectedRow: false, + setIsDraggingOverSelectedRow: (isDraggingOverSelectedRow) => { + if (get().isDraggingOverSelectedRow !== isDraggingOverSelectedRow) { + set({ isDraggingOverSelectedRow }) + } + }, + dragTargetAssetId: null, + setDragTargetAssetId: (dragTargetAssetId) => { + if (get().dragTargetAssetId !== dragTargetAssetId) { + set({ dragTargetAssetId }) + } + }, + nodeMap: { current: new Map() }, + setNodeMap: (nodeMap) => { + if (get().nodeMap.current !== nodeMap) { + unsafeMutable(get().nodeMap).current = nodeMap + set({ nodeMap: get().nodeMap }) + } + }, })), ) @@ -234,10 +295,16 @@ export function useSelectedKeys() { return zustand.useStore(store, (state) => state.selectedKeys) } -/** A function to set the selected keys in the Asset Table. */ -export function useSetSelectedKeys() { +/** The selected assets in the Asset Table. */ +export function useSelectedAssets() { + const store = useDriveStore() + return zustand.useStore(store, (state) => state.selectedAssets) +} + +/** A function to set the selected assets in the Asset Table. */ +export function useSetSelectedAssets() { const store = useDriveStore() - return zustand.useStore(store, (state) => state.setSelectedKeys) + return zustand.useStore(store, (state) => state.setSelectedAssets) } /** The visually selected keys in the Asset Table. */ @@ -256,6 +323,57 @@ export function useSetVisuallySelectedKeys() { }) } +/** The drag payload of labels. */ +export function useLabelsDragPayload() { + const store = useDriveStore() + return zustand.useStore(store, (state) => state.labelsDragPayload) +} + +/** A function to set the drag payload of labels. */ +export function useSetLabelsDragPayload() { + const store = useDriveStore() + return zustand.useStore(store, (state) => state.setLabelsDragPayload) +} + +/** The map of keys to {@link AssetTreeNode}s. */ +export function useNodeMap() { + const store = useDriveStore() + return zustand.useStore(store, (state) => state.nodeMap) +} + +/** A function to set the map of keys to {@link AssetTreeNode}s. */ +export function useSetNodeMap() { + const store = useDriveStore() + return zustand.useStore(store, (state) => state.setNodeMap) +} + +/** + * Whether dragging is currently active for a selected row. + * This is true if and only if this row, or another selected row, is being dragged over. + */ +export function useIsDraggingOverSelectedRow(selected: boolean) { + const store = useDriveStore() + return zustand.useStore(store, (state) => selected && state.isDraggingOverSelectedRow) +} + +/** A function to set whether dragging is currently over a selected row. */ +export function useSetIsDraggingOverSelectedRow() { + const store = useDriveStore() + return zustand.useStore(store, (state) => state.setIsDraggingOverSelectedRow) +} + +/** Whether the given {@link AssetId} is the one currently being dragged over. */ +export function useIsDragTargetAssetId(assetId: AssetId) { + const store = useDriveStore() + return zustand.useStore(store, (state) => assetId === state.dragTargetAssetId) +} + +/** A function to set which {@link AssetId} is the one currently being dragged over. */ +export function useSetDragTargetAssetId() { + const store = useDriveStore() + return zustand.useStore(store, (state) => state.setDragTargetAssetId) +} + /** Toggle whether a specific directory is expanded. */ export function useToggleDirectoryExpansion() { const driveStore = useDriveStore() diff --git a/app/gui/src/dashboard/services/LocalBackend.ts b/app/gui/src/dashboard/services/LocalBackend.ts index 10ca1ba80636..923936b553e0 100644 --- a/app/gui/src/dashboard/services/LocalBackend.ts +++ b/app/gui/src/dashboard/services/LocalBackend.ts @@ -35,8 +35,8 @@ export function newDirectoryId(path: projectManager.Path) { } /** Create a {@link backend.ProjectId} from a UUID. */ -export function newProjectId(uuid: projectManager.UUID) { - return backend.ProjectId(`${backend.AssetType.project}-${uuid}`) +export function newProjectId(uuid: projectManager.UUID, path: projectManager.Path) { + return backend.ProjectId(`${backend.AssetType.project}-${uuid}-${path}`) } /** Create a {@link backend.FileId} from a path. */ @@ -54,6 +54,7 @@ interface DirectoryTypeAndId { interface ProjectTypeAndId { readonly type: backend.AssetType.project readonly id: projectManager.UUID + readonly directory: projectManager.Path } /** The internal asset type and properly typed corresponding internal ID of a file. */ @@ -80,7 +81,12 @@ export function extractTypeAndId(id: Id): AssetTypeA return { type: backend.AssetType.directory, id: projectManager.Path(idRaw) } } case backend.AssetType.project: { - return { type: backend.AssetType.project, id: projectManager.UUID(idRaw) } + const [, idRaw2 = '', directoryRaw = ''] = idRaw.match(/(\w+-\w+-\w+-\w+-\w+)-(.+)/) ?? [] + return { + type: backend.AssetType.project, + id: projectManager.UUID(idRaw2), + directory: projectManager.Path(directoryRaw), + } } case backend.AssetType.file: { return { type: backend.AssetType.file, id: projectManager.Path(idRaw) } @@ -177,7 +183,7 @@ export default class LocalBackend extends Backend { case projectManager.FileSystemEntryType.ProjectEntry: { return { type: backend.AssetType.project, - id: newProjectId(entry.metadata.id), + id: newProjectId(entry.metadata.id, extractTypeAndId(parentId).id), title: entry.metadata.name, modifiedAt: entry.metadata.lastOpened ?? entry.metadata.created, parentId, @@ -240,7 +246,7 @@ export default class LocalBackend extends Backend { return result.projects.map((project) => ({ name: project.name, organizationId: backend.OrganizationId('organization-'), - projectId: newProjectId(project.id), + projectId: newProjectId(project.id, this.projectManager.rootDirectory), packageName: project.name, state: { type: backend.ProjectState.closed, @@ -270,7 +276,10 @@ export default class LocalBackend extends Backend { return { name: project.projectName, organizationId: backend.OrganizationId('organization-'), - projectId: newProjectId(project.projectId), + projectId: newProjectId( + project.projectId, + projectsDirectory ?? this.projectManager.rootDirectory, + ), packageName: project.projectName, state: { type: backend.ProjectState.closed, volumeId: '' }, } @@ -307,15 +316,11 @@ export default class LocalBackend extends Backend { * Close the project identified by the given project ID. * @throws An error if the JSON-RPC call fails. */ - override async getProjectDetails( - projectId: backend.ProjectId, - directory: backend.DirectoryId | null, - ): Promise { - const { id } = extractTypeAndId(projectId) + override async getProjectDetails(projectId: backend.ProjectId): Promise { + const { id, directory } = extractTypeAndId(projectId) const state = this.projectManager.projects.get(id) if (state == null) { - const directoryId = directory == null ? null : extractTypeAndId(directory).id - const entries = await this.projectManager.listDirectory(directoryId) + const entries = await this.projectManager.listDirectory(directory) const project = entries .flatMap((entry) => entry.type === projectManager.FileSystemEntryType.ProjectEntry ? [entry.metadata] : [], @@ -450,10 +455,11 @@ export default class LocalBackend extends Backend { /** Duplicate a specific version of a project. */ override async duplicateProject(projectId: backend.ProjectId): Promise { - const id = extractTypeAndId(projectId).id + const typeAndId = extractTypeAndId(projectId) + const id = typeAndId.id const project = await this.projectManager.duplicateProject({ projectId: id }) return { - projectId: newProjectId(project.projectId), + projectId: newProjectId(project.projectId, typeAndId.directory), name: project.projectName, packageName: project.projectNormalizedName, organizationId: backend.OrganizationId('organization-'), @@ -508,7 +514,7 @@ export default class LocalBackend extends Backend { throw new Error('Cannot duplicate project to a different directory on the Local Backend.') } else { const asset = { - id: newProjectId(project.projectId), + id: newProjectId(project.projectId, parentPath), parentId: parentDirectoryId, title: project.projectName, } @@ -641,9 +647,7 @@ export default class LocalBackend extends Backend { } } - /** - * Begin uploading a large file. - */ + /** Begin uploading a large file. */ override async uploadFileStart( body: backend.UploadFileRequestParams, file: File, @@ -686,8 +690,8 @@ export default class LocalBackend extends Backend { const response = await fetch(path, { method: 'POST', body: file }) id = await response.text() } - const projectId = newProjectId(projectManager.UUID(id)) - const project = await this.getProjectDetails(projectId, body.parentDirectoryId) + const projectId = newProjectId(projectManager.UUID(id), parentPath) + const project = await this.getProjectDetails(projectId) this.uploadedFiles.set(uploadId, { id: projectId, project }) } return { presignedUrls: [], uploadId, sourcePath: backend.S3FilePath('') } @@ -747,10 +751,20 @@ export default class LocalBackend extends Backend { } } - /** Download from an arbitrary URL that is assumed to originate from this backend. */ - override async download(url: string, name?: string) { - download(url, name) - return Promise.resolve() + /** Download an asset. */ + override async download(id: backend.AssetId, title: string) { + const asset = backend.extractTypeFromId(id) + if (asset.type === backend.AssetType.project) { + const typeAndId = extractTypeAndId(asset.id) + const queryString = new URLSearchParams({ + projectsDirectory: typeAndId.directory, + }).toString() + download( + `./api/project-manager/projects/${typeAndId.id}/enso-project?${queryString}`, + `${title}.enso-project`, + ) + } + await Promise.resolve() } /** Invalid operation. */ diff --git a/app/gui/src/dashboard/services/ProjectManager.ts b/app/gui/src/dashboard/services/ProjectManager.ts index 8b1677d24925..9af79ecddefa 100644 --- a/app/gui/src/dashboard/services/ProjectManager.ts +++ b/app/gui/src/dashboard/services/ProjectManager.ts @@ -11,19 +11,11 @@ import * as dateTime from '#/utilities/dateTime' import * as newtype from '#/utilities/newtype' import { getDirectoryAndName, normalizeSlashes } from '#/utilities/path' -// ================= -// === Constants === -// ================= - /** Duration before the {@link ProjectManager} tries to create a WebSocket again. */ const RETRY_INTERVAL_MS = 1000 /** The maximum amount of time for which the {@link ProjectManager} should try loading. */ const MAXIMUM_DELAY_MS = 10_000 -// ============= -// === Types === -// ============= - /** Possible actions to take when a component is missing. */ export enum MissingComponentAction { fail = 'Fail', @@ -216,10 +208,6 @@ interface OpenedProjectState { */ type ProjectState = OpenedProjectState | OpenInProgressProjectState -// ================================ -// === Parameters for endpoints === -// ================================ - /** Parameters for the "open project" endpoint. */ export interface OpenProjectParams { readonly projectId: UUID @@ -265,10 +253,6 @@ export interface DeleteProjectParams { readonly projectsDirectory?: Path } -// ======================= -// === Project Manager === -// ======================= - /** Possible events that may be emitted by a {@link ProjectManager}. */ export enum ProjectManagerEvents { // If this member is renamed, the corresponding event listener should also be renamed in @@ -503,6 +487,10 @@ export default class ProjectManager { /** Delete a project. */ async deleteProject(params: Omit): Promise { + const cached = this.internalProjects.get(params.projectId) + if (cached && backend.IS_OPENING_OR_OPENED[cached.state]) { + await this.closeProject({ projectId: params.projectId }) + } const path = this.internalProjectPaths.get(params.projectId) const directoryPath = path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath diff --git a/app/gui/src/dashboard/services/RemoteBackend.ts b/app/gui/src/dashboard/services/RemoteBackend.ts index e63866ea8584..7f08d5327a05 100644 --- a/app/gui/src/dashboard/services/RemoteBackend.ts +++ b/app/gui/src/dashboard/services/RemoteBackend.ts @@ -18,6 +18,7 @@ import { DirectoryId, UserGroupId } from '#/services/Backend' import * as download from '#/utilities/download' import type HttpClient from '#/utilities/HttpClient' import * as object from '#/utilities/object' +import invariant from 'tiny-invariant' // ================= // === Constants === @@ -837,7 +838,6 @@ export default class RemoteBackend extends Backend { */ override async getProjectDetails( projectId: backend.ProjectId, - _directoryId: null, getPresignedUrl = false, ): Promise { const paramsString = new URLSearchParams({ @@ -1332,10 +1332,40 @@ export default class RemoteBackend extends Backend { } } - /** Download from an arbitrary URL that is assumed to originate from this backend. */ - override async download(url: string, name?: string) { - download.download(url, name) - return Promise.resolve() + /** Download an asset. */ + override async download(id: backend.AssetId, title: string) { + const asset = backend.extractTypeFromId(id) + 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.') + download.download(details.url, `${title}.enso-project`) + break + } + 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.') + download.download(details.url, details.file.fileName ?? '') + break + } + case backend.AssetType.datalink: { + const value = await this.getDatalink(asset.id, title) + const fileName = `${title}.datalink` + download.download( + URL.createObjectURL( + new File([JSON.stringify(value)], fileName, { + type: 'application/json+x-enso-data-link', + }), + ), + fileName, + ) + break + } + default: { + invariant(`'${asset.type}' assets cannot be downloaded.`) + break + } + } } /** Fetch the URL of the customer portal. */ diff --git a/app/gui/src/dashboard/services/remoteBackendPaths.ts b/app/gui/src/dashboard/services/remoteBackendPaths.ts index d6d3840c0ce5..33ae9cb4ea05 100644 --- a/app/gui/src/dashboard/services/remoteBackendPaths.ts +++ b/app/gui/src/dashboard/services/remoteBackendPaths.ts @@ -2,10 +2,6 @@ import * as backend from '#/services/Backend' import { newtypeConstructor, type Newtype } from 'enso-common/src/utilities/data/newtype' -// ============= -// === Paths === -// ============= - /** Relative HTTP path to the "list users" endpoint of the Cloud backend API. */ export const LIST_USERS_PATH = 'users' /** Relative HTTP path to the "create user" endpoint of the Cloud backend API. */ @@ -196,15 +192,10 @@ export function getCheckoutSessionPath(checkoutSessionId: backend.CheckoutSessio return `${GET_CHECKOUT_SESSION_PATH}/${checkoutSessionId}` } -// =========== -// === IDs === -// =========== - /** Unique identifier for a directory. */ type DirectoryId = Newtype // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-redeclare const DirectoryId = newtypeConstructor() -export const ROOT_PARENT_DIRECTORY_ID = backend.DirectoryId('directory-') /** The ID of the directory containing the home directories of all users. */ export const USERS_DIRECTORY_ID = backend.DirectoryId('directory-0000000000000000000000users') /** The ID of the directory containing home directories of all teams. */ diff --git a/app/gui/src/dashboard/styles.css b/app/gui/src/dashboard/styles.css index d670163c2092..af36dfdd18dc 100644 --- a/app/gui/src/dashboard/styles.css +++ b/app/gui/src/dashboard/styles.css @@ -20,6 +20,6 @@ } } -:where(.enso-dashboard) { - @apply absolute inset-0 isolate flex flex-col overflow-hidden; +:where(.enso-app) { + @apply absolute inset-0 isolate overflow-hidden; } diff --git a/app/gui/src/dashboard/tailwind.css b/app/gui/src/dashboard/tailwind.css index b38559898897..5cdb2af6081e 100644 --- a/app/gui/src/dashboard/tailwind.css +++ b/app/gui/src/dashboard/tailwind.css @@ -405,8 +405,8 @@ } /* These styles MUST still be copied - * as `.enso-dashboard body` and `.enso-dashboard html` make no sense. */ -:where(.enso-dashboard, .enso-chat, .enso-portal-root) { + * as `.enso-app body` and `.enso-app html` make no sense. */ +:where(.enso-app, .enso-chat, .enso-portal-root) { line-height: 1.5; -webkit-text-size-adjust: 100%; -moz-tab-size: 4; diff --git a/app/gui/src/dashboard/utilities/AssetTreeNode.ts b/app/gui/src/dashboard/utilities/AssetTreeNode.ts index bcc9aae1e532..b50b7a87bf8d 100644 --- a/app/gui/src/dashboard/utilities/AssetTreeNode.ts +++ b/app/gui/src/dashboard/utilities/AssetTreeNode.ts @@ -1,6 +1,4 @@ /** @file A node in the drive's item tree. */ -import type * as assetEvent from '#/events/assetEvent' - import * as backendModule from '#/services/Backend' // ===================== @@ -8,17 +6,7 @@ import * as backendModule from '#/services/Backend' // ===================== /** An {@link AssetTreeNode}, but excluding its methods. */ -export type AssetTreeNodeData = Pick< - AssetTreeNode, - | 'children' - | 'depth' - | 'directoryId' - | 'directoryKey' - | 'initialAssetEvents' - | 'item' - | 'key' - | 'path' -> +export type AssetTreeNodeData = Pick /** All possible variants of {@link AssetTreeNode}s. */ // The `Item extends Item` is required to trigger distributive conditional types: @@ -36,13 +24,6 @@ export default class AssetTreeNode( this: void, asset: Asset, - directoryKey: backendModule.DirectoryId, - directoryId: backendModule.DirectoryId, depth: number, path: string, - initialAssetEvents: readonly assetEvent.AssetEvent[] | null = null, - key: Asset['id'] = asset.id, ): AnyAssetTreeNode { - return new AssetTreeNode( - asset, - directoryKey, - directoryId, - null, - depth, - path, - initialAssetEvents, - key, - ).asUnion() + return new AssetTreeNode(asset, null, depth, path).asUnion() } /** Return `this`, coerced into an {@link AnyAssetTreeNode}. */ @@ -111,15 +72,11 @@ export default class AssetTreeNode): AnyAssetTreeNode { return new AssetTreeNode( update.item ?? this.item, - update.directoryKey ?? this.directoryKey, - update.directoryId ?? this.directoryId, - // `null` MUST be special-cases in the following line. + // `null` MUST be special-cased in the following line. // eslint-disable-next-line eqeqeq update.children === null ? update.children : update.children ?? this.children, update.depth ?? this.depth, update.path ?? this.path, - update.initialAssetEvents ?? this.initialAssetEvents, - update.key ?? this.key, ).asUnion() } @@ -227,7 +184,7 @@ export default class AssetTreeNode { - const isSelf = sibling.key === this.key + const isSelf = sibling.item.id === this.item.id const hasSameType = sibling.item.type === this.item.type const hasSameTitle = sibling.item.title === newTitle return !(!isSelf && hasSameType && hasSameTitle) diff --git a/app/gui/src/entrypoint.ts b/app/gui/src/entrypoint.ts index 5d275f20b430..50f81dcbf313 100644 --- a/app/gui/src/entrypoint.ts +++ b/app/gui/src/entrypoint.ts @@ -1,57 +1,154 @@ -import * as dashboard from '#/index' import '#/styles.css' import '#/tailwind.css' -import { AsyncApp } from '@/asyncApp' -import { baseConfig, configValue, mergeConfig } from '@/util/config' -import { urlParams } from '@/util/urlParams' -import * as vueQuery from '@tanstack/vue-query' -import { isOnLinux } from 'enso-common/src/detect' -import * as commonQuery from 'enso-common/src/queryClient' +import * as sentry from '@sentry/react' +import { VueQueryPlugin } from '@tanstack/vue-query' +import * as detect from 'enso-common/src/detect' +import { createQueryClient } from 'enso-common/src/queryClient' +import { MotionGlobalConfig } from 'framer-motion' import * as idbKeyval from 'idb-keyval' -import { lazyVueInReact } from 'veaury' -import { type App } from 'vue' +import { useEffect } from 'react' +import { + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom' +import { createApp } from 'vue' +import App from './App.vue' -const INITIAL_URL_KEY = `Enso-initial-url` +const HTTP_STATUS_BAD_REQUEST = 400 +const API_HOST = + process.env.ENSO_CLOUD_API_URL != null ? new URL(process.env.ENSO_CLOUD_API_URL).host : null +/** The fraction of non-erroring interactions that should be sampled by Sentry. */ +const SENTRY_SAMPLE_RATE = 0.005 const SCAM_WARNING_TIMEOUT = 1000 -export const isDevMode = process.env.NODE_ENV === 'development' - -function printScamWarning() { - if (isDevMode) return - const headerCss = ` - color: white; - background: crimson; - display: block; - border-radius: 8px; - font-weight: bold; - padding: 10px 20px 10px 20px; - ` - .trim() - .replace(/\n\s+/, ' ') - const headerCss1 = headerCss + ' font-size: 46px;' - const headerCss2 = headerCss + ' font-size: 20px;' - const msgCSS = 'font-size: 16px;' - - const msg1 = - 'This is a browser feature intended for developers. If someone told you to ' + - 'copy-paste something here, it is a scam and will give them access to your ' + - 'account and data.' - const msg2 = 'See https://enso.org/selfxss for more information.' - console.log('%cStop!', headerCss1) - console.log('%cYou may be the victim of a scam!', headerCss2) - console.log('%c' + msg1, msgCSS) - console.log('%c' + msg2, msgCSS) +const INITIAL_URL_KEY = `Enso-initial-url` + +function main() { + setupScamWarning() + setupSentry() + configureAnimations() + const appProps = imNotSureButPerhapsFixingRefreshingWithAuthentication() + const queryClient = createQueryClientOfPersistCache() + + const app = createApp(App, appProps) + app.use(VueQueryPlugin, { queryClient }) + app.mount('#enso-app') } -printScamWarning() -let scamWarningHandle = 0 +function setupScamWarning() { + function printScamWarning() { + if (process.env.NODE_ENV === 'development') return + const headerCss = ` + color: white; + background: crimson; + display: block; + border-radius: 8px; + font-weight: bold; + padding: 10px 20px 10px 20px; + ` + .trim() + .replace(/\n\s+/, ' ') + const headerCss1 = headerCss + ' font-size: 46px;' + const headerCss2 = headerCss + ' font-size: 20px;' + const msgCSS = 'font-size: 16px;' + + const msg1 = + 'This is a browser feature intended for developers. If someone told you to ' + + 'copy-paste something here, it is a scam and will give them access to your ' + + 'account and data.' + const msg2 = 'See https://enso.org/selfxss for more information.' + console.log('%cStop!', headerCss1) + console.log('%cYou may be the victim of a scam!', headerCss2) + console.log('%c' + msg1, msgCSS) + console.log('%c' + msg2, msgCSS) + } -window.addEventListener('resize', () => { - window.clearTimeout(scamWarningHandle) - scamWarningHandle = window.setTimeout(printScamWarning, SCAM_WARNING_TIMEOUT) -}) + printScamWarning() + let scamWarningHandle = 0 -/** The entrypoint into the IDE. */ -function main() { + window.addEventListener('resize', () => { + window.clearTimeout(scamWarningHandle) + scamWarningHandle = window.setTimeout(printScamWarning, SCAM_WARNING_TIMEOUT) + }) +} + +function setupSentry() { + if ( + !detect.IS_DEV_MODE && + process.env.ENSO_CLOUD_SENTRY_DSN != null && + process.env.ENSO_CLOUD_API_URL != null + ) { + const version: unknown = import.meta.env.ENSO_IDE_VERSION + sentry.init({ + dsn: process.env.ENSO_CLOUD_SENTRY_DSN, + environment: process.env.ENSO_CLOUD_ENVIRONMENT, + release: version?.toString() ?? 'dev', + integrations: [ + sentry.reactRouterV6BrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + sentry.extraErrorDataIntegration({ captureErrorCause: true }), + sentry.replayIntegration(), + new sentry.BrowserProfilingIntegration(), + ], + profilesSampleRate: SENTRY_SAMPLE_RATE, + tracesSampleRate: SENTRY_SAMPLE_RATE, + tracePropagationTargets: [process.env.ENSO_CLOUD_API_URL.split('//')[1] ?? ''], + replaysSessionSampleRate: SENTRY_SAMPLE_RATE, + replaysOnErrorSampleRate: 1.0, + beforeSend: (event) => { + if ( + (event.breadcrumbs ?? []).some( + (breadcrumb) => + breadcrumb.type === 'http' && + breadcrumb.category === 'fetch' && + breadcrumb.data && + breadcrumb.data.status_code === HTTP_STATUS_BAD_REQUEST && + typeof breadcrumb.data.url === 'string' && + new URL(breadcrumb.data.url).host === API_HOST, + ) + ) { + return null + } + return event + }, + }) + } +} + +function configureAnimations() { + const areAnimationsDisabled = + window.DISABLE_ANIMATIONS === true || + localStorage.getItem('disableAnimations') === 'true' || + false + + MotionGlobalConfig.skipAnimations = areAnimationsDisabled + + if (areAnimationsDisabled) { + document.documentElement.classList.add('disable-animations') + } else { + document.documentElement.classList.remove('disable-animations') + } +} + +function createQueryClientOfPersistCache() { + const store = idbKeyval.createStore('enso', 'query-persist-cache') + return createQueryClient({ + persisterStorage: { + getItem: async (key) => idbKeyval.get(key, store), + setItem: async (key, value) => idbKeyval.set(key, value, store), + removeItem: async (key) => idbKeyval.del(key, store), + clear: async () => idbKeyval.clear(store), + }, + }) +} + +function imNotSureButPerhapsFixingRefreshingWithAuthentication() { /** * Note: Signing out always redirects to `/`. It is impossible to make this work, * as it is not possible to distinguish between having just logged out, and explicitly @@ -73,49 +170,7 @@ function main() { localStorage.setItem(INITIAL_URL_KEY, location.href) } - const resolveEnvUrl = (url: string | undefined) => - url?.replace('__HOSTNAME__', window.location.hostname) - - const config = configValue(mergeConfig(baseConfig, urlParams())) - const supportsVibrancy = config.window.vibrancy - const shouldUseAuthentication = config.authentication.enabled - const projectManagerUrl = - (config.engine.projectManagerUrl || resolveEnvUrl(PROJECT_MANAGER_URL)) ?? null - const ydocUrl = (config.engine.ydocUrl || resolveEnvUrl(YDOC_SERVER_URL)) ?? null - const initialProjectName = config.startup.project || null - const urlWithoutStartupProject = new URL(location.toString()) - urlWithoutStartupProject.searchParams.delete('startup.project') - history.replaceState(null, '', urlWithoutStartupProject) - - const store = idbKeyval.createStore('enso', 'query-persist-cache') - const queryClient = commonQuery.createQueryClient({ - persisterStorage: { - getItem: async (key) => idbKeyval.get(key, store), - setItem: async (key, value) => idbKeyval.set(key, value, store), - removeItem: async (key) => idbKeyval.del(key, store), - clear: async () => idbKeyval.clear(store), - }, - }) - - const registerPlugins = (app: App) => { - app.use(vueQuery.VueQueryPlugin, { queryClient }) - } - - const appRunner = lazyVueInReact(AsyncApp as any /* async VueComponent */, { - beforeVueAppMount: (app) => registerPlugins(app as App), - }) as dashboard.GraphEditorRunner - - dashboard.run({ - appRunner, - logger: console, - vibrancy: supportsVibrancy, - supportsLocalBackend: !IS_CLOUD_BUILD, - supportsDeepLinks: !isDevMode && !isOnLinux(), - projectManagerUrl, - ydocUrl, - isAuthenticationDisabled: !shouldUseAuthentication, - shouldShowDashboard: true, - initialProjectName, + return { onAuthenticated() { if (isInAuthenticationFlow) { const initialUrl = localStorage.getItem(INITIAL_URL_KEY) @@ -126,8 +181,7 @@ function main() { } } }, - queryClient, - }) + } } main() diff --git a/app/gui/src/project-view/App.vue b/app/gui/src/project-view/App.vue deleted file mode 100644 index 0127b8110599..000000000000 --- a/app/gui/src/project-view/App.vue +++ /dev/null @@ -1,180 +0,0 @@ - - - - - diff --git a/app/gui/src/project-view/ProjectView.vue b/app/gui/src/project-view/ProjectView.vue new file mode 100644 index 000000000000..d9f818b13f16 --- /dev/null +++ b/app/gui/src/project-view/ProjectView.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/app/gui/src/project-view/ProjectViewTab.vue b/app/gui/src/project-view/ProjectViewTab.vue new file mode 100644 index 000000000000..438831003cbd --- /dev/null +++ b/app/gui/src/project-view/ProjectViewTab.vue @@ -0,0 +1,23 @@ + + + diff --git a/app/gui/src/project-view/asyncApp.ts b/app/gui/src/project-view/asyncApp.ts deleted file mode 100644 index bf60c6c69187..000000000000 --- a/app/gui/src/project-view/asyncApp.ts +++ /dev/null @@ -1,7 +0,0 @@ -import '@/assets/base.css' - -/** Load App.vue asynchronously. */ -export async function AsyncApp() { - const app = await import('@/App.vue') - return app -} diff --git a/app/gui/src/project-view/components/GraphEditor.vue b/app/gui/src/project-view/components/GraphEditor.vue index 6b42c5843f09..c474ccf60d14 100644 --- a/app/gui/src/project-view/components/GraphEditor.vue +++ b/app/gui/src/project-view/components/GraphEditor.vue @@ -690,11 +690,8 @@ const groupColors = computed(() => { diff --git a/app/gui/src/project-view/views/ProjectView.vue b/app/gui/src/project-view/views/ProjectView.vue deleted file mode 100644 index 3c20d457072f..000000000000 --- a/app/gui/src/project-view/views/ProjectView.vue +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/gui/vite.config.ts b/app/gui/vite.config.ts index decff30fc04a..017ae978b669 100644 --- a/app/gui/vite.config.ts +++ b/app/gui/vite.config.ts @@ -1,6 +1,4 @@ import { sentryVitePlugin } from '@sentry/vite-plugin' -/// - import react from '@vitejs/plugin-react' import vue from '@vitejs/plugin-vue' import { COOP_COEP_CORP_HEADERS } from 'enso-common' diff --git a/app/ide-desktop/client/tasks/signArchivesMacOs.ts b/app/ide-desktop/client/tasks/signArchivesMacOs.ts index 47c387d154e4..121027b49350 100644 --- a/app/ide-desktop/client/tasks/signArchivesMacOs.ts +++ b/app/ide-desktop/client/tasks/signArchivesMacOs.ts @@ -102,7 +102,7 @@ async function ensoPackageSignables(resourcesDir: string): Promise { ], ['component/jna-*.jar', ['com/sun/jna/*/libjnidispatch.jnilib']], [ - 'component/jline-*.jar', + 'component/jline-native-*.jar', [ 'org/jline/nativ/Mac/arm64/libjlinenative.jnilib', 'org/jline/nativ/Mac/x86_64/libjlinenative.jnilib', diff --git a/build.sbt b/build.sbt index 6bb3f19b6a48..3eceb965b477 100644 --- a/build.sbt +++ b/build.sbt @@ -314,8 +314,8 @@ lazy val enso = (project in file(".")) `edition-updater`, `edition-uploader`, `engine-common`, - `engine-runner-common`, `engine-runner`, + `engine-runner-common`, `enso-test-java-helpers`, `exploratory-benchmark-java-helpers`, `fansi-wrapper`, @@ -324,8 +324,8 @@ lazy val enso = (project in file(".")) `interpreter-dsl`, `interpreter-dsl-test`, `jna-wrapper`, - `json-rpc-server-test`, `json-rpc-server`, + `json-rpc-server-test`, `language-server`, `language-server-deps-wrapper`, launcher, @@ -351,8 +351,6 @@ lazy val enso = (project in file(".")) `runtime-and-langs`, `runtime-benchmarks`, `runtime-compiler`, - `runtime-integration-tests`, - `runtime-parser`, `runtime-language-arrow`, `runtime-language-epb`, `runtime-instrument-common`, @@ -537,11 +535,9 @@ val jmh = Seq( "org.openjdk.jmh" % "jmh-generator-annprocess" % jmhVersion % Benchmark ) -// === Scala Compiler ========================================================= - -val scalaCompiler = Seq( - "org.scala-lang" % "scala-reflect" % scalacVersion, - "org.scala-lang" % "scala-compiler" % scalacVersion +// === Scala ========================================================= +val scalaReflect = Seq( + "org.scala-lang" % "scala-reflect" % scalacVersion ) val scalaLibrary = Seq( "org.scala-lang" % "scala-library" % scalacVersion @@ -589,6 +585,15 @@ val bouncyCastle = Seq( "org.bouncycastle" % "bcprov-jdk18on" % bouncyCastleVersion ) +// === JLine ================================================================== +val jlineVersion = "3.26.3" +val jline = Seq( + "org.jline" % "jline-terminal" % jlineVersion, + "org.jline" % "jline-terminal-jna" % jlineVersion, + "org.jline" % "jline-reader" % jlineVersion, + "org.jline" % "jline-native" % jlineVersion +) + // === Google ================================================================= val googleApiClientVersion = "2.2.0" val googleApiServicesSheetsVersion = "v4-rev612-1.25.0" @@ -603,7 +608,6 @@ val diffsonVersion = "4.4.0" val directoryWatcherVersion = "0.18.0" val flatbuffersVersion = "24.3.25" val guavaVersion = "32.0.0-jre" -val jlineVersion = "3.26.3" val jgitVersion = "6.7.0.202309050840-r" val kindProjectorVersion = "0.13.3" val mockitoScalaVersion = "1.17.14" @@ -671,12 +675,13 @@ lazy val componentModulesPaths = GraalVM.modules ++ GraalVM.langsPkgs ++ GraalVM.toolsPkgs ++ + scalaReflect ++ helidon ++ scalaLibrary ++ ioSentry ++ logbackPkg ++ + jline ++ Seq( - "org.scala-lang" % "scala-reflect" % scalacVersion, "org.netbeans.api" % "org-openide-util-lookup" % netbeansApiVersion, "org.netbeans.api" % "org-netbeans-modules-sampler" % netbeansApiVersion, "com.google.flatbuffers" % "flatbuffers-java" % flatbuffersVersion, @@ -687,7 +692,6 @@ lazy val componentModulesPaths = "org.eclipse.jgit" % "org.eclipse.jgit" % jgitVersion, "com.typesafe" % "config" % typesafeConfigVersion, "org.reactivestreams" % "reactive-streams" % reactiveStreamsVersion, - "org.jline" % "jline" % jlineVersion, "org.apache.commons" % "commons-lang3" % commonsLangVersion, "org.apache.commons" % "commons-compress" % commonsCompressVersion, "org.apache.tika" % "tika-core" % tikaVersion, @@ -1198,30 +1202,24 @@ lazy val `scala-libs-wrapper` = project modularFatJarWrapperSettings, scalaModuleDependencySetting, javaModuleName := "org.enso.scala.wrapper", - libraryDependencies ++= circe ++ scalaCompiler ++ Seq( + libraryDependencies ++= circe ++ scalaReflect ++ Seq( "com.typesafe.scala-logging" %% "scala-logging" % scalaLoggingVersion, "org.slf4j" % "slf4j-api" % slf4jVersion, "org.typelevel" %% "cats-core" % catsVersion, - "org.jline" % "jline" % jlineVersion, "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % jsoniterVersion, "net.java.dev.jna" % "jna" % jnaVersion ), - Compile / moduleDependencies ++= scalaLibrary ++ Seq( - "org.scala-lang" % "scala-reflect" % scalacVersion, - "org.jline" % "jline" % jlineVersion, - "org.slf4j" % "slf4j-api" % slf4jVersion + Compile / moduleDependencies ++= scalaLibrary ++ scalaReflect ++ Seq( + "org.slf4j" % "slf4j-api" % slf4jVersion ), assembly / assemblyExcludedJars := { JPMSUtils.filterModulesFromClasspath( (Compile / fullClasspath).value, scalaLibrary ++ - scalaCompiler ++ + scalaReflect ++ Seq( - "org.scala-lang" % "scala-reflect" % scalacVersion, - "org.slf4j" % "slf4j-api" % slf4jVersion, - "io.github.java-diff-utils" % "java-diff-utils" % javaDiffVersion, - "org.jline" % "jline" % jlineVersion, - "net.java.dev.jna" % "jna" % jnaVersion + "org.slf4j" % "slf4j-api" % slf4jVersion, + "net.java.dev.jna" % "jna" % jnaVersion ), streams.value.log, moduleName.value, @@ -1433,7 +1431,7 @@ lazy val `akka-wrapper` = project .settings( modularFatJarWrapperSettings, scalaModuleDependencySetting, - libraryDependencies ++= akka ++ scalaLibrary ++ scalaCompiler ++ Seq( + libraryDependencies ++= akka ++ scalaLibrary ++ scalaReflect ++ Seq( "org.scala-lang.modules" %% "scala-parser-combinators" % scalaParserCombinatorsVersion, "org.scala-lang.modules" %% "scala-java8-compat" % scalaJavaCompatVersion, akkaURL %% "akka-http" % akkaHTTPVersion, @@ -1447,7 +1445,6 @@ lazy val `akka-wrapper` = project "com.google.protobuf" % "protobuf-java" % googleProtobufVersion, "io.github.java-diff-utils" % "java-diff-utils" % javaDiffVersion, "org.reactivestreams" % "reactive-streams" % reactiveStreamsVersion, - "org.jline" % "jline" % jlineVersion, "net.java.dev.jna" % "jna" % jnaVersion, "io.spray" %% "spray-json" % sprayJsonVersion ), @@ -1460,12 +1457,11 @@ lazy val `akka-wrapper` = project assembly / assemblyExcludedJars := { val excludedJars = JPMSUtils.filterModulesFromUpdate( update.value, - scalaLibrary ++ scalaCompiler ++ Seq( + scalaLibrary ++ scalaReflect ++ Seq( "org.scala-lang.modules" %% "scala-java8-compat" % scalaJavaCompatVersion, "org.slf4j" % "slf4j-api" % slf4jVersion, "com.typesafe" % "config" % typesafeConfigVersion, "io.github.java-diff-utils" % "java-diff-utils" % javaDiffVersion, - "org.jline" % "jline" % jlineVersion, "com.google.protobuf" % "protobuf-java" % googleProtobufVersion, "org.reactivestreams" % "reactive-streams" % reactiveStreamsVersion, "net.java.dev.jna" % "jna" % jnaVersion @@ -1481,7 +1477,7 @@ lazy val `akka-wrapper` = project Compile / patchModules := { val scalaLibs = JPMSUtils.filterModulesFromUpdate( update.value, - scalaLibrary ++ scalaCompiler ++ + scalaLibrary ++ scalaReflect ++ Seq( "org.scala-lang.modules" %% "scala-parser-combinators" % scalaParserCombinatorsVersion, "com.typesafe" % "config" % typesafeConfigVersion, @@ -1523,7 +1519,7 @@ lazy val `zio-wrapper` = project val excludedJars = JPMSUtils.filterModulesFromUpdate( update.value, scalaLibrary ++ - scalaCompiler ++ + scalaReflect ++ Seq("dev.zio" %% "zio-interop-cats" % zioInteropCatsVersion), streams.value.log, moduleName.value, @@ -1754,7 +1750,9 @@ lazy val `project-manager` = (project in file("lib/scala/project-manager")) "scala.util.Random", "zio.internal.ZScheduler$$anon$4", "zio.Runtime$", - "zio.FiberRef$" + "zio.FiberRef$", + "com.typesafe.config.impl.ConfigImpl$EnvVariablesHolder", + "com.typesafe.config.impl.ConfigImpl$SystemPropertiesHolder" ) ) .dependsOn(VerifyReflectionSetup.run) @@ -2346,10 +2344,10 @@ lazy val `language-server` = (project in file("engine/language-server")) necessaryModules }, // More dependencies needed for modules for testing - libraryDependencies ++= ioSentry.map(_ % Test) ++ logbackTest ++ Seq( + libraryDependencies ++= ioSentry.map(_ % Test) ++ + logbackTest ++ Seq( "com.google.protobuf" % "protobuf-java" % googleProtobufVersion % Test, "org.reactivestreams" % "reactive-streams" % reactiveStreamsVersion % Test, - "org.jline" % "jline" % jlineVersion % Test, "org.apache.tika" % "tika-core" % tikaVersion % Test, "com.google.flatbuffers" % "flatbuffers-java" % flatbuffersVersion % Test, "org.netbeans.api" % "org-netbeans-modules-sampler" % netbeansApiVersion % Test, @@ -2359,7 +2357,7 @@ lazy val `language-server` = (project in file("engine/language-server")) "com.ibm.icu" % "icu4j" % icuVersion % Test ), Test / moduleDependencies := { - GraalVM.modules ++ GraalVM.langsPkgs ++ logbackPkg ++ helidon ++ ioSentry ++ bouncyCastle ++ scalaLibrary ++ scalaCompiler ++ Seq( + GraalVM.modules ++ GraalVM.langsPkgs ++ logbackPkg ++ helidon ++ ioSentry ++ bouncyCastle ++ scalaLibrary ++ scalaReflect ++ Seq( "org.slf4j" % "slf4j-api" % slf4jVersion, "org.netbeans.api" % "org-netbeans-modules-sampler" % netbeansApiVersion, "com.google.flatbuffers" % "flatbuffers-java" % flatbuffersVersion, @@ -2370,7 +2368,6 @@ lazy val `language-server` = (project in file("engine/language-server")) "commons-io" % "commons-io" % commonsIoVersion, "com.google.protobuf" % "protobuf-java" % googleProtobufVersion, "org.reactivestreams" % "reactive-streams" % reactiveStreamsVersion, - "org.jline" % "jline" % jlineVersion, "org.apache.tika" % "tika-core" % tikaVersion, "com.ibm.icu" % "icu4j" % icuVersion, "org.netbeans.api" % "org-openide-util-lookup" % netbeansApiVersion @@ -2884,7 +2881,7 @@ lazy val `runtime-integration-tests` = ), Test / javaOptions ++= testLogProviderOptions, Test / moduleDependencies := { - GraalVM.modules ++ GraalVM.langsPkgs ++ GraalVM.insightPkgs ++ logbackPkg ++ helidon ++ ioSentry ++ scalaLibrary ++ scalaCompiler ++ Seq( + GraalVM.modules ++ GraalVM.langsPkgs ++ GraalVM.insightPkgs ++ logbackPkg ++ helidon ++ ioSentry ++ scalaLibrary ++ scalaReflect ++ Seq( "org.apache.commons" % "commons-lang3" % commonsLangVersion, "org.apache.commons" % "commons-compress" % commonsCompressVersion, "commons-io" % "commons-io" % commonsIoVersion, @@ -2897,7 +2894,6 @@ lazy val `runtime-integration-tests` = "org.graalvm.truffle" % "truffle-tck-common" % graalMavenPackagesVersion, "org.graalvm.truffle" % "truffle-tck-tests" % graalMavenPackagesVersion, "com.ibm.icu" % "icu4j" % icuVersion, - "org.jline" % "jline" % jlineVersion, "com.google.flatbuffers" % "flatbuffers-java" % flatbuffersVersion, "org.yaml" % "snakeyaml" % snakeyamlVersion, "com.typesafe" % "config" % typesafeConfigVersion @@ -3058,7 +3054,7 @@ lazy val `runtime-benchmarks` = ), parallelExecution := false, Compile / moduleDependencies ++= { - GraalVM.modules ++ GraalVM.langsPkgs ++ GraalVM.insightPkgs ++ logbackPkg ++ helidon ++ ioSentry ++ scalaCompiler ++ Seq( + GraalVM.modules ++ GraalVM.langsPkgs ++ GraalVM.insightPkgs ++ logbackPkg ++ helidon ++ ioSentry ++ scalaReflect ++ Seq( "org.apache.commons" % "commons-lang3" % commonsLangVersion, "org.apache.commons" % "commons-compress" % commonsCompressVersion, "commons-io" % "commons-io" % commonsIoVersion, @@ -3068,7 +3064,6 @@ lazy val `runtime-benchmarks` = "org.netbeans.api" % "org-openide-util-lookup" % netbeansApiVersion, "org.netbeans.api" % "org-netbeans-modules-sampler" % netbeansApiVersion, "com.ibm.icu" % "icu4j" % icuVersion, - "org.jline" % "jline" % jlineVersion, "com.google.flatbuffers" % "flatbuffers-java" % flatbuffersVersion, "org.yaml" % "snakeyaml" % snakeyamlVersion, "com.typesafe" % "config" % typesafeConfigVersion, @@ -3246,7 +3241,6 @@ lazy val `runtime-compiler` = "org.scalatest" %% "scalatest" % scalatestVersion % Test, "org.netbeans.api" % "org-openide-util-lookup" % netbeansApiVersion % "provided", "org.yaml" % "snakeyaml" % snakeyamlVersion % Test, - "org.jline" % "jline" % jlineVersion % Test, "com.typesafe" % "config" % typesafeConfigVersion % Test, "org.graalvm.polyglot" % "polyglot" % graalMavenPackagesVersion % Test, "org.hamcrest" % "hamcrest-all" % hamcrestVersion % Test @@ -3265,10 +3259,9 @@ lazy val `runtime-compiler` = (`editions` / Compile / exportedModule).value ), Test / moduleDependencies := { - (Compile / moduleDependencies).value ++ scalaLibrary ++ scalaCompiler ++ Seq( + (Compile / moduleDependencies).value ++ scalaLibrary ++ scalaReflect ++ Seq( "org.apache.commons" % "commons-compress" % commonsCompressVersion, "org.yaml" % "snakeyaml" % snakeyamlVersion, - "org.jline" % "jline" % jlineVersion, "com.typesafe" % "config" % typesafeConfigVersion, "org.graalvm.polyglot" % "polyglot" % graalMavenPackagesVersion ) @@ -3552,24 +3545,23 @@ lazy val `engine-runner` = project Compile / run / mainClass := Some("org.enso.runner.Main"), commands += WithDebugCommand.withDebug, inConfig(Compile)(truffleRunOptionsSettings), - libraryDependencies ++= GraalVM.modules ++ Seq( + libraryDependencies ++= GraalVM.modules ++ jline ++ Seq( "org.graalvm.polyglot" % "polyglot" % graalMavenPackagesVersion, "org.graalvm.sdk" % "polyglot-tck" % graalMavenPackagesVersion % Provided, "commons-cli" % "commons-cli" % commonsCliVersion, "com.monovore" %% "decline" % declineVersion, - "org.jline" % "jline" % jlineVersion, "junit" % "junit" % junitVersion % Test, "com.github.sbt" % "junit-interface" % junitIfVersion % Test, "org.hamcrest" % "hamcrest-all" % hamcrestVersion % Test, "org.scala-lang.modules" %% "scala-collection-compat" % scalaCollectionCompatVersion ), Compile / moduleDependencies ++= + jline ++ Seq( "org.graalvm.polyglot" % "polyglot" % graalMavenPackagesVersion, "org.graalvm.sdk" % "nativeimage" % graalMavenPackagesVersion, "org.graalvm.sdk" % "word" % graalMavenPackagesVersion, "commons-cli" % "commons-cli" % commonsCliVersion, - "org.jline" % "jline" % jlineVersion, "org.slf4j" % "slf4j-api" % slf4jVersion ), Compile / internalModuleDependencies := Seq( diff --git a/distribution/engine/THIRD-PARTY/NOTICE b/distribution/engine/THIRD-PARTY/NOTICE index e6174fdd8e3a..9a275d2f28e5 100644 --- a/distribution/engine/THIRD-PARTY/NOTICE +++ b/distribution/engine/THIRD-PARTY/NOTICE @@ -1,5 +1,5 @@ Enso -Copyright 2020 - 2024 New Byte Order sp. z o. o. +Copyright 2020 - 2025 New Byte Order sp. z o. o. 'shapeless_2.13', licensed under the Apache 2, is distributed with the engine. The license file can be found at `licenses/APACHE2.0`. @@ -191,11 +191,6 @@ The license file can be found at `licenses/APACHE2.0`. Copyright notices related to this dependency can be found in the directory `io.circe.circe-parser_2.13-0.14.7`. -'java-diff-utils', licensed under the The Apache Software License, Version 2.0, is distributed with the engine. -The license file can be found at `licenses/APACHE2.0`. -Copyright notices related to this dependency can be found in the directory `io.github.java-diff-utils.java-diff-utils-4.12`. - - 'helidon-builder-api', licensed under the Apache 2.0, is distributed with the engine. The license file can be found at `licenses/APACHE2.0`. Copyright notices related to this dependency can be found in the directory `io.helidon.builder.helidon-builder-api-4.1.2`. @@ -506,9 +501,24 @@ The license file can be found at `licenses/Universal_Permissive_License__Version Copyright notices related to this dependency can be found in the directory `org.graalvm.truffle.truffle-runtime-24.0.0`. -'jline', licensed under the The BSD License, is distributed with the engine. +'jline-native', licensed under the The BSD License, is distributed with the engine. +The license file can be found at `licenses/BSD-3-Clause`. +Copyright notices related to this dependency can be found in the directory `org.jline.jline-native-3.26.3`. + + +'jline-reader', licensed under the The BSD License, is distributed with the engine. The license file can be found at `licenses/BSD-3-Clause`. -Copyright notices related to this dependency can be found in the directory `org.jline.jline-3.26.3`. +Copyright notices related to this dependency can be found in the directory `org.jline.jline-reader-3.26.3`. + + +'jline-terminal', licensed under the The BSD License, is distributed with the engine. +The license file can be found at `licenses/BSD-3-Clause`. +Copyright notices related to this dependency can be found in the directory `org.jline.jline-terminal-3.26.3`. + + +'jline-terminal-jna', licensed under the The BSD License, is distributed with the engine. +The license file can be found at `licenses/BSD-3-Clause`. +Copyright notices related to this dependency can be found in the directory `org.jline.jline-terminal-jna-3.26.3`. 'org-netbeans-modules-sampler', licensed under the The Apache Software License, Version 2.0, is distributed with the engine. @@ -541,11 +551,6 @@ The license file can be found at `licenses/APACHE2.0`. Copyright notices related to this dependency can be found in the directory `org.scala-lang.modules.scala-parser-combinators_2.13-1.1.2`. -'scala-compiler', licensed under the Apache-2.0, is distributed with the engine. -The license file can be found at `licenses/APACHE2.0`. -Copyright notices related to this dependency can be found in the directory `org.scala-lang.scala-compiler-2.13.15`. - - 'scala-library', licensed under the Apache-2.0, is distributed with the engine. The license file can be found at `licenses/APACHE2.0`. Copyright notices related to this dependency can be found in the directory `org.scala-lang.scala-library-2.13.15`. diff --git a/distribution/engine/THIRD-PARTY/io.github.java-diff-utils.java-diff-utils-4.12/NOTICES b/distribution/engine/THIRD-PARTY/io.github.java-diff-utils.java-diff-utils-4.12/NOTICES deleted file mode 100644 index a1238ebd303e..000000000000 --- a/distribution/engine/THIRD-PARTY/io.github.java-diff-utils.java-diff-utils-4.12/NOTICES +++ /dev/null @@ -1,13 +0,0 @@ -Copyright (C) 2009 - 2017 java-diff-utils - -Copyright 2009-2017 java-diff-utils. - -Copyright 2017 java-diff-utils. - -Copyright 2018 java-diff-utils. - -Copyright 2019 java-diff-utils. - -Copyright 2020 java-diff-utils. - -Copyright 2021 java-diff-utils. diff --git a/distribution/engine/THIRD-PARTY/org.jline.jline-3.26.3/NOTICES b/distribution/engine/THIRD-PARTY/org.jline.jline-3.26.3/NOTICES deleted file mode 100644 index 5cf1d7180f6d..000000000000 --- a/distribution/engine/THIRD-PARTY/org.jline.jline-3.26.3/NOTICES +++ /dev/null @@ -1,49 +0,0 @@ -Copyright (C) 2022 the original author(s). - -Copyright (c) 2000-2005 Dieter Wimberger - -Copyright (c) 2002-2016, the original author or authors. - -Copyright (c) 2002-2016, the original author(s). - -Copyright (c) 2002-2017, the original author(s). - -Copyright (c) 2002-2018, the original author or authors. - -Copyright (c) 2002-2018, the original author(s). - -Copyright (c) 2002-2019, the original author(s). - -Copyright (c) 2002-2020, the original author or authors. - -Copyright (c) 2002-2020, the original author(s). - -Copyright (c) 2002-2021, the original author or authors. - -Copyright (c) 2002-2021, the original author(s). - -Copyright (c) 2002-2022, the original author or authors. - -Copyright (c) 2002-2022, the original author(s). - -Copyright (c) 2002-2023, the original author(s). - -Copyright (c) 2002-2024, the original author(s). - -Copyright (c) 2009-2018, the original author(s). - -Copyright (c) 2009-2023, the original author(s). - -Copyright (c) 2022, the original author(s). - -Copyright (c) 2022-2023, the original author(s). - -Copyright (c) 2023, the original author or authors. - -Copyright (c) 2023, the original author(s). - -Copyright 2019 the original author or authors. - -regarding copyright ownership. The ASF licenses this file - -this work for additional information regarding copyright ownership. diff --git a/distribution/engine/THIRD-PARTY/org.jline.jline-native-3.26.3/NOTICES b/distribution/engine/THIRD-PARTY/org.jline.jline-native-3.26.3/NOTICES new file mode 100644 index 000000000000..e4def4922e92 --- /dev/null +++ b/distribution/engine/THIRD-PARTY/org.jline.jline-native-3.26.3/NOTICES @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2009-2023, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +/* + * Copyright (c) 2023, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +Copyright (c) 2023, the original author or authors. + +Copyright 2019 the original author or authors. diff --git a/distribution/engine/THIRD-PARTY/org.jline.jline-reader-3.26.3/NOTICES b/distribution/engine/THIRD-PARTY/org.jline.jline-reader-3.26.3/NOTICES new file mode 100644 index 000000000000..97d4d7de4691 --- /dev/null +++ b/distribution/engine/THIRD-PARTY/org.jline.jline-reader-3.26.3/NOTICES @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2002-2016, the original author or authors. + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +/* + * Copyright (c) 2002-2016, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +/* + * Copyright (c) 2002-2017, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +/* + * Copyright (c) 2002-2018, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +/* + * Copyright (c) 2002-2019, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +/* + * Copyright (c) 2002-2020, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +/* + * Copyright (c) 2002-2021, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +/* + * Copyright (c) 2002-2023, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +/* + * Copyright (c) 2023, the original author(s). + * + * This software is distributable under the BSD license. See the terms of the + * BSD license in the documentation provided with this software. + * + * https://opensource.org/licenses/BSD-3-Clause + */ + +