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

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

1
.gitattributes vendored
View File

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

View File

@ -12,6 +12,16 @@
- [Changed the way of adding new column in Table Input Widget][11388]. The - [Changed the way of adding new column in Table Input Widget][11388]. The
"virtual column" is replaced with an explicit (+) button. "virtual column" is replaced with an explicit (+) button.
- [New dropdown-based component menu][11398]. - [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 [11151]: https://github.com/enso-org/enso/pull/11151
[11271]: https://github.com/enso-org/enso/pull/11271 [11271]: https://github.com/enso-org/enso/pull/11271
@ -20,6 +30,13 @@
[11383]: https://github.com/enso-org/enso/pull/11383 [11383]: https://github.com/enso-org/enso/pull/11383
[11388]: https://github.com/enso-org/enso/pull/11388 [11388]: https://github.com/enso-org/enso/pull/11388
[11398]: https://github.com/enso-org/enso/pull/11398 [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 #### Enso Standard Library
@ -28,10 +45,12 @@
- [The user may set description and labels of an Enso Cloud asset - [The user may set description and labels of an Enso Cloud asset
programmatically.][11255] programmatically.][11255]
- [DB_Table may be saved as a Data Link.][11371] - [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 [11235]: https://github.com/enso-org/enso/pull/11235
[11255]: https://github.com/enso-org/enso/pull/11255 [11255]: https://github.com/enso-org/enso/pull/11255
[11371]: https://github.com/enso-org/enso/pull/11371 [11371]: https://github.com/enso-org/enso/pull/11371
[11373]: https://github.com/enso-org/enso/pull/11373
#### Enso Language & Runtime #### Enso Language & Runtime
@ -101,6 +120,9 @@
range.][11135] range.][11135]
- [Added `format` parameter to `Decimal.parse`.][11205] - [Added `format` parameter to `Decimal.parse`.][11205]
- [Added `format` parameter to `Float.parse`.][11229] - [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 [10614]: https://github.com/enso-org/enso/pull/10614
[10660]: https://github.com/enso-org/enso/pull/10660 [10660]: https://github.com/enso-org/enso/pull/10660
@ -116,6 +138,8 @@
[11135]: https://github.com/enso-org/enso/pull/11135 [11135]: https://github.com/enso-org/enso/pull/11135
[11205]: https://github.com/enso-org/enso/pull/11205 [11205]: https://github.com/enso-org/enso/pull/11205
[11229]: https://github.com/enso-org/enso/pull/11229 [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 #### Enso Language & Runtime

View File

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

View File

@ -1410,6 +1410,13 @@ export function stripProjectExtension(name: string) {
return name.replace(/[.](?:tar[.]gz|zip|enso-project)$/, '') 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). * Return both the name and extension of the project file name (if any).
* Otherwise, returns the entire name as the basename. * Otherwise, returns the entire name as the basename.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -174,6 +174,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
useBackendMutationState(backend, 'undoDeleteAsset', { useBackendMutationState(backend, 'undoDeleteAsset', {
predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id, predicate: ({ state: { variables: [assetId] = [] } }) => assetId === asset.id,
}).length !== 0 }).length !== 0
const isCloud = isCloudCategory(category) const isCloud = isCloudCategory(category)
const { data: projectState } = useQuery({ const { data: projectState } = useQuery({
@ -538,7 +539,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
} }
}} }}
className={tailwindMerge.twMerge( 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, visibility,
(isDraggedOver || selected) && 'selected', (isDraggedOver || selected) && 'selected',
)} )}

View File

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

View File

