mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 11:52:59 +03:00
Merge branch 'develop' into wip/sb/fix-react-compiler-lints
This commit is contained in:
commit
1c317b4fec
1
.gitattributes
vendored
1
.gitattributes
vendored
@ -1,2 +1,3 @@
|
|||||||
*.enso text eol=lf
|
*.enso text eol=lf
|
||||||
*.png binary
|
*.png binary
|
||||||
|
CHANGELOG.md merge=union
|
||||||
|
24
CHANGELOG.md
24
CHANGELOG.md
@ -12,6 +12,16 @@
|
|||||||
- [Changed the way of adding new column in Table Input Widget][11388]. The
|
- [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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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',
|
||||||
|
@ -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>
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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}</>
|
||||||
|
}
|
||||||
|
@ -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 }) => {
|
||||||
|
@ -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 : (
|
||||||
|
@ -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'
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
|
@ -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',
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
||||||
|
@ -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. */
|
||||||
|
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
@ -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. */
|
||||||
|
159
app/gui/src/dashboard/hooks/storeHooks.ts
Normal file
159
app/gui/src/dashboard/hooks/storeHooks.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* @file
|
||||||
|
*
|
||||||
|
* This file contains hooks for using Zustand store with tearing transitions.
|
||||||
|
*/
|
||||||
|
import type { DispatchWithoutAction, Reducer, RefObject } from 'react'
|
||||||
|
import { useEffect, useReducer, useRef } from 'react'
|
||||||
|
import { type StoreApi } from 'zustand'
|
||||||
|
import { useStoreWithEqualityFn } from 'zustand/traditional'
|
||||||
|
import { objectEquality, refEquality, shallowEquality } from '../utilities/equalities'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A type that allows to choose between different equality functions.
|
||||||
|
*/
|
||||||
|
export type AreEqual<T> = EqualityFunction<T> | EqualityFunctionName
|
||||||
|
/**
|
||||||
|
* Custom equality function.
|
||||||
|
*/
|
||||||
|
export type EqualityFunction<T> = (a: T, b: T) => boolean
|
||||||
|
/**
|
||||||
|
* Equality function name from a list of predefined ones.
|
||||||
|
*/
|
||||||
|
export type EqualityFunctionName = 'object' | 'shallow' | 'strict'
|
||||||
|
|
||||||
|
const EQUALITY_FUNCTIONS: Record<EqualityFunctionName, (a: unknown, b: unknown) => boolean> = {
|
||||||
|
object: objectEquality,
|
||||||
|
shallow: shallowEquality,
|
||||||
|
strict: refEquality,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for the `useStore` hook. */
|
||||||
|
export interface UseStoreOptions<Slice> {
|
||||||
|
/**
|
||||||
|
* Adds support for React transitions.
|
||||||
|
*
|
||||||
|
* Use it with caution, as it may lead to inconsistent state during transitions.
|
||||||
|
*/
|
||||||
|
readonly unsafeEnableTransition?: boolean
|
||||||
|
/**
|
||||||
|
* Specifies the equality function to use.
|
||||||
|
* @default 'Object.is'
|
||||||
|
*/
|
||||||
|
readonly areEqual?: AreEqual<Slice>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper that allows to choose between tearing transition and standard Zustand store.
|
||||||
|
*
|
||||||
|
* # `options.unsafeEnableTransition` must not be changed during the component lifecycle.
|
||||||
|
*/
|
||||||
|
export function useStore<State, Slice>(
|
||||||
|
store: StoreApi<State>,
|
||||||
|
selector: (state: State) => Slice,
|
||||||
|
options: UseStoreOptions<Slice> = {},
|
||||||
|
) {
|
||||||
|
const { unsafeEnableTransition = false, areEqual } = options
|
||||||
|
|
||||||
|
const prevUnsafeEnableTransition = useRef(unsafeEnableTransition)
|
||||||
|
|
||||||
|
const equalityFunction = resolveAreEqual(areEqual)
|
||||||
|
|
||||||
|
return useNonCompilableConditionalStore(
|
||||||
|
store,
|
||||||
|
selector,
|
||||||
|
unsafeEnableTransition,
|
||||||
|
equalityFunction,
|
||||||
|
prevUnsafeEnableTransition,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A hook that allows to use React transitions with Zustand store. */
|
||||||
|
export function useTearingTransitionStore<State, Slice>(
|
||||||
|
store: StoreApi<State>,
|
||||||
|
selector: (state: State) => Slice,
|
||||||
|
areEqual: AreEqual<Slice> = 'object',
|
||||||
|
) {
|
||||||
|
const state = store.getState()
|
||||||
|
|
||||||
|
const equalityFunction = resolveAreEqual(areEqual)
|
||||||
|
|
||||||
|
const [[sliceFromReducer, storeFromReducer], rerender] = useReducer<
|
||||||
|
Reducer<
|
||||||
|
readonly [Slice, StoreApi<State>, State],
|
||||||
|
readonly [Slice, StoreApi<State>, State] | undefined
|
||||||
|
>,
|
||||||
|
undefined
|
||||||
|
>(
|
||||||
|
(prev, fromSelf) => {
|
||||||
|
if (fromSelf) {
|
||||||
|
return fromSelf
|
||||||
|
}
|
||||||
|
const nextState = store.getState()
|
||||||
|
if (Object.is(prev[2], nextState) && prev[1] === store) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
const nextSlice = selector(nextState)
|
||||||
|
if (equalityFunction(prev[0], nextSlice) && prev[1] === store) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return [nextSlice, store, nextState]
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
() => [selector(state), store, state],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = store.subscribe(() => {
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
;(rerender as DispatchWithoutAction)()
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
;(rerender as DispatchWithoutAction)()
|
||||||
|
return unsubscribe
|
||||||
|
}, [store])
|
||||||
|
|
||||||
|
if (storeFromReducer !== store) {
|
||||||
|
const slice = selector(state)
|
||||||
|
rerender([slice, store, state])
|
||||||
|
return slice
|
||||||
|
}
|
||||||
|
|
||||||
|
return sliceFromReducer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the equality function. */
|
||||||
|
function resolveAreEqual<Slice>(areEqual: AreEqual<Slice> | null | undefined) {
|
||||||
|
return (
|
||||||
|
areEqual == null ? EQUALITY_FUNCTIONS.object
|
||||||
|
: typeof areEqual === 'string' ? EQUALITY_FUNCTIONS[areEqual]
|
||||||
|
: areEqual
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal hook that isolates the conditional store logic from the `useStore` hook.
|
||||||
|
* To enable compiler optimizations for the `useStore` hook.
|
||||||
|
* @internal
|
||||||
|
* @throws An error if the `unsafeEnableTransition` option is changed during the component lifecycle.
|
||||||
|
*/
|
||||||
|
function useNonCompilableConditionalStore<State, Slice>(
|
||||||
|
store: StoreApi<State>,
|
||||||
|
selector: (state: State) => Slice,
|
||||||
|
unsafeEnableTransition: boolean,
|
||||||
|
equalityFunction: EqualityFunction<Slice>,
|
||||||
|
prevUnsafeEnableTransition: RefObject<boolean>,
|
||||||
|
) {
|
||||||
|
/* eslint-disable react-compiler/react-compiler */
|
||||||
|
/* eslint-disable react-hooks/rules-of-hooks */
|
||||||
|
if (prevUnsafeEnableTransition.current !== unsafeEnableTransition) {
|
||||||
|
throw new Error(
|
||||||
|
'useStore shall not change the `unsafeEnableTransition` option during the component lifecycle',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return unsafeEnableTransition ?
|
||||||
|
useTearingTransitionStore(store, selector, equalityFunction)
|
||||||
|
: useStoreWithEqualityFn(store, selector, equalityFunction)
|
||||||
|
/* eslint-enable react-compiler/react-compiler */
|
||||||
|
/* eslint-enable react-hooks/rules-of-hooks */
|
||||||
|
}
|
@ -21,7 +21,7 @@ import LoggerProvider, { type Logger } from '#/providers/LoggerProvider'
|
|||||||
|
|
||||||
import LoadingScreen from '#/pages/authentication/LoadingScreen'
|
import 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>,
|
||||||
)
|
)
|
||||||
|
@ -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'
|
||||||
|
@ -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}
|
||||||
|
@ -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 && (
|
||||||
|
@ -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. */
|
||||||
|
@ -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
|
||||||
|
@ -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} />
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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 {}
|
||||||
|
|
||||||
// =======================
|
// =======================
|
||||||
|
51
app/gui/src/dashboard/utilities/equalities.ts
Normal file
51
app/gui/src/dashboard/utilities/equalities.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* @file
|
||||||
|
*
|
||||||
|
* This file contains functions for checking equality between values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict equality check.
|
||||||
|
*/
|
||||||
|
export function refEquality<T>(a: T, b: T) {
|
||||||
|
return a === b
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object.is equality check.
|
||||||
|
*/
|
||||||
|
export function objectEquality<T>(a: T, b: T) {
|
||||||
|
return Object.is(a, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shallow equality check.
|
||||||
|
*/
|
||||||
|
export function shallowEquality<T>(a: T, b: T) {
|
||||||
|
if (Object.is(a, b)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof a !== 'object' || a == null || typeof b !== 'object' || b == null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysA = Object.keys(a)
|
||||||
|
|
||||||
|
if (keysA.length !== Object.keys(b).length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < keysA.length; i++) {
|
||||||
|
const key = keysA[i]
|
||||||
|
|
||||||
|
if (key != null) {
|
||||||
|
// @ts-expect-error Typescript doesn't know that key is in a and b, but it doesn't matter here
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
14
app/gui/src/dashboard/utilities/zustand.ts
Normal file
14
app/gui/src/dashboard/utilities/zustand.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* @file
|
||||||
|
*
|
||||||
|
* Re-exporting zustand functions and types.
|
||||||
|
* Overrides the default `useStore` with a custom one, that supports equality functions and React.transition
|
||||||
|
*/
|
||||||
|
export { useStore, useTearingTransitionStore } from '#/hooks/storeHooks'
|
||||||
|
export type {
|
||||||
|
AreEqual,
|
||||||
|
EqualityFunction,
|
||||||
|
EqualityFunctionName,
|
||||||
|
UseStoreOptions,
|
||||||
|
} from '#/hooks/storeHooks'
|
||||||
|
export * from 'zustand'
|
@ -12,11 +12,13 @@ const isVisualizationEnabled = defineModel<boolean>('isVisualizationEnabled', {
|
|||||||
const props = defineProps<{
|
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>
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -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(
|
||||||
|
@ -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 don’t need to add `Any` to the list, because the caller already did that.
|
||||||
|
while (entry != null && entry.parentType != null && entry.parentType !== ANY_TYPE) {
|
||||||
|
list.push(entry.parentType)
|
||||||
|
entry = db.getEntryByQualifiedName(entry.parentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -248,9 +248,13 @@ export class Filtering {
|
|||||||
if (currentModule != null) this.currentModule = currentModule
|
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)
|
||||||
|
@ -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>
|
||||||
|
42
app/gui/src/project-view/components/ControlButtons.vue
Normal file
42
app/gui/src/project-view/components/ControlButtons.vue
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ControlButtons">
|
||||||
|
<div class="control left-end">
|
||||||
|
<slot name="left"></slot>
|
||||||
|
</div>
|
||||||
|
<div class="control right-end">
|
||||||
|
<slot name="right"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ControlButtons {
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
background: var(--color-frame-bg);
|
||||||
|
backdrop-filter: var(--blur-app-bg);
|
||||||
|
padding: 4px 4px;
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-end {
|
||||||
|
border-radius: var(--radius-full) 0 0 var(--radius-full);
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin: 0 0 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-end {
|
||||||
|
border-radius: 0 var(--radius-full) var(--radius-full) 0;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin: 0 auto 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -21,8 +21,8 @@ import type { QualifiedName } from '@/util/qualifiedName'
|
|||||||
import { qnSegments, qnSlice } from '@/util/qualifiedName'
|
import { 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>(() => {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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.`)
|
||||||
|
@ -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"
|
||||||
|
@ -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>
|
||||||
|
@ -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)"
|
||||||
|
@ -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) {
|
||||||
|
@ -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.`)
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
})
|
||||||
|
@ -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'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 function’s argument names. */
|
/** The list of extracted function’s 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 }
|
||||||
}
|
}
|
||||||
|
@ -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 ?? '')}`,
|
||||||
|
@ -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 }
|
||||||
|
@ -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 = {
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
},
|
},
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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)!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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 []
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
})
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
37
app/gui/src/project-view/components/GraphMissingView.vue
Normal file
37
app/gui/src/project-view/components/GraphMissingView.vue
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SvgIcon from '@/components/SvgIcon.vue'
|
||||||
|
import { useProjectStore } from '@/stores/project'
|
||||||
|
import StandaloneButton from './StandaloneButton.vue'
|
||||||
|
|
||||||
|
const project = useProjectStore()
|
||||||
|
|
||||||
|
function goToMain() {
|
||||||
|
project.executionContext.desiredStack = [project.executionContext.getStackBottom()]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="GraphMissingView">
|
||||||
|
<SvgIcon class="header-icon" name="error" />
|
||||||
|
<span>The component you are viewing no longer exists.</span>
|
||||||
|
<StandaloneButton icon="home2" label="Go back" @click="goToMain" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.GraphMissingView {
|
||||||
|
background-image: linear-gradient(to bottom, #00000000, #00000030);
|
||||||
|
background-size: 100% 100%;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
--icon-size: 64px;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,13 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<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;
|
||||||
}
|
}
|
||||||
|
26
app/gui/src/project-view/components/StandaloneButton.vue
Normal file
26
app/gui/src/project-view/components/StandaloneButton.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { URLString } from '@/util/data/urlString'
|
||||||
|
import type { Icon } from '@/util/iconName'
|
||||||
|
import SvgButton from './SvgButton.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
icon?: Icon | URLString | undefined
|
||||||
|
label?: string | undefined
|
||||||
|
disabled?: boolean
|
||||||
|
title?: string | undefined
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="StandaloneButton">
|
||||||
|
<SvgButton v-bind="props" :name="icon" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.StandaloneButton {
|
||||||
|
background-color: var(--color-frame-bg);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
</style>
|
@ -5,7 +5,7 @@ import type { URLString } from '@/util/data/urlString'
|
|||||||
import type { Icon } from '@/util/iconName'
|
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>
|
||||||
|
@ -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"
|
||||||
|
32
app/gui/src/project-view/components/UndoRedoButtons.vue
Normal file
32
app/gui/src/project-view/components/UndoRedoButtons.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SvgButton from '@/components/SvgButton.vue'
|
||||||
|
import { useGraphStore } from '@/stores/graph'
|
||||||
|
import ControlButtons from './ControlButtons.vue'
|
||||||
|
|
||||||
|
const graphStore = useGraphStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ControlButtons class="UndoRedoButtons">
|
||||||
|
<template #left>
|
||||||
|
<SvgButton
|
||||||
|
title="Undo"
|
||||||
|
class="iconButton"
|
||||||
|
name="undo"
|
||||||
|
draggable="false"
|
||||||
|
:disabled="!graphStore.undoManager.canUndo.value"
|
||||||
|
@click.stop="graphStore.undoManager.undo"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #right>
|
||||||
|
<SvgButton
|
||||||
|
title="Redo"
|
||||||
|
class="iconButton"
|
||||||
|
name="redo"
|
||||||
|
draggable="false"
|
||||||
|
:disabled="!graphStore.undoManager.canRedo.value"
|
||||||
|
@click.stop="graphStore.undoManager.redo"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ControlButtons>
|
||||||
|
</template>
|
@ -19,7 +19,6 @@ export interface LexicalPlugin {
|
|||||||
|
|
||||||
/** TODO: Add docs */
|
/** 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)) {
|
||||||
|
@ -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,
|
||||||
|
@ -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)!,
|
||||||
|
@ -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' ?
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
@ -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()
|
||||||
|
@ -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)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
|
@ -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 }
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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.
|
||||||
|
@ -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>,
|
||||||
|
@ -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))!
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -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[]
|
||||||
|
@ -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({
|
||||||
|
@ -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)
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -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) {
|
||||||
|
@ -0,0 +1,90 @@
|
|||||||
|
import { assert } from '@/util/assert'
|
||||||
|
import { Ast } from '@/util/ast'
|
||||||
|
import { test } from '@fast-check/vitest'
|
||||||
|
import { expect } from 'vitest'
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ code: '## Simple\nnode', documentation: 'Simple' },
|
||||||
|
{
|
||||||
|
code: '## Preferred indent\n 2nd line\n 3rd line\nnode',
|
||||||
|
documentation: 'Preferred indent\n2nd line\n3rd line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '## Extra-indented child\n 2nd line\n 3rd line\nnode',
|
||||||
|
documentation: 'Extra-indented child\n2nd line\n3rd line',
|
||||||
|
normalized: '## Extra-indented child\n 2nd line\n 3rd line\nnode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '## Extra-indented child, beyond 4th column\n 2nd line\n 3rd line\nnode',
|
||||||
|
documentation: 'Extra-indented child, beyond 4th column\n2nd line\n 3rd line',
|
||||||
|
normalized: '## Extra-indented child, beyond 4th column\n 2nd line\n 3rd line\nnode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '##Preferred indent, no initial space\n 2nd line\n 3rd line\nnode',
|
||||||
|
documentation: 'Preferred indent, no initial space\n2nd line\n3rd line',
|
||||||
|
normalized: '## Preferred indent, no initial space\n 2nd line\n 3rd line\nnode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: '## Minimum indent\n 2nd line\n 3rd line\nnode',
|
||||||
|
documentation: 'Minimum indent\n2nd line\n3rd line',
|
||||||
|
normalized: '## Minimum indent\n 2nd line\n 3rd line\nnode',
|
||||||
|
},
|
||||||
|
])('Documentation edit round-trip: $code', (docCase) => {
|
||||||
|
const { code, documentation } = docCase
|
||||||
|
const parsed = Ast.parseStatement(code)!
|
||||||
|
const parsedDocumentation = parsed.documentationText()
|
||||||
|
expect(parsedDocumentation).toBe(documentation)
|
||||||
|
const edited = Ast.MutableModule.Transient().copy(parsed)
|
||||||
|
assert('setDocumentationText' in edited)
|
||||||
|
edited.setDocumentationText(parsedDocumentation)
|
||||||
|
expect(edited.code()).toBe(docCase.normalized ?? code)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
'## Some documentation\nf x = 123',
|
||||||
|
'## Some documentation\n and a second line\nf x = 123',
|
||||||
|
'## Some documentation## Another documentation??\nf x = 123',
|
||||||
|
])('Finding documentation: $code', (code) => {
|
||||||
|
const block = Ast.parseBlock(code)
|
||||||
|
const method = Ast.findModuleMethod(block, 'f')!.statement
|
||||||
|
expect(method.documentationText()).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
code: '## Already documented\nf x = 123',
|
||||||
|
expected: '## Already documented\nf x = 123',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'f x = 123',
|
||||||
|
expected: '##\nf x = 123',
|
||||||
|
},
|
||||||
|
])('Adding documentation: $code', ({ code, expected }) => {
|
||||||
|
const block = Ast.parseBlock(code)
|
||||||
|
const module = block.module
|
||||||
|
const method = module.getVersion(Ast.findModuleMethod(block, 'f')!.statement)
|
||||||
|
if (method.documentationText() === undefined) {
|
||||||
|
method.setDocumentationText('')
|
||||||
|
}
|
||||||
|
expect(block.code()).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Creating comments', () => {
|
||||||
|
const block = Ast.parseBlock('2 + 2')
|
||||||
|
block.module.setRoot(block)
|
||||||
|
const statement = [...block.statements()][0]! as Ast.MutableExpressionStatement
|
||||||
|
const docText = 'Calculate five'
|
||||||
|
statement.setDocumentationText(docText)
|
||||||
|
expect(statement.module.root()?.code()).toBe(`## ${docText}\n2 + 2`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Creating comments: indented', () => {
|
||||||
|
const block = Ast.parseBlock('main =\n x = 1')
|
||||||
|
const module = block.module
|
||||||
|
module.setRoot(block)
|
||||||
|
const main = module.getVersion(Ast.findModuleMethod(block, 'main')!.statement)
|
||||||
|
const statement = [...main.bodyAsBlock().statements()][0]! as Ast.MutableAssignment
|
||||||
|
const docText = 'The smallest natural number'
|
||||||
|
statement.setDocumentationText(docText)
|
||||||
|
expect(statement.module.root()?.code()).toBe(`main =\n ## ${docText}\n x = 1`)
|
||||||
|
})
|
@ -80,9 +80,9 @@ test.each([
|
|||||||
extracted: ['with_enabled_context', "'current_context_name'", 'a + b'],
|
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)
|
||||||
})
|
})
|
||||||
|
@ -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
Loading…
Reference in New Issue
Block a user