mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 03:32:23 +03:00
Merge branch 'develop' into wip/sb/fix-react-compiler-lints
This commit is contained in:
commit
1c317b4fec
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -1,2 +1,3 @@
|
||||
*.enso text eol=lf
|
||||
*.png binary
|
||||
CHANGELOG.md merge=union
|
||||
|
24
CHANGELOG.md
24
CHANGELOG.md
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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}</>
|
||||
}
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 : (
|
||||
|
@ -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'
|
||||
|
||||
// ==================
|
||||
|
@ -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',
|
||||
)}
|
||||
|
@ -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>
|
||||
|
@ -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. */
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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. */
|
||||
|
159
app/gui/src/dashboard/hooks/storeHooks.ts
Normal file
159
app/gui/src/dashboard/hooks/storeHooks.ts
Normal 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 */
|
||||
}
|
@ -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>,
|
||||
)
|
||||
|
@ -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'
|
||||
|
@ -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}
|
||||
|
@ -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 && (
|
||||
|
@ -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. */
|
||||
|
@ -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
|
||||
|
@ -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} />
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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 {}
|
||||
|
||||
// =======================
|
||||
|
51
app/gui/src/dashboard/utilities/equalities.ts
Normal file
51
app/gui/src/dashboard/utilities/equalities.ts
Normal 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
|
||||
}
|
14
app/gui/src/dashboard/utilities/zustand.ts
Normal file
14
app/gui/src/dashboard/utilities/zustand.ts
Normal 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'
|
@ -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>
|
||||
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
@ -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(
|
||||
|
@ -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 don’t 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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
42
app/gui/src/project-view/components/ControlButtons.vue
Normal file
42
app/gui/src/project-view/components/ControlButtons.vue
Normal 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>
|
@ -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>(() => {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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.`)
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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)"
|
||||
|
@ -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) {
|
||||
|
@ -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.`)
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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'),
|
||||
}
|
||||
}
|
||||
|
@ -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 function’s 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 }
|
||||
}
|
||||
|
@ -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 ?? '')}`,
|
||||
|
@ -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 }
|
||||
|
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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)!)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 []
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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"
|
||||
|
37
app/gui/src/project-view/components/GraphMissingView.vue
Normal file
37
app/gui/src/project-view/components/GraphMissingView.vue
Normal 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>
|
@ -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;
|
||||
}
|
||||
|
26
app/gui/src/project-view/components/StandaloneButton.vue
Normal file
26
app/gui/src/project-view/components/StandaloneButton.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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"
|
||||
|
32
app/gui/src/project-view/components/UndoRedoButtons.vue
Normal file
32
app/gui/src/project-view/components/UndoRedoButtons.vue
Normal 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>
|
@ -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)) {
|
||||
|
@ -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,
|
||||
|
@ -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)!,
|
||||
|
@ -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' ?
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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.
|
||||
|
@ -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>,
|
||||
|
@ -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))!
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
@ -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[]
|
||||
|
@ -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({
|
||||
|
@ -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)
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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) {
|
||||
|
@ -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`)
|
||||
})
|
@ -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)
|
||||
})
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user