Merge branch 'develop' into wip/sb/fix-react-compiler-lints

This commit is contained in:
somebody1234 2024-11-06 21:42:13 +10:00
commit 1c317b4fec
712 changed files with 11676 additions and 7307 deletions

1
.gitattributes vendored
View File

@ -1,2 +1,3 @@
*.enso text eol=lf
*.png binary
CHANGELOG.md merge=union

View File

@ -12,6 +12,16 @@
- [Changed the way of adding new column in Table Input Widget][11388]. The
"virtual column" is replaced with an explicit (+) button.
- [New dropdown-based component menu][11398].
- [Methods defined on Standard.Base.Any type are now visible on all
components][11451].
- [Undo/redo buttons in the top bar][11433].
- [Size of Table Input Widget is preserved and restored after project
re-opening][11435]
- [Added application version to the title bar.][11446]
- [Added "open grouped components" action to the context menu.][11447]
- [Table Input Widget has now a limit of 256 cells.][11448]
- [Added an error message screen displayed when viewing a deleted
component.][11452]
[11151]: https://github.com/enso-org/enso/pull/11151
[11271]: https://github.com/enso-org/enso/pull/11271
@ -20,6 +30,13 @@
[11383]: https://github.com/enso-org/enso/pull/11383
[11388]: https://github.com/enso-org/enso/pull/11388
[11398]: https://github.com/enso-org/enso/pull/11398
[11451]: https://github.com/enso-org/enso/pull/11451
[11433]: https://github.com/enso-org/enso/pull/11433
[11435]: https://github.com/enso-org/enso/pull/11435
[11446]: https://github.com/enso-org/enso/pull/11446
[11447]: https://github.com/enso-org/enso/pull/11447
[11448]: https://github.com/enso-org/enso/pull/11448
[11452]: https://github.com/enso-org/enso/pull/11452
#### Enso Standard Library
@ -28,10 +45,12 @@
- [The user may set description and labels of an Enso Cloud asset
programmatically.][11255]
- [DB_Table may be saved as a Data Link.][11371]
- [Support for dates before 1900 in Excel and signed AWS requests.][11373]
[11235]: https://github.com/enso-org/enso/pull/11235
[11255]: https://github.com/enso-org/enso/pull/11255
[11371]: https://github.com/enso-org/enso/pull/11371
[11373]: https://github.com/enso-org/enso/pull/11373
#### Enso Language & Runtime
@ -101,6 +120,9 @@
range.][11135]
- [Added `format` parameter to `Decimal.parse`.][11205]
- [Added `format` parameter to `Float.parse`.][11229]
- [Implemented a cache for HTTP data requests, as well as a per-file response
size limit.][11342]
- [Overhauled Google Analytics APIs.][11484]
[10614]: https://github.com/enso-org/enso/pull/10614
[10660]: https://github.com/enso-org/enso/pull/10660
@ -116,6 +138,8 @@
[11135]: https://github.com/enso-org/enso/pull/11135
[11205]: https://github.com/enso-org/enso/pull/11205
[11229]: https://github.com/enso-org/enso/pull/11229
[11342]: https://github.com/enso-org/enso/pull/11342
[11484]: https://github.com/enso-org/enso/pull/11484
#### Enso Language & Runtime

View File