@ -64,7 +64,7 @@ export const COLUMN_SHOW_TEXT_ID: Readonly<Record<Column, text.TextId>> = {
} satisfies { [C in Column]: `${C}ColumnShow` } } satisfies { [C in Column]: `${C}ColumnShow` }
const COLUMN_CSS_CLASSES = 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}` const NORMAL_COLUMN_CSS_CLASSES = `px-cell-x py ${COLUMN_CSS_CLASSES}`
/** CSS classes for every column. */ /** CSS classes for every column. */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -109,14 +109,48 @@ export const CATEGORY_TO_FILTER_BY: Readonly<Record<Category['type'], FilterBy |
'local-directory': FilterBy.active, '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. */ /** Whether the category is only accessible from the cloud. */
export function isCloudCategory(category: Category): category is AnyCloudCategory { 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. */ /** Whether the category is only accessible locally. */
export function isLocalCategory(category: Category): category is AnyLocalCategory { 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. */ /** Whether the given categories are equal. */

View File

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

View File

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

View File

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

View File

@ -20,6 +20,12 @@
--color-invert-opacity: 100%; --color-invert-opacity: 100%;
--color-invert: rgb(var(--color-invert-rgb) / var(--color-invert-opacity)); --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; --top-bar-height: 3rem;
--row-height: 2rem; --row-height: 2rem;
--table-row-height: 2.3125rem; --table-row-height: 2.3125rem;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ export function useAI(
const lsRpc = project.lsRpcConnection const lsRpc = project.lsRpcConnection
const sourceNodeId = graphDb.getIdentDefiningNode(sourceIdentifier) const sourceNodeId = graphDb.getIdentDefiningNode(sourceIdentifier)
const contextId = 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}`) if (!contextId) return Err(`Cannot find node with name ${sourceIdentifier}`)
const prompt = await withContext( const prompt = await withContext(

View File

@ -10,7 +10,8 @@ import { isSome } from '@/util/data/opt'
import { Range } from '@/util/data/range' import { Range } from '@/util/data/range'
import { displayedIconOf } from '@/util/getIconName' import { displayedIconOf } from '@/util/getIconName'
import type { Icon } from '@/util/iconName' 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 { interface ComponentLabelInfo {
label: string 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. */ /** Create {@link Component} list from filtered suggestions. */
export function makeComponentList(db: SuggestionDb, filtering: Filtering): Component[] { export function makeComponentList(db: SuggestionDb, filtering: Filtering): Component[] {
function* matchSuggestions() { 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()) { for (const [id, entry] of db.entries()) {
const match = filtering.filter(entry) const match = filtering.filter(entry, additionalSelfTypes)
if (isSome(match)) { if (isSome(match)) {
yield { id, entry, match } yield { id, entry, match }
} }
@ -120,3 +131,16 @@ export function makeComponentList(db: SuggestionDb, filtering: Filtering): Compo
const matched = Array.from(matchSuggestions()).sort(compareSuggestions) const matched = Array.from(matchSuggestions()).sort(compareSuggestions)
return Array.from(matched, (info) => makeComponent(info)) return Array.from(matched, (info) => makeComponent(info))
} }
/**
* Type can inherit methods from `parentType`, and it can do that recursively.
* In practice, these hierarchies are at most two levels deep.
*/
function populateAdditionalSelfTypes(db: SuggestionDb, list: QualifiedName[], name: QualifiedName) {
let entry = db.getEntryByQualifiedName(name)
// We dont need to add `Any` to the list, because the caller already did that.
while (entry != null && entry.parentType != null && entry.parentType !== ANY_TYPE) {
list.push(entry.parentType)
entry = db.getEntryByQualifiedName(entry.parentType)
}
}

View File

@ -248,9 +248,13 @@ export class Filtering {
if (currentModule != null) this.currentModule = currentModule 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 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 else return entry.selfType != null
} }
@ -271,11 +275,11 @@ export class Filtering {
} }
/** TODO: Add docs */ /** 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) if (entry.isPrivate || entry.kind != SuggestionKind.Method || entry.memberOf == null)
return null return null
if (this.selfArg == null && isInternal(entry)) 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 (this.pattern) {
if (entry.memberOf == null) return null if (entry.memberOf == null) return null
const patternMatch = this.pattern.tryMatch(entry.name, entry.aliases, entry.memberOf) const patternMatch = this.pattern.tryMatch(entry.name, entry.aliases, entry.memberOf)

View File

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

View File

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

View File

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

View File

@ -8,47 +8,59 @@ export class HistoryStack {
private index: Ref<number> private index: Ref<number>
public current: ComputedRef<SuggestionId | undefined> public current: ComputedRef<SuggestionId | undefined>
/** TODO: Add docs */ /**
* Initializes the history stack.
*/
constructor() { constructor() {
this.stack = reactive([]) this.stack = reactive([])
this.index = ref(0) this.index = ref(0)
this.current = computed(() => this.stack[this.index.value] ?? undefined) 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) { public reset(current: SuggestionId) {
this.stack.length = 0 this.stack.length = 0
this.stack.push(current) this.stack.push(current)
this.index.value = 0 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) { public record(id: SuggestionId) {
this.stack.splice(this.index.value + 1) this.stack.splice(this.index.value + 1)
this.stack.push(id) this.stack.push(id)
this.index.value = this.stack.length - 1 this.index.value = this.stack.length - 1
} }
/** TODO: Add docs */ /**
* Moves the history index forward by one step if possible.
*/
public forward() { public forward() {
if (this.canGoForward()) { if (this.canGoForward()) {
this.index.value += 1 this.index.value += 1
} }
} }
/** TODO: Add docs */ /**
* Navigates backward in the history if possible.
*/
public backward() { public backward() {
if (this.canGoBackward()) { if (this.canGoBackward()) {
this.index.value -= 1 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 { public canGoBackward(): boolean {
return this.index.value > 0 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 { public canGoForward(): boolean {
return this.index.value < this.stack.length - 1 return this.index.value < this.stack.length - 1
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -126,7 +126,10 @@ export function useVisualizationData({
const preprocessor = visPreprocessor.value const preprocessor = visPreprocessor.value
const args = preprocessor.positionalArgumentsExpressions const args = preprocessor.positionalArgumentsExpressions
const tempModule = Ast.MutableModule.Transient() 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. // 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. // Tracked in https://github.com/orgs/enso-org/discussions/6832#discussioncomment-7754474.
if (!isIdentifier(preprocessor.expression)) { if (!isIdentifier(preprocessor.expression)) {
@ -140,9 +143,9 @@ export function useVisualizationData({
) )
const preprocessorInvocation = Ast.App.PositionalSequence(preprocessorQn, [ const preprocessorInvocation = Ast.App.PositionalSequence(preprocessorQn, [
Ast.Wildcard.new(tempModule), 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) const expression = Ast.OprApp.new(tempModule, preprocessorInvocation, '<|', rhs)
return projectStore.executeExpression(dataSourceValue.contextId, expression.code()) return projectStore.executeExpression(dataSourceValue.contextId, expression.code())
} catch (e) { } catch (e) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,7 +45,10 @@ const operatorStyle = computed(() => {
application.value.appTree instanceof Ast.OprApp || application.value.appTree instanceof Ast.OprApp ||
application.value.appTree instanceof Ast.PropertyAccess 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 { return {
'--whitespace-pre': `${JSON.stringify(opr?.whitespace ?? '')}`, '--whitespace-pre': `${JSON.stringify(opr?.whitespace ?? '')}`,
'--whitespace-post': `${JSON.stringify(rhs?.whitespace ?? '')}`, '--whitespace-post': `${JSON.stringify(rhs?.whitespace ?? '')}`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,12 +3,23 @@ import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import { WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry' import { WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import { computed } from 'vue' import { computed } from 'vue'
import { isToken } from 'ydoc-shared/ast'
const props = defineProps(widgetProps(widgetDefinition)) const props = defineProps(widgetProps(widgetDefinition))
const spanClass = computed(() => props.input.value.typeName()) 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) const childInput = WidgetInput.FromAst(child)
if (props.input.value instanceof Ast.PropertyAccess && child.id === props.input.value.lhs?.id) if (props.input.value instanceof Ast.PropertyAccess && child.id === props.input.value.lhs?.id)
childInput.forcePort = true childInput.forcePort = true
@ -36,8 +47,8 @@ export const widgetDefinition = defineWidget(
<template> <template>
<div class="WidgetHierarchy" :class="spanClass"> <div class="WidgetHierarchy" :class="spanClass">
<NodeWidget <NodeWidget
v-for="(child, index) in props.input.value.children()" v-for="child in expressionChildren(props.input.value)"
:key="child.id ?? index" :key="child.id"
:input="transformChild(child)" :input="transformChild(child)"
/> />
</div> </div>

View File

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

View File

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

View File

@ -2,6 +2,7 @@
import { WidgetInputIsSpecificMethodCall } from '@/components/GraphEditor/widgets/WidgetFunction.vue' import { WidgetInputIsSpecificMethodCall } from '@/components/GraphEditor/widgets/WidgetFunction.vue'
import TableHeader from '@/components/GraphEditor/widgets/WidgetTableEditor/TableHeader.vue' import TableHeader from '@/components/GraphEditor/widgets/WidgetTableEditor/TableHeader.vue'
import { import {
CELLS_LIMIT,
tableNewCallMayBeHandled, tableNewCallMayBeHandled,
useTableNewArgument, useTableNewArgument,
type RowData, type RowData,
@ -16,6 +17,7 @@ import { useGraphStore } from '@/stores/graph'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase' import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { Rect } from '@/util/data/rect' import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2' 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-grid.css'
import '@ag-grid-community/styles/ag-theme-alpine.css' import '@ag-grid-community/styles/ag-theme-alpine.css'
import type { import type {
@ -29,11 +31,29 @@ import type {
} from 'ag-grid-enterprise' } from 'ag-grid-enterprise'
import { computed, markRaw, ref } from 'vue' import { computed, markRaw, ref } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers' import type { ComponentExposed } from 'vue-component-type-helpers'
import { z } from 'zod'
const props = defineProps(widgetProps(widgetDefinition)) const props = defineProps(widgetProps(widgetDefinition))
const graph = useGraphStore() const graph = useGraphStore()
const suggestionDb = useSuggestionDbStore() const suggestionDb = useSuggestionDbStore()
const grid = ref<ComponentExposed<typeof AgGridTableView<RowData, any>>>() 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( const { rowData, columnDefs, moveColumn, moveRow, pasteFromClipboard } = useTableNewArgument(
() => props.input, () => props.input,
@ -131,15 +151,22 @@ const headerEditHandler = new HeaderEditing()
// === Resizing === // === Resizing ===
const size = ref(new Vec2(200, 150))
const graphNav = injectGraphNavigator() const graphNav = injectGraphNavigator()
const size = computed(() => Vec2.FromXY(config.value.size))
const clientBounds = computed({ const clientBounds = computed({
get() { get() {
return new Rect(Vec2.Zero, size.value.scale(graphNav.scale)) return new Rect(Vec2.Zero, size.value.scale(graphNav.scale))
}, },
set(value) { 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() const focusedCell = api.getFocusedCell()
if (focusedCell === null) console.warn('Pasting while no cell is focused!') if (focusedCell === null) console.warn('Pasting while no cell is focused!')
else { else {
pasteFromClipboard(data, { const pasted = pasteFromClipboard(data, {
rowIndex: focusedCell.rowIndex, rowIndex: focusedCell.rowIndex,
colId: focusedCell.column.getColId(), 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 [] return []
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import SvgButton from '@/components/SvgButton.vue' import SvgButton from '@/components/SvgButton.vue'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import ControlButtons from './ControlButtons.vue'
const project = useProjectStore() const project = useProjectStore()
</script> </script>
<template> <template>
<div class="RecordControl"> <ControlButtons class="RecordControl">
<div class="control left-end"> <template #left>
<SvgButton <SvgButton
title="Refresh" title="Refresh"
class="iconButton" class="iconButton"
@ -15,8 +16,8 @@ const project = useProjectStore()
draggable="false" draggable="false"
@click.stop="project.executionContext.recompute()" @click.stop="project.executionContext.recompute()"
/> />
</div> </template>
<div class="control right-end"> <template #right>
<SvgButton <SvgButton
title="Write All" title="Write All"
class="iconButton" class="iconButton"
@ -24,41 +25,11 @@ const project = useProjectStore()
draggable="false" draggable="false"
@click.stop="project.executionContext.recompute('all', 'Live')" @click.stop="project.executionContext.recompute('all', 'Live')"
/> />
</div> </template>
</div> </ControlButtons>
</template> </template>
<style scoped> <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 { .iconButton:active {
color: #ba4c40; color: #ba4c40;
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,6 @@ export interface LexicalPlugin {
/** TODO: Add docs */ /** TODO: Add docs */
export function lexicalTheme(theme: Record<string, string>): EditorThemeClasses { 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> {} interface EditorThemeShape extends Record<string, EditorThemeShape | string> {}
const editorClasses: EditorThemeShape = {} const editorClasses: EditorThemeShape = {}
for (const [classPath, className] of Object.entries(theme)) { for (const [classPath, className] of Object.entries(theme)) {

View File

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

View File

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

View File

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

View File

@ -13,7 +13,9 @@ export const defaultPreprocessor = [
] as const ] as const
const removeWarnings = computed(() => 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> </script>

View File

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

View File

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

View File

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

View File

@ -126,9 +126,13 @@ export function useNodeCreation(
const createdIdentifiers = new Set<Identifier>() const createdIdentifiers = new Set<Identifier>()
const identifiersRenameMap = new Map<Identifier, Identifier>() const identifiersRenameMap = new Map<Identifier, Identifier>()
graphStore.edit((edit) => { graphStore.edit((edit) => {
const statements = new Array<Ast.Owned>() const statements = new Array<Ast.Owned<Ast.MutableStatement>>()
for (const options of placedNodes) { 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) const ident = getIdentifier(rhs, options, createdIdentifiers)
createdIdentifiers.add(ident) createdIdentifiers.add(ident)
const { id, rootExpression } = newAssignmentNode( const { id, rootExpression } = newAssignmentNode(
@ -192,19 +196,16 @@ export function useNodeCreation(
function newAssignmentNode( function newAssignmentNode(
edit: Ast.MutableModule, edit: Ast.MutableModule,
ident: Ast.Identifier, ident: Ast.Identifier,
rhs: Ast.Owned, rhs: Ast.Owned<Ast.MutableExpression>,
options: NodeCreationOptions, options: NodeCreationOptions,
identifiersRenameMap: Map<Ast.Identifier, Ast.Identifier>, identifiersRenameMap: Map<Ast.Identifier, Ast.Identifier>,
) { ) {
rhs.setNodeMetadata(options.metadata ?? {}) 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) afterCreation(edit, assignment, ident, options, identifiersRenameMap)
const id = asNodeId(rhs.externalId) const id = asNodeId(rhs.externalId)
const rootExpression = return { rootExpression: assignment, id }
options.documentation != null ?
Ast.Documented.new(options.documentation, assignment)
: assignment
return { rootExpression, id }
} }
function getIdentifier( 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 * 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. * 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 lines = bodyBlock.lines
const lastStatement = lines[lines.length - 1]?.statement?.node
const index = const index =
lines[lines.length - 1]?.expression?.node.isBindingStatement !== false ? lastStatement instanceof Ast.MutableAssignment || lastStatement instanceof Ast.MutableFunction ?
lines.length lines.length
: lines.length - 1 : lines.length - 1
bodyBlock.insert(index, ...statements) bodyBlock.insert(index, ...statements)

View File

@ -1,7 +1,6 @@
import type { BreadcrumbItem } from '@/components/NavBreadcrumbs.vue' import type { BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
import { type GraphStore, type NodeId } from '@/stores/graph' import { type GraphStore, type NodeId } from '@/stores/graph'
import { type ProjectStore } from '@/stores/project' import { type ProjectStore } from '@/stores/project'
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { methodPointerEquals, type StackItem } from 'ydoc-shared/languageServerTypes' import { methodPointerEquals, type StackItem } from 'ydoc-shared/languageServerTypes'
@ -45,19 +44,8 @@ export function useStackNavigator(projectStore: ProjectStore, graphStore: GraphS
} }
function enterNode(id: NodeId) { function enterNode(id: NodeId) {
const expressionInfo = graphStore.db.getExpressionInfo(id) if (!graphStore.nodeCanBeEntered(id)) {
if (expressionInfo == null || expressionInfo.methodCall == null) { console.warn('Trying to enter a node that cannot be entered.')
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.')
return return
} }
projectStore.executionContext.push(id) projectStore.executionContext.push(id)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { usePlacement } from '@/components/ComponentBrowser/placement'
import { createContextStore } from '@/providers' import { createContextStore } from '@/providers'
import type { PortId } from '@/providers/portInfo' import type { PortId } from '@/providers/portInfo'
import type { WidgetUpdate } from '@/providers/widgetRegistry' 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 { import {
addImports, addImports,
detectImportConflicts, detectImportConflicts,
@ -22,10 +22,11 @@ import { isAstId, isIdentifier } from '@/util/ast/abstract'
import { RawAst, visitRecursive } from '@/util/ast/raw' import { RawAst, visitRecursive } from '@/util/ast/raw'
import { reactiveModule } from '@/util/ast/reactive' import { reactiveModule } from '@/util/ast/reactive'
import { partition } from '@/util/data/array' import { partition } from '@/util/data/array'
import { Events, stringUnionToArray } from '@/util/data/observable'
import { Rect } from '@/util/data/rect' 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 { Vec2 } from '@/util/data/vec2'
import { normalizeQualifiedName, tryQualifiedName } from '@/util/qualifiedName' import { normalizeQualifiedName, qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import { useWatchContext } from '@/util/reactivity' import { useWatchContext } from '@/util/reactivity'
import { computedAsync } from '@vueuse/core' import { computedAsync } from '@vueuse/core'
import { map, set } from 'lib0' import { map, set } from 'lib0'
@ -58,6 +59,7 @@ import type {
VisualizationMetadata, VisualizationMetadata,
} from 'ydoc-shared/yjsModel' } from 'ydoc-shared/yjsModel'
import { defaultLocalOrigin, sourceRangeKey, visMetadataEquals } from 'ydoc-shared/yjsModel' import { defaultLocalOrigin, sourceRangeKey, visMetadataEquals } from 'ydoc-shared/yjsModel'
import { UndoManager } from 'yjs'
const FALLBACK_BINDING_PREFIX = 'node' 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') return Err('Method pointer is not a module method')
const method = Ast.findModuleMethod(topLevel, ptr.name) const method = Ast.findModuleMethod(topLevel, ptr.name)
if (!method) return Err(`No method with name ${ptr.name} in ${modulePath.value}`) 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) updatePortValue(edit, usage, undefined)
} }
const outerExpr = edit.getVersion(node.outerExpr) const outerAst = edit.getVersion(node.outerAst)
if (outerExpr) Ast.deleteFromParentBlock(outerExpr) if (outerAst.isStatement()) Ast.deleteFromParentBlock(outerAst)
nodeRects.delete(id) nodeRects.delete(id)
nodeHoverAnimations.delete(id) nodeHoverAnimations.delete(id)
deletedNodes.add(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 = { const undoManager = {
undo() { undo() {
proj.module?.undoManager.undo() proj.module?.undoManager.undo()
@ -374,6 +399,8 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
undoStackBoundary() { undoStackBoundary() {
proj.module?.undoManager.stopCapturing() proj.module?.undoManager.stopCapturing()
}, },
canUndo: computed(() => undoManagerStatus.canUndo),
canRedo: computed(() => undoManagerStatus.canRedo),
} }
function setNodePosition(nodeId: NodeId, position: Vec2) { function setNodePosition(nodeId: NodeId, position: Vec2) {
@ -549,7 +576,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
function updatePortValue( function updatePortValue(
edit: MutableModule, edit: MutableModule,
id: PortId, id: PortId,
value: Ast.Owned | undefined, value: Ast.Owned<Ast.MutableExpression> | undefined,
): boolean { ): boolean {
const update = getPortPrimaryInstance(id)?.onUpdate const update = getPortPrimaryInstance(id)?.onUpdate
if (!update) return false if (!update) return false
@ -665,7 +692,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
const body = func.bodyExpressions() const body = func.bodyExpressions()
const result: NodeId[] = [] const result: NodeId[] = []
for (const expr of body) { for (const expr of body) {
const nodeId = nodeIdFromOuterExpr(expr) const nodeId = nodeIdFromOuterAst(expr)
if (nodeId && ids.has(nodeId)) result.push(nodeId) if (nodeId && ids.has(nodeId)) result.push(nodeId)
} }
return result return result
@ -683,14 +710,14 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
sourceNodeId: NodeId, sourceNodeId: NodeId,
targetNodeId: NodeId, targetNodeId: NodeId,
) { ) {
const sourceExpr = db.nodeIdToNode.get(sourceNodeId)?.outerExpr.id const sourceExpr = db.nodeIdToNode.get(sourceNodeId)?.outerAst.id
const targetExpr = db.nodeIdToNode.get(targetNodeId)?.outerExpr.id const targetExpr = db.nodeIdToNode.get(targetNodeId)?.outerAst.id
const body = edit.getVersion(unwrap(getExecutedMethodAst(edit))).bodyAsBlock() const body = edit.getVersion(unwrap(getExecutedMethodAst(edit))).bodyAsBlock()
assert(sourceExpr != null) assert(sourceExpr != null)
assert(targetExpr != null) assert(targetExpr != null)
const lines = body.lines const lines = body.lines
const sourceIdx = lines.findIndex((line) => line.expression?.node.id === sourceExpr) const sourceIdx = lines.findIndex((line) => line.statement?.node.id === sourceExpr)
const targetIdx = lines.findIndex((line) => line.expression?.node.id === targetExpr) const targetIdx = lines.findIndex((line) => line.statement?.node.id === targetExpr)
assert(sourceIdx != null) assert(sourceIdx != null)
assert(targetIdx != 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 deps = reachable([targetNodeId], (node) => db.nodeDependents.lookup(node))
const dependantLines = new Set( 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. // Include the new target itself in the set of lines that must be placed after source node.
dependantLines.add(targetExpr) 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. // Split those lines into two buckets, whether or not they depend on the target.
const [linesAfter, linesBefore] = partition(linesToSort, (line) => 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. // 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 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( const modulePath: Ref<LsPath | undefined> = computedAsync(
async () => { async () => {
const rootId = await proj.projectRootId const rootId = await proj.projectRootId
@ -789,6 +832,7 @@ export const { injectFn: useGraphStore, provideFn: provideGraphStore } = createC
addMissingImports, addMissingImports,
addMissingImportsDisregardConflicts, addMissingImportsDisregardConflicts,
isConnectedTarget, isConnectedTarget,
nodeCanBeEntered,
currentMethodPointer() { currentMethodPointer() {
const currentMethod = proj.executionContext.getStackTop() const currentMethod = proj.executionContext.getStackTop()
if (currentMethod.type === 'ExplicitCall') return currentMethod.methodPointer if (currentMethod.type === 'ExplicitCall') return currentMethod.methodPointer

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,17 +10,17 @@ test.each`
${'## Documentation\n2 + 2'} | ${undefined} | ${'2 + 2'} | ${'Documentation'} ${'## Documentation\n2 + 2'} | ${undefined} | ${'2 + 2'} | ${'Documentation'}
${'## Documentation\nfoo = 2 + 2'} | ${'foo'} | ${'2 + 2'} | ${'Documentation'} ${'## Documentation\nfoo = 2 + 2'} | ${'foo'} | ${'2 + 2'} | ${'Documentation'}
`('Node information from AST $line line', ({ line, pattern, rootExpr, 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) const node = nodeFromAst(ast, false)
expect(node?.outerExpr).toBe(ast) expect(node?.outerAst).toBe(ast)
expect(node?.pattern?.code()).toBe(pattern) expect(node?.pattern?.code()).toBe(pattern)
expect(node?.rootExpr.code()).toBe(rootExpr) expect(node?.rootExpr.code()).toBe(rootExpr)
expect(node?.innerExpr.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) => { 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) const node = nodeFromAst(ast, false)
expect(node).toBeUndefined() expect(node).toBeUndefined()
}) })
@ -47,7 +47,7 @@ test.each([
}, },
{ code: 'operator1 + operator2', expected: undefined }, { code: 'operator1 + operator2', expected: undefined },
])('Primary application subject of $code', ({ code, expected }) => { ])('Primary application subject of $code', ({ code, expected }) => {
const ast = Ast.Ast.parse(code) const ast = Ast.parseExpression(code)
const module = ast.module const module = ast.module
const primaryApplication = primaryApplicationSubject(ast) const primaryApplication = primaryApplicationSubject(ast)
const analyzed = primaryApplication && { const analyzed = primaryApplication && {

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