@ -44,7 +44,7 @@ export async function readEnvironmentFromFile() {
if (!isProduction || entries.length > 0) {
Object.assign(process.env, variables)
}
process.env.ENSO_CLOUD_DASHBOARD_VERSION ??= buildInfo.version
process.env.ENSO_CLOUD_DASHBOARD_VERSION ??= buildInfo.version ?? '0.0.0-dev'
process.env.ENSO_CLOUD_DASHBOARD_COMMIT_HASH ??= buildInfo.commit
} catch (error) {
process.env.ENSO_CLOUD_DASHBOARD_VERSION ??= buildInfo.version

View File

@ -1410,6 +1410,13 @@ export function stripProjectExtension(name: string) {
return name.replace(/[.](?:tar[.]gz|zip|enso-project)$/, '')
}
/**
* Escape special characters in a project name to prevent them from being interpreted as path or regex
*/
export function escapeSpecialCharacters(name: string): string {
return name.replace(/[*+?^${}()|[\]\\]/g, ':')
}
/**
* Return both the name and extension of the project file name (if any).
* Otherwise, returns the entire name as the basename.

View File

@ -2,6 +2,7 @@ import { test, type Page } from '@playwright/test'
import * as actions from './actions'
import { expect } from './customExpect'
import { mockExpressionUpdate } from './expressionUpdates'
import { CONTROL_KEY } from './keyboard'
import * as locate from './locate'
import { graphNodeByBinding } from './locate'
@ -36,19 +37,19 @@ test('Copy from Table Visualization', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await actions.goToGraph(page)
actions.openVisualization(page, 'Table')
await actions.openVisualization(page, 'Table')
const tableVisualization = locate.tableVisualization(page)
await expect(tableVisualization).toExist()
await tableVisualization.getByText('0,0').hover()
await page.mouse.down()
await tableVisualization.getByText('2,1').hover()
await page.mouse.up()
await page.keyboard.press('Control+C')
await page.keyboard.press(`${CONTROL_KEY}+C`)
// Paste to Node.
await actions.clickAtBackground(page)
const nodesCount = await locate.graphNode(page).count()
await page.keyboard.press('Control+V')
await page.keyboard.press(`${CONTROL_KEY}+V`)
await expect(locate.graphNode(page)).toHaveCount(nodesCount + 1)
await expect(locate.graphNode(page).last().locator('input')).toHaveValue(
'0,0\t0,11,0\t1,12,0\t2,1',
@ -60,7 +61,7 @@ test('Copy from Table Visualization', async ({ page, context }) => {
await expect(widget).toBeVisible()
await widget.getByRole('button', { name: 'Add new column' }).click()
await widget.locator('.ag-cell', { hasNotText: /0/ }).first().click()
await page.keyboard.press('Control+V')
await page.keyboard.press(`${CONTROL_KEY}+V`)
await expect(widget.locator('.ag-cell')).toHaveText([
'0',
'0,0',

View File

@ -37,7 +37,7 @@
maximum-scale = 1.0,
user-scalable = no"
/>
<title>Enso Analytics</title>
<title>Enso %ENSO_IDE_VERSION%</title>
</head>
<body>
<div id="enso-spotlight" class="enso-spotlight"></div>

View File

@ -58,6 +58,7 @@
"react": "^18.3.1",
"react-aria": "^3.34.3",
"react-aria-components": "^1.3.3",
"react-compiler-runtime": "19.0.0-beta-8a03594-20241020",
"react-dom": "^18.3.1",
"react-error-boundary": "4.0.13",
"react-hook-form": "^7.51.4",
@ -78,6 +79,7 @@
"@ag-grid-enterprise/core": "^31.1.1",
"@ag-grid-enterprise/range-selection": "^31.1.1",
"@babel/parser": "^7.24.7",
"babel-plugin-react-compiler": "19.0.0-beta-9ee70a1-20241017",
"@codemirror/commands": "^6.6.0",
"@codemirror/language": "^6.10.2",
"@codemirror/lint": "^6.8.1",
@ -136,7 +138,7 @@
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"@types/validator": "^13.11.7",
"@vitejs/plugin-react": "^4.2.1",
"@vitejs/plugin-react": "^4.3.3",
"chalk": "^5.3.0",
"cross-env": "^7.0.3",
"enso-chat": "git://github.com/enso-org/enso-bot",
@ -150,7 +152,7 @@
"tailwindcss-animate": "1.0.7",
"tailwindcss-react-aria-components": "^1.1.1",
"typescript": "^5.5.3",
"vite": "^5.3.5",
"vite": "^5.4.10",
"vitest": "^1.3.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@danmarshall/deckgl-typings": "^4.9.28",

View File

@ -519,13 +519,11 @@ function AppRouter(props: AppRouterProps) {
<LocalBackendPathSynchronizer />
<VersionChecker />
{routes}
{detect.IS_DEV_MODE && (
<suspense.Suspense>
<errorBoundary.ErrorBoundary>
<devtools.EnsoDevtools />
</errorBoundary.ErrorBoundary>
</suspense.Suspense>
)}
<suspense.Suspense>
<errorBoundary.ErrorBoundary>
<devtools.EnsoDevtools />
</errorBoundary.ErrorBoundary>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</DriveProvider>
</InputBindingsProvider>

View File

@ -24,6 +24,7 @@ import {
useEnableVersionChecker,
usePaywallDevtools,
useSetEnableVersionChecker,
useShowDevtools,
} from './EnsoDevtoolsProvider'
import * as ariaComponents from '#/components/AriaComponents'
@ -54,6 +55,9 @@ export function EnsoDevtools() {
const { authQueryKey, session } = authProvider.useAuth()
const queryClient = reactQuery.useQueryClient()
const { getFeature } = billing.usePaywallFeatures()
const showDevtools = useShowDevtools()
const { features, setFeature } = usePaywallDevtools()
const enableVersionChecker = useEnableVersionChecker()
const setEnableVersionChecker = useSetEnableVersionChecker()
@ -66,6 +70,10 @@ export function EnsoDevtools() {
const featureFlags = useFeatureFlags()
const setFeatureFlags = useSetFeatureFlags()
if (!showDevtools) {
return null
}
return (
<Portal>
<ariaComponents.DialogTrigger>

View File

@ -3,6 +3,8 @@
* This file provides a zustand store that contains the state of the Enso devtools.
*/
import type { PaywallFeatureName } from '#/hooks/billing'
import { IS_DEV_MODE } from 'enso-common/src/detect'
import * as React from 'react'
import * as zustand from 'zustand'
/** Configuration for a paywall feature. */
@ -16,13 +18,23 @@ export interface PaywallDevtoolsFeatureConfiguration {
/** The state of this zustand store. */
interface EnsoDevtoolsStore {
readonly showDevtools: boolean
readonly setShowDevtools: (showDevtools: boolean) => void
readonly toggleDevtools: () => void
readonly showVersionChecker: boolean | null
readonly paywallFeatures: Record<PaywallFeatureName, PaywallDevtoolsFeatureConfiguration>
readonly setPaywallFeature: (feature: PaywallFeatureName, isForceEnabled: boolean | null) => void
readonly setEnableVersionChecker: (showVersionChecker: boolean | null) => void
}
const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) => ({
export const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) => ({
showDevtools: IS_DEV_MODE,
setShowDevtools: (showDevtools) => {
set({ showDevtools })
},
toggleDevtools: () => {
set(({ showDevtools }) => ({ showDevtools: !showDevtools }))
},
showVersionChecker: false,
paywallFeatures: {
share: { isForceEnabled: null },
@ -67,3 +79,23 @@ export function usePaywallDevtools() {
setFeature: state.setPaywallFeature,
}))
}
/** A hook that provides access to the show devtools state. */
export function useShowDevtools() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.showDevtools)
}
// =================================
// === DevtoolsProvider ===
// =================================
/**
* Provide the Enso devtools to the app.
*/
export function DevtoolsProvider(props: { children: React.ReactNode }) {
React.useEffect(() => {
window.toggleDevtools = ensoDevtoolsStore.getState().toggleDevtools
}, [])
return <>{props.children}</>
}

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as reactQueryDevtools from '@tanstack/react-query-devtools'
import * as errorBoundary from 'react-error-boundary'
import { useShowDevtools } from './EnsoDevtoolsProvider'
const ReactQueryDevtoolsProduction = React.lazy(() =>
import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({
@ -13,19 +14,13 @@ const ReactQueryDevtoolsProduction = React.lazy(() =>
/** Show the React Query Devtools and provide the ability to show them in production. */
export function ReactQueryDevtools() {
const [showDevtools, setShowDevtools] = React.useState(false)
const showDevtools = useShowDevtools()
// It is safer to pass the client directly to the devtools
// since there might be a chance that we have multiple versions of `react-query`,
// in case we forget to update the devtools, npm messes up the versions,
// or there are hoisting issues.
const client = reactQuery.useQueryClient()
React.useEffect(() => {
window.toggleDevtools = () => {
setShowDevtools((old) => !old)
}
}, [])
return (
<errorBoundary.ErrorBoundary
fallbackRender={({ resetErrorBoundary }) => {

View File

@ -136,11 +136,11 @@ export default function MenuEntry(props: MenuEntryProps) {
// at once.
if (isDisabled) {
return
} else {
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
[action]: doAction,
})
}
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
[action]: doAction,
})
}, [isDisabled, inputBindings, action, doAction])
return hidden ? null : (

View File

@ -2,14 +2,12 @@
import type { Mutable } from 'enso-common/src/utilities/data/object'
import * as aria from 'react-aria'
export * from '@react-aria/interactions'
export { ClearPressResponder } from '@react-aria/interactions'
export type * from '@react-types/shared'
// @ts-expect-error The conflicting exports are props types ONLY.
export * from 'react-aria'
// @ts-expect-error The conflicting exports are props types ONLY.
export * from 'react-aria-components'
// @ts-expect-error The conflicting exports are props types ONLY.
export * from '@react-aria/interactions'
export { useTooltipTriggerState, type OverlayTriggerState } from 'react-stately'
// ==================

View File

@ -174,6 +174,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
useBackendMutationState(backend, 'undoDeleteAsset', {
predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id,
}).length !== 0
const isCloud = isCloudCategory(category)
const { data: projectState } = useQuery({
@ -538,7 +539,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
}
}}
className={tailwindMerge.twMerge(
'h-table-row rounded-full transition-all ease-in-out rounded-rows-child',
'h-table-row rounded-full transition-all ease-in-out rounded-rows-child [contain-intrinsic-size:40px] [content-visibility:auto]',
visibility,
(isDraggedOver || selected) && 'selected',
)}

View File

@ -1,4 +1,5 @@
/** @file A component that renders the modal instance from the modal React Context. */
import { Pressable } from '#/components/aria'
import { DialogTrigger } from '#/components/AriaComponents'
import * as modalProvider from '#/providers/ModalProvider'
import { AnimatePresence, motion } from 'framer-motion'
@ -22,7 +23,11 @@ export default function TheModal() {
transition={{ duration: 0.2 }}
>
<DialogTrigger key={key} defaultOpen>
<></>
{/* This component suppresses the warning about the target not being pressable element. */}
<Pressable>
<></>
</Pressable>
{modal}
</DialogTrigger>
</motion.div>

View File

@ -64,7 +64,7 @@ export const COLUMN_SHOW_TEXT_ID: Readonly<Record<Column, text.TextId>> = {
} satisfies { [C in Column]: `${C}ColumnShow` }
const COLUMN_CSS_CLASSES =
'text-left bg-clip-padding border-transparent border-y border-2 last:border-r-0 last:rounded-r-full last:w-full'
'text-left bg-clip-padding last:border-r-0 last:rounded-r-full last:w-full'
const NORMAL_COLUMN_CSS_CLASSES = `px-cell-x py ${COLUMN_CSS_CLASSES}`
/** CSS classes for every column. */

View File

@ -41,8 +41,14 @@ export default function ModifiedColumnHeading(props: AssetColumnHeadingProps) {
variant="custom"
className="flex grow justify-start gap-icon-with-text"
onPress={() => {
if (!sortInfo) {
setSortInfo({ field: Column.modified, direction: SortDirection.ascending })
return
}
const nextDirection =
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {

View File

@ -27,6 +27,11 @@ export default function NameColumnHeading(props: AssetColumnHeadingProps) {
}
className="group flex h-table-row w-full items-center justify-start gap-icon-with-text px-name-column-x"
onPress={() => {
if (!sortInfo) {
setSortInfo({ field: Column.name, direction: SortDirection.ascending })
return
}
const nextDirection =
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {

View File

@ -7,7 +7,6 @@ import * as React from 'react'
// This must not be a `symbol` as it cannot be sent to Playright.
/** The type of the state returned by {@link useRefresh}. */
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface RefreshState {}
/** A hook that contains no state. It is used to trigger React re-renders. */

View File

@ -0,0 +1,159 @@
/**
* @file
*
* This file contains hooks for using Zustand store with tearing transitions.
*/
import type { DispatchWithoutAction, Reducer, RefObject } from 'react'
import { useEffect, useReducer, useRef } from 'react'
import { type StoreApi } from 'zustand'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { objectEquality, refEquality, shallowEquality } from '../utilities/equalities'
/**
* A type that allows to choose between different equality functions.
*/
export type AreEqual<T> = EqualityFunction<T> | EqualityFunctionName
/**
* Custom equality function.
*/
export type EqualityFunction<T> = (a: T, b: T) => boolean
/**
* Equality function name from a list of predefined ones.
*/
export type EqualityFunctionName = 'object' | 'shallow' | 'strict'
const EQUALITY_FUNCTIONS: Record<EqualityFunctionName, (a: unknown, b: unknown) => boolean> = {
object: objectEquality,
shallow: shallowEquality,
strict: refEquality,
}
/** Options for the `useStore` hook. */
export interface UseStoreOptions<Slice> {
/**
* Adds support for React transitions.
*
* Use it with caution, as it may lead to inconsistent state during transitions.
*/
readonly unsafeEnableTransition?: boolean
/**
* Specifies the equality function to use.
* @default 'Object.is'
*/
readonly areEqual?: AreEqual<Slice>
}
/**
* A wrapper that allows to choose between tearing transition and standard Zustand store.
*
* # `options.unsafeEnableTransition` must not be changed during the component lifecycle.
*/
export function useStore<State, Slice>(
store: StoreApi<State>,
selector: (state: State) => Slice,
options: UseStoreOptions<Slice> = {},
) {
const { unsafeEnableTransition = false, areEqual } = options
const prevUnsafeEnableTransition = useRef(unsafeEnableTransition)
const equalityFunction = resolveAreEqual(areEqual)
return useNonCompilableConditionalStore(
store,
selector,
unsafeEnableTransition,
equalityFunction,
prevUnsafeEnableTransition,
)
}
/** A hook that allows to use React transitions with Zustand store. */
export function useTearingTransitionStore<State, Slice>(
store: StoreApi<State>,
selector: (state: State) => Slice,
areEqual: AreEqual<Slice> = 'object',
) {
const state = store.getState()
const equalityFunction = resolveAreEqual(areEqual)
const [[sliceFromReducer, storeFromReducer], rerender] = useReducer<
Reducer<
readonly [Slice, StoreApi<State>, State],
readonly [Slice, StoreApi<State>, State] | undefined
>,
undefined
>(
(prev, fromSelf) => {
if (fromSelf) {
return fromSelf
}
const nextState = store.getState()
if (Object.is(prev[2], nextState) && prev[1] === store) {
return prev
}
const nextSlice = selector(nextState)
if (equalityFunction(prev[0], nextSlice) && prev[1] === store) {
return prev
}
return [nextSlice, store, nextState]
},
undefined,
() => [selector(state), store, state],
)
useEffect(() => {
const unsubscribe = store.subscribe(() => {
// eslint-disable-next-line no-restricted-syntax
;(rerender as DispatchWithoutAction)()
})
// eslint-disable-next-line no-restricted-syntax
;(rerender as DispatchWithoutAction)()
return unsubscribe
}, [store])
if (storeFromReducer !== store) {
const slice = selector(state)
rerender([slice, store, state])
return slice
}
return sliceFromReducer
}
/** Resolves the equality function. */
function resolveAreEqual<Slice>(areEqual: AreEqual<Slice> | null | undefined) {
return (
areEqual == null ? EQUALITY_FUNCTIONS.object
: typeof areEqual === 'string' ? EQUALITY_FUNCTIONS[areEqual]
: areEqual
)
}
/**
* Internal hook that isolates the conditional store logic from the `useStore` hook.
* To enable compiler optimizations for the `useStore` hook.
* @internal
* @throws An error if the `unsafeEnableTransition` option is changed during the component lifecycle.
*/
function useNonCompilableConditionalStore<State, Slice>(
store: StoreApi<State>,
selector: (state: State) => Slice,
unsafeEnableTransition: boolean,
equalityFunction: EqualityFunction<Slice>,
prevUnsafeEnableTransition: RefObject<boolean>,
) {
/* eslint-disable react-compiler/react-compiler */
/* eslint-disable react-hooks/rules-of-hooks */
if (prevUnsafeEnableTransition.current !== unsafeEnableTransition) {
throw new Error(
'useStore shall not change the `unsafeEnableTransition` option during the component lifecycle',
)
}
return unsafeEnableTransition ?
useTearingTransitionStore(store, selector, equalityFunction)
: useStoreWithEqualityFn(store, selector, equalityFunction)
/* eslint-enable react-compiler/react-compiler */
/* eslint-enable react-hooks/rules-of-hooks */
}

View File

@ -21,7 +21,7 @@ import LoggerProvider, { type Logger } from '#/providers/LoggerProvider'
import LoadingScreen from '#/pages/authentication/LoadingScreen'
import { ReactQueryDevtools } from '#/components/Devtools'
import { DevtoolsProvider, ReactQueryDevtools } from '#/components/Devtools'
import { ErrorBoundary } from '#/components/ErrorBoundary'
import { OfflineNotificationManager } from '#/components/OfflineNotificationManager'
import { Suspense } from '#/components/Suspense'
@ -113,21 +113,23 @@ export function run(props: DashboardProps) {
reactDOM.createRoot(root).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<Suspense fallback={<LoadingScreen />}>
<OfflineNotificationManager>
<LoggerProvider logger={logger}>
<HttpClientProvider httpClient={httpClient}>
<UIProviders locale="en-US" portalRoot={portalRoot}>
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
</UIProviders>
</HttpClientProvider>
</LoggerProvider>
</OfflineNotificationManager>
</Suspense>
</ErrorBoundary>
<DevtoolsProvider>
<ErrorBoundary>
<Suspense fallback={<LoadingScreen />}>
<OfflineNotificationManager>
<LoggerProvider logger={logger}>
<HttpClientProvider httpClient={httpClient}>
<UIProviders locale="en-US" portalRoot={portalRoot}>
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
</UIProviders>
</HttpClientProvider>
</LoggerProvider>
</OfflineNotificationManager>
</Suspense>
</ErrorBoundary>
<ReactQueryDevtools />
<ReactQueryDevtools />
</DevtoolsProvider>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@ -19,7 +19,7 @@ import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as categoryModule from '#/layouts/CategorySwitcher/Category'
import GlobalContextMenu from '#/layouts/GlobalContextMenu'
import { GlobalContextMenu } from '#/layouts/GlobalContextMenu'
import ContextMenu from '#/components/ContextMenu'
import ContextMenuEntry from '#/components/ContextMenuEntry'

View File

@ -66,7 +66,6 @@ import { useIntersectionRatio } from '#/hooks/intersectionHooks'
import { useOpenProject } from '#/hooks/projectHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import useOnScroll from '#/hooks/useOnScroll'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
@ -120,6 +119,7 @@ import {
createSpecialLoadingAsset,
DatalinkId,
DirectoryId,
escapeSpecialCharacters,
extractProjectExtension,
fileIsNotProject,
fileIsProject,
@ -198,13 +198,6 @@ const MINIMUM_DROPZONE_INTERSECTION_RATIO = 0.5
const ROW_HEIGHT_PX = 38
/** The size of the loading spinner. */
const LOADING_SPINNER_SIZE_PX = 36
/**
* The number of pixels the header bar should shrink when the column selector is visible,
* assuming 0 icons are visible in the column selector.
*/
const COLUMNS_SELECTOR_BASE_WIDTH_PX = 4
/** The number of pixels the header bar should shrink per collapsed column. */
const COLUMNS_SELECTOR_ICON_WIDTH_PX = 28
const SUGGESTIONS_FOR_NO: assetSearchBar.Suggestion[] = [
{
@ -363,7 +356,6 @@ export default function AssetsTable(props: AssetsTableProps) {
const inputBindings = useInputBindings()
const navigator2D = useNavigator2D()
const toastAndLog = useToastAndLog()
const previousCategoryRef = useRef(category)
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const setCanCreateAssets = useSetCanCreateAssets()
@ -500,7 +492,8 @@ export default function AssetsTable(props: AssetsTableProps) {
// This reduces the amount of rerenders by batching them together, so they happen less often.
useQuery({
queryKey: [backend.type, 'refetchListDirectory'],
queryFn: () => queryClient.refetchQueries({ queryKey: [backend.type, 'listDirectory'] }),
queryFn: () =>
queryClient.refetchQueries({ queryKey: [backend.type, 'listDirectory'] }).then(() => null),
refetchInterval:
enableAssetsTableBackgroundRefresh ? assetsTableBackgroundRefreshInterval : false,
refetchOnMount: 'always',
@ -828,7 +821,6 @@ export default function AssetsTable(props: AssetsTableProps) {
/** Events sent when the asset list was still loading. */
const queuedAssetListEventsRef = useRef<AssetListEvent[]>([])
const rootRef = useRef<HTMLDivElement | null>(null)
const cleanupRootRef = useRef(() => {})
const mainDropzoneRef = useRef<HTMLButtonElement | null>(null)
const lastSelectedIdsRef = useRef<AssetId | ReadonlySet<AssetId> | null>(null)
const headerRowRef = useRef<HTMLTableRowElement>(null)
@ -850,10 +842,6 @@ export default function AssetsTable(props: AssetsTableProps) {
true,
)
useEffect(() => {
previousCategoryRef.current = category
})
const setTargetDirectory = useEventCallback(
(targetDirectory: AssetTreeNode<DirectoryAsset> | null) => {
const targetDirectorySelfPermission =
@ -1688,12 +1676,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const siblingProjects = siblings.filter(assetIsProject)
const siblingFileTitles = new Set(siblingFiles.map((asset) => asset.title))
const siblingProjectTitles = new Set(siblingProjects.map((asset) => asset.title))
const files = reversedFiles.filter(fileIsNotProject)
const projects = reversedFiles.filter(fileIsProject)
const duplicateFiles = files.filter((file) => siblingFileTitles.has(file.name))
const duplicateProjects = projects.filter((project) =>
siblingProjectTitles.has(stripProjectExtension(project.name)),
)
const ownerPermission = tryCreateOwnerPermission(
parent?.path ?? '',
category,
@ -1701,7 +1684,35 @@ export default function AssetsTable(props: AssetsTableProps) {
users ?? [],
userGroups ?? [],
)
const fileMap = new Map<AssetId, File>()
const files = reversedFiles.filter(fileIsNotProject).map((file) => {
const asset = createPlaceholderFileAsset(
escapeSpecialCharacters(file.name),
event.parentId,
ownerPermission,
)
return { asset, file }
})
const projects = reversedFiles.filter(fileIsProject).map((file) => {
const basename = escapeSpecialCharacters(stripProjectExtension(file.name))
const asset = createPlaceholderProjectAsset(
basename,
event.parentId,
ownerPermission,
user,
localBackend?.joinPath(event.parentId, basename) ?? null,
)
return { asset, file }
})
const duplicateFiles = files.filter((file) => siblingFileTitles.has(file.asset.title))
const duplicateProjects = projects.filter((project) =>
siblingProjectTitles.has(stripProjectExtension(project.asset.title)),
)
const fileMap = new Map<AssetId, File>([
...files.map(({ asset, file }) => [asset.id, file] as const),
...projects.map(({ asset, file }) => [asset.id, file] as const),
])
const uploadedFileIds: AssetId[] = []
const addIdToSelection = (id: AssetId) => {
uploadedFileIds.push(id)
@ -1718,7 +1729,7 @@ export default function AssetsTable(props: AssetsTableProps) {
switch (true) {
case assetIsProject(asset): {
const { extension } = extractProjectExtension(file.name)
const title = stripProjectExtension(asset.title)
const title = escapeSpecialCharacters(stripProjectExtension(asset.title))
await uploadFileMutation
.mutateAsync(
@ -1739,11 +1750,9 @@ export default function AssetsTable(props: AssetsTableProps) {
break
}
case assetIsFile(asset): {
const title = escapeSpecialCharacters(asset.title)
await uploadFileMutation
.mutateAsync(
{ fileId, fileName: asset.title, parentDirectoryId: asset.parentId },
file,
)
.mutateAsync({ fileId, fileName: title, parentDirectoryId: asset.parentId }, file)
.then(({ id }) => {
addIdToSelection(id)
})
@ -1757,26 +1766,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
if (duplicateFiles.length === 0 && duplicateProjects.length === 0) {
const placeholderFiles = files.map((file) => {
const asset = createPlaceholderFileAsset(file.name, event.parentId, ownerPermission)
fileMap.set(asset.id, file)
return asset
})
const placeholderProjects = projects.map((project) => {
const basename = stripProjectExtension(project.name)
const asset = createPlaceholderProjectAsset(
basename,
event.parentId,
ownerPermission,
user,
localBackend?.joinPath(event.parentId, basename) ?? null,
)
fileMap.set(asset.id, project)
return asset
})
const assets = [...placeholderFiles, ...placeholderProjects]
const assets = [...files, ...projects].map(({ asset }) => asset)
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
@ -1790,12 +1780,12 @@ export default function AssetsTable(props: AssetsTableProps) {
// This is SAFE, as `duplicateFiles` only contains files that have siblings
// with the same name.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
current: siblingFilesByName.get(file.name)!,
new: createPlaceholderFileAsset(file.name, event.parentId, ownerPermission),
file,
current: siblingFilesByName.get(file.asset.title)!,
new: createPlaceholderFileAsset(file.asset.title, event.parentId, ownerPermission),
file: file.file,
}))
const conflictingProjects = duplicateProjects.map((project) => {
const basename = stripProjectExtension(project.name)
const basename = stripProjectExtension(project.asset.title)
return {
// This is SAFE, as `duplicateProjects` only contains projects that have
// siblings with the same name.
@ -1808,7 +1798,7 @@ export default function AssetsTable(props: AssetsTableProps) {
user,
localBackend?.joinPath(event.parentId, basename) ?? null,
),
file: project,
file: project.file,
}
})
setModal(
@ -1835,23 +1825,24 @@ export default function AssetsTable(props: AssetsTableProps) {
doToggleDirectoryExpansion(event.parentId, event.parentKey, true)
const newFiles = files
.filter((file) => !siblingFileTitles.has(file.name))
.filter((file) => !siblingFileTitles.has(file.asset.title))
.map((file) => {
const asset = createPlaceholderFileAsset(
file.name,
file.asset.title,
event.parentId,
ownerPermission,
)
fileMap.set(asset.id, file)
fileMap.set(asset.id, file.file)
return asset
})
const newProjects = projects
.filter(
(project) => !siblingProjectTitles.has(stripProjectExtension(project.name)),
(project) =>
!siblingProjectTitles.has(stripProjectExtension(project.asset.title)),
)
.map((project) => {
const basename = stripProjectExtension(project.name)
const basename = stripProjectExtension(project.asset.title)
const asset = createPlaceholderProjectAsset(
basename,
event.parentId,
@ -1859,7 +1850,7 @@ export default function AssetsTable(props: AssetsTableProps) {
user,
localBackend?.joinPath(event.parentId, basename) ?? null,
)
fileMap.set(asset.id, project)
fileMap.set(asset.id, project.file)
return asset
})
@ -2057,6 +2048,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const doCopy = useEventCallback(() => {
unsetModal()
const { selectedKeys } = driveStore.getState()
setPasteData({
type: 'copy',
data: { backendType: backend.type, category, ids: selectedKeys },
@ -2080,7 +2072,9 @@ export default function AssetsTable(props: AssetsTableProps) {
const cutAndPaste = useCutAndPaste(category)
const doPaste = useEventCallback((newParentKey: DirectoryId, newParentId: DirectoryId) => {
unsetModal()
const { pasteData } = driveStore.getState()
if (
pasteData?.data.backendType === backend.type &&
canTransferBetweenCategories(pasteData.data.category, category)
@ -2235,26 +2229,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}, [hidden])
// This is required to prevent the table body from overlapping the table header, because
// the table header is transparent.
const updateClipPath = useOnScroll(() => {
if (bodyRef.current != null && rootRef.current != null) {
bodyRef.current.style.clipPath = `inset(${rootRef.current.scrollTop}px 0 0 0)`
}
if (
backend.type === BackendType.remote &&
rootRef.current != null &&
headerRowRef.current != null
) {
const shrinkBy =
COLUMNS_SELECTOR_BASE_WIDTH_PX + COLUMNS_SELECTOR_ICON_WIDTH_PX * hiddenColumns.length
const rightOffset = rootRef.current.clientWidth + rootRef.current.scrollLeft - shrinkBy
headerRowRef.current.style.clipPath = `polygon(0 0, ${rightOffset}px 0, ${rightOffset}px 100%, 0 100%)`
}
}, [backend.type, hiddenColumns.length])
const updateClipPathObserver = useMemo(() => new ResizeObserver(updateClipPath), [updateClipPath])
useEffect(
() =>
inputBindings.attach(
@ -2607,7 +2581,7 @@ export default function AssetsTable(props: AssetsTableProps) {
)
const headerRow = (
<tr ref={headerRowRef} className="sticky top-[1px] text-sm font-semibold">
<tr ref={headerRowRef} className="rounded-none text-sm font-semibold">
{columns.map((column) => {
// This is a React component, even though it does not contain JSX.
const Heading = COLUMN_HEADING[column]
@ -2703,8 +2677,8 @@ export default function AssetsTable(props: AssetsTableProps) {
}
}}
>
<table className="table-fixed border-collapse rounded-rows">
<thead>{headerRow}</thead>
<table className="isolate table-fixed border-collapse rounded-rows">
<thead className="sticky top-0 z-1 bg-dashboard">{headerRow}</thead>
<tbody ref={bodyRef}>
{itemRows}
<tr className="hidden h-row first:table-row">
@ -2804,21 +2778,8 @@ export default function AssetsTable(props: AssetsTableProps) {
{(innerProps) => (
<div
{...mergeProps<JSX.IntrinsicElements['div']>()(innerProps, {
ref: (value) => {
rootRef.current = value
cleanupRootRef.current()
if (value) {
updateClipPathObserver.observe(value)
cleanupRootRef.current = () => {
updateClipPathObserver.unobserve(value)
}
} else {
cleanupRootRef.current = () => {}
}
},
className: 'flex-1 overflow-auto container-size w-full h-full',
onKeyDown,
onScroll: updateClipPath,
onBlur: (event) => {
if (
event.relatedTarget instanceof HTMLElement &&
@ -2840,6 +2801,7 @@ export default function AssetsTable(props: AssetsTableProps) {
onDragEnd: () => {
setIsDraggingFiles(false)
},
ref: rootRef,
})}
>
{!hidden && hiddenContextMenu}

View File

@ -6,22 +6,16 @@ import * as React from 'react'
import { useStore } from 'zustand'
import { uniqueString } from 'enso-common/src/utilities/uniqueString'
import * as authProvider from '#/providers/AuthProvider'
import { useDriveStore, useSelectedKeys, useSetSelectedKeys } from '#/providers/DriveProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import {
canTransferBetweenCategories,
type Category,
isCloudCategory,
} from '#/layouts/CategorySwitcher/Category'
import GlobalContextMenu from '#/layouts/GlobalContextMenu'
import { GlobalContextMenu } from '#/layouts/GlobalContextMenu'
import ContextMenu from '#/components/ContextMenu'
import ContextMenuEntry from '#/components/ContextMenuEntry'
@ -32,6 +26,10 @@ import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import * as permissions from '#/utilities/permissions'
import { EMPTY_SET } from '#/utilities/set'
@ -64,17 +62,22 @@ export interface AssetsTableContextMenuProps {
* are selected.
*/
export default function AssetsTableContextMenu(props: AssetsTableContextMenuProps) {
// eslint-disable-next-line react-compiler/react-compiler
'use no memo'
const { hidden = false, backend, category } = props
const { nodeMapRef, event, rootDirectoryId } = props
const { doCopy, doCut, doPaste, doDelete } = props
const { user } = authProvider.useFullUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const { user } = useFullUserSession()
const { setModal, unsetModal } = useSetModal()
const { getText } = useText()
const isCloud = isCloudCategory(category)
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetEvent = useDispatchAssetEvent()
const selectedKeys = useSelectedKeys()
const setSelectedKeys = useSetSelectedKeys()
const driveStore = useDriveStore()
const hasPasteData = useStore(driveStore, ({ pasteData }) => {
const effectivePasteData =
(
@ -86,6 +89,8 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
return (effectivePasteData?.data.ids.size ?? 0) > 0
})
const id = React.useId()
// This works because all items are mutated, ensuring their value stays
// up to date.
const ownsAllSelectedAssets =
@ -140,6 +145,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
const [firstKey] = selectedKeys
const selectedNode =
selectedKeys.size === 1 && firstKey != null ? nodeMapRef.current.get(firstKey) : null
if (selectedNode?.type === backendModule.AssetType.directory) {
doPaste(selectedNode.key, selectedNode.item.id)
} else {
@ -152,7 +158,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
if (category.type === 'trash') {
return (
selectedKeys.size !== 0 && (
<ContextMenus key={uniqueString()} hidden={hidden} event={event}>
<ContextMenus key={id} hidden={hidden} event={event}>
<ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}>
<ContextMenuEntry
hidden={hidden}
@ -203,7 +209,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
return null
} else {
return (
<ContextMenus key={uniqueString()} hidden={hidden} event={event}>
<ContextMenus key={id} hidden={hidden} event={event}>
{(selectedKeys.size !== 0 || pasteAllMenuEntry !== false) && (
<ContextMenu aria-label={getText('assetsTableContextMenuLabel')} hidden={hidden}>
{selectedKeys.size !== 0 && ownsAllSelectedAssets && (

View File

@ -109,14 +109,48 @@ export const CATEGORY_TO_FILTER_BY: Readonly<Record<Category['type'], FilterBy |
'local-directory': FilterBy.active,
}
/**
* The type of the cached value for a category.
* We use const enums because they compile to numeric values and they are faster than strings.
*/
const enum CategoryCacheType {
cloud = 0,
local = 1,
}
const CATEGORY_CACHE = new Map<Category['type'], CategoryCacheType>()
/** Whether the category is only accessible from the cloud. */
export function isCloudCategory(category: Category): category is AnyCloudCategory {
return ANY_CLOUD_CATEGORY_SCHEMA.safeParse(category).success
const cached = CATEGORY_CACHE.get(category.type)
if (cached != null) {
return cached === CategoryCacheType.cloud
}
const result = ANY_CLOUD_CATEGORY_SCHEMA.safeParse(category)
CATEGORY_CACHE.set(
category.type,
result.success ? CategoryCacheType.cloud : CategoryCacheType.local,
)
return result.success
}
/** Whether the category is only accessible locally. */
export function isLocalCategory(category: Category): category is AnyLocalCategory {
return ANY_LOCAL_CATEGORY_SCHEMA.safeParse(category).success
const cached = CATEGORY_CACHE.get(category.type)
if (cached != null) {
return cached === CategoryCacheType.local
}
const result = ANY_LOCAL_CATEGORY_SCHEMA.safeParse(category)
CATEGORY_CACHE.set(
category.type,
result.success ? CategoryCacheType.local : CategoryCacheType.cloud,
)
return result.success
}
/** Whether the given categories are equal. */

View File

@ -1,22 +1,21 @@
/** @file A context menu available everywhere in the directory. */
import { useStore } from 'zustand'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import ContextMenu from '#/components/ContextMenu'
import ContextMenuEntry from '#/components/ContextMenuEntry'
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useDispatchAssetListEvent } from '#/layouts/AssetsTable/EventListProvider'
import { useDriveStore } from '#/providers/DriveProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import type * as backendModule from '#/services/Backend'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import { BackendType } from '#/services/Backend'
import { inputFiles } from '#/utilities/input'
/** Props for a {@link GlobalContextMenu}. */
@ -33,19 +32,32 @@ export interface GlobalContextMenuProps {
}
/** A context menu available everywhere in the directory. */
export default function GlobalContextMenu(props: GlobalContextMenuProps) {
const { hidden = false, backend, directoryKey, directoryId, rootDirectoryId } = props
export const GlobalContextMenu = function GlobalContextMenu(props: GlobalContextMenuProps) {
// For some reason, applying the ReactCompiler for this component breaks the copy-paste functionality
// eslint-disable-next-line react-compiler/react-compiler
'use no memo'
const {
hidden = false,
backend,
directoryKey = null,
directoryId = null,
rootDirectoryId,
} = props
const { doPaste } = props
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const isCloud = backend.type === backendModule.BackendType.remote
const { getText } = useText()
const { setModal, unsetModal } = useSetModal()
const dispatchAssetListEvent = useDispatchAssetListEvent()
const driveStore = useDriveStore()
const hasPasteData = useStore(
driveStore,
(storeState) => (storeState.pasteData?.data.ids.size ?? 0) > 0,
)
const isCloud = backend.type === BackendType.remote
return (
<ContextMenu aria-label={getText('globalContextMenuLabel')} hidden={hidden}>
<ContextMenuEntry

View File

@ -5,7 +5,7 @@ import * as z from 'zod'
import { Button, Checkbox, Dialog, Form, Text } from '#/components/AriaComponents'
import { useAuth } from '#/providers/AuthProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import { useLocalStorageState } from '#/providers/LocalStorageProvider'
import { useText } from '#/providers/TextProvider'
import LocalStorage from '#/utilities/LocalStorage'
@ -71,46 +71,38 @@ LocalStorage.registerKey('privacyPolicy', { schema: PRIVACY_POLICY_SCHEMA })
/** Modal for accepting the terms of service. */
export function AgreementsModal() {
const { getText } = useText()
const { localStorage } = useLocalStorage()
const { session } = useAuth()
const cachedTosHash = localStorage.get('termsOfService')?.versionHash
const [cachedTosHash, setCachedTosHash] = useLocalStorageState('termsOfService')
const [cachedPrivacyPolicyHash, setCachedPrivacyPolicyHash] =
useLocalStorageState('privacyPolicy')
const { data: tosHash } = useSuspenseQuery({
...latestTermsOfServiceQueryOptions,
// If the user has already accepted the EULA, we don't need to
// block user interaction with the app while we fetch the latest version.
// We can use the local version hash as the initial data.
// and refetch in the background to check for updates.
...(cachedTosHash != null && {
initialData: { hash: cachedTosHash },
...(cachedTosHash?.versionHash != null && {
initialData: { hash: cachedTosHash.versionHash },
}),
select: (data) => data.hash,
})
const cachedPrivacyPolicyHash = localStorage.get('privacyPolicy')?.versionHash
const { data: privacyPolicyHash } = useSuspenseQuery({
...latestPrivacyPolicyQueryOptions,
...(cachedPrivacyPolicyHash != null && {
initialData: { hash: cachedPrivacyPolicyHash },
...(cachedPrivacyPolicyHash?.versionHash != null && {
initialData: { hash: cachedPrivacyPolicyHash.versionHash },
}),
select: (data) => data.hash,
})
const isLatest = tosHash === cachedTosHash && privacyPolicyHash === cachedPrivacyPolicyHash
const isLatest =
tosHash === cachedTosHash?.versionHash &&
privacyPolicyHash === cachedPrivacyPolicyHash?.versionHash
const isAccepted = cachedTosHash != null
const shouldDisplay = !(isAccepted && isLatest)
const formSchema = Form.useFormSchema((schema) =>
schema.object({
// The user must agree to the ToS to proceed.
agreedToTos: schema
.array(schema.string())
.min(1, { message: getText('licenseAgreementCheckboxError') }),
agreedToPrivacyPolicy: schema
.array(schema.string())
.min(1, { message: getText('privacyPolicyCheckboxError') }),
}),
)
if (shouldDisplay) {
// Note that this produces warnings about missing a `<Heading slot="title">`, even though
// all `ariaComponents.Dialog`s contain one. This is likely caused by Suspense discarding
@ -126,16 +118,27 @@ export function AgreementsModal() {
id="agreements-modal"
>
<Form
schema={formSchema}
schema={(schema) =>
schema.object({
// The user must agree to the ToS to proceed.
agreedToTos: schema
.array(schema.string())
.min(1, { message: getText('licenseAgreementCheckboxError') }),
agreedToPrivacyPolicy: schema
.array(schema.string())
.min(1, { message: getText('privacyPolicyCheckboxError') }),
})
}
defaultValues={{
agreedToTos: tosHash === cachedTosHash ? ['agree'] : [],
agreedToPrivacyPolicy: privacyPolicyHash === cachedPrivacyPolicyHash ? ['agree'] : [],
agreedToTos: tosHash === cachedTosHash?.versionHash ? ['agree'] : [],
agreedToPrivacyPolicy:
privacyPolicyHash === cachedPrivacyPolicyHash?.versionHash ? ['agree'] : [],
}}
testId="agreements-form"
method="dialog"
onSubmit={() => {
localStorage.set('termsOfService', { versionHash: tosHash })
localStorage.set('privacyPolicy', { versionHash: privacyPolicyHash })
setCachedTosHash({ versionHash: tosHash })
setCachedPrivacyPolicyHash({ versionHash: privacyPolicyHash })
}}
>
{({ form }) => (
@ -174,7 +177,7 @@ export function AgreementsModal() {
</Form>
</Dialog>
)
} else {
return <Outlet context={session} />
}
return <Outlet context={session} />
}

View File

@ -65,6 +65,7 @@ export default function ModalProvider(props: ModalProviderProps) {
),
[children, modalRef, setModalStableCallback],
)
return <ModalContext.Provider value={{ modal, key }}>{setModalProvider}</ModalContext.Provider>
}
@ -112,10 +113,13 @@ export function useModalRef() {
/** A React context hook exposing functions to set and unset the currently active modal. */
export function useSetModal() {
const { setModal: setModalRaw } = React.useContext(ModalStaticContext)
const setModal: (modal: Modal) => void = setModalRaw
const updateModal: (updater: (modal: Modal | null) => Modal | null) => void = setModalRaw
const unsetModal = React.useCallback(() => {
const setModal = useEventCallback(setModalRaw)
const updateModal = useEventCallback(setModalRaw)
const unsetModal = useEventCallback(() => {
setModalRaw(null)
}, [setModalRaw])
})
return { setModal, updateModal, unsetModal } as const
}

View File

@ -20,6 +20,12 @@
--color-invert-opacity: 100%;
--color-invert: rgb(var(--color-invert-rgb) / var(--color-invert-opacity));
--color-dashboard-background-rgb: 239 234 228;
--color-dashboard-background-opacity: 100%;
--color-dashboard-background: rgb(
var(--color-dashboard-background-rgb) / var(--color-dashboard-background-opacity)
);
--top-bar-height: 3rem;
--row-height: 2rem;
--table-row-height: 2.3125rem;

View File

@ -43,7 +43,6 @@ export interface LocalStorageKeyMetadata<K extends LocalStorageKey> {
* The data that can be stored in a {@link LocalStorage}.
* Declaration merge into this interface to add a new key.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface LocalStorageData {}
// =======================

View File

@ -0,0 +1,51 @@
/**
* @file
*
* This file contains functions for checking equality between values.
*/
/**
* Strict equality check.
*/
export function refEquality<T>(a: T, b: T) {
return a === b
}
/**
* Object.is equality check.
*/
export function objectEquality<T>(a: T, b: T) {
return Object.is(a, b)
}
/**
* Shallow equality check.
*/
export function shallowEquality<T>(a: T, b: T) {
if (Object.is(a, b)) {
return true
}
if (typeof a !== 'object' || a == null || typeof b !== 'object' || b == null) {
return false
}
const keysA = Object.keys(a)
if (keysA.length !== Object.keys(b).length) {
return false
}
for (let i = 0; i < keysA.length; i++) {
const key = keysA[i]
if (key != null) {
// @ts-expect-error Typescript doesn't know that key is in a and b, but it doesn't matter here
if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) {
return false
}
}
}
return true
}

View File

@ -0,0 +1,14 @@
/**
* @file
*
* Re-exporting zustand functions and types.
* Overrides the default `useStore` with a custom one, that supports equality functions and React.transition
*/
export { useStore, useTearingTransitionStore } from '#/hooks/storeHooks'
export type {
AreEqual,
EqualityFunction,
EqualityFunctionName,
UseStoreOptions,
} from '#/hooks/storeHooks'
export * from 'zustand'

View File

@ -12,11 +12,13 @@ const isVisualizationEnabled = defineModel<boolean>('isVisualizationEnabled', {
const props = defineProps<{
isRecordingEnabledGlobally: boolean
isRemovable: boolean
isEnterable: boolean
matchableNodeColors: Set<string>
documentationUrl: string | undefined
}>()
const emit = defineEmits<{
'update:isVisualizationEnabled': [isVisualizationEnabled: boolean]
enterNode: []
startEditing: []
startEditingComment: []
openFullMenu: []
@ -94,6 +96,14 @@ function readableBinding(binding: keyof (typeof graphBindings)['bindings']) {
<SvgIcon name="paint_palette" class="rowIcon" />
<span>Color Component</span>
</MenuButton>
<MenuButton
v-if="isEnterable"
data-testid="enter-node-button"
@click.stop="closeDropdown(), emit('enterNode')"
>
<SvgIcon name="open" class="rowIcon" />
<span>Open Grouped Components</span>
</MenuButton>
<MenuButton data-testid="edit-button" @click.stop="closeDropdown(), emit('startEditing')">
<SvgIcon name="edit" class="rowIcon" />
<span>Code Edit</span>

View File

@ -12,7 +12,7 @@ import {
makeStaticMethod,
SuggestionEntry,
} from '@/stores/suggestionDatabase/entry'
import { qnLastSegment } from '@/util/qualifiedName'
import { qnLastSegment, QualifiedName } from '@/util/qualifiedName'
import { Opt } from 'ydoc-shared/util/data/opt'
test.each([
@ -24,7 +24,7 @@ test.each([
makeStaticMethod('local.Project.Internalization.internalize'),
])('$name entry is in the CB main view', (entry) => {
const filtering = new Filtering({})
expect(filtering.filter(entry)).not.toBeNull()
expect(filtering.filter(entry, [])).not.toBeNull()
})
test.each([
@ -36,7 +36,7 @@ test.each([
makeStaticMethod('Standard.Base.Internal.Foo.bar'), // Internal method
])('$name entry is not in the CB main view', (entry) => {
const filtering = new Filtering({})
expect(filtering.filter(entry)).toBeNull()
expect(filtering.filter(entry, [])).toBeNull()
})
test('An Instance method is shown when self arg matches', () => {
@ -45,16 +45,34 @@ test('An Instance method is shown when self arg matches', () => {
const filteringWithSelfType = new Filtering({
selfArg: { type: 'known', typename: 'Standard.Base.Data.Vector.Vector' },
})
expect(filteringWithSelfType.filter(entry1)).not.toBeNull()
expect(filteringWithSelfType.filter(entry2)).toBeNull()
expect(filteringWithSelfType.filter(entry1, [])).not.toBeNull()
expect(filteringWithSelfType.filter(entry2, [])).toBeNull()
const filteringWithAnySelfType = new Filtering({
selfArg: { type: 'unknown' },
})
expect(filteringWithAnySelfType.filter(entry1)).not.toBeNull()
expect(filteringWithAnySelfType.filter(entry2)).not.toBeNull()
expect(filteringWithAnySelfType.filter(entry1, [])).not.toBeNull()
expect(filteringWithAnySelfType.filter(entry2, [])).not.toBeNull()
const filteringWithoutSelfType = new Filtering({ pattern: 'get' })
expect(filteringWithoutSelfType.filter(entry1)).toBeNull()
expect(filteringWithoutSelfType.filter(entry2)).toBeNull()
expect(filteringWithoutSelfType.filter(entry1, [])).toBeNull()
expect(filteringWithoutSelfType.filter(entry2, [])).toBeNull()
})
test('Additional self types are taken into account when filtering', () => {
const entry1 = makeMethod('Standard.Base.Data.Vector.Vector.get')
const entry2 = makeMethod('Standard.Base.Any.Any.to_string')
const additionalSelfType = 'Standard.Base.Any.Any' as QualifiedName
const filtering = new Filtering({
selfArg: { type: 'known', typename: 'Standard.Base.Data.Vector.Vector' },
})
expect(filtering.filter(entry1, [additionalSelfType])).not.toBeNull()
expect(filtering.filter(entry2, [additionalSelfType])).not.toBeNull()
expect(filtering.filter(entry2, [])).toBeNull()
const filteringWithoutSelfType = new Filtering({})
expect(filteringWithoutSelfType.filter(entry1, [additionalSelfType])).toBeNull()
expect(filteringWithoutSelfType.filter(entry2, [additionalSelfType])).toBeNull()
expect(filteringWithoutSelfType.filter(entry1, [])).toBeNull()
expect(filteringWithoutSelfType.filter(entry2, [])).toBeNull()
})
test.each([
@ -69,7 +87,7 @@ test.each([
const filtering = new Filtering({
selfArg: { type: 'known', typename: 'Standard.Base.Data.Vector.Vector' },
})
expect(filtering.filter(entry)).toBeNull()
expect(filtering.filter(entry, [])).toBeNull()
})
test.each`
@ -84,7 +102,7 @@ test.each`
`('$name is not matched by pattern $pattern', ({ name, pattern }) => {
const entry = makeModuleMethod(`local.Project.${name}`)
const filtering = new Filtering({ pattern })
expect(filtering.filter(entry)).toBeNull()
expect(filtering.filter(entry, [])).toBeNull()
})
function matchedText(ownerName: string, name: string, matchResult: MatchResult) {
@ -200,7 +218,7 @@ test.each([
...makeModuleMethod(`${module ?? 'local.Project'}.${name}`),
aliases: aliases ?? [],
}))
const matchResults = Array.from(matchedSortedEntries, (entry) => filtering.filter(entry))
const matchResults = Array.from(matchedSortedEntries, (entry) => filtering.filter(entry, []))
// Checking matching entries
function checkResult(entry: SuggestionEntry, result: Opt<MatchResult>) {
expect(result, `Matching entry ${entryQn(entry)}`).not.toBeNull()
@ -226,6 +244,6 @@ test.each([
...makeModuleMethod(`${module ?? 'local.Project'}.${name}`),
aliases: aliases ?? [],
}
expect(filtering.filter(entry), entryQn(entry)).toBeNull()
expect(filtering.filter(entry, []), entryQn(entry)).toBeNull()
}
})

View File

@ -26,7 +26,7 @@ export function useAI(
const lsRpc = project.lsRpcConnection
const sourceNodeId = graphDb.getIdentDefiningNode(sourceIdentifier)
const contextId =
sourceNodeId && graphDb.nodeIdToNode.get(sourceNodeId)?.outerExpr.externalId
sourceNodeId && graphDb.nodeIdToNode.get(sourceNodeId)?.outerAst.externalId
if (!contextId) return Err(`Cannot find node with name ${sourceIdentifier}`)
const prompt = await withContext(

View File

@ -10,7 +10,8 @@ import { isSome } from '@/util/data/opt'
import { Range } from '@/util/data/range'
import { displayedIconOf } from '@/util/getIconName'
import type { Icon } from '@/util/iconName'
import { qnLastSegmentIndex } from '@/util/qualifiedName'
import { qnLastSegmentIndex, QualifiedName, tryQualifiedName } from '@/util/qualifiedName'
import { unwrap } from 'ydoc-shared/util/data/result'
interface ComponentLabelInfo {
label: string
@ -107,11 +108,21 @@ export function makeComponent({ id, entry, match }: ComponentInfo): Component {
}
}
const ANY_TYPE = unwrap(tryQualifiedName('Standard.Base.Any.Any'))
/** Create {@link Component} list from filtered suggestions. */
export function makeComponentList(db: SuggestionDb, filtering: Filtering): Component[] {
function* matchSuggestions() {
// All types are descendants of `Any`, so we can safely prepopulate it here.
// This way, we will use it even when `selfArg` is not a valid qualified name.
const additionalSelfTypes: QualifiedName[] = [ANY_TYPE]
if (filtering.selfArg?.type === 'known') {
const maybeName = tryQualifiedName(filtering.selfArg.typename)
if (maybeName.ok) populateAdditionalSelfTypes(db, additionalSelfTypes, maybeName.value)
}
for (const [id, entry] of db.entries()) {
const match = filtering.filter(entry)
const match = filtering.filter(entry, additionalSelfTypes)
if (isSome(match)) {
yield { id, entry, match }
}
@ -120,3 +131,16 @@ export function makeComponentList(db: SuggestionDb, filtering: Filtering): Compo
const matched = Array.from(matchSuggestions()).sort(compareSuggestions)
return Array.from(matched, (info) => makeComponent(info))
}
/**
* Type can inherit methods from `parentType`, and it can do that recursively.
* In practice, these hierarchies are at most two levels deep.
*/
function populateAdditionalSelfTypes(db: SuggestionDb, list: QualifiedName[], name: QualifiedName) {
let entry = db.getEntryByQualifiedName(name)
// We dont need to add `Any` to the list, because the caller already did that.
while (entry != null && entry.parentType != null && entry.parentType !== ANY_TYPE) {
list.push(entry.parentType)
entry = db.getEntryByQualifiedName(entry.parentType)
}
}

View File

@ -248,9 +248,13 @@ export class Filtering {
if (currentModule != null) this.currentModule = currentModule
}
private selfTypeMatches(entry: SuggestionEntry): boolean {
private selfTypeMatches(entry: SuggestionEntry, additionalSelfTypes: QualifiedName[]): boolean {
if (this.selfArg == null) return entry.selfType == null
else if (this.selfArg.type == 'known') return entry.selfType === this.selfArg.typename
else if (this.selfArg.type == 'known')
return (
entry.selfType === this.selfArg.typename ||
additionalSelfTypes.some((t) => entry.selfType === t)
)
else return entry.selfType != null
}
@ -271,11 +275,11 @@ export class Filtering {
}
/** TODO: Add docs */
filter(entry: SuggestionEntry): MatchResult | null {
filter(entry: SuggestionEntry, additionalSelfTypes: QualifiedName[]): MatchResult | null {
if (entry.isPrivate || entry.kind != SuggestionKind.Method || entry.memberOf == null)
return null
if (this.selfArg == null && isInternal(entry)) return null
if (!this.selfTypeMatches(entry)) return null
if (!this.selfTypeMatches(entry, additionalSelfTypes)) return null
if (this.pattern) {
if (entry.memberOf == null) return null
const patternMatch = this.pattern.tryMatch(entry.name, entry.aliases, entry.memberOf)

View File

@ -2,12 +2,12 @@
import DocumentationPanel from '@/components/DocumentationPanel.vue'
import { injectGraphSelection } from '@/providers/graphSelection'
import { useGraphStore } from '@/stores/graph'
import { computed } from 'vue'
import { computed, watch } from 'vue'
import type { SuggestionId } from 'ydoc-shared/languageServerTypes/suggestions'
import { Err, Ok } from 'ydoc-shared/util/data/result'
import { Err, Ok, unwrapOr } from 'ydoc-shared/util/data/result'
const props = defineProps<{ displayedSuggestionId: SuggestionId | null }>()
const emit = defineEmits<{ 'update:displayedSuggestionId': [SuggestionId] }>()
// A displayed component can be overridren by this model, e.g. when the user clicks links in the documenation.
const overrideDisplayed = defineModel<SuggestionId | null>({ default: null })
const selection = injectGraphSelection()
const graphStore = useGraphStore()
@ -19,20 +19,20 @@ function docsForSelection() {
return Ok(suggestionId)
}
const displayedId = computed(() =>
props.displayedSuggestionId != null ? Ok(props.displayedSuggestionId) : docsForSelection(),
)
const docs = computed(() => docsForSelection())
// When the selection changes, we cancel the displayed suggestion override that can be in place.
watch(docs, (_) => (overrideDisplayed.value = null))
const displayedId = computed(() => overrideDisplayed.value ?? unwrapOr(docs.value, null))
</script>
<template>
<DocumentationPanel
v-if="displayedId?.ok"
:selectedEntry="displayedId.value"
@update:selectedEntry="emit('update:displayedSuggestionId', $event)"
v-if="displayedId"
:selectedEntry="displayedId"
@update:selectedEntry="overrideDisplayed = $event"
/>
<div v-else-if="displayedId?.ok === false" class="help-placeholder">
{{ displayedId.error.payload }}.
</div>
<div v-else-if="!displayedId && !docs.ok" class="help-placeholder">{{ docs.error.payload }}.</div>
</template>
<style scoped>

View File

@ -0,0 +1,42 @@
<template>
<div class="ControlButtons">
<div class="control left-end">
<slot name="left"></slot>
</div>
<div class="control right-end">
<slot name="right"></slot>
</div>
</div>
</template>
<style scoped>
.ControlButtons {
user-select: none;
display: flex;
place-items: center;
gap: 1px;
}
.control {
background: var(--color-frame-bg);
backdrop-filter: var(--blur-app-bg);
padding: 4px 4px;
width: 32px;
}
.left-end {
border-radius: var(--radius-full) 0 0 var(--radius-full);
> * {
margin: 0 0 0 auto;
}
}
.right-end {
border-radius: 0 var(--radius-full) var(--radius-full) 0;
> * {
margin: 0 auto 0 0;
}
}
</style>

View File

@ -21,8 +21,8 @@ import type { QualifiedName } from '@/util/qualifiedName'
import { qnSegments, qnSlice } from '@/util/qualifiedName'
import { computed, watch } from 'vue'
const props = defineProps<{ selectedEntry: Opt<SuggestionId>; aiMode?: boolean }>()
const emit = defineEmits<{ 'update:selectedEntry': [id: SuggestionId] }>()
const props = defineProps<{ selectedEntry: SuggestionId | null; aiMode?: boolean }>()
const emit = defineEmits<{ 'update:selectedEntry': [value: SuggestionId | null] }>()
const db = useSuggestionDbStore()
const documentation = computed<Docs>(() => {

View File

@ -8,47 +8,59 @@ export class HistoryStack {
private index: Ref<number>
public current: ComputedRef<SuggestionId | undefined>
/** TODO: Add docs */
/**
* Initializes the history stack.
*/
constructor() {
this.stack = reactive([])
this.index = ref(0)
this.current = computed(() => this.stack[this.index.value] ?? undefined)
}
/** TODO: Add docs */
/**
* Resets the history stack to contain only the current suggestion.
* @param current - The current suggestion ID to reset the stack with.
*/
public reset(current: SuggestionId) {
this.stack.length = 0
this.stack.push(current)
this.index.value = 0
}
/** TODO: Add docs */
/**
* Adds a new suggestion ID to the history stack, removing any forward history.
* @param id - The suggestion ID to record.
*/
public record(id: SuggestionId) {
this.stack.splice(this.index.value + 1)
this.stack.push(id)
this.index.value = this.stack.length - 1
}
/** TODO: Add docs */
/**
* Moves the history index forward by one step if possible.
*/
public forward() {
if (this.canGoForward()) {
this.index.value += 1
}
}
/** TODO: Add docs */
/**
* Navigates backward in the history if possible.
*/
public backward() {
if (this.canGoBackward()) {
this.index.value -= 1
}
}
/** TODO: Add docs */
/** @returns whether or not it is possible to navigate back in history from current position. */
public canGoBackward(): boolean {
return this.index.value > 0
}
/** TODO: Add docs */
/** @returns whether or not it is possible to navigate forward in history from current position. */
public canGoForward(): boolean {
return this.index.value < this.stack.length - 1
}

View File

@ -21,6 +21,7 @@ import { performCollapse, prepareCollapsedInfo } from '@/components/GraphEditor/
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
import { useGraphEditorToasts } from '@/components/GraphEditor/toasts'
import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload'
import GraphMissingView from '@/components/GraphMissingView.vue'
import GraphMouse from '@/components/GraphMouse.vue'
import PlusButton from '@/components/PlusButton.vue'
import SceneScroller from '@/components/SceneScroller.vue'
@ -51,6 +52,7 @@ import type { SuggestionId } from '@/stores/suggestionDatabase/entry'
import { suggestionDocumentationUrl, type Typename } from '@/stores/suggestionDatabase/entry'
import { provideVisualizationStore } from '@/stores/visualization'
import { bail } from '@/util/assert'
import { Ast } from '@/util/ast'
import type { AstId } from '@/util/ast/abstract'
import { colorFromString } from '@/util/colors'
import { partition } from '@/util/data/array'
@ -214,6 +216,7 @@ function panToSelected() {
// == Breadcrumbs ==
const stackNavigator = provideStackNavigator(projectStore, graphStore)
const graphMissing = computed(() => graphStore.moduleRoot != null && !graphStore.methodAst.ok)
// === Toasts ===
@ -577,7 +580,7 @@ function clearFocus() {
function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[]) {
const sourcePort = graphStore.db.getNodeFirstOutputPort(sourceNode)
if (sourcePort == null) return
const sourcePortAst = graphStore.viewModule.get(sourcePort)
const sourcePortAst = graphStore.viewModule.get(sourcePort) as Ast.Expression
const [toCommit, toEdit] = partition(options, (opts) => opts.commit)
createNodes(
toCommit.map((options: NodeCreationOptions) => ({
@ -629,14 +632,14 @@ function collapseNodes() {
}
const selectedNodeRects = filterDefined(Array.from(selected, graphStore.visibleArea))
graphStore.edit((edit) => {
const { refactoredExpressionAstId, collapsedNodeIds, outputAstId } = performCollapse(
const { collapsedCallRoot, collapsedNodeIds, outputAstId } = performCollapse(
info.value,
edit.getVersion(topLevel),
graphStore.db,
currentMethodName,
)
const position = collapsedNodePlacement(selectedNodeRects)
edit.get(refactoredExpressionAstId).mutableNodeMetadata().set('position', position.xy())
edit.get(collapsedCallRoot).mutableNodeMetadata().set('position', position.xy())
if (outputAstId != null) {
const collapsedNodeRects = filterDefined(
Array.from(collapsedNodeIds, graphStore.visibleArea),
@ -724,25 +727,29 @@ const documentationEditorFullscreen = ref(false)
>
<div class="vertical">
<div ref="viewportNode" class="viewport" @click="handleClick">
<GraphNodes
@nodeOutputPortDoubleClick="handleNodeOutputPortDoubleClick"
@nodeDoubleClick="(id) => stackNavigator.enterNode(id)"
@createNodes="createNodesFromSource"
@toggleDocPanel="toggleRightDockHelpPanel"
/>
<GraphEdges :navigator="graphNavigator" @createNodeFromEdge="handleEdgeDrop" />
<ComponentBrowser
v-if="componentBrowserVisible"
ref="componentBrowser"
:navigator="graphNavigator"
:nodePosition="componentBrowserNodePosition"
:usage="componentBrowserUsage"
:associatedElements="componentBrowserElements"
@accepted="commitComponentBrowser"
@canceled="hideComponentBrowser"
@selectedSuggestionId="displayedDocs = $event"
@isAiPrompt="aiMode = $event"
/>
<GraphMissingView v-if="graphMissing" />
<template v-else>
<GraphNodes
@nodeOutputPortDoubleClick="handleNodeOutputPortDoubleClick"
@enterNode="(id) => stackNavigator.enterNode(id)"
@createNodes="createNodesFromSource"
@toggleDocPanel="toggleRightDockHelpPanel"
/>
<GraphEdges :navigator="graphNavigator" @createNodeFromEdge="handleEdgeDrop" />
<ComponentBrowser
v-if="componentBrowserVisible"
ref="componentBrowser"
:navigator="graphNavigator"
:nodePosition="componentBrowserNodePosition"
:usage="componentBrowserUsage"
:associatedElements="componentBrowserElements"
@accepted="commitComponentBrowser"
@canceled="hideComponentBrowser"
@selectedSuggestionId="displayedDocs = $event"
@isAiPrompt="aiMode = $event"
/>
<PlusButton title="Add Component" @click.stop="addNodeDisconnected()" />
</template>
<TopBar
v-model:recordMode="projectStore.recordMode"
v-model:showColorPicker="showColorPicker"
@ -757,7 +764,6 @@ const documentationEditorFullscreen = ref(false)
@collapseNodes="collapseNodes"
@removeNodes="deleteSelected"
/>
<PlusButton title="Add Component" @click.stop="addNodeDisconnected()" />
<SceneScroller
:navigator="graphNavigator"
:scrollableArea="Rect.Bounding(...graphStore.visibleNodeAreas)"
@ -786,11 +792,7 @@ const documentationEditorFullscreen = ref(false)
/>
</template>
<template #help>
<ComponentDocumentation
:displayedSuggestionId="displayedDocs"
:aiMode="aiMode"
@update:displayedSuggestionId="displayedDocs = $event"
/>
<ComponentDocumentation v-model="displayedDocs" :aiMode="aiMode" />
</template>
</DockPanel>
</div>

View File

@ -101,7 +101,7 @@ function createEdge(source: AstId, target: PortId) {
// Creating this edge would create a circular dependency. Prevent that and display error.
toast.error('Could not connect due to circular dependency.')
} else {
const identAst = Ast.parse(ident, edit)
const identAst = Ast.parseExpression(ident, edit)!
if (!graph.updatePortValue(edit, target, identAst)) {
if (isAstId(target)) {
console.warn(`Failed to connect edge to port ${target}, falling back to direct edit.`)

View File

@ -61,7 +61,7 @@ const emit = defineEmits<{
replaceSelection: []
outputPortClick: [event: PointerEvent, portId: AstId]
outputPortDoubleClick: [event: PointerEvent, portId: AstId]
doubleClick: []
enterNode: []
createNodes: [options: NodeCreationOptions[]]
setNodeColor: [color: string | undefined]
toggleDocPanel: []
@ -377,7 +377,7 @@ const handleNodeClick = useDoubleClick(
}
},
() => {
if (!significantMove.value) emit('doubleClick')
if (!significantMove.value) emit('enterNode')
},
).handleClick
@ -469,6 +469,8 @@ watchEffect(() => {
:matchableNodeColors="matchableNodeColors"
:documentationUrl="documentationUrl"
:isRemovable="props.node.type === 'component'"
:isEnterable="graph.nodeCanBeEntered(nodeId)"
@enterNode="emit('enterNode')"
@startEditing="startEditingNode"
@startEditingComment="editingComment = true"
@openFullMenu="openFullMenu"

View File

@ -13,10 +13,7 @@ const textEditor = ref<ComponentInstance<typeof PlainTextEditor>>()
const textEditorContent = computed(() => textEditor.value?.contentElement)
const graphStore = useGraphStore()
const { documentation } = useAstDocumentation(
graphStore,
() => props.node.docs ?? props.node.outerExpr,
)
const { documentation } = useAstDocumentation(graphStore, () => props.node.outerAst)
syncRef(editing, useFocusDelayed(textEditorContent).focused)
</script>

View File

@ -17,7 +17,7 @@ import { stackItemsEqual } from 'ydoc-shared/languageServerTypes'
const emit = defineEmits<{
nodeOutputPortDoubleClick: [portId: AstId]
nodeDoubleClick: [nodeId: NodeId]
enterNode: [nodeId: NodeId]
createNodes: [source: NodeId, options: NodeCreationOptions[]]
toggleDocPanel: []
}>()
@ -75,7 +75,7 @@ const graphNodeSelections = shallowRef<HTMLElement>()
@draggingCancelled="dragging.cancelDrag()"
@outputPortClick="(event, port) => graphStore.createEdgeFromOutput(port, event)"
@outputPortDoubleClick="(_event, port) => emit('nodeOutputPortDoubleClick', port)"
@doubleClick="emit('nodeDoubleClick', id)"
@enterNode="emit('enterNode', id)"
@createNodes="emit('createNodes', id, $event)"
@toggleDocPanel="emit('toggleDocPanel')"
@setNodeColor="graphStore.overrideNodeColor(id, $event)"

View File

@ -126,7 +126,10 @@ export function useVisualizationData({
const preprocessor = visPreprocessor.value
const args = preprocessor.positionalArgumentsExpressions
const tempModule = Ast.MutableModule.Transient()
const preprocessorModule = Ast.parse(preprocessor.visualizationModule, tempModule)
const preprocessorModule = Ast.parseExpression(
preprocessor.visualizationModule,
tempModule,
)!
// TODO[ao]: it work with builtin visualization, but does not work in general case.
// Tracked in https://github.com/orgs/enso-org/discussions/6832#discussioncomment-7754474.
if (!isIdentifier(preprocessor.expression)) {
@ -140,9 +143,9 @@ export function useVisualizationData({
)
const preprocessorInvocation = Ast.App.PositionalSequence(preprocessorQn, [
Ast.Wildcard.new(tempModule),
...args.map((arg) => Ast.Group.new(tempModule, Ast.parse(arg, tempModule))),
...args.map((arg) => Ast.Group.new(tempModule, Ast.parseExpression(arg, tempModule)!)),
])
const rhs = Ast.parse(dataSourceValue.expression, tempModule)
const rhs = Ast.parseExpression(dataSourceValue.expression, tempModule)!
const expression = Ast.OprApp.new(tempModule, preprocessorInvocation, '<|', rhs)
return projectStore.executeExpression(dataSourceValue.contextId, expression.code())
} catch (e) {

View File

@ -13,7 +13,7 @@ import { computed, toRef, watch } from 'vue'
import { DisplayIcon } from './widgets/WidgetIcon.vue'
const props = defineProps<{
ast: Ast.Ast
ast: Ast.Expression
nodeId: NodeId
nodeElement: HTMLElement | undefined
nodeType: NodeType
@ -67,16 +67,23 @@ function handleWidgetUpdates(update: WidgetUpdate) {
selectNode()
const edit = update.edit ?? graph.startEdit()
if (update.portUpdate) {
const { value, origin } = update.portUpdate
const { origin } = update.portUpdate
if (Ast.isAstId(origin)) {
const ast =
value instanceof Ast.Ast ? value
: value == null ? Ast.Wildcard.new(edit)
: undefined
if (ast) {
edit.replaceValue(origin, ast)
} else if (typeof value === 'string') {
edit.tryGet(origin)?.syncToCode(value)
if ('value' in update.portUpdate) {
const value = update.portUpdate.value
const ast =
value instanceof Ast.Ast ? value
: value == null ? Ast.Wildcard.new(edit)
: undefined
if (ast) {
edit.replaceValue(origin, ast)
} else if (typeof value === 'string') {
edit.tryGet(origin)?.syncToCode(value)
}
}
if ('metadata' in update.portUpdate) {
const { metadataKey, metadata } = update.portUpdate
edit.tryGet(origin)?.setWidgetMetadata(metadataKey, metadata)
}
} else {
console.error(`[UPDATE ${origin}] Invalid top-level origin. Expected expression ID.`)

View File

@ -68,7 +68,7 @@ const testNodeInputs: {
{ code: '## Documentation\nfoo = 2 + 2' },
]
const testNodes = testNodeInputs.map(({ code, visualization, colorOverride }) => {
const root = Ast.Ast.parse(code)
const root = [...Ast.parseBlock(code).statements()][0]!
root.setNodeMetadata({ visualization, colorOverride })
const node = nodeFromAst(root, false)
assertDefined(node)
@ -82,7 +82,9 @@ test.each([...testNodes.map((node) => [node]), testNodes])(
const clipboardItem = clipboardItemFromTypes(nodesToClipboardData(sourceNodes))
const pastedNodes = await nodesFromClipboardContent([clipboardItem])
sourceNodes.forEach((sourceNode, i) => {
expect(pastedNodes[i]?.documentation).toBe(sourceNode.docs?.documentation())
const documentation =
sourceNode.outerAst.isStatement() ? sourceNode.outerAst.documentationText() : undefined
expect(pastedNodes[i]?.documentation).toBe(documentation)
expect(pastedNodes[i]?.expression).toBe(sourceNode.innerExpr.code())
expect(pastedNodes[i]?.metadata?.colorOverride).toBe(sourceNode.colorOverride)
expect(pastedNodes[i]?.metadata?.visualization).toBe(sourceNode.vis)

View File

@ -1,11 +1,18 @@
import { prepareCollapsedInfo } from '@/components/GraphEditor/collapsing'
import { performCollapseImpl, prepareCollapsedInfo } from '@/components/GraphEditor/collapsing'
import { GraphDb, type NodeId } from '@/stores/graph/graphDatabase'
import { assert } from '@/util/assert'
import { Ast, RawAst } from '@/util/ast'
import { findExpressions } from '@/util/ast/__tests__/testCase'
import { unwrap } from '@/util/data/result'
import { tryIdentifier } from '@/util/qualifiedName'
import { expect, test } from 'vitest'
import { watchEffect } from 'vue'
import { Identifier } from 'ydoc-shared/ast'
import { nodeIdFromOuterAst } from '../../../stores/graph/graphDatabase'
// ===============================
// === Collapse Analysis Tests ===
// ===============================
function setupGraphDb(code: string, graphDb: GraphDb) {
const { root, toRaw, getSpan } = Ast.parseExtended(code)
@ -211,3 +218,73 @@ main =
expect(refactored.pattern).toEqual('sum')
expect(refactored.arguments).toEqual(['input', 'four'])
})
// ================================
// === Collapse Execution Tests ===
// ================================
test('Perform collapse', () => {
const root = Ast.parseModule(
[
'main =',
' keep1 = 1',
' extract1 = keep1',
' keep2 = 2',
' extract2 = extract1 + 1',
' target = extract2',
].join('\n'),
)
root.module.setRoot(root)
const before = findExpressions(root, {
'keep1 = 1': Ast.Assignment,
'extract1 = keep1': Ast.Assignment,
'keep2 = 2': Ast.Assignment,
'extract2 = extract1 + 1': Ast.Assignment,
'target = extract2': Ast.Assignment,
})
const statementsToExtract = new Set<Ast.AstId>()
const statementToReplace = before['target = extract2'].id
statementsToExtract.add(before['extract1 = keep1'].id)
statementsToExtract.add(before['extract2 = extract1 + 1'].id)
statementsToExtract.add(statementToReplace)
const { collapsedCallRoot, outputAstId, collapsedNodeIds } = performCollapseImpl(
root,
{
args: ['keep1' as Identifier],
statementsToExtract,
statementToReplace: before['target = extract2'].id,
},
'main',
)
expect(root.code()).toBe(
[
'## ICON group',
'collapsed keep1 =',
' extract1 = keep1',
' extract2 = extract1 + 1',
' target = extract2',
' target',
'',
'main =',
' keep1 = 1',
' keep2 = 2',
' target = Main.collapsed keep1',
].join('\n'),
)
const after = findExpressions(root, {
'extract1 = keep1': Ast.Assignment,
'extract2 = extract1 + 1': Ast.Assignment,
'target = extract2': Ast.Assignment,
target: Ast.ExpressionStatement,
'keep1 = 1': Ast.Assignment,
'keep2 = 2': Ast.Assignment,
'target = Main.collapsed keep1': Ast.Assignment,
})
expect(collapsedNodeIds).toStrictEqual(
[after['target = extract2'], after['extract2 = extract1 + 1'], after['extract1 = keep1']].map(
nodeIdFromOuterAst,
),
)
expect(outputAstId).toBe(after['target'].expression.id)
expect(collapsedCallRoot).toBe(after['target = Main.collapsed keep1'].expression.id)
})

View File

@ -148,7 +148,7 @@ const spreadsheetDecoder: ClipboardDecoder<CopiedNode[]> = {
},
}
const toTable = computed(() => Pattern.parse('__.to Table'))
const toTable = computed(() => Pattern.parseExpression('__.to Table'))
/** Create Enso Expression generating table from this tsvData. */
export function tsvTableToEnsoExpression(tsvData: string) {
@ -186,9 +186,10 @@ export function writeClipboard(data: MimeData) {
// === Serializing nodes ===
function nodeStructuredData(node: Node): CopiedNode {
const documentation = node.outerAst.isStatement() ? node.outerAst.documentationText() : undefined
return {
expression: node.innerExpr.code(),
documentation: node.docs?.documentation(),
documentation,
metadata: node.rootExpr.serializeMetadata(),
...(node.pattern ? { binding: node.pattern.code() } : {}),
}
@ -204,6 +205,6 @@ export function clipboardNodeData(nodes: CopiedNode[]): MimeData {
export function nodesToClipboardData(nodes: Node[]): MimeData {
return {
...clipboardNodeData(nodes.map(nodeStructuredData)),
'text/plain': nodes.map((node) => node.outerExpr.code()).join('\n'),
'text/plain': nodes.map((node) => node.outerAst.code()).join('\n'),
}
}

View File

@ -1,14 +1,9 @@
import { asNodeId, GraphDb, nodeIdFromOuterExpr, type NodeId } from '@/stores/graph/graphDatabase'
import { assert, assertDefined } from '@/util/assert'
import { GraphDb, NodeId, nodeIdFromOuterAst } from '@/stores/graph/graphDatabase'
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { autospaced, isIdentifier, moduleMethodNames, type Identifier } from '@/util/ast/abstract'
import { filterDefined } from '@/util/data/iterable'
import { Err, Ok, unwrap, type Result } from '@/util/data/result'
import {
isIdentifierOrOperatorIdentifier,
tryIdentifier,
type IdentifierOrOperatorIdentifier,
} from '@/util/qualifiedName'
import { Identifier, isIdentifier, moduleMethodNames } from '@/util/ast/abstract'
import { Err, Ok, Result, unwrap } from '@/util/data/result'
import { tryIdentifier } from '@/util/qualifiedName'
import * as set from 'lib0/set'
// === Types ===
@ -24,7 +19,7 @@ interface ExtractedInfo {
/** Nodes with these ids should be moved to the function body, in their original order. */
ids: Set<NodeId>
/** The output information of the function. */
output: Output | null
output: Output
/** The list of extracted functions argument names. */
inputs: Identifier[]
}
@ -110,9 +105,11 @@ export function prepareCollapsedInfo(
output = { node: arbitraryLeaf, identifier }
}
const pattern = graphDb.nodeIdToNode.get(output.node)?.pattern?.code() ?? ''
assert(isIdentifier(pattern))
const pattern = graphDb.nodeIdToNode.get(output.node)?.pattern?.code()
assert(pattern != null && isIdentifier(pattern))
const inputs = Array.from(inputSet)
assert(selected.has(output.node))
return Ok({
extracted: {
ids: selected,
@ -128,10 +125,7 @@ export function prepareCollapsedInfo(
}
/** Generate a safe method name for a collapsed function using `baseName` as a prefix. */
function findSafeMethodName(
topLevel: Ast.BodyBlock,
baseName: IdentifierOrOperatorIdentifier,
): IdentifierOrOperatorIdentifier {
function findSafeMethodName(topLevel: Ast.BodyBlock, baseName: Identifier): Identifier {
const allIdentifiers = moduleMethodNames(topLevel)
if (!allIdentifiers.has(baseName)) {
return baseName
@ -141,107 +135,98 @@ function findSafeMethodName(
index++
}
const name = `${baseName}${index}`
assert(isIdentifierOrOperatorIdentifier(name))
assert(isIdentifier(name))
return name
}
// === performCollapse ===
// We support working inside `Main` module of the project at the moment.
const MODULE_NAME = 'Main' as IdentifierOrOperatorIdentifier
const COLLAPSED_FUNCTION_NAME = 'collapsed' as IdentifierOrOperatorIdentifier
const MODULE_NAME = 'Main' as Identifier
const COLLAPSED_FUNCTION_NAME = 'collapsed' as Identifier
interface CollapsingResult {
/** The ID of the node refactored to the collapsed function call. */
refactoredNodeId: NodeId
refactoredExpressionAstId: Ast.AstId
collapsedCallRoot: Ast.AstId
/**
* IDs of nodes inside the collapsed function, except the output node.
* The order of these IDs is reversed comparing to the order of nodes in the source code.
*/
collapsedNodeIds: NodeId[]
/** ID of the output AST node inside the collapsed function. */
outputAstId?: Ast.AstId | undefined
outputAstId: Ast.AstId
}
interface PreparedCollapseInfo {
args: Identifier[]
statementsToExtract: Set<Ast.AstId>
statementToReplace: Ast.AstId
}
/** Perform the actual AST refactoring for collapsing nodes. */
export function performCollapse(
info: CollapsedInfo,
topLevel: Ast.MutableBodyBlock,
db: GraphDb,
graphDb: GraphDb,
currentMethodName: string,
): CollapsingResult {
const nodeIdToStatementId = (nodeId: NodeId) => graphDb.nodeIdToNode.get(nodeId)!.outerAst.id
const preparedInfo = {
args: info.extracted.inputs,
statementsToExtract: new Set([...info.extracted.ids].map(nodeIdToStatementId)),
statementToReplace: nodeIdToStatementId(info.refactored.id),
outputIdentifier: info.extracted.output.identifier,
}
return performCollapseImpl(topLevel, preparedInfo, currentMethodName)
}
/** @internal */
export function performCollapseImpl(
topLevel: Ast.MutableBodyBlock,
info: PreparedCollapseInfo,
currentMethodName: string,
) {
const edit = topLevel.module
const functionAst = Ast.findModuleMethod(topLevel, currentMethodName)
assertDefined(functionAst)
const functionBlock = edit.getVersion(functionAst).bodyAsBlock()
const posToInsert = findInsertionPos(topLevel, currentMethodName)
const collapsedName = findSafeMethodName(topLevel, COLLAPSED_FUNCTION_NAME)
const astIdsToExtract = new Set(
[...info.extracted.ids].map((nodeId) => db.nodeIdToNode.get(nodeId)?.outerExpr.id),
)
const astIdToReplace = db.nodeIdToNode.get(info.refactored.id)?.outerExpr.id
const {
ast: refactoredAst,
nodeId: refactoredNodeId,
expressionAstId: refactoredExpressionAstId,
} = collapsedCallAst(info, collapsedName, edit)
const collapsed: Ast.Owned[] = []
const { statement: currentMethod, index: currentMethodLine } = Ast.findModuleMethod(
topLevel,
currentMethodName,
)!
// Update the definition of the refactored function.
functionBlock.updateLines((lines) => {
const refactored: Ast.OwnedBlockLine[] = []
for (const line of lines) {
const ast = line.expression?.node
if (!ast) continue
if (astIdsToExtract.has(ast.id)) {
collapsed.push(ast)
if (ast.id === astIdToReplace) {
refactored.push({ expression: autospaced(refactoredAst) })
}
} else {
refactored.push(line)
}
}
return refactored
const extractedLines = currentMethod
.bodyAsBlock()
.extractIf(({ id }) => info.statementsToExtract.has(id) && id !== info.statementToReplace)
const collapsedCall = Ast.App.PositionalSequence(
Ast.PropertyAccess.new(edit, Ast.Ident.new(edit, MODULE_NAME), collapsedName),
info.args.map((arg) => Ast.Ident.new(edit, arg)),
)
const statementToReplace = edit.get(info.statementToReplace)
assert(statementToReplace instanceof Ast.MutableAssignment)
const outputIdentifier = statementToReplace.pattern.code() as Identifier
extractedLines.push({
statement: {
whitespace: undefined,
node: statementToReplace.replace(
Ast.Assignment.new(outputIdentifier, collapsedCall, { edit }),
),
},
})
const collapsedNodeIds = extractedLines
.map(({ statement }) => statement && nodeIdFromOuterAst(statement.node))
.filter((id) => id != null)
.reverse()
// Insert a new function.
const collapsedNodeIds = [...filterDefined(collapsed.map(nodeIdFromOuterExpr))].reverse()
let outputAstId: Ast.AstId | undefined
const outputIdentifier = info.extracted.output?.identifier
if (outputIdentifier != null) {
const ident = Ast.Ident.new(edit, outputIdentifier)
collapsed.push(ident)
outputAstId = ident.id
}
const argNames = info.extracted.inputs
const collapsedFunction = Ast.Function.fromStatements(edit, collapsedName, argNames, collapsed)
const collapsedFunctionWithIcon = Ast.Documented.new('ICON group', collapsedFunction)
topLevel.insert(posToInsert, collapsedFunctionWithIcon, undefined)
return { refactoredNodeId, refactoredExpressionAstId, collapsedNodeIds, outputAstId }
}
/** Prepare a method call expression for collapsed method. */
function collapsedCallAst(
info: CollapsedInfo,
collapsedName: IdentifierOrOperatorIdentifier,
edit: Ast.MutableModule,
): { ast: Ast.Owned; expressionAstId: Ast.AstId; nodeId: NodeId } {
const pattern = info.refactored.pattern
const args = info.refactored.arguments
const functionName = `${MODULE_NAME}.${collapsedName}`
const expression = functionName + (args.length > 0 ? ' ' : '') + args.join(' ')
const expressionAst = Ast.parse(expression, edit)
const ast = Ast.Assignment.new(edit, pattern, expressionAst)
return { ast, expressionAstId: expressionAst.id, nodeId: asNodeId(expressionAst.externalId) }
}
/** Find the position before the current method to insert a collapsed one. */
function findInsertionPos(topLevel: Ast.BodyBlock, currentMethodName: string): number {
const currentFuncPosition = topLevel.lines.findIndex((line) => {
const expr = line.expression?.node?.innerExpression()
return expr instanceof Ast.Function && expr.name?.code() === currentMethodName
const collapsedBody = Ast.BodyBlock.new(extractedLines, edit)
const outputAst = Ast.Ident.new(edit, outputIdentifier)
collapsedBody.push(outputAst)
const collapsedFunction = Ast.Function.new(collapsedName, info.args, collapsedBody, {
edit,
documentation: 'ICON group',
})
topLevel.insert(currentMethodLine, collapsedFunction, undefined)
return currentFuncPosition === -1 ? 0 : currentFuncPosition
return { collapsedCallRoot: collapsedCall.id, outputAstId: outputAst.id, collapsedNodeIds }
}

View File

@ -45,7 +45,10 @@ const operatorStyle = computed(() => {
application.value.appTree instanceof Ast.OprApp ||
application.value.appTree instanceof Ast.PropertyAccess
) {
const [_lhs, opr, rhs] = application.value.appTree.concreteChildren()
const [_lhs, opr, rhs] = application.value.appTree.concreteChildren({
verbatim: true,
indent: '',
})
return {
'--whitespace-pre': `${JSON.stringify(opr?.whitespace ?? '')}`,
'--whitespace-post': `${JSON.stringify(rhs?.whitespace ?? '')}`,

View File

@ -64,14 +64,14 @@ const argumentName = computed(() => {
</script>
<script lang="ts">
function isBoolNode(ast: Ast.Ast) {
function isBoolNode(ast: Ast.Expression) {
const candidate =
ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean' ? ast.rhs
: ast instanceof Ast.Ident ? ast.token
: undefined
return candidate && ['True', 'False'].includes(candidate.code())
}
function setBoolNode(ast: Ast.Mutable, value: Identifier): { requiresImport: boolean } {
function setBoolNode(ast: Ast.MutableExpression, value: Identifier): { requiresImport: boolean } {
if (ast instanceof Ast.MutablePropertyAccess) {
ast.setRhs(value)
return { requiresImport: false }

View File

@ -53,16 +53,15 @@ const label = computed(() => {
}
})
const fileConPattern = Pattern.parse(`${FILE_TYPE}.new __`)
const fileShortConPattern = Pattern.parse(`File.new __`)
const fileConPattern = Pattern.parseExpression(`${FILE_TYPE}.new __`)
const fileShortConPattern = Pattern.parseExpression(`File.new __`)
const currentPath = computed(() => {
if (typeof props.input.value === 'string') {
return props.input.value
} else if (props.input.value) {
const expression = props.input.value.innerExpression()
const expression = props.input.value
const match = fileShortConPattern.match(expression) ?? fileConPattern.match(expression)
const pathAst =
match && match[0] ? expression.module.get(match[0]).innerExpression() : expression
const pathAst = match && match[0] ? expression.module.get(match[0]) : expression
if (pathAst instanceof TextLiteral) {
return pathAst.rawTextContent
}
@ -70,7 +69,11 @@ const currentPath = computed(() => {
return undefined
})
function makeValue(edit: Ast.MutableModule, useFileConstructor: boolean, path: string): Ast.Owned {
function makeValue(
edit: Ast.MutableModule,
useFileConstructor: boolean,
path: string,
): Ast.Owned<Ast.MutableExpression> {
if (useFileConstructor) {
const arg = Ast.TextLiteral.new(path, edit)
const requiredImport = {

View File

@ -82,6 +82,11 @@ const innerInput = computed(() => {
function handleArgUpdate(update: WidgetUpdate): boolean {
const app = application.value
if (update.portUpdate && app instanceof ArgumentApplication) {
if (!('value' in update.portUpdate)) {
if (!Ast.isAstId(update.portUpdate.origin))
console.error('Tried to set metadata on arg placeholder. This is not implemented yet!')
return false
}
const { value, origin } = update.portUpdate
const edit = update.edit ?? graph.startEdit()
// Find the updated argument by matching origin port/expression with the appropriate argument.
@ -96,11 +101,11 @@ function handleArgUpdate(update: WidgetUpdate): boolean {
// Perform appropriate AST update, either insertion or deletion.
if (value != null && argApp?.argument instanceof ArgumentPlaceholder) {
/* Case: Inserting value to a placeholder. */
let newArg: Ast.Owned
let newArg: Ast.Owned<Ast.MutableExpression>
if (value instanceof Ast.Ast) {
newArg = value
} else {
newArg = Ast.parse(value, edit)
newArg = Ast.parseExpression(value, edit)!
}
const name =
argApp.argument.insertAsNamed && isIdentifier(argApp.argument.argInfo.name) ?
@ -143,8 +148,7 @@ function handleArgUpdate(update: WidgetUpdate): boolean {
// Named argument can always be removed immediately. Replace the whole application with its
// target, effectively removing the argument from the call.
const func = edit.take(argApp.appTree.function.id)
assert(func != null)
const func = edit.getVersion(argApp.appTree.function).take()
props.onUpdate({
edit,
portUpdate: {
@ -158,7 +162,7 @@ function handleArgUpdate(update: WidgetUpdate): boolean {
// Infix application is removed as a whole. Only the target is kept.
if (argApp.appTree.lhs) {
const lhs = edit.take(argApp.appTree.lhs.id)
const lhs = edit.getVersion(argApp.appTree.lhs).take()
props.onUpdate({
edit,
portUpdate: {
@ -183,9 +187,9 @@ function handleArgUpdate(update: WidgetUpdate): boolean {
const appTree = edit.getVersion(argApp.appTree)
if (graph.db.isNodeId(appTree.externalId)) {
// If the modified application is a node root, preserve its identity and metadata.
appTree.replaceValue(appTree.function.take())
appTree.updateValue((appTree) => appTree.function.take())
} else {
appTree.replace(appTree.function.take())
appTree.update((appTree) => appTree.function.take())
}
props.onUpdate({ edit })
return true

View File

@ -60,13 +60,13 @@ test.each`
...(attachedSpan != null ? { attached: attachedSpan as [number, number] } : {}),
}
const { ast, eid, id } = parseWithSpans(code, spans)
const line = ast.lines[0]?.expression
assert(line != null)
expect(line.node.externalId).toBe(eid('entireFunction'))
const node = (ast.lines[0]?.statement?.node as Ast.ExpressionStatement).expression
assert(node != null)
expect(node.externalId).toBe(eid('entireFunction'))
let visConfig: Ref<Opt<NodeVisualizationConfiguration>> | undefined
useWidgetFunctionCallInfo(
WidgetInput.FromAst(line.node),
WidgetInput.FromAst(node),
{
getMethodCallInfo(astId) {
if (astId === id('entireFunction')) {
@ -93,7 +93,7 @@ test.each`
},
{
useVisualizationData(config) {
expect(visConfig, 'Only one visualizaiton is expected').toBeUndefined()
expect(visConfig, 'Only one visualization is expected').toBeUndefined()
visConfig = config
return ref(null)
},

View File

@ -29,7 +29,7 @@ export const GET_WIDGETS_METHOD = 'get_widget_json'
* expression updates.
*/
export function useWidgetFunctionCallInfo(
input: ToValue<WidgetInput & { value: Ast.Ast }>,
input: ToValue<WidgetInput & { value: Ast.Expression }>,
graphDb: {
getMethodCallInfo(id: AstId): MethodCallInfo | undefined
getExpressionInfo(id: AstId): ExpressionInfo | undefined

View File

@ -3,12 +3,23 @@ import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast'
import { computed } from 'vue'
import { isToken } from 'ydoc-shared/ast'
const props = defineProps(widgetProps(widgetDefinition))
const spanClass = computed(() => props.input.value.typeName())
function transformChild(child: Ast.Ast | Ast.Token) {
function* expressionChildren(expression: Ast.Expression) {
for (const child of expression.children()) {
if (isToken(child) || child.isExpression()) {
yield child
} else {
console.error('Unable to render non-expression AST node in component', child)
}
}
}
function transformChild(child: Ast.Expression | Ast.Token) {
const childInput = WidgetInput.FromAst(child)
if (props.input.value instanceof Ast.PropertyAccess && child.id === props.input.value.lhs?.id)
childInput.forcePort = true
@ -36,8 +47,8 @@ export const widgetDefinition = defineWidget(
<template>
<div class="WidgetHierarchy" :class="spanClass">
<NodeWidget
v-for="(child, index) in props.input.value.children()"
:key="child.id ?? index"
v-for="child in expressionChildren(props.input.value)"
:key="child.id"
:input="transformChild(child)"
/>
</div>

View File

@ -42,7 +42,7 @@ const dropdownElement = ref<HTMLElement>()
const activityElement = ref<HTMLElement>()
const editedWidget = ref<string>()
const editedValue = ref<Ast.Owned | string | undefined>()
const editedValue = ref<Ast.Owned<Ast.MutableExpression> | string | undefined>()
const isHovered = ref(false)
/** See @{link Actions.setActivity} */
const activity = shallowRef<VNode>()
@ -96,7 +96,7 @@ const { floatingStyles } = dropdownStyles(dropdownElement, true)
const { floatingStyles: activityStyles } = dropdownStyles(activityElement, false)
class ExpressionTag {
private cachedExpressionAst: Ast.Ast | undefined
private cachedExpressionAst: Ast.Expression | undefined
constructor(
readonly expression: string,
@ -135,7 +135,7 @@ class ExpressionTag {
get expressionAst() {
if (this.cachedExpressionAst == null) {
this.cachedExpressionAst = Ast.parse(this.expression)
this.cachedExpressionAst = Ast.parseExpression(this.expression)
}
return this.cachedExpressionAst
}
@ -154,7 +154,7 @@ class ActionTag {
type ExpressionFilter = (tag: ExpressionTag) => boolean
function makeExpressionFilter(pattern: Ast.Ast | string): ExpressionFilter | undefined {
const editedAst = typeof pattern === 'string' ? Ast.parse(pattern) : pattern
const editedAst = typeof pattern === 'string' ? Ast.parseExpression(pattern) : pattern
const editedCode = pattern instanceof Ast.Ast ? pattern.code() : pattern
if (editedAst instanceof Ast.TextLiteral) {
return (tag: ExpressionTag) =>
@ -249,11 +249,7 @@ provideSelectionArrow(
if (node instanceof Ast.AutoscopedIdentifier) return node.identifier.id
if (node instanceof Ast.PropertyAccess) return node.rhs.id
if (node instanceof Ast.App) node = node.function
else {
const wrapped = node.wrappedExpression()
if (wrapped != null) node = wrapped
else break
}
else break
}
return null
}),
@ -369,7 +365,7 @@ function toggleVectorValue(vector: Ast.MutableVector, value: string, previousSta
if (previousState) {
vector.keep((ast) => ast.code() !== value)
} else {
vector.push(Ast.parse(value, vector.module))
vector.push(Ast.parseExpression(value, vector.module)!)
}
}

View File

@ -23,7 +23,7 @@ const displayedIcon = computed(() => {
const iconInput = computed(() => {
const lhs = props.input.value.lhs
if (!lhs) return
const input = WidgetInput.FromAstWithPort(lhs)
const input = WidgetInput.WithPort(WidgetInput.FromAst(lhs))
const icon = displayedIcon.value
if (icon) input[DisplayIcon] = { icon, showContents: showFullAccessChain.value }
return input

View File

@ -2,6 +2,7 @@
import { WidgetInputIsSpecificMethodCall } from '@/components/GraphEditor/widgets/WidgetFunction.vue'
import TableHeader from '@/components/GraphEditor/widgets/WidgetTableEditor/TableHeader.vue'
import {
CELLS_LIMIT,
tableNewCallMayBeHandled,
useTableNewArgument,
type RowData,
@ -16,6 +17,7 @@ import { useGraphStore } from '@/stores/graph'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { useToast } from '@/util/toast'
import '@ag-grid-community/styles/ag-grid.css'
import '@ag-grid-community/styles/ag-theme-alpine.css'
import type {
@ -29,11 +31,29 @@ import type {
} from 'ag-grid-enterprise'
import { computed, markRaw, ref } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import { z } from 'zod'
const props = defineProps(widgetProps(widgetDefinition))
const graph = useGraphStore()
const suggestionDb = useSuggestionDbStore()
const grid = ref<ComponentExposed<typeof AgGridTableView<RowData, any>>>()
const pasteWarning = useToast.warning()
const configSchema = z.object({ size: z.object({ x: z.number(), y: z.number() }) })
type Config = z.infer<typeof configSchema>
const DEFAULT_CFG: Config = { size: { x: 200, y: 150 } }
const config = computed(() => {
const configObj = props.input.value.widgetMetadata('WidgetTableEditor')
if (configObj == null) return DEFAULT_CFG
const parsed = configSchema.safeParse(configObj)
if (parsed.success) return parsed.data
else {
console.warn('Table Editor Widget: could not read config; invalid format: ', parsed.error)
return DEFAULT_CFG
}
})
const { rowData, columnDefs, moveColumn, moveRow, pasteFromClipboard } = useTableNewArgument(
() => props.input,
@ -131,15 +151,22 @@ const headerEditHandler = new HeaderEditing()
// === Resizing ===
const size = ref(new Vec2(200, 150))
const graphNav = injectGraphNavigator()
const size = computed(() => Vec2.FromXY(config.value.size))
const clientBounds = computed({
get() {
return new Rect(Vec2.Zero, size.value.scale(graphNav.scale))
},
set(value) {
size.value = new Vec2(value.width / graphNav.scale, value.height / graphNav.scale)
props.onUpdate({
portUpdate: {
origin: props.input.portId,
metadataKey: 'WidgetTableEditor',
metadata: { size: { x: value.width / graphNav.scale, y: value.height / graphNav.scale } },
},
})
},
})
@ -170,10 +197,13 @@ function processDataFromClipboard({ data, api }: ProcessDataFromClipboardParams<
const focusedCell = api.getFocusedCell()
if (focusedCell === null) console.warn('Pasting while no cell is focused!')
else {
pasteFromClipboard(data, {
const pasted = pasteFromClipboard(data, {
rowIndex: focusedCell.rowIndex,
colId: focusedCell.column.getColId(),
})
if (pasted.rows < data.length || pasted.columns < (data[0]?.length ?? 0)) {
pasteWarning.show(`Truncated pasted data to keep table within ${CELLS_LIMIT} limit`)
}
}
return []
}

View File

@ -16,11 +16,8 @@ export interface HeaderEditHandlers {
* (not in defaultColumnDef).
*/
export type ColumnSpecificHeaderParams =
| {
type: 'astColumn'
editHandlers: HeaderEditHandlers
}
| { type: 'newColumn'; newColumnRequested: () => void }
| { type: 'astColumn'; editHandlers: HeaderEditHandlers }
| { type: 'newColumn'; enabled?: boolean; newColumnRequested: () => void }
| { type: 'rowIndexColumn' }
/**
@ -105,6 +102,7 @@ function onMouseRightClick(event: MouseEvent) {
class="addColumnButton"
name="add"
title="Add new column"
:disabled="!(params.enabled ?? true)"
@click.stop="params.newColumnRequested()"
/>
<div

View File

@ -1,4 +1,5 @@
import {
CELLS_LIMIT,
DEFAULT_COLUMN_PREFIX,
NEW_COLUMN_ID,
ROW_INDEX_HEADER,
@ -14,6 +15,7 @@ import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { GetContextMenuItems, GetMainMenuItems } from 'ag-grid-enterprise'
import { expect, test, vi } from 'vitest'
import { assertDefined } from 'ydoc-shared/util/assert'
function suggestionDbWithNothing() {
const db = new SuggestionDb()
@ -21,9 +23,19 @@ function suggestionDbWithNothing() {
return db
}
function generateTableOfOnes(rows: number, cols: number) {
const code = `Table.new [${[...Array(cols).keys()].map((i) => `['Column #${i}', [${Array(rows).fill('1').join(',')}]]`).join(',')}]`
const ast = Ast.parseExpression(code)
assertDefined(ast)
return ast
}
const expectedRowIndexColumnDef = { headerName: ROW_INDEX_HEADER }
const expectedNewColumnDef = { cellStyle: { display: 'none' } }
const CELLS_LIMIT_SQRT = Math.sqrt(CELLS_LIMIT)
assert(CELLS_LIMIT_SQRT === Math.floor(CELLS_LIMIT_SQRT))
test.each([
{
code: 'Table.new [["a", [1, 2, 3]], ["b", [4, 5, "six"]], ["empty", [Nothing, Standard.Base.Nothing, Nothing]]]',
@ -79,7 +91,8 @@ test.each([
],
},
])('Read table from $code', ({ code, expectedColumnDefs, expectedRows }) => {
const ast = Ast.parse(code)
const ast = Ast.parseExpression(code)
assertDefined(ast)
expect(tableNewCallMayBeHandled(ast)).toBeTruthy()
const input = WidgetInput.FromAst(ast)
const startEdit = vi.fn()
@ -112,6 +125,54 @@ test.each([
expect(addMissingImports).not.toHaveBeenCalled()
})
test.each([
{
rows: Math.floor(CELLS_LIMIT / 2) + 1,
cols: 1,
expectNewRowEnabled: true,
expectNewColEnabled: false,
},
{
rows: 1,
cols: Math.floor(CELLS_LIMIT / 2) + 1,
expectNewRowEnabled: false,
expectNewColEnabled: true,
},
{
rows: 1,
cols: CELLS_LIMIT,
expectNewRowEnabled: false,
expectNewColEnabled: false,
},
{
rows: CELLS_LIMIT,
cols: 1,
expectNewRowEnabled: false,
expectNewColEnabled: false,
},
{
rows: CELLS_LIMIT_SQRT,
cols: CELLS_LIMIT_SQRT,
expectNewRowEnabled: false,
expectNewColEnabled: false,
},
])(
'Allowed actions in table near limit (rows: $rows, cols: $cols)',
({ rows, cols, expectNewRowEnabled, expectNewColEnabled }) => {
const input = WidgetInput.FromAst(generateTableOfOnes(rows, cols))
const tableNewArgs = useTableNewArgument(
input,
{ startEdit: vi.fn(), addMissingImports: vi.fn() },
suggestionDbWithNothing(),
vi.fn(),
)
expect(tableNewArgs.rowData.value.length).toBe(rows + (expectNewRowEnabled ? 1 : 0))
const lastColDef = tableNewArgs.columnDefs.value[tableNewArgs.columnDefs.value.length - 1]
assert(lastColDef?.headerComponentParams?.type === 'newColumn')
expect(lastColDef.headerComponentParams.enabled ?? true).toBe(expectNewColEnabled)
},
)
test.each([
'Table.new 14',
'Table.new array1',
@ -120,14 +181,16 @@ test.each([
"Table.new [['a', [123]], ['a'.repeat 170, [123]]]",
"Table.new [['a', [1, 2, 3, 3 + 1]]]",
])('"%s" is not valid input for Table Editor Widget', (code) => {
const ast = Ast.parse(code)
const ast = Ast.parseExpression(code)
assertDefined(ast)
expect(tableNewCallMayBeHandled(ast)).toBeFalsy()
})
function tableEditFixture(code: string, expectedCode: string) {
const ast = Ast.parseBlock(code)
const inputAst = [...ast.statements()][0]
assert(inputAst != null)
const firstStatement = [...ast.statements()][0]
assert(firstStatement instanceof Ast.MutableExpressionStatement)
const inputAst = firstStatement.expression
const input = WidgetInput.FromAst(inputAst)
const startEdit = vi.fn(() => ast.module.edit())
const onUpdate = vi.fn((update) => {
@ -517,3 +580,35 @@ test.each([
else expect(addMissingImports).not.toHaveBeenCalled()
},
)
test('Pasted data which would exceed cells limit is truncated', () => {
const initialRows = CELLS_LIMIT_SQRT - 2
const initialCols = CELLS_LIMIT_SQRT - 1
const ast = generateTableOfOnes(initialRows, initialCols)
const input = WidgetInput.FromAst(ast)
const startEdit = vi.fn(() => ast.module.edit())
const onUpdate = vi.fn((update) => {
const inputAst = update.edit!.getVersion(ast)
// We expect the table to be fully extended, so the number of cells (numbers or Nothings) should be equal to the limit.
let cellCount = 0
inputAst.visitRecursive((ast: Ast.Ast | Ast.Token) => {
if (ast instanceof Ast.Token) return
if (ast instanceof Ast.NumericLiteral || ast.code() === 'Nothing') cellCount++
})
expect(cellCount).toBe(CELLS_LIMIT)
})
const addMissingImports = vi.fn()
const tableNewArgs = useTableNewArgument(
input,
{ startEdit, addMissingImports },
suggestionDbWithNothing(),
onUpdate,
)
const focusedCol = tableNewArgs.columnDefs.value[initialCols - 2]
assert(focusedCol?.colId != null)
tableNewArgs.pasteFromClipboard(Array(4).fill(Array(4).fill('2')), {
rowIndex: initialRows - 2,
colId: focusedCol.colId,
})
expect(onUpdate).toHaveBeenCalledOnce()
})

View File

@ -23,6 +23,11 @@ export const ROW_INDEX_HEADER = '#'
export const DEFAULT_COLUMN_PREFIX = 'Column #'
const NOTHING_PATH = 'Standard.Base.Nothing.Nothing' as QualifiedName
const NOTHING_NAME = qnLastSegment(NOTHING_PATH)
/**
* The cells limit of the table; any modification which would exceed this limt should be
* disallowed in UI
*/
export const CELLS_LIMIT = 256
export type RowData = {
index: number
@ -45,7 +50,7 @@ export interface ColumnDef extends ColDef<RowData> {
namespace cellValueConversion {
/** Convert AST node to a value for Grid (to be returned from valueGetter, for example). */
export function astToAgGrid(ast: Ast.Ast) {
export function astToAgGrid(ast: Ast.Expression) {
if (ast instanceof Ast.TextLiteral) return Ok(ast.rawTextContent)
else if (ast instanceof Ast.Ident && ast.code() === NOTHING_NAME) return Ok(null)
else if (ast instanceof Ast.PropertyAccess && ast.rhs.code() === NOTHING_NAME) return Ok(null)
@ -64,7 +69,7 @@ namespace cellValueConversion {
export function agGridToAst(
value: unknown,
module: Ast.MutableModule,
): { ast: Ast.Owned; requireNothingImport: boolean } {
): { ast: Ast.Owned<Ast.MutableExpression>; requireNothingImport: boolean } {
if (value == null || value === '') {
return { ast: Ast.Ident.new(module, 'Nothing' as Ast.Identifier), requireNothingImport: true }
} else if (typeof value === 'number') {
@ -83,7 +88,7 @@ namespace cellValueConversion {
}
}
function retrieveColumnsAst(call: Ast.Ast) {
function retrieveColumnsAst(call: Ast.Expression): Result<Ast.Vector | undefined> {
if (!(call instanceof Ast.App)) return Ok(undefined)
if (call.argument instanceof Ast.Vector) return Ok(call.argument)
if (call.argument instanceof Ast.Wildcard) return Ok(undefined)
@ -91,7 +96,7 @@ function retrieveColumnsAst(call: Ast.Ast) {
}
function readColumn(
ast: Ast.Ast,
ast: Ast.Expression,
): Result<{ id: Ast.AstId; name: Ast.TextLiteral; data: Ast.Vector }> {
const errormsg = () => `${ast.code} is not a vector of two elements`
if (!(ast instanceof Ast.Vector)) return Err(errormsg())
@ -120,7 +125,7 @@ function retrieveColumnsDefinitions(columnsAst: Ast.Vector) {
*
* This widget may handle table definitions filled with literals or `Nothing` values.
*/
export function tableNewCallMayBeHandled(call: Ast.Ast) {
export function tableNewCallMayBeHandled(call: Ast.Expression) {
const columnsAst = retrieveColumnsAst(call)
if (!columnsAst.ok) return false
if (!columnsAst.value) return true // We can handle lack of the argument
@ -142,7 +147,7 @@ export function tableNewCallMayBeHandled(call: Ast.Ast) {
* @param onUpdate callback called when AGGrid was edited by user, resulting in AST change.
*/
export function useTableNewArgument(
input: ToValue<WidgetInput & { value: Ast.Ast }>,
input: ToValue<WidgetInput & { value: Ast.Expression }>,
graph: {
startEdit(): Ast.MutableModule
addMissingImports(edit: Ast.MutableModule, newImports: RequiredImport[]): void
@ -180,10 +185,28 @@ export function useTableNewArgument(
}
}
function mayAddNewRow(
rowCount_: number = rowCount.value,
colCount: number = columns.value.length,
): boolean {
return (rowCount_ + 1) * colCount <= CELLS_LIMIT
}
function mayAddNewColumn(
rowCount_: number = rowCount.value,
colCount: number = columns.value.length,
): boolean {
return rowCount_ * (colCount + 1) <= CELLS_LIMIT
}
function addRow(
edit: Ast.MutableModule,
valueGetter: (column: Ast.AstId, index: number) => unknown = () => null,
) {
if (!mayAddNewRow()) {
console.error(`Cannot add new row: the ${CELLS_LIMIT} limit of cells would be exceeded.`)
return
}
for (const [index, column] of columns.value.entries()) {
const editedCol = edit.getVersion(column.data)
editedCol.push(convertWithImport(valueGetter(column.data.id, index), edit))
@ -204,6 +227,10 @@ export function useTableNewArgument(
size: number = rowCount.value,
columns?: Ast.Vector,
) {
if (!mayAddNewColumn()) {
console.error(`Cannot add new column: the ${CELLS_LIMIT} limit of cells would be exceeded.`)
return
}
function* cellsGenerator() {
for (let i = 0; i < size; ++i) {
yield convertWithImport(valueGetter(i), edit)
@ -273,6 +300,7 @@ export function useTableNewArgument(
maxWidth: 40,
headerComponentParams: {
type: 'newColumn',
enabled: mayAddNewColumn(),
newColumnRequested: () => {
const edit = graph.startEdit()
fixColumns(edit)
@ -315,7 +343,7 @@ export function useTableNewArgument(
if (data == null) return undefined
const ast = toValue(input).value.module.tryGet(data.cells[col.data.id])
if (ast == null) return null
const value = cellValueConversion.astToAgGrid(ast)
const value = cellValueConversion.astToAgGrid(ast as Ast.Expression)
if (!value.ok) {
console.error(
`Cannot read \`${ast.code}\` as value in Table Widget; the Table widget should not be matched here!`,
@ -382,7 +410,9 @@ export function useTableNewArgument(
}
}
}
rows.push({ index: rows.length, cells: {} })
if (mayAddNewRow()) {
rows.push({ index: rows.length, cells: {} })
}
return rows
})
@ -434,7 +464,7 @@ export function useTableNewArgument(
}
function pasteFromClipboard(data: string[][], focusedCell: { rowIndex: number; colId: string }) {
if (data.length === 0) return
if (data.length === 0) return { rows: 0, columns: 0 }
const edit = graph.startEdit()
const focusedColIndex =
findIndexOpt(columns.value, ({ id }) => id === focusedCell.colId) ?? columns.value.length
@ -446,6 +476,9 @@ export function useTableNewArgument(
}
const pastedRowsEnd = focusedCell.rowIndex + data.length
const pastedColsEnd = focusedColIndex + data[0]!.length
// First we assume we'll paste all data. If not, these vars will be updated.
let actuallyPastedRowsEnd = pastedRowsEnd
let actuallyPastedColsEnd = pastedColsEnd
// Set data in existing cells.
for (
@ -467,11 +500,20 @@ export function useTableNewArgument(
// Extend the table if necessary.
const newRowCount = Math.max(pastedRowsEnd, rowCount.value)
for (let i = rowCount.value; i < newRowCount; ++i) {
if (!mayAddNewRow(i)) {
actuallyPastedRowsEnd = i
break
}
addRow(edit, (_colId, index) => newValueGetter(i, index))
}
const newColCount = Math.max(pastedColsEnd, columns.value.length)
let modifiedColumnsAst: Ast.Vector | undefined
for (let i = columns.value.length; i < newColCount; ++i) {
if (!mayAddNewColumn(newRowCount, i)) {
actuallyPastedColsEnd = i
break
}
modifiedColumnsAst = addColumn(
edit,
`${DEFAULT_COLUMN_PREFIX}${i + 1}`,
@ -481,7 +523,10 @@ export function useTableNewArgument(
)
}
onUpdate({ edit })
return
return {
rows: actuallyPastedRowsEnd - focusedCell.rowIndex,
columns: actuallyPastedColsEnd - focusedColIndex,
}
}
return {
@ -513,6 +558,8 @@ export function useTableNewArgument(
* If the pasted data are to be placed outside current table, the table is extended.
* @param data the clipboard data, as retrieved in `processDataFromClipboard`.
* @param focusedCell the currently focused cell: will become the left-top cell of pasted data.
* @returns number of actually pasted rows and columns; may be smaller than `data` size in case
* it would exceed {@link CELLS_LIMIT}.
*/
pasteFromClipboard,
}

View File

@ -19,7 +19,7 @@ const itemConfig = computed(() =>
const defaultItem = computed(() =>
props.input.dynamicConfig?.kind === 'Vector_Editor' ?
Ast.parse(props.input.dynamicConfig.item_default)
Ast.parseExpression(props.input.dynamicConfig.item_default)
: DEFAULT_ITEM.value,
)
@ -45,22 +45,27 @@ const value = computed({
const navigator = injectGraphNavigator(true)
function useChildEditForwarding(input: WatchSource<Ast.Ast | unknown>) {
function useChildEditForwarding(input: WatchSource<Ast.Expression | unknown>) {
let editStarted = false
const childEdit = shallowRef<{ origin: PortId; editedValue: Ast.Owned | string }>()
const childEdit = shallowRef<{
origin: PortId
editedValue: Ast.Owned<Ast.MutableExpression> | string
}>()
watchEffect(() => {
if (!editStarted && !childEdit.value) return
const inputValue = toValue(input)
if (!(inputValue instanceof Ast.Ast)) return
const editedAst = Ast.copyIntoNewModule(inputValue)
const editedAst = Ast.copyIntoNewModule(inputValue as Ast.Expression)
if (childEdit.value) {
const module = editedAst.module
const origin = childEdit.value.origin
const ast = isAstId(origin) ? module.tryGet(origin) : undefined
if (ast) {
const replacement = childEdit.value.editedValue
ast.replace(typeof replacement === 'string' ? Ast.parse(replacement, module) : replacement)
ast.replace(
typeof replacement === 'string' ? Ast.parseExpression(replacement, module)! : replacement,
)
}
}
editHandler.edit(editedAst)
@ -71,7 +76,7 @@ function useChildEditForwarding(input: WatchSource<Ast.Ast | unknown>) {
childEnded: (origin: PortId) => {
if (childEdit.value?.origin === origin) childEdit.value = undefined
},
edit: (origin: PortId, value: Ast.Owned | string) => {
edit: (origin: PortId, value: Ast.Owned<Ast.MutableExpression> | string) => {
// The ID is used to locate a subtree; if the port isn't identified by an AstId, the lookup will simply fail.
childEdit.value = { origin, editedValue: value }
},
@ -86,7 +91,7 @@ const editHandler = WidgetEditHandler.New('WidgetVector', props.input, {
edit,
})
function itemInput(ast: Ast.Ast): WidgetInput {
function itemInput(ast: Ast.Expression): WidgetInput {
return {
...WidgetInput.FromAst(ast),
dynamicConfig: itemConfig.value,
@ -118,11 +123,11 @@ const DEFAULT_ITEM = computed(() => Ast.Wildcard.new())
<ListWidget
v-model="value"
:newItem="newItem"
:getKey="(ast: Ast.Ast) => ast.id"
:getKey="(ast: Ast.Expression) => ast.id"
dragMimeType="application/x-enso-ast-node"
:toPlainText="(ast: Ast.Ast) => ast.code()"
:toDragPayload="(ast: Ast.Ast) => Ast.serialize(ast)"
:fromDragPayload="Ast.deserialize"
:toPlainText="(ast: Ast.Expression) => ast.code()"
:toDragPayload="(ast: Ast.Expression) => Ast.serializeExpression(ast)"
:fromDragPayload="Ast.deserializeExpression"
:toDragPosition="(p) => navigator?.clientToScenePos(p) ?? p"
class="WidgetVector"
contenteditable="false"

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon.vue'
import { useProjectStore } from '@/stores/project'
import StandaloneButton from './StandaloneButton.vue'
const project = useProjectStore()
function goToMain() {
project.executionContext.desiredStack = [project.executionContext.getStackBottom()]
}
</script>
<template>
<div class="GraphMissingView">
<SvgIcon class="header-icon" name="error" />
<span>The component you are viewing no longer exists.</span>
<StandaloneButton icon="home2" label="Go back" @click="goToMain" />
</div>
</template>
<style scoped>
.GraphMissingView {
background-image: linear-gradient(to bottom, #00000000, #00000030);
background-size: 100% 100%;
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
}
.header-icon {
--icon-size: 64px;
}
</style>

View File

@ -1,13 +1,14 @@
<script setup lang="ts">
import SvgButton from '@/components/SvgButton.vue'
import { useProjectStore } from '@/stores/project'
import ControlButtons from './ControlButtons.vue'
const project = useProjectStore()
</script>
<template>
<div class="RecordControl">
<div class="control left-end">
<ControlButtons class="RecordControl">
<template #left>
<SvgButton
title="Refresh"
class="iconButton"
@ -15,8 +16,8 @@ const project = useProjectStore()
draggable="false"
@click.stop="project.executionContext.recompute()"
/>
</div>
<div class="control right-end">
</template>
<template #right>
<SvgButton
title="Write All"
class="iconButton"
@ -24,41 +25,11 @@ const project = useProjectStore()
draggable="false"
@click.stop="project.executionContext.recompute('all', 'Live')"
/>
</div>
</div>
</template>
</ControlButtons>
</template>
<style scoped>
.RecordControl {
user-select: none;
display: flex;
place-items: center;
gap: 1px;
}
.control {
background: var(--color-frame-bg);
backdrop-filter: var(--blur-app-bg);
padding: 4px 4px;
width: 42px;
}
.left-end {
border-radius: var(--radius-full) 0 0 var(--radius-full);
.iconButton {
margin: 0 0 0 auto;
}
}
.right-end {
border-radius: 0 var(--radius-full) var(--radius-full) 0;
.iconButton {
margin: 0 auto 0 0;
}
}
.iconButton:active {
color: #ba4c40;
}

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import type { URLString } from '@/util/data/urlString'
import type { Icon } from '@/util/iconName'
import SvgButton from './SvgButton.vue'
const props = defineProps<{
icon?: Icon | URLString | undefined
label?: string | undefined
disabled?: boolean
title?: string | undefined
}>()
</script>
<template>
<div class="StandaloneButton">
<SvgButton v-bind="props" :name="icon" />
</div>
</template>
<style scoped>
.StandaloneButton {
background-color: var(--color-frame-bg);
padding: 4px;
border-radius: var(--radius-full);
}
</style>

View File

@ -5,7 +5,7 @@ import type { URLString } from '@/util/data/urlString'
import type { Icon } from '@/util/iconName'
const _props = defineProps<{
name: Icon | URLString
name?: Icon | URLString | undefined
label?: string | undefined
disabled?: boolean
title?: string | undefined
@ -14,7 +14,7 @@ const _props = defineProps<{
<template>
<MenuButton :disabled="disabled" class="SvgButton" :title="title">
<SvgIcon :name="name" />
<SvgIcon v-if="name" :name="name" />
<div v-if="label">{{ label }}</div>
</MenuButton>
</template>

View File

@ -3,6 +3,7 @@ import ExtendedMenu from '@/components/ExtendedMenu.vue'
import NavBreadcrumbs from '@/components/NavBreadcrumbs.vue'
import RecordControl from '@/components/RecordControl.vue'
import SelectionMenu from '@/components/SelectionMenu.vue'
import UndoRedoButtons from './UndoRedoButtons.vue'
const showColorPicker = defineModel<boolean>('showColorPicker', { required: true })
const showCodeEditor = defineModel<boolean>('showCodeEditor', { required: true })
@ -24,6 +25,7 @@ const emit = defineEmits<{
<div class="TopBar">
<NavBreadcrumbs />
<RecordControl />
<UndoRedoButtons />
<Transition name="selection-menu">
<SelectionMenu
v-if="componentsSelected > 1"

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import SvgButton from '@/components/SvgButton.vue'
import { useGraphStore } from '@/stores/graph'
import ControlButtons from './ControlButtons.vue'
const graphStore = useGraphStore()
</script>
<template>
<ControlButtons class="UndoRedoButtons">
<template #left>
<SvgButton
title="Undo"
class="iconButton"
name="undo"
draggable="false"
:disabled="!graphStore.undoManager.canUndo.value"
@click.stop="graphStore.undoManager.undo"
/>
</template>
<template #right>
<SvgButton
title="Redo"
class="iconButton"
name="redo"
draggable="false"
:disabled="!graphStore.undoManager.canRedo.value"
@click.stop="graphStore.undoManager.redo"
/>
</template>
</ControlButtons>
</template>

View File

@ -19,7 +19,6 @@ export interface LexicalPlugin {
/** TODO: Add docs */
export function lexicalTheme(theme: Record<string, string>): EditorThemeClasses {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface EditorThemeShape extends Record<string, EditorThemeShape | string> {}
const editorClasses: EditorThemeShape = {}
for (const [classPath, className] of Object.entries(theme)) {

View File

@ -16,7 +16,9 @@ const { data } = defineProps<{ data: unknown }>()
const config = useVisualizationConfig()
type ConstructivePattern = (placeholder: Ast.Owned) => Ast.Owned
type ConstructivePattern = (
placeholder: Ast.Owned<Ast.MutableExpression>,
) => Ast.Owned<Ast.MutableExpression>
const JSON_OBJECT_TYPE = 'Standard.Base.Data.Json.JS_Object'
@ -26,7 +28,7 @@ function projector(parentPattern: ConstructivePattern | undefined) {
const style = {
spaced: parentPattern !== undefined,
}
return (selector: number | string) => (source: Ast.Owned) =>
return (selector: number | string) => (source: Ast.Owned<Ast.MutableExpression>) =>
Ast.App.positional(
Ast.PropertyAccess.new(
source.module,

View File

@ -572,7 +572,7 @@ function getPlotData(data: Data) {
return data.data
}
const filterPattern = computed(() => Pattern.parse('__ (..Between __ __)'))
const filterPattern = computed(() => Pattern.parseExpression('__ (..Between __ __)'))
const makeFilterPattern = (
module: Ast.MutableModule,
columnName: string,
@ -596,24 +596,24 @@ function getAstPatternFilterAndSort(
minY: number,
maxY: number,
) {
return Pattern.new((ast) => {
let pattern: Ast.Owned<Ast.MutableOprApp> | Ast.Owned<Ast.MutableApp> = Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('filter')!),
makeFilterPattern(ast.module, xColName, minX, maxX),
)
for (const s of series) {
pattern = Ast.OprApp.new(
ast.module,
pattern,
'.',
Ast.App.positional(
Ast.Ident.new(ast.module, Ast.identifier('filter')!),
makeFilterPattern(ast.module, s!, minY, maxY),
return Pattern.new<Ast.Expression>((ast) =>
series.reduce<Ast.Owned<Ast.MutableExpression>>(
(pattern, s) =>
Ast.OprApp.new(
ast.module,
pattern,
'.',
Ast.App.positional(
Ast.Ident.new(ast.module, Ast.identifier('filter')!),
makeFilterPattern(ast.module, s!, minY, maxY),
),
),
)
}
return pattern
})
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('filter')!),
makeFilterPattern(ast.module, xColName, minX, maxX),
),
),
)
}
const createNewFilterNode = () => {
const seriesLabels = Object.keys(data.value.axis)
@ -639,7 +639,7 @@ const createNewFilterNode = () => {
function getAstPattern(selector?: number, action?: string) {
if (action && selector != null) {
return Pattern.new((ast) =>
return Pattern.new<Ast.Expression>((ast) =>
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier(action)!),
Ast.tryNumberToEnso(selector, ast.module)!,

View File

@ -367,7 +367,7 @@ function toRowField(name: string, valueType?: ValueType | null | undefined) {
function getAstPattern(selector?: string | number, action?: string) {
if (action && selector != null) {
return Pattern.new((ast) =>
return Pattern.new<Ast.Expression>((ast) =>
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier(action)!),
typeof selector === 'number' ?

View File

@ -13,7 +13,9 @@ export const defaultPreprocessor = [
] as const
const removeWarnings = computed(() =>
Pattern.new((ast) => Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('remove_warnings')!)),
Pattern.new<Ast.Expression>((ast) =>
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('remove_warnings')!),
),
)
</script>

View File

@ -39,7 +39,7 @@ function useSortFilterNodesButton({
isFilterSortNodeEnabled,
createNodes,
}: SortFilterNodesButtonOptions): ComputedRef<ToolbarItem | undefined> {
const sortPatternPattern = computed(() => Pattern.parse('(..Name __ __ )'))
const sortPatternPattern = computed(() => Pattern.parseExpression('(..Name __ __ )')!)
const sortDirection = computed(() => ({
asc: '..Ascending',
@ -53,36 +53,36 @@ function useSortFilterNodesButton({
.map((sort) =>
sortPatternPattern.value.instantiateCopied([
Ast.TextLiteral.new(sort.columnName),
Ast.parse(sortDirection.value[sort.sortDirection as SortDirection]),
Ast.parseExpression(sortDirection.value[sort.sortDirection as SortDirection])!,
]),
)
return Ast.Vector.new(module, columnSortExpressions)
}
const filterPattern = computed(() => Pattern.parse('__ (__ __)'))
const filterPattern = computed(() => Pattern.parseExpression('__ (__ __)')!)
function makeFilterPattern(module: Ast.MutableModule, columnName: string, items: string[]) {
if (
(items?.length === 1 && items.indexOf('true') != -1) ||
(items?.length === 1 && items.indexOf('false') != -1)
) {
const boolToInclude = items.indexOf('false') != -1 ? Ast.parse('False') : Ast.parse('True')
const boolToInclude = Ast.Ident.tryParse(items.indexOf('false') != -1 ? 'False' : 'True')!
return filterPattern.value.instantiateCopied([
Ast.TextLiteral.new(columnName),
Ast.parse('..Equal'),
Ast.parseExpression('..Equal')!,
boolToInclude,
])
}
const itemList = items.map((i) => Ast.TextLiteral.new(i))
return filterPattern.value.instantiateCopied([
Ast.TextLiteral.new(columnName),
Ast.parse('..Is_In'),
Ast.parseExpression('..Is_In')!,
Ast.Vector.new(module, itemList),
])
}
function getAstPatternSort() {
return Pattern.new((ast) =>
return Pattern.new<Ast.Expression>((ast) =>
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('sort')!),
makeSortPattern(ast.module),
@ -91,7 +91,7 @@ function useSortFilterNodesButton({
}
function getAstPatternFilter(columnName: string, items: string[]) {
return Pattern.new((ast) =>
return Pattern.new<Ast.Expression>((ast) =>
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('filter')!),
makeFilterPattern(ast.module, columnName, items),
@ -100,7 +100,7 @@ function useSortFilterNodesButton({
}
function getAstPatternFilterAndSort(columnName: string, items: string[]) {
return Pattern.new((ast) =>
return Pattern.new<Ast.Expression>((ast) =>
Ast.OprApp.new(
ast.module,
Ast.App.positional(

View File

@ -15,7 +15,7 @@ test.each([
])('New node location in block', (...linesWithInsertionPoint: string[]) => {
const inputLines = linesWithInsertionPoint.filter((line) => line !== '*')
const bodyBlock = Ast.parseBlock(inputLines.join('\n'))
insertNodeStatements(bodyBlock, [Ast.parse('newNodePositionMarker')])
insertNodeStatements(bodyBlock, [Ast.parseStatement('newNodePositionMarker')!])
const lines = bodyBlock
.code()
.split('\n')
@ -26,11 +26,13 @@ test.each([
// This is a special case because when a block is empty, adding a line requires adding *two* linebreaks.
test('Adding node to empty block', () => {
const module = Ast.MutableModule.Transient()
const func = Ast.Function.fromStatements(module, identifier('f')!, [], [])
const func = Ast.Function.new(identifier('f')!, [], Ast.BodyBlock.new([], module), {
edit: module,
})
const rootBlock = Ast.BodyBlock.new([], module)
rootBlock.push(func)
expect(rootBlock.code().trimEnd()).toBe('f =')
insertNodeStatements(func.bodyAsBlock(), [Ast.parse('newNode')])
insertNodeStatements(func.bodyAsBlock(), [Ast.parseStatement('newNode')!])
expect(
rootBlock
.code()

View File

@ -1,26 +1,31 @@
import { type GraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast'
import { type ToValue } from '@/util/reactivity'
import { computed, toValue } from 'vue'
import type { Ast } from 'ydoc-shared/ast'
/** A composable for reactively retrieving and setting documentation from given Ast node. */
export function useAstDocumentation(graphStore: GraphStore, ast: ToValue<Ast | undefined>) {
export function useAstDocumentation(graphStore: GraphStore, ast: ToValue<Ast.Ast | undefined>) {
return {
documentation: {
state: computed(() => toValue(ast)?.documentingAncestor()?.documentation() ?? ''),
set: (value: string) => {
state: computed(() => {
const astValue = toValue(ast)
if (!astValue) return
if (value.trimStart() !== '') {
graphStore.getMutable(astValue).getOrInitDocumentation().setDocumentationText(value)
} else {
// Remove the documentation node.
const documented = astValue.documentingAncestor()
if (documented && documented.expression)
graphStore.edit((edit) =>
edit.getVersion(documented).update((documented) => documented.expression!.take()),
)
}
return (astValue?.isStatement() ? astValue.documentationText() : undefined) ?? ''
}),
set: (text: string | undefined) => {
const astValue = toValue(ast)
graphStore.edit((edit) => {
if (astValue?.isStatement()) {
const editAst = edit.getVersion(astValue)
// If the statement can have documentation attached (for example, it is a `Function`, `Assignment`, or
// `ExpressionStatement`), do so. If in cannot (for example, it is an `import` declaration), an error will
// be reported below.
if ('setDocumentationText' in editAst) {
editAst.setDocumentationText(text)
return
}
}
console.error('Unable to set documentation', astValue?.id)
})
},
},
}

View File

@ -126,9 +126,13 @@ export function useNodeCreation(
const createdIdentifiers = new Set<Identifier>()
const identifiersRenameMap = new Map<Identifier, Identifier>()
graphStore.edit((edit) => {
const statements = new Array<Ast.Owned>()
const statements = new Array<Ast.Owned<Ast.MutableStatement>>()
for (const options of placedNodes) {
const rhs = Ast.parse(options.expression, edit)
const rhs = Ast.parseExpression(options.expression, edit)
if (!rhs) {
console.error('Cannot create node: invalid expression', options.expression)
continue
}
const ident = getIdentifier(rhs, options, createdIdentifiers)
createdIdentifiers.add(ident)
const { id, rootExpression } = newAssignmentNode(
@ -192,19 +196,16 @@ export function useNodeCreation(
function newAssignmentNode(
edit: Ast.MutableModule,
ident: Ast.Identifier,
rhs: Ast.Owned,
rhs: Ast.Owned<Ast.MutableExpression>,
options: NodeCreationOptions,
identifiersRenameMap: Map<Ast.Identifier, Ast.Identifier>,
) {
rhs.setNodeMetadata(options.metadata ?? {})
const assignment = Ast.Assignment.new(edit, ident, rhs)
const { documentation } = options
const assignment = Ast.Assignment.new(ident, rhs, { edit, documentation })
afterCreation(edit, assignment, ident, options, identifiersRenameMap)
const id = asNodeId(rhs.externalId)
const rootExpression =
options.documentation != null ?
Ast.Documented.new(options.documentation, assignment)
: assignment
return { rootExpression, id }
return { rootExpression: assignment, id }
}
function getIdentifier(
@ -270,10 +271,14 @@ function existingNameToPrefix(name: string): string {
* The location will be after any statements in the block that bind identifiers; if the block ends in an expression
* statement, the location will be before it so that the value of the block will not be affected.
*/
export function insertNodeStatements(bodyBlock: Ast.MutableBodyBlock, statements: Ast.Owned[]) {
export function insertNodeStatements(
bodyBlock: Ast.MutableBodyBlock,
statements: Ast.Owned<Ast.MutableStatement>[],
) {
const lines = bodyBlock.lines
const lastStatement = lines[lines.length - 1]?.statement?.node
const index =
lines[lines.length - 1]?.expression?.node.isBindingStatement !== false ?
lastStatement instanceof Ast.MutableAssignment || lastStatement instanceof Ast.MutableFunction ?
lines.length
: lines.length - 1
bodyBlock.insert(index, ...statements)

View File

@ -1,7 +1,6 @@
import type { BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
import { type GraphStore, type NodeId } from '@/stores/graph'
import { type ProjectStore } from '@/stores/project'
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import { computed, onMounted, ref } from 'vue'
import { methodPointerEquals, type StackItem } from 'ydoc-shared/languageServerTypes'
@ -45,19 +44,8 @@ export function useStackNavigator(projectStore: ProjectStore, graphStore: GraphS
}
function enterNode(id: NodeId) {
const expressionInfo = graphStore.db.getExpressionInfo(id)
if (expressionInfo == null || expressionInfo.methodCall == null) {
console.debug('Cannot enter node that has no method call.')
return
}
const definedOnType = tryQualifiedName(expressionInfo.methodCall.methodPointer.definedOnType)
if (!projectStore.modulePath?.ok) {
console.warn('Cannot enter node while no module is open.')
return
}
const openModuleName = qnLastSegment(projectStore.modulePath.value)
if (definedOnType.ok && qnLastSegment(definedOnType.value) !== openModuleName) {
console.debug('Cannot enter node that is not defined on current module.')
if (!graphStore.nodeCanBeEntered(id)) {
console.warn('Trying to enter a node that cannot be entered.')
return
}
projectStore.executionContext.push(id)

View File

@ -459,8 +459,10 @@ export const mockLSHandler: MockTransportData = async (method, data, transport)
expressionId: ExpressionId
expression: string
}
const aiPromptPat = Pattern.parse('Standard.Visualization.AI.build_ai_prompt __ . to_json')
const exprAst = Ast.parse(data_.expression)
const aiPromptPat = Pattern.parseExpression(
'Standard.Visualization.AI.build_ai_prompt __ . to_json',
)
const exprAst = Ast.parseExpression(data_.expression)!
if (aiPromptPat.test(exprAst)) {
sendVizUpdate(
data_.visualizationId,

View File

@ -56,8 +56,8 @@ describe('WidgetRegistry', () => {
}),
)
const someAst = WidgetInput.FromAst(Ast.parse('foo'))
const blankAst = WidgetInput.FromAst(Ast.parse('_'))
const someAst = WidgetInput.FromAst(Ast.parseExpression('foo'))
const blankAst = WidgetInput.FromAst(Ast.parseExpression('_'))
const someArgPlaceholder: WidgetInput = {
portId: '57d429dc-df85-49f8-b150-567c7d1fb502' as PortId,
value: 'bar',

View File

@ -12,26 +12,25 @@ import type { WidgetEditHandlerParent } from './widgetRegistry/editHandler'
export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
export namespace WidgetInput {
/** TODO: Add docs */
export function FromAst<A extends Ast.Ast | Ast.Token>(ast: A): WidgetInput & { value: A } {
return {
portId: ast.id,
value: ast,
}
}
/** TODO: Add docs */
export function FromAstWithPort<A extends Ast.Ast | Ast.Token>(
/** Returns widget-input data for the given AST expression or token. */
export function FromAst<A extends Ast.Expression | Ast.Token>(
ast: A,
): WidgetInput & { value: A } {
return {
portId: ast.id,
value: ast,
}
}
/** Returns the input marked to be a port. */
export function WithPort<T extends WidgetInput>(input: T): T {
return {
...input,
forcePort: true,
}
}
/** TODO: Add docs */
/** A string representation of widget's value - the code in case of AST value. */
export function valueRepr(input: WidgetInput): string | undefined {
if (typeof input.value === 'string') return input.value
else return input.value?.code()
@ -56,27 +55,27 @@ export namespace WidgetInput {
isPlaceholder(input) || input.value instanceof nodeType
}
/** TODO: Add docs */
export function isAst(input: WidgetInput): input is WidgetInput & { value: Ast.Ast } {
return input.value instanceof Ast.Ast
/** Check if input's value is existing AST node (not placeholder or token). */
export function isAst(input: WidgetInput): input is WidgetInput & { value: Ast.Expression } {
return input.value instanceof Ast.Ast && input.value.isExpression()
}
/** Rule out token inputs. */
/** Check if input's value is existing AST node or placeholder. Rule out token inputs. */
export function isAstOrPlaceholder(
input: WidgetInput,
): input is WidgetInput & { value: Ast.Ast | string | undefined } {
): input is WidgetInput & { value: Ast.Expression | string | undefined } {
return isPlaceholder(input) || isAst(input)
}
/** TODO: Add docs */
/** Check if input's value is an AST token. */
export function isToken(input: WidgetInput): input is WidgetInput & { value: Ast.Token } {
return input.value instanceof Ast.Token
}
/** TODO: Add docs */
export function isFunctionCall(
input: WidgetInput,
): input is WidgetInput & { value: Ast.App | Ast.Ident | Ast.PropertyAccess | Ast.OprApp } {
/** Check if input's value is an AST which potentially may be a function call. */
export function isFunctionCall(input: WidgetInput): input is WidgetInput & {
value: Ast.App | Ast.Ident | Ast.PropertyAccess | Ast.OprApp | Ast.AutoscopedIdentifier
} {
return (
input.value instanceof Ast.App ||
input.value instanceof Ast.Ident ||
@ -119,10 +118,10 @@ export interface WidgetInput {
*/
portId: PortId
/**
* An expected widget value. If Ast.Ast or Ast.Token, the widget represents an existing part of
* An expected widget value. If Ast.Expression or Ast.Token, the widget represents an existing part of
* code. If string, it may be e.g. a default value of an argument.
*/
value: Ast.Ast | Ast.Token | string | undefined
value: Ast.Expression | Ast.Token | string | undefined
/** An expected type which widget should set. */
expectedType?: Typename | undefined
/** Configuration provided by engine. */
@ -163,15 +162,18 @@ export interface WidgetProps<T> {
* port may not represent any existing AST node) with `edit` containing any additional modifications
* (like inserting necessary imports).
*
* The same way widgets may set their metadata (as this is also technically an AST modification).
* Every widget type should set it's name as `metadataKey`.
*
* The handlers interested in a specific port update should apply it using received edit. The edit
* is committed in {@link NodeWidgetTree}.
*/
export interface WidgetUpdate {
edit?: MutableModule | undefined
portUpdate?: {
value: Ast.Owned | string | undefined
origin: PortId
}
portUpdate?: { origin: PortId } & (
| { value: Ast.Owned<Ast.MutableExpression> | string | undefined }
| { metadataKey: string; metadata: unknown }
)
}
/**

View File

@ -60,7 +60,7 @@ export abstract class WidgetEditHandlerParent {
this.parent?.unsetActiveChild(this)
}
protected onEdit(origin: PortId, value: Ast.Owned | string): void {
protected onEdit(origin: PortId, value: Ast.Owned<Ast.MutableExpression> | string): void {
this.hooks.edit?.(origin, value)
this.parent?.onEdit(origin, value)
}
@ -265,8 +265,8 @@ export class WidgetEditHandler extends WidgetEditHandlerParent {
this.onStart(this.portId)
}
/** TODO: Add docs */
edit(value: Ast.Owned | string) {
/** Emit an event updating the widget's value. */
edit(value: Ast.Owned<Ast.MutableExpression> | string) {
this.onEdit(this.portId, value)
}
}
@ -281,7 +281,7 @@ export interface WidgetEditHooks extends Interaction {
end?(origin?: PortId | undefined): void
childEnded?(origin?: PortId | undefined): void
/** Hook called when a child widget provides an updated value. */
edit?(origin: PortId, value: Ast.Owned | string): void
edit?(origin: PortId, value: Ast.Owned<Ast.MutableExpression> | string): void
/**
* Hook enabling a widget to provide a handler for the add-item intent of a child widget. The parent can return true
* to indicate that creating the new item has been handled and the child should not perform its action in this case.

View File

@ -10,7 +10,7 @@ export { injectFn as injectWidgetTree, provideFn as provideWidgetTree }
const { provideFn, injectFn } = createContextStore(
'Widget tree',
(
astRoot: Ref<Ast.Ast>,
astRoot: Ref<Ast.Expression>,
nodeId: Ref<NodeId>,
nodeElement: Ref<HTMLElement | undefined>,
nodeSize: Ref<Vec2>,

View File

@ -25,7 +25,7 @@ export function parseWithSpans<T extends Record<string, SourceRange>>(code: stri
const { root: ast, toRaw, getSpan } = Ast.parseExtended(code, idMap)
const idFromExternal = new Map<ExternalId, AstId>()
ast.visitRecursiveAst((ast) => {
ast.visitRecursive((ast) => {
idFromExternal.set(ast.externalId, ast.id)
})
const id = (name: keyof T) => idFromExternal.get(eid(name))!

View File

@ -5,14 +5,16 @@ import type { SuggestionEntry } from '@/stores/suggestionDatabase/entry'
import { assert } from '@/util/assert'
import { Ast, RawAst } from '@/util/ast'
import type { AstId, NodeMetadata } from '@/util/ast/abstract'
import { autospaced, MutableModule } from '@/util/ast/abstract'
import { MutableModule } from '@/util/ast/abstract'
import { AliasAnalyzer } from '@/util/ast/aliasAnalysis'
import { inputNodeFromAst, nodeFromAst, nodeRootExpr } from '@/util/ast/node'
import { MappedKeyMap, MappedSet } from '@/util/containers'
import { tryGetIndex } from '@/util/data/array'
import { recordEqual } from '@/util/data/object'
import { unwrap } from '@/util/data/result'
import { Vec2 } from '@/util/data/vec2'
import { ReactiveDb, ReactiveIndex, ReactiveMapping } from '@/util/database/reactiveDb'
import { tryIdentifier } from '@/util/qualifiedName'
import {
nonReactiveView,
resumeReactivity,
@ -67,8 +69,10 @@ export class BindingsDb {
// Add or update bindings.
for (const [bindingRange, usagesRanges] of analyzer.aliases) {
const aliasAst = bindingRangeToTree.get(bindingRange)
assert(aliasAst != null)
if (aliasAst == null) continue
if (aliasAst == null) {
console.warn(`Binding not found`, bindingRange)
continue
}
const aliasAstId = aliasAst.id
const info = this.bindings.get(aliasAstId)
if (info == null) {
@ -121,7 +125,7 @@ export class BindingsDb {
bindingRanges.add(binding)
for (const usage of usages) bindingRanges.add(usage)
}
ast.visitRecursiveAst((ast) => {
ast.visitRecursive((ast) => {
const span = getSpan(ast.id)
assert(span != null)
if (bindingRanges.has(span)) {
@ -153,13 +157,13 @@ export class GraphDb {
private nodeIdToPatternExprIds = new ReactiveIndex(this.nodeIdToNode, (id, entry) => {
const exprs: AstId[] = []
if (entry.pattern) entry.pattern.visitRecursiveAst((ast) => void exprs.push(ast.id))
if (entry.pattern) entry.pattern.visitRecursive((ast) => void exprs.push(ast.id))
return Array.from(exprs, (expr) => [id, expr])
})
private nodeIdToExprIds = new ReactiveIndex(this.nodeIdToNode, (id, entry) => {
const exprs: AstId[] = []
entry.innerExpr.visitRecursiveAst((ast) => void exprs.push(ast.id))
entry.innerExpr.visitRecursive((ast) => void exprs.push(ast.id))
return Array.from(exprs, (expr) => [id, expr])
})
@ -195,7 +199,7 @@ export class GraphDb {
nodeOutputPorts = new ReactiveIndex(this.nodeIdToNode, (id, entry) => {
if (entry.pattern == null) return []
const ports = new Set<AstId>()
entry.pattern.visitRecursiveAst((ast) => {
entry.pattern.visitRecursive((ast) => {
if (this.bindings.bindings.has(ast.id)) {
ports.add(ast.id)
return false
@ -350,7 +354,7 @@ export class GraphDb {
const args = functionAst_.argumentDefinitions
const update = (
nodeId: NodeId,
ast: Ast.Ast,
ast: Ast.Expression | Ast.Statement,
isInput: boolean,
isOutput: boolean,
argIndex: number | undefined,
@ -383,7 +387,7 @@ export class GraphDb {
update(nodeId, argPattern, true, false, index)
})
body.forEach((outerAst, index) => {
const nodeId = nodeIdFromOuterExpr(outerAst)
const nodeId = nodeIdFromOuterAst(outerAst)
if (!nodeId) return
const isLastInBlock = index === body.length - 1
update(nodeId, outerAst, false, isLastInBlock, undefined)
@ -400,12 +404,15 @@ export class GraphDb {
/** Scan a node's content from its outer expression down to, but not including, its inner expression. */
private updateNodeStructure(
nodeId: NodeId,
ast: Ast.Ast,
ast: Ast.Statement | Ast.Expression,
isOutput: boolean,
isInput: boolean,
argIndex?: number,
) {
const newNode = isInput ? inputNodeFromAst(ast, argIndex ?? 0) : nodeFromAst(ast, isOutput)
const newNode =
isInput ?
inputNodeFromAst(ast as Ast.Expression, argIndex ?? 0)
: nodeFromAst(ast as Ast.Statement, isOutput)
if (!newNode) return
const oldNode = this.nodeIdToNode.getUntracked(nodeId)
if (oldNode == null) {
@ -424,14 +431,13 @@ export class GraphDb {
} else {
const {
type,
outerExpr,
outerAst,
pattern,
rootExpr,
innerExpr,
primarySubject,
prefixes,
conditionalPorts,
docs,
argIndex,
} = newNode
const node = resumeReactivity(oldNode)
@ -440,7 +446,7 @@ export class GraphDb {
const updateAst = (field: NodeAstField) => {
if (oldNode[field]?.id !== newNode[field]?.id) node[field] = newNode[field] as any
}
const astFields: NodeAstField[] = ['outerExpr', 'pattern', 'rootExpr', 'innerExpr', 'docs']
const astFields: NodeAstField[] = ['outerAst', 'pattern', 'rootExpr', 'innerExpr']
astFields.forEach(updateAst)
if (oldNode.primarySubject !== primarySubject) node.primarySubject = primarySubject
if (!recordEqual(oldNode.prefixes, prefixes)) node.prefixes = prefixes
@ -448,14 +454,13 @@ export class GraphDb {
// Ensure new fields can't be added to `NodeAstData` without this code being updated.
const _allFieldsHandled = {
type,
outerExpr,
outerAst,
pattern,
rootExpr,
innerExpr,
primarySubject,
prefixes,
conditionalPorts,
docs,
argIndex,
} satisfies NodeDataFromAst
}
@ -475,7 +480,7 @@ export class GraphDb {
updateExternalIds(topLevel: Ast.Ast) {
const idToExternalNew = new Map()
const idFromExternalNew = new Map()
topLevel.visitRecursiveAst((ast) => {
topLevel.visitRecursive((ast) => {
idToExternalNew.set(ast.id, ast.externalId)
idFromExternalNew.set(ast.externalId, ast.id)
})
@ -540,14 +545,10 @@ export class GraphDb {
/** TODO: Add docs */
mockNode(binding: string, id: NodeId, code?: string): Node {
const edit = MutableModule.Transient()
const pattern = Ast.parse(binding, edit)
const expression = Ast.parse(code ?? '0', edit)
const outerExpr = Ast.Assignment.concrete(
edit,
autospaced(pattern),
{ node: Ast.Token.new('='), whitespace: ' ' },
{ node: expression, whitespace: ' ' },
)
const ident = unwrap(tryIdentifier(binding))
const expression = Ast.parseExpression(code ?? '0', edit)!
const outerAst = Ast.Assignment.new(ident, expression, { edit })
const pattern = outerAst.pattern
const node: Node = {
type: 'component',
@ -557,11 +558,10 @@ export class GraphDb {
primarySubject: undefined,
colorOverride: undefined,
conditionalPorts: new Set(),
docs: undefined,
outerExpr,
outerAst,
pattern,
rootExpr: Ast.parse(code ?? '0'),
innerExpr: Ast.parse(code ?? '0'),
rootExpr: expression,
innerExpr: expression,
zIndex: this.highestZIndex,
argIndex: undefined,
}
@ -574,7 +574,7 @@ export class GraphDb {
/** Source code data of the specific node. */
interface NodeSource {
/** The outer AST of the node (see {@link NodeDataFromAst.outerExpr}). */
/** The outer AST of the node (see {@link NodeDataFromAst.outerAst}). */
outerAst: Ast.Ast
/**
* Whether the node is `output` of the function or not. Mutually exclusive with `isInput`.
@ -602,28 +602,37 @@ export function asNodeId(id: ExternalId | undefined): NodeId | undefined {
return id != null ? (id as NodeId) : undefined
}
/** Given an expression at the top level of a block, return the `NodeId` for the expression. */
export function nodeIdFromOuterExpr(outerExpr: Ast.Ast) {
const { root } = nodeRootExpr(outerExpr)
/** Given the outermost AST for a node, returns its {@link NodeId}. */
export function nodeIdFromOuterAst(outerAst: Ast.Statement | Ast.Expression) {
const { root } = nodeRootExpr(outerAst)
return root && asNodeId(root.externalId)
}
export interface NodeDataFromAst {
type: NodeType
/** The outer expression, usually an assignment expression (`a = b`). */
outerExpr: Ast.Ast
/** The left side of the assignment expression, if `outerExpr` is an assignment expression. */
pattern: Ast.Ast | undefined
/**
* The value of the node. The right side of the assignment, if `outerExpr` is an assignment
* expression, else the entire `outerExpr`.
* The statement or top-level expression.
*
* If the function has a body block, the nodes derived from the block are statements:
* - Assignment expressions (`a = b`)
* - Expression-statements (unnamed nodes and output nodes)
* If the function has a single-line body, the corresponding node will be an expression.
*
* Nodes for the function's inputs have (pattern) expressions as their outer ASTs.
*/
rootExpr: Ast.Ast
outerAst: Ast.Statement | Ast.Expression
/** The left side of the assignment expression, if `outerAst` is an assignment expression. */
pattern: Ast.Expression | undefined
/**
* The value of the node. The right side of the assignment, if `outerAst` is an assignment
* expression, else the entire `outerAst`.
*/
rootExpr: Ast.Expression
/**
* The expression displayed by the node. This is `rootExpr`, minus the prefixes, which are in
* `prefixes`.
*/
innerExpr: Ast.Ast
innerExpr: Ast.Expression
/**
Prefixes that are present in `rootExpr` but omitted in `innerExpr` to ensure a clean output.
*/
@ -632,8 +641,6 @@ export interface NodeDataFromAst {
primarySubject: Ast.AstId | undefined
/** Ports that are not targetable by default; they can be targeted while holding the modifier key. */
conditionalPorts: Set<Ast.AstId>
/** An AST node containing the node's documentation comment. */
docs: Ast.Documented | undefined
/** The index of the argument in the function's argument list, if the node is an input node. */
argIndex: number | undefined
}

View File

@ -99,9 +99,9 @@ export interface UnqualifiedImport {
}
/** Read imports from given module block */
export function readImports(ast: Ast.Ast): Import[] {
export function readImports(ast: Ast.BodyBlock): Import[] {
const imports: Import[] = []
ast.visitRecursiveAst((node) => {
ast.visitRecursive((node) => {
if (node instanceof Ast.Import) {
const recognized = recognizeImport(node)
if (recognized) {
@ -132,8 +132,8 @@ function newImportsLocation(scope: Ast.BodyBlock): number {
const lines = scope.lines
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!
if (line.expression) {
if (line.expression.node?.innerExpression() instanceof Ast.Import) {
if (line.statement) {
if (line.statement.node instanceof Ast.Import) {
lastImport = i
} else {
break

View File

@ -2,7 +2,7 @@ import { usePlacement } from '@/components/ComponentBrowser/placement'
import { createContextStore } from '@/providers'
import type { PortId } from '@/providers/portInfo'
import type { WidgetUpdate } from '@/providers/widgetRegistry'
import { GraphDb, nodeIdFromOuterExpr, type NodeId } from '@/stores/graph/graphDatabase'
import { GraphDb, nodeIdFromOuterAst, type NodeId } from '@/stores/graph/graphDatabase'
import {
addImports,
detectImportConflicts,
@ -22,10 +22,11 @@ import { isAstId, isIdentifier } from '@/util/ast/abstract'
import { RawAst, visitRecursive } from '@/util/ast/raw'
import { reactiveModule } from '@/util/ast/reactive'
import { partition } from '@/util/data/array'
import { Events, stringUnionToArray } from '@/util/data/observable'
import { Rect } from '@/util/data/rect'
import { Err, Ok, mapOk, unwrap, type Result } from '@/util/data/result'
import { Err, mapOk, Ok, unwrap, type Result } from '@/util/data/result'
import { Vec2 } from '@/util/data/vec2'
import { normalizeQualifiedName, tryQualifiedName } from '@/util/qualifiedName'
import { normalizeQualifiedName, qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import { useWatchContext } from '@/util/reactivity'
import { computedAsync } from '@vueuse/core'
import { map, set } from 'lib0'
@ -58,6 +59,7 @@ import type {
VisualizationMetadata,
} from 'ydoc-shared/yjsModel'
import { defaultLocalOrigin, sourceRangeKey, visMetadataEquals } from 'ydoc-shared/yjsModel'
import { UndoManager } from 'yjs'
const FALLBACK_BINDING_PREFIX = 'node'
@ -221,7 +223,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
return Err('Method pointer is not a module method')
const method = Ast.findModuleMethod(topLevel, ptr.name)
if (!method) return Err(`No method with name ${ptr.name} in ${modulePath.value}`)
return Ok(method)
return Ok(method.statement)
}
/**
@ -328,8 +330,8 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
updatePortValue(edit, usage, undefined)
}
const outerExpr = edit.getVersion(node.outerExpr)
if (outerExpr) Ast.deleteFromParentBlock(outerExpr)
const outerAst = edit.getVersion(node.outerAst)
if (outerAst.isStatement()) Ast.deleteFromParentBlock(outerAst)
nodeRects.delete(id)
nodeHoverAnimations.delete(id)
deletedNodes.add(id)
@ -364,6 +366,29 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
})
}
const undoManagerStatus = reactive({
canUndo: false,
canRedo: false,
update(m: UndoManager) {
this.canUndo = m.canUndo()
this.canRedo = m.canRedo()
},
})
watch(
() => proj.module?.undoManager,
(m) => {
if (m) {
const update = () => undoManagerStatus.update(m)
const events = stringUnionToArray<keyof Events<UndoManager>>()(
'stack-item-added',
'stack-item-popped',
'stack-cleared',
'stack-item-updated',
)
events.forEach((event) => m.on(event, update))
}
},
)
const undoManager = {
undo() {
proj.module?.undoManager.undo()
@ -374,6 +399,8 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
undoStackBoundary() {
proj.module?.undoManager.stopCapturing()
},
canUndo: computed(() => undoManagerStatus.canUndo),
canRedo: computed(() => undoManagerStatus.canRedo),
}
function setNodePosition(nodeId: NodeId, position: Vec2) {
@ -549,7 +576,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
function updatePortValue(
edit: MutableModule,
id: PortId,
value: Ast.Owned | undefined,
value: Ast.Owned<Ast.MutableExpression> | undefined,
): boolean {
const update = getPortPrimaryInstance(id)?.onUpdate
if (!update) return false
@ -665,7 +692,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
const body = func.bodyExpressions()
const result: NodeId[] = []
for (const expr of body) {
const nodeId = nodeIdFromOuterExpr(expr)
const nodeId = nodeIdFromOuterAst(expr)
if (nodeId && ids.has(nodeId)) result.push(nodeId)
}
return result
@ -683,14 +710,14 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
sourceNodeId: NodeId,
targetNodeId: NodeId,
) {
const sourceExpr = db.nodeIdToNode.get(sourceNodeId)?.outerExpr.id
const targetExpr = db.nodeIdToNode.get(targetNodeId)?.outerExpr.id
const sourceExpr = db.nodeIdToNode.get(sourceNodeId)?.outerAst.id
const targetExpr = db.nodeIdToNode.get(targetNodeId)?.outerAst.id
const body = edit.getVersion(unwrap(getExecutedMethodAst(edit))).bodyAsBlock()
assert(sourceExpr != null)
assert(targetExpr != null)
const lines = body.lines
const sourceIdx = lines.findIndex((line) => line.expression?.node.id === sourceExpr)
const targetIdx = lines.findIndex((line) => line.expression?.node.id === targetExpr)
const sourceIdx = lines.findIndex((line) => line.statement?.node.id === sourceExpr)
const targetIdx = lines.findIndex((line) => line.statement?.node.id === targetExpr)
assert(sourceIdx != null)
assert(targetIdx != null)
@ -700,7 +727,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
const deps = reachable([targetNodeId], (node) => db.nodeDependents.lookup(node))
const dependantLines = new Set(
Array.from(deps, (id) => db.nodeIdToNode.get(id)?.outerExpr.id),
Array.from(deps, (id) => db.nodeIdToNode.get(id)?.outerAst.id),
)
// Include the new target itself in the set of lines that must be placed after source node.
dependantLines.add(targetExpr)
@ -717,7 +744,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
// Split those lines into two buckets, whether or not they depend on the target.
const [linesAfter, linesBefore] = partition(linesToSort, (line) =>
dependantLines.has(line.expression?.node.id),
dependantLines.has(line.statement?.node.id),
)
// Recombine all lines after splitting, keeping existing dependants below the target.
@ -734,6 +761,22 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
return isAstId(portId) && db.connections.reverseLookup(portId).size > 0
}
function nodeCanBeEntered(id: NodeId): boolean {
if (!proj.modulePath?.ok) return false
const expressionInfo = db.getExpressionInfo(id)
if (expressionInfo?.methodCall == null) return false
const definedOnType = tryQualifiedName(expressionInfo.methodCall.methodPointer.definedOnType)
const openModuleName = qnLastSegment(proj.modulePath.value)
if (definedOnType.ok && qnLastSegment(definedOnType.value) !== openModuleName) {
// Cannot enter node that is not defined on current module.
// TODO: Support entering nodes in other modules within the same project.
return false
}
return true
}
const modulePath: Ref<LsPath | undefined> = computedAsync(
async () => {
const rootId = await proj.projectRootId
@ -789,6 +832,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
addMissingImports,
addMissingImportsDisregardConflicts,
isConnectedTarget,
nodeCanBeEntered,
currentMethodPointer() {
const currentMethod = proj.executionContext.getStackTop()
if (currentMethod.type === 'ExplicitCall') return currentMethod.methodPointer

View File

@ -320,6 +320,7 @@ class Fixture {
aliases: ['Test Type'],
isPrivate: false,
isUnstable: false,
parentType: unwrap(tryQualifiedName('Standard.Base.Any.Any')),
reexportedIn: unwrap(tryQualifiedName('Standard.Base.Another.Module')),
annotations: [],
}
@ -415,6 +416,7 @@ class Fixture {
name: 'Type',
params: [this.arg1],
documentation: this.typeDocs,
parentType: 'Standard.Base.Any.Any',
reexport: 'Standard.Base.Another.Module',
},
},

View File

@ -59,6 +59,8 @@ export interface SuggestionEntry {
arguments: SuggestionEntryArgument[]
/** A type returned by the suggested object. */
returnType: Typename
/** Qualified name of the parent type. */
parentType?: QualifiedName
/** A least-nested module reexporting this entity. */
reexportedIn?: QualifiedName
documentation: Doc.Section[]

View File

@ -37,6 +37,7 @@ interface UnfinishedEntry {
selfType?: Typename
arguments?: SuggestionEntryArgument[]
returnType?: Typename
parentType?: QualifiedName
reexportedIn?: QualifiedName
documentation?: Doc.Section[]
scope?: SuggestionEntryScope
@ -110,6 +111,16 @@ function setLsReexported(
return true
}
function setLsParentType(
entry: UnfinishedEntry,
parentType: string,
): entry is UnfinishedEntry & { parentType: QualifiedName } {
const qn = tryQualifiedName(parentType)
if (!qn.ok) return false
entry.parentType = normalizeQualifiedName(qn.value)
return true
}
function setLsDocumentation(
entry: UnfinishedEntry & { definedIn: QualifiedName },
documentation: Opt<string>,
@ -171,6 +182,8 @@ export function entryFromLs(
if (!setLsModule(entry, lsEntry.module)) return Err('Invalid module name')
if (lsEntry.reexport != null && !setLsReexported(entry, lsEntry.reexport))
return Err('Invalid reexported module name')
if (lsEntry.parentType != null && !setLsParentType(entry, lsEntry.parentType))
return Err('Invalid parent type')
setLsDocumentation(entry, lsEntry.documentation, groups)
assert(entry.returnType !== '') // Should be overwriten
return Ok({

View File

@ -101,7 +101,7 @@ test.each`
expectedPattern,
fixture: { allowInfix, mockSuggestion, argsParameters },
}: TestData) => {
const ast = Ast.parse(expression.trim())
const ast = Ast.parseExpression(expression.trim())
const configuration: widgetCfg.FunctionCall = {
kind: 'FunctionCall',
@ -207,7 +207,7 @@ test.each([
({ code, subapplicationIndex, notAppliedArguments, expectedNotAppliedArguments }: TestCase) => {
const { db, expectedMethodCall, expectedSuggestion, setExpressionInfo } =
prepareMocksForGetMethodCallTest()
const ast = Ast.parse(code)
const ast = Ast.parseExpression(code)
db.updateExternalIds(ast)
const subApplication = nthSubapplication(ast, subapplicationIndex)
assert(subApplication)
@ -345,7 +345,7 @@ test.each([
'Computing IDs of arguments: $description',
({ code, subapplicationIndex, notAppliedArguments, expectedSameIds }: ArgsTestCase) => {
const { db, expectedMethodCall, setExpressionInfo } = prepareMocksForGetMethodCallTest()
const ast = Ast.parse(code)
const ast = Ast.parseExpression(code)
const subApplication = nthSubapplication(ast, subapplicationIndex)
assert(subApplication)
db.updateExternalIds(ast)

View File

@ -15,6 +15,7 @@ import {
} from '@/util/ast/abstract'
import { fc, test } from '@fast-check/vitest'
import { describe, expect } from 'vitest'
import { BodyBlock } from 'ydoc-shared/ast'
import { findExpressions, testCase, tryFindExpressions } from './testCase'
test('Raw block abstracts to Ast.BodyBlock', () => {
@ -25,10 +26,21 @@ test('Raw block abstracts to Ast.BodyBlock', () => {
expect(abstracted.root).toBeInstanceOf(Ast.BodyBlock)
})
//const disabledCases = [
// ' a',
// 'a ',
//]
// FIXME: Parsing source code and reprinting it should produce exactly the same output as input. The following cases are
// known to be incorrectly handled. For each such case the test checks the result of parsing and reprinting to ensure
// it is at least a reasonable normalization of the input.
const normalizingCases = [
{ input: ' a', normalized: ' a' },
{ input: 'a ', normalized: 'a \n' },
{
input: ['main =', ' foo', ' bar', ' baz'].join('\n'),
normalized: ['main =', ' foo', ' bar', ' baz'].join('\n'),
},
{
input: ['main =', ' foo', ' bar', 'baz'].join('\n'),
normalized: ['main =', ' foo', ' bar', 'baz'].join('\n'),
},
]
const cases = [
'Console.',
'(',
@ -309,8 +321,6 @@ const cases = [
['foo', ' + bar +'].join('\n'),
['foo', ' + bar', ' - baz'].join('\n'),
['main =', ' foo', 'bar'].join('\n'),
['main =', ' foo', ' bar', ' baz'].join('\n'),
['main =', ' foo', ' bar', 'baz'].join('\n'),
['main ~foo = x'].join('\n'),
['main =', ' ', ' x'].join('\n'),
['main =', ' ', ' x'].join('\n'),
@ -375,13 +385,18 @@ const cases = [
'\n\n',
'\na',
'\n\na',
...normalizingCases,
]
test.each(cases)('parse/print round trip: %s', (code) => {
test.each(cases)('parse/print round-trip: %s', (testCase) => {
const code = typeof testCase === 'object' ? testCase.input : testCase
const expectedCode = typeof testCase === 'object' ? testCase.normalized : testCase
// Get an AST.
const { root } = Ast.parseModuleWithSpans(code)
const root = Ast.parseModule(code)
root.module.setRoot(root)
// Print AST back to source.
const printed = Ast.print(root)
expect(printed.code).toEqual(code)
expect(printed.code).toEqual(expectedCode)
// Loading token IDs from IdMaps is not implemented yet, fix during sync.
printed.info.tokens.clear()
const idMap = Ast.spanMapToIdMap(printed.info)
@ -403,22 +418,29 @@ test.each(cases)('parse/print round trip: %s', (code) => {
})
const parseCases = [
{ code: 'foo bar+baz', tree: ['', [['foo'], [['bar'], '+', ['baz']]]] },
{ code: '(foo)', tree: ['', ['(', ['foo'], ')']] },
{ code: 'foo bar+baz', tree: [['foo'], [['bar'], '+', ['baz']]] },
{ code: '(foo)', tree: ['(', ['foo'], ')'] },
]
test.each(parseCases)('parse: %s', (testCase) => {
const root = Ast.parseBlock(testCase.code)
const root = Ast.parseExpression(testCase.code)
assertDefined(root)
expect(Ast.tokenTree(root)).toEqual(testCase.tree)
})
function functionBlock(topLevel: BodyBlock, name: string) {
const func = findModuleMethod(topLevel, name)
if (!(func?.statement.body instanceof BodyBlock)) return undefined
return func.statement.body
}
test('Insert new expression', () => {
const code = 'main =\n text1 = "foo"\n'
const root = Ast.parseBlock(code)
const main = Ast.functionBlock(root, 'main')!
const main = functionBlock(root, 'main')!
expect(main).toBeDefined()
const edit = root.module.edit()
const rhs = Ast.parse('42', edit)
const assignment = Ast.Assignment.new(edit, 'baz' as Identifier, rhs)
const rhs = Ast.parseExpression('42', edit)!
const assignment = Ast.Assignment.new('baz' as Identifier, rhs, { edit })
edit.getVersion(main).push(assignment)
const printed = edit.getVersion(root).code()
expect(printed).toEqual('main =\n text1 = "foo"\n baz = 42\n')
@ -433,7 +455,7 @@ type SimpleModule = {
function simpleModule(): SimpleModule {
const code = 'main =\n text1 = "foo"\n'
const root = Ast.parseBlock(code)
const main = findModuleMethod(root, 'main')!
const main = findModuleMethod(root, 'main')!.statement
const mainBlock = main.body instanceof Ast.BodyBlock ? main.body : null
assert(mainBlock != null)
expect(mainBlock).toBeInstanceOf(Ast.BodyBlock)
@ -475,8 +497,8 @@ test('Replace subexpression', () => {
const newValue = Ast.TextLiteral.new('bar', edit)
expect(newValue.code()).toBe("'bar'")
edit.replace(assignment.expression!.id, newValue)
const assignment_ = edit.tryGet(assignment.id)!
assert(assignment_ instanceof Ast.Assignment)
const assignment_ = edit.tryGet(assignment.id)
assert(assignment_ instanceof Ast.MutableAssignment)
expect(assignment_.expression!.id).toBe(newValue.id)
expect(edit.tryGet(assignment_.expression!.id)?.code()).toBe("'bar'")
const printed = edit.getVersion(root).code()
@ -487,14 +509,16 @@ test('Modify subexpression - setting a vector', () => {
// A case where the #9357 bug was visible.
const code = 'main =\n text1 = foo\n'
const root = Ast.parseBlock(code)
const main = Ast.functionBlock(root, 'main')!
const main = functionBlock(root, 'main')!
expect(main).not.toBeNull()
const assignment: Ast.Assignment = main.statements().next().value
expect(assignment).toBeInstanceOf(Ast.Assignment)
const edit = root.module.edit()
const transientModule = MutableModule.Transient()
const newValue = Ast.Vector.new(transientModule, [Ast.parse('bar')])
const barExpression = Ast.parseExpression('bar')
assertDefined(barExpression)
const newValue = Ast.Vector.new(transientModule, [barExpression])
expect(newValue.code()).toBe('[bar]')
edit.replaceValue(assignment.expression.id, newValue)
const printed = edit.getVersion(root).code()
@ -520,10 +544,10 @@ test('Block lines interface', () => {
const block = Ast.parseBlock('VLE \nSISI\nGNIK \n')
// Sort alphabetically, but keep the blank line at the end.
const reordered = block.takeLines().sort((a, b) => {
if (a.expression?.node.code() === b.expression?.node.code()) return 0
if (!a.expression) return 1
if (!b.expression) return -1
return a.expression.node.code() < b.expression.node.code() ? -1 : 1
if (a.statement?.node.code() === b.statement?.node.code()) return 0
if (!a.statement) return 1
if (!b.statement) return -1
return a.statement.node.code() < b.statement.node.code() ? -1 : 1
})
const edit = block.module.edit()
const newBlock = Ast.BodyBlock.new(reordered, edit)
@ -560,16 +584,19 @@ test('Construct app', () => {
})
test('Automatic parenthesis', () => {
const block = Ast.parseBlock('main = func arg1 arg2')
const block = Ast.parseModule('main = func arg1 arg2')
block.module.setRoot(block)
let arg1: Ast.MutableAst | undefined
block.visitRecursiveAst((ast) => {
block.visitRecursive((ast) => {
if (ast instanceof Ast.MutableIdent && ast.code() === 'arg1') {
assert(!arg1)
arg1 = ast
}
})
assert(arg1 != null)
arg1.replace(Ast.parse('innerfunc innerarg', block.module))
const replacementExpr = Ast.parseExpression('innerfunc innerarg', block.module)
assertDefined(replacementExpr)
arg1.replace(replacementExpr)
const correctCode = 'main = func (innerfunc innerarg) arg2'
// This assertion will fail when smart printing handles this case.
// At that point we should test tree repair separately.
@ -583,7 +610,7 @@ test('Tree repair: Non-canonical block line attribution', () => {
'func a b =': Ast.Function,
' c = a + b': Ast.Assignment,
'main =': Ast.Function,
' func arg1 arg2': Ast.App,
' func arg1 arg2': Ast.ExpressionStatement,
})
const before = beforeCase.statements
@ -601,7 +628,7 @@ test('Tree repair: Non-canonical block line attribution', () => {
'func a b =': Ast.Function,
'c = a + b': Ast.Assignment,
'main =': Ast.Function,
'func arg1 arg2': Ast.App,
'func arg1 arg2': Ast.ExpressionStatement,
})
const repairedFunc = afterRepair['func a b =']
assert(repairedFunc.body instanceof Ast.BodyBlock)
@ -617,8 +644,9 @@ test('Tree repair: Non-canonical block line attribution', () => {
describe('Code edit', () => {
test('Change argument type', () => {
const beforeRoot = Ast.parse('func arg1 arg2')
beforeRoot.module.replaceRoot(beforeRoot)
const beforeRoot = Ast.parseExpression('func arg1 arg2')
assertDefined(beforeRoot)
beforeRoot.module.setRoot(beforeRoot)
const before = findExpressions(beforeRoot, {
func: Ast.Ident,
arg1: Ast.Ident,
@ -646,8 +674,9 @@ describe('Code edit', () => {
})
test('Insert argument names', () => {
const beforeRoot = Ast.parse('func arg1 arg2')
beforeRoot.module.replaceRoot(beforeRoot)
const beforeRoot = Ast.parseExpression('func arg1 arg2')
assertDefined(beforeRoot)
beforeRoot.module.setRoot(beforeRoot)
const before = findExpressions(beforeRoot, {
func: Ast.Ident,
arg1: Ast.Ident,
@ -676,8 +705,9 @@ describe('Code edit', () => {
})
test('Remove argument names', () => {
const beforeRoot = Ast.parse('func name1=arg1 name2=arg2')
beforeRoot.module.replaceRoot(beforeRoot)
const beforeRoot = Ast.parseExpression('func name1=arg1 name2=arg2')
assertDefined(beforeRoot)
beforeRoot.module.setRoot(beforeRoot)
const before = findExpressions(beforeRoot, {
func: Ast.Ident,
arg1: Ast.Ident,
@ -768,8 +798,9 @@ describe('Code edit', () => {
})
test('Inline expression change', () => {
const beforeRoot = Ast.parse('func name1=arg1 name2=arg2')
beforeRoot.module.replaceRoot(beforeRoot)
const beforeRoot = Ast.parseExpression('func name1=arg1 name2=arg2')
assertDefined(beforeRoot)
beforeRoot.module.setRoot(beforeRoot)
const before = findExpressions(beforeRoot, {
func: Ast.Ident,
arg1: Ast.Ident,
@ -800,9 +831,10 @@ describe('Code edit', () => {
test('No-op inline expression change', () => {
const code = 'a = 1'
const expression = Ast.parse(code)
const expression = Ast.parseStatement(code)
assertDefined(expression)
const module = expression.module
module.replaceRoot(expression)
module.setRoot(expression)
expression.syncToCode(code)
expect(module.root()?.code()).toBe(code)
})
@ -811,14 +843,14 @@ describe('Code edit', () => {
const code = 'a = 1\nb = 2\n'
const block = Ast.parseBlock(code)
const module = block.module
module.replaceRoot(block)
module.setRoot(block)
block.syncToCode(code)
expect(module.root()?.code()).toBe(code)
})
test('Shifting whitespace ownership', () => {
const beforeRoot = Ast.parseModuleWithSpans('value = 1 +\n').root
beforeRoot.module.replaceRoot(beforeRoot)
const beforeRoot = Ast.parseModule('value = 1 +\n')
beforeRoot.module.setRoot(beforeRoot)
const before = findExpressions(beforeRoot, {
value: Ast.Ident,
'1': Ast.NumericLiteral,
@ -841,9 +873,9 @@ describe('Code edit', () => {
})
test('merging', () => {
const block = Ast.parseModuleWithSpans('a = 1\nb = 2').root
const block = Ast.parseModule('a = 1\nb = 2')
const module = block.module
module.replaceRoot(block)
module.setRoot(block)
const editA = module.edit()
editA.getVersion(block).syncToCode('a = 10\nb = 2')
@ -858,7 +890,8 @@ describe('Code edit', () => {
})
test('Analyze app-like', () => {
const appLike = Ast.parse('(Preprocessor.default_preprocessor 3 _ 5 _ <| 4) <| 6')
const appLike = Ast.parseExpression('(Preprocessor.default_preprocessor 3 _ 5 _ <| 4) <| 6')
assertDefined(appLike)
const { func, args } = Ast.analyzeAppLike(appLike)
expect(func.code()).toBe('Preprocessor.default_preprocessor')
expect(args.map((ast) => ast.code())).toEqual(['3', '4', '5', '6'])
@ -904,9 +937,9 @@ test.each([
])(
'Substitute qualified name $pattern inside $original',
({ original, pattern, substitution, expected }) => {
const expression = Ast.parse(original)
const expression = Ast.parseExpression(original) ?? Ast.parseStatement(original)
const module = expression.module
module.replaceRoot(expression)
module.setRoot(expression)
const edit = expression.module.edit()
substituteQualifiedName(expression, pattern as Ast.Identifier, substitution as Ast.Identifier)
module.applyEdit(edit)
@ -960,9 +993,9 @@ test.each([
])(
'Substitute identifier $pattern inside $original',
({ original, pattern, substitution, expected }) => {
const expression = Ast.parse(original)
const expression = Ast.parseExpression(original) ?? Ast.parseStatement(original)
const module = expression.module
module.replaceRoot(expression)
module.setRoot(expression)
const edit = expression.module.edit()
substituteIdentifier(expression, pattern as Ast.Identifier, substitution as Ast.Identifier)
module.applyEdit(edit)
@ -1037,79 +1070,6 @@ test('setRawTextContent promotes single-line uninterpolated text to interpolated
expect(literal.code()).toBe(`'${escapeTextLiteral(rawText)}'`)
})
const docEditCases = [
{ code: '## Simple\nnode', documentation: 'Simple' },
{
code: '## Preferred indent\n 2nd line\n 3rd line\nnode',
documentation: 'Preferred indent\n2nd line\n3rd line',
},
{
code: '## Extra-indented child\n 2nd line\n 3rd line\nnode',
documentation: 'Extra-indented child\n2nd line\n3rd line',
normalized: '## Extra-indented child\n 2nd line\n 3rd line\nnode',
},
{
code: '## Extra-indented child, beyond 4th column\n 2nd line\n 3rd line\nnode',
documentation: 'Extra-indented child, beyond 4th column\n2nd line\n 3rd line',
normalized: '## Extra-indented child, beyond 4th column\n 2nd line\n 3rd line\nnode',
},
{
code: '##Preferred indent, no initial space\n 2nd line\n 3rd line\nnode',
documentation: 'Preferred indent, no initial space\n2nd line\n3rd line',
normalized: '## Preferred indent, no initial space\n 2nd line\n 3rd line\nnode',
},
{
code: '## Minimum indent\n 2nd line\n 3rd line\nnode',
documentation: 'Minimum indent\n2nd line\n3rd line',
normalized: '## Minimum indent\n 2nd line\n 3rd line\nnode',
},
]
test.each(docEditCases)('Documentation edit round trip: $code', (docCase) => {
const { code, documentation } = docCase
const parsed = Ast.Documented.tryParse(code)
assert(parsed != null)
const parsedDocumentation = parsed.documentation()
expect(parsedDocumentation).toBe(documentation)
const edited = MutableModule.Transient().copy(parsed)
edited.setDocumentationText(parsedDocumentation)
expect(edited.code()).toBe(docCase.normalized ?? code)
})
test.each([
'## Some documentation\nf x = 123',
'## Some documentation\n and a second line\nf x = 123',
'## Some documentation## Another documentation??\nf x = 123',
])('Finding documentation: $code', (code) => {
const block = Ast.parseBlock(code)
const method = Ast.findModuleMethod(block, 'f')
assertDefined(method)
expect(method.documentingAncestor()).toBeDefined()
})
test.each([
{
code: '## Already documented\nf x = 123',
expected: '## Already documented\nf x = 123',
},
{
code: 'f x = 123',
expected: '## \nf x = 123',
},
])('Adding documentation: $code', ({ code, expected }) => {
const block = Ast.parseBlock(code)
const module = block.module
const method = module.getVersion(Ast.findModuleMethod(block, 'f')!)
method.getOrInitDocumentation()
expect(block.code()).toBe(expected)
})
test('Creating comments', () => {
const expr = Ast.parse('2 + 2')
expr.module.replaceRoot(expr)
expr.update((expr) => Ast.Documented.new('Calculate five', expr))
expect(expr.module.root()?.code()).toBe('## Calculate five\n2 + 2')
})
test.each([
{ code: 'operator1', expected: { subject: 'operator1', accesses: [] } },
{ code: 'operator1 foo bar', expected: { subject: 'operator1 foo bar', accesses: [] } },
@ -1132,7 +1092,7 @@ test.each([
},
{ code: 'operator1 + operator2', expected: { subject: 'operator1 + operator2', accesses: [] } },
])('Access chain in $code', ({ code, expected }) => {
const ast = Ast.parse(code)
const ast = Ast.parseExpression(code)
const { subject, accessChain } = Ast.accessChain(ast)
expect({
subject: subject.code(),
@ -1148,7 +1108,7 @@ test.each`
`('Pushing $pushed to vector $initial', ({ initial, pushed, expected }) => {
const vector = Ast.Vector.tryParse(initial)
assertDefined(vector)
vector.push(Ast.parse(pushed, vector.module))
vector.push(Ast.parseExpression(pushed, vector.module))
expect(vector.code()).toBe(expected)
})
@ -1228,7 +1188,7 @@ test.each`
({ initial, index, value, expected }) => {
const vector = Ast.Vector.tryParse(initial)
assertDefined(vector)
vector.set(index, Ast.parse(value, vector.module))
vector.set(index, Ast.parseExpression(value, vector.module))
expect(vector.code()).toBe(expected)
},
)
@ -1250,7 +1210,7 @@ test.each`
'Conversions between enso literals and js numbers: $ensoNumber',
({ ensoNumber, jsNumber, expectedEnsoNumber }) => {
if (ensoNumber != null) {
const literal = Ast.parse(ensoNumber)
const literal = Ast.parseExpression(ensoNumber)
expect(tryEnsoToNumber(literal)).toBe(jsNumber)
}
if (jsNumber != null) {

View File

@ -0,0 +1,90 @@
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { test } from '@fast-check/vitest'
import { expect } from 'vitest'
test.each([
{ code: '## Simple\nnode', documentation: 'Simple' },
{
code: '## Preferred indent\n 2nd line\n 3rd line\nnode',
documentation: 'Preferred indent\n2nd line\n3rd line',
},
{
code: '## Extra-indented child\n 2nd line\n 3rd line\nnode',
documentation: 'Extra-indented child\n2nd line\n3rd line',
normalized: '## Extra-indented child\n 2nd line\n 3rd line\nnode',
},
{
code: '## Extra-indented child, beyond 4th column\n 2nd line\n 3rd line\nnode',
documentation: 'Extra-indented child, beyond 4th column\n2nd line\n 3rd line',
normalized: '## Extra-indented child, beyond 4th column\n 2nd line\n 3rd line\nnode',
},
{
code: '##Preferred indent, no initial space\n 2nd line\n 3rd line\nnode',
documentation: 'Preferred indent, no initial space\n2nd line\n3rd line',
normalized: '## Preferred indent, no initial space\n 2nd line\n 3rd line\nnode',
},
{
code: '## Minimum indent\n 2nd line\n 3rd line\nnode',
documentation: 'Minimum indent\n2nd line\n3rd line',
normalized: '## Minimum indent\n 2nd line\n 3rd line\nnode',
},
])('Documentation edit round-trip: $code', (docCase) => {
const { code, documentation } = docCase
const parsed = Ast.parseStatement(code)!
const parsedDocumentation = parsed.documentationText()
expect(parsedDocumentation).toBe(documentation)
const edited = Ast.MutableModule.Transient().copy(parsed)
assert('setDocumentationText' in edited)
edited.setDocumentationText(parsedDocumentation)
expect(edited.code()).toBe(docCase.normalized ?? code)
})
test.each([
'## Some documentation\nf x = 123',
'## Some documentation\n and a second line\nf x = 123',
'## Some documentation## Another documentation??\nf x = 123',
])('Finding documentation: $code', (code) => {
const block = Ast.parseBlock(code)
const method = Ast.findModuleMethod(block, 'f')!.statement
expect(method.documentationText()).toBeTruthy()
})
test.each([
{
code: '## Already documented\nf x = 123',
expected: '## Already documented\nf x = 123',
},
{
code: 'f x = 123',
expected: '##\nf x = 123',
},
])('Adding documentation: $code', ({ code, expected }) => {
const block = Ast.parseBlock(code)
const module = block.module
const method = module.getVersion(Ast.findModuleMethod(block, 'f')!.statement)
if (method.documentationText() === undefined) {
method.setDocumentationText('')
}
expect(block.code()).toBe(expected)
})
test('Creating comments', () => {
const block = Ast.parseBlock('2 + 2')
block.module.setRoot(block)
const statement = [...block.statements()][0]! as Ast.MutableExpressionStatement
const docText = 'Calculate five'
statement.setDocumentationText(docText)
expect(statement.module.root()?.code()).toBe(`## ${docText}\n2 + 2`)
})
test('Creating comments: indented', () => {
const block = Ast.parseBlock('main =\n x = 1')
const module = block.module
module.setRoot(block)
const main = module.getVersion(Ast.findModuleMethod(block, 'main')!.statement)
const statement = [...main.bodyAsBlock().statements()][0]! as Ast.MutableAssignment
const docText = 'The smallest natural number'
statement.setDocumentationText(docText)
expect(statement.module.root()?.code()).toBe(`main =\n ## ${docText}\n x = 1`)
})

View File

@ -80,9 +80,9 @@ test.each([
extracted: ['with_enabled_context', "'current_context_name'", 'a + b'],
},
])('`isMatch` and `extractMatches`', ({ target, pattern, extracted }) => {
const targetAst = Ast.parse(target)
const targetAst = Ast.parseExpression(target)
const module = targetAst.module
const patternAst = Pattern.parse(pattern)
const patternAst = Pattern.parseExpression(pattern)
expect(
patternAst.match(targetAst) !== undefined,
`'${target}' has CST ${extracted != null ? '' : 'not '}matching '${pattern}'`,
@ -101,9 +101,9 @@ test.each([
{ template: 'a __ c', source: 'b', result: 'a b c' },
{ template: 'a . __ . c', source: 'b', result: 'a . b . c' },
])('instantiate', ({ template, source, result }) => {
const pattern = Pattern.parse(template)
const pattern = Pattern.parseExpression(template)
const edit = MutableModule.Transient()
const intron = Ast.parse(source, edit)
const intron = Ast.parseExpression(source, edit)
const instantiated = pattern.instantiate(edit, [intron])
expect(instantiated.code()).toBe(result)
})

View File

@ -10,17 +10,17 @@ test.each`
${'## Documentation\n2 + 2'} | ${undefined} | ${'2 + 2'} | ${'Documentation'}
${'## Documentation\nfoo = 2 + 2'} | ${'foo'} | ${'2 + 2'} | ${'Documentation'}
`('Node information from AST $line line', ({ line, pattern, rootExpr, documentation }) => {
const ast = Ast.Ast.parse(line)
const ast = [...Ast.parseBlock(line).statements()][0]!
const node = nodeFromAst(ast, false)
expect(node?.outerExpr).toBe(ast)
expect(node?.outerAst).toBe(ast)
expect(node?.pattern?.code()).toBe(pattern)
expect(node?.rootExpr.code()).toBe(rootExpr)
expect(node?.innerExpr.code()).toBe(rootExpr)
expect(node?.docs?.documentation()).toBe(documentation)
expect(node?.outerAst.isStatement() && node.outerAst.documentationText()).toBe(documentation)
})
test.each(['## Documentation only'])("'%s' should not be a node", (line) => {
const ast = Ast.Ast.parse(line)
const ast = Ast.parseStatement(line)
const node = nodeFromAst(ast, false)
expect(node).toBeUndefined()
})
@ -47,7 +47,7 @@ test.each([
},
{ code: 'operator1 + operator2', expected: undefined },
])('Primary application subject of $code', ({ code, expected }) => {
const ast = Ast.Ast.parse(code)
const ast = Ast.parseExpression(code)
const module = ast.module
const primaryApplication = primaryApplicationSubject(ast)
const analyzed = primaryApplication && {

Some files were not shown because too many files have changed in this diff Show More