mirror of
https://github.com/enso-org/enso.git
synced 2024-11-29 03:08:55 +03:00
Merge branch 'refs/heads/develop' into wip/akirathan/err-log
This commit is contained in:
commit
3aab7780be
7
.envrc
Normal file
7
.envrc
Normal file
@ -0,0 +1,7 @@
|
||||
strict_env
|
||||
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 3.0.4; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.4/direnvrc" "sha256-DzlYZ33mWF/Gs8DDeyjr8mnVmQGx7ASYqA5WlxwvBG4="
|
||||
fi
|
||||
|
||||
use flake
|
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
5
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -21,9 +21,8 @@ Please ensure that the following checklist has been satisfied before submitting
|
||||
- [ ] All code follows the
|
||||
[Scala](https://github.com/enso-org/enso/blob/develop/docs/style-guide/scala.md),
|
||||
[Java](https://github.com/enso-org/enso/blob/develop/docs/style-guide/java.md),
|
||||
[TypeScript](https://github.com/enso-org/enso/blob/develop/docs/style-guide/typescript.md),
|
||||
and
|
||||
[Rust](https://github.com/enso-org/enso/blob/develop/docs/style-guide/rust.md)
|
||||
style guides. In case you are using a language not listed above, follow the [Rust](https://github.com/enso-org/enso/blob/develop/docs/style-guide/rust.md) style guide.
|
||||
- All code has been tested:
|
||||
- [ ] Unit tests have been written where possible.
|
||||
- [ ] If GUI codebase was changed, the GUI was tested when built using `./run ide build`.
|
||||
- [ ] Unit tests have been written where possible.
|
||||
|
6
.github/workflows/engine-nightly.yml
vendored
6
.github/workflows/engine-nightly.yml
vendored
@ -107,7 +107,7 @@ jobs:
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-x86_64:
|
||||
name: Engine (GraalVM CE) (macos, x86_64)
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
@ -354,7 +354,7 @@ jobs:
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-x86_64:
|
||||
name: JVM Tests (GraalVM CE) (macos, x86_64)
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
@ -640,7 +640,7 @@ jobs:
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-x86_64:
|
||||
name: Standard Library Tests (GraalVM CE) (macos, x86_64)
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
|
12
.github/workflows/gui.yml
vendored
12
.github/workflows/gui.yml
vendored
@ -61,7 +61,7 @@ jobs:
|
||||
enso-build-ci-gen-job-build-backend-macos-x86_64:
|
||||
name: Build Backend (macos, x86_64)
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
@ -193,6 +193,7 @@ jobs:
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }}
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }}
|
||||
ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }}
|
||||
ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }}
|
||||
ENSO_CLOUD_REDIRECT: ${{ vars.ENSO_CLOUD_REDIRECT }}
|
||||
ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }}
|
||||
ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }}
|
||||
@ -213,7 +214,7 @@ jobs:
|
||||
enso-build-ci-gen-job-new-gui-build-macos-x86_64:
|
||||
name: GUI build (macos, x86_64)
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
@ -247,6 +248,7 @@ jobs:
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }}
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }}
|
||||
ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }}
|
||||
ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }}
|
||||
ENSO_CLOUD_REDIRECT: ${{ vars.ENSO_CLOUD_REDIRECT }}
|
||||
ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }}
|
||||
ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }}
|
||||
@ -302,6 +304,7 @@ jobs:
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }}
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }}
|
||||
ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }}
|
||||
ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }}
|
||||
ENSO_CLOUD_REDIRECT: ${{ vars.ENSO_CLOUD_REDIRECT }}
|
||||
ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }}
|
||||
ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }}
|
||||
@ -359,6 +362,7 @@ jobs:
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }}
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }}
|
||||
ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }}
|
||||
ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }}
|
||||
ENSO_CLOUD_REDIRECT: ${{ vars.ENSO_CLOUD_REDIRECT }}
|
||||
ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }}
|
||||
ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }}
|
||||
@ -381,7 +385,7 @@ jobs:
|
||||
needs:
|
||||
- enso-build-ci-gen-job-build-backend-macos-x86_64
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
@ -428,6 +432,7 @@ jobs:
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }}
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }}
|
||||
ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }}
|
||||
ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }}
|
||||
ENSO_CLOUD_REDIRECT: ${{ vars.ENSO_CLOUD_REDIRECT }}
|
||||
ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }}
|
||||
ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }}
|
||||
@ -485,6 +490,7 @@ jobs:
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }}
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }}
|
||||
ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }}
|
||||
ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }}
|
||||
ENSO_CLOUD_REDIRECT: ${{ vars.ENSO_CLOUD_REDIRECT }}
|
||||
ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }}
|
||||
ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }}
|
||||
|
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@ -202,7 +202,7 @@ jobs:
|
||||
needs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
@ -389,6 +389,7 @@ jobs:
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }}
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }}
|
||||
ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }}
|
||||
ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }}
|
||||
ENSO_CLOUD_REDIRECT: ${{ vars.ENSO_CLOUD_REDIRECT }}
|
||||
ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }}
|
||||
ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }}
|
||||
@ -464,6 +465,7 @@ jobs:
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }}
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }}
|
||||
ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }}
|
||||
ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }}
|
||||
ENSO_CLOUD_REDIRECT: ${{ vars.ENSO_CLOUD_REDIRECT }}
|
||||
ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }}
|
||||
ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }}
|
||||
@ -490,7 +492,7 @@ jobs:
|
||||
- enso-build-ci-gen-draft-release-linux-x86_64
|
||||
- enso-build-ci-gen-job-upload-backend-macos-x86_64
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
@ -537,6 +539,7 @@ jobs:
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }}
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }}
|
||||
ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }}
|
||||
ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }}
|
||||
ENSO_CLOUD_REDIRECT: ${{ vars.ENSO_CLOUD_REDIRECT }}
|
||||
ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }}
|
||||
ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }}
|
||||
@ -598,6 +601,7 @@ jobs:
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_ID }}
|
||||
ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: ${{ vars.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID }}
|
||||
ENSO_CLOUD_ENVIRONMENT: ${{ vars.ENSO_CLOUD_ENVIRONMENT }}
|
||||
ENSO_CLOUD_GOOGLE_ANALYTICS_TAG: ${{ vars.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG }}
|
||||
ENSO_CLOUD_REDIRECT: ${{ vars.ENSO_CLOUD_REDIRECT }}
|
||||
ENSO_CLOUD_SENTRY_DSN: ${{ vars.ENSO_CLOUD_SENTRY_DSN }}
|
||||
ENSO_CLOUD_STRIPE_KEY: ${{ vars.ENSO_CLOUD_STRIPE_KEY }}
|
||||
|
6
.github/workflows/scala-new.yml
vendored
6
.github/workflows/scala-new.yml
vendored
@ -75,7 +75,7 @@ jobs:
|
||||
enso-build-ci-gen-job-ci-check-backend-graal-vm-ce-macos-x86_64:
|
||||
name: Engine (GraalVM CE) (macos, x86_64)
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
@ -220,7 +220,7 @@ jobs:
|
||||
enso-build-ci-gen-job-jvm-tests-graal-vm-ce-macos-x86_64:
|
||||
name: JVM Tests (GraalVM CE) (macos, x86_64)
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
@ -390,7 +390,7 @@ jobs:
|
||||
enso-build-ci-gen-job-standard-library-tests-graal-vm-ce-macos-x86_64:
|
||||
name: Standard Library Tests (GraalVM CE) (macos, x86_64)
|
||||
runs-on:
|
||||
- macos-latest
|
||||
- macos-12
|
||||
steps:
|
||||
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
|
||||
name: Installing wasm-pack
|
||||
|
6
.gitignore
vendored
6
.gitignore
vendored
@ -162,3 +162,9 @@ test-results
|
||||
*.ir
|
||||
*.meta
|
||||
.enso/
|
||||
|
||||
##################
|
||||
## direnv cache ##
|
||||
##################
|
||||
|
||||
.direnv
|
||||
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -23,5 +23,7 @@
|
||||
],
|
||||
"files.watcherExclude": {
|
||||
"**/target": true
|
||||
}
|
||||
},
|
||||
"metals.inlayHints.implicitArguments.enable": true,
|
||||
"metals.inlayHints.implicitConversions.enable": true
|
||||
}
|
||||
|
13
CHANGELOG.md
13
CHANGELOG.md
@ -650,9 +650,14 @@
|
||||
- [Added `Decimal.min` and `.max`.][9663]
|
||||
- [Added `Decimal.round`.][9672]
|
||||
- [Implemented write support for Enso Cloud files.][9686]
|
||||
- [Added `Integer.to_decimal` and `Float.to_decimal`.][9716]
|
||||
- [Added `Decimal.floor`, `.ceil`, and `.trunc`.][9694]
|
||||
- [Added `recursive` option to `File.delete`.][9719]
|
||||
- [Added `Vector.build`.][9725]
|
||||
- [Added `Table.running` method][9577]
|
||||
- [Added `Excel_Workbook.read_many` allowing reading more than one sheet at a
|
||||
time.][9759]
|
||||
- [Added ability to write to Data Links.][9750]
|
||||
|
||||
[debug-shortcuts]:
|
||||
https://github.com/enso-org/enso/blob/develop/app/gui/docs/product/shortcuts.md#debug
|
||||
@ -954,8 +959,12 @@
|
||||
[9672]: https://github.com/enso-org/enso/pull/9672
|
||||
[9686]: https://github.com/enso-org/enso/pull/9686
|
||||
[9694]: https://github.com/enso-org/enso/pull/9694
|
||||
[9716]: https://github.com/enso-org/enso/pull/9716
|
||||
[9719]: https://github.com/enso-org/enso/pull/9719
|
||||
[9725]: https://github.com/enso-org/enso/pull/9725
|
||||
[9759]: https://github.com/enso-org/enso/pull/9759
|
||||
[9577]: https://github.com/enso-org/enso/pull/9577
|
||||
[9750]: https://github.com/enso-org/enso/pull/9750
|
||||
|
||||
#### Enso Compiler
|
||||
|
||||
@ -1117,6 +1126,8 @@
|
||||
- [Check type of `self` when calling a method using the static syntax][8867]
|
||||
- [Autoscoped constructors][9190]
|
||||
- [Allow Oracle GraalVM JDK][9322]
|
||||
- [`Table.join` can access its `right` argument][9410]
|
||||
- [Atom constructors can be project-private][9692]
|
||||
|
||||
[3227]: https://github.com/enso-org/enso/pull/3227
|
||||
[3248]: https://github.com/enso-org/enso/pull/3248
|
||||
@ -1277,6 +1288,8 @@
|
||||
[8867]: https://github.com/enso-org/enso/pull/8867
|
||||
[9190]: https://github.com/enso-org/enso/pull/9190
|
||||
[9322]: https://github.com/enso-org/enso/pull/9322
|
||||
[9692]: https://github.com/enso-org/enso/pull/9692
|
||||
[9410]: https://github.com/enso-org/enso/pull/9410
|
||||
|
||||
# Enso 2.0.0-alpha.18 (2021-10-12)
|
||||
|
||||
|
@ -21,7 +21,7 @@
|
||||
<img src="https://img.shields.io/static/v1?label=Compiler%20License&message=Apache%20v2&color=2ec352&labelColor=2c3239"
|
||||
alt="License">
|
||||
</a>
|
||||
<a href="https://github.com/enso-org/enso/blob/develop/app/gui/LICENSE">
|
||||
<a href="https://github.com/enso-org/enso/blob/develop/app/gui2/LICENSE">
|
||||
<img src="https://img.shields.io/static/v1?label=GUI%20License&message=AGPL%20v3&color=2ec352&labelColor=2c3239"
|
||||
alt="License">
|
||||
</a>
|
||||
@ -222,7 +222,7 @@ The Enso Engine is licensed under the
|
||||
[LICENSE](https://github.com/enso-org/enso/blob/develop/LICENSE) file. The Enso
|
||||
IDE is licensed under the [AGPL 3.0](https://opensource.org/licenses/AGPL-3.0),
|
||||
as specified in the
|
||||
[LICENSE](https://github.com/enso-org/enso/blob/develop/app/gui/LICENSE) file.
|
||||
[LICENSE](https://github.com/enso-org/enso/blob/develop/app/gui2/LICENSE) file.
|
||||
|
||||
This license set was chosen to provide you with complete freedom to use Enso,
|
||||
create libraries, and release them under any license of your choice, while also
|
||||
|
661
app/gui2/LICENSE
Normal file
661
app/gui2/LICENSE
Normal file
@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
@ -5,7 +5,7 @@ import { mockCollapsedFunctionInfo } from './expressionUpdates'
|
||||
import { CONTROL_KEY } from './keyboard'
|
||||
import * as locate from './locate'
|
||||
|
||||
const MAIN_FILE_NODES = 11
|
||||
const MAIN_FILE_NODES = 12
|
||||
|
||||
const COLLAPSE_SHORTCUT = `${CONTROL_KEY}+G`
|
||||
|
||||
@ -79,7 +79,7 @@ test('Collapsing nodes', async ({ page }) => {
|
||||
.locator('.icon')
|
||||
.click({ modifiers: ['Shift'] })
|
||||
|
||||
await page.keyboard.press(COLLAPSE_SHORTCUT)
|
||||
await page.getByAltText('Group components').click()
|
||||
await expect(locate.graphNode(page)).toHaveCount(initialNodesCount - 2)
|
||||
const collapsedNode = locate.graphNodeByBinding(page, 'prod')
|
||||
await expect(collapsedNode.locator('.WidgetToken')).toHaveText(['Main', '.', 'collapsed', 'five'])
|
||||
@ -120,6 +120,7 @@ async function expectInsideMain(page: Page) {
|
||||
await expect(locate.graphNodeByBinding(page, 'data')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'aggregated')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'filtered')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'autoscoped')).toExist()
|
||||
}
|
||||
|
||||
async function expectInsideFunc1(page: Page) {
|
||||
|
@ -8,7 +8,7 @@ const ACCEPT_SUGGESTION_SHORTCUT = `${CONTROL_KEY}+Enter`
|
||||
|
||||
async function deselectAllNodes(page: Page) {
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.GraphNode.selected')).toHaveCount(0)
|
||||
await expect(locate.selectedNodes(page)).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function expectAndCancelBrowser(page: Page, expectedInput: string) {
|
||||
@ -101,7 +101,7 @@ test('Graph Editor pans to Component Browser', async ({ page }) => {
|
||||
await expect(locate.graphNodeByBinding(page, 'five')).toBeInViewport()
|
||||
const outputPort = await locate.outputPortCoordinates(locate.graphNodeByBinding(page, 'final'))
|
||||
await page.mouse.click(outputPort.x, outputPort.y)
|
||||
await page.mouse.click(100, 1550)
|
||||
await page.mouse.click(100, 1700)
|
||||
await expect(locate.graphNodeByBinding(page, 'five')).not.toBeInViewport()
|
||||
await expectAndCancelBrowser(page, 'final.')
|
||||
})
|
||||
|
@ -39,7 +39,7 @@ test('Disconnect an edge from a port', async ({ page }) => {
|
||||
/**
|
||||
* Scenario: We replace the `sum` parameter in the `prod` node` with the `ten` node.
|
||||
*/
|
||||
test('Connect an node to a port via dragging the edge', async ({ page }) => {
|
||||
test('Connect an node to a port', async ({ page }) => {
|
||||
await initGraph(page)
|
||||
|
||||
await expect(await edgesToNodeWithBinding(page, 'sum')).toHaveCount(2 * EDGE_PARTS)
|
||||
@ -58,6 +58,23 @@ test('Connect an node to a port via dragging the edge', async ({ page }) => {
|
||||
await expect(graphNodeByBinding(page, 'prod')).toContainText('ten')
|
||||
})
|
||||
|
||||
/**
|
||||
* As above, but by dragging edge instead of clicking source and target separately.
|
||||
*/
|
||||
test('Connect an node to a port via dragging the edge', async ({ page }) => {
|
||||
await initGraph(page)
|
||||
|
||||
await expect(await edgesToNodeWithBinding(page, 'sum')).toHaveCount(2 * EDGE_PARTS)
|
||||
const targetEdge = page.locator('svg.behindNodes g:nth-child(2) path.edge.visible')
|
||||
const targetPort = page.locator('span').filter({ hasText: /^sum$/ })
|
||||
// Hover over edge to the left of node with binding `ten`.
|
||||
await targetEdge.dragTo(targetPort, {
|
||||
sourcePosition: { x: 450, y: 5.0 },
|
||||
force: true,
|
||||
})
|
||||
await expect(graphNodeByBinding(page, 'prod')).toContainText('ten')
|
||||
})
|
||||
|
||||
test('Conditional ports: Disabled', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
const node = graphNodeByBinding(page, 'filtered')
|
||||
|
@ -12,7 +12,7 @@ test('Existence of edges between nodes', async ({ page }) => {
|
||||
|
||||
await expect(await edgesFromNodeWithBinding(page, 'aggregated')).toHaveCount(0)
|
||||
await expect(await edgesFromNodeWithBinding(page, 'filtered')).toHaveCount(0)
|
||||
await expect(await edgesFromNodeWithBinding(page, 'data')).toHaveCount(3 * EDGE_PARTS)
|
||||
await expect(await edgesFromNodeWithBinding(page, 'data')).toHaveCount(4 * EDGE_PARTS)
|
||||
await expect(await edgesFromNodeWithBinding(page, 'list')).toHaveCount(0)
|
||||
await expect(await edgesFromNodeWithBinding(page, 'final')).toHaveCount(0)
|
||||
await expect(await edgesFromNodeWithBinding(page, 'selected')).toHaveCount(0)
|
||||
|
@ -128,6 +128,9 @@ export function graphNodeByBinding(page: Locator | Page, binding: string): Node
|
||||
export function graphNodeIcon(node: Node) {
|
||||
return node.locator('.nodeCategoryIcon')
|
||||
}
|
||||
export function selectedNodes(page: Page | Locator): Node {
|
||||
return page.locator('.GraphNode.selected') as Node
|
||||
}
|
||||
|
||||
// === Data locators ===
|
||||
|
||||
|
@ -36,7 +36,8 @@ test('Copy node with comment', async ({ page }) => {
|
||||
// Copy and paste it.
|
||||
await page.keyboard.press(`${CONTROL_KEY}+C`)
|
||||
await page.keyboard.press(`${CONTROL_KEY}+V`)
|
||||
await expect(nodeToCopy).toBeSelected()
|
||||
await expect(nodeToCopy).not.toBeSelected()
|
||||
await expect(locate.selectedNodes(page)).toHaveCount(1)
|
||||
|
||||
// Node and comment have been copied.
|
||||
await expect(locate.graphNode(page)).toHaveCount(originalNodes + 1)
|
||||
@ -61,8 +62,9 @@ test('Copy multiple nodes', async ({ page }) => {
|
||||
// Copy and paste.
|
||||
await page.keyboard.press(`${CONTROL_KEY}+C`)
|
||||
await page.keyboard.press(`${CONTROL_KEY}+V`)
|
||||
await expect(node1).toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
await expect(node1).not.toBeSelected()
|
||||
await expect(node2).not.toBeSelected()
|
||||
await expect(locate.selectedNodes(page)).toHaveCount(2)
|
||||
|
||||
// Nodes and comment have been copied.
|
||||
await expect(locate.graphNode(page)).toHaveCount(originalNodes + 2)
|
||||
|
@ -8,32 +8,39 @@ test('Selecting nodes by click', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
const node1 = locate.graphNodeByBinding(page, 'five')
|
||||
const node2 = locate.graphNodeByBinding(page, 'final')
|
||||
const selectionMenu = page.locator('.SelectionMenu')
|
||||
await expect(node1).not.toBeSelected()
|
||||
await expect(node2).not.toBeSelected()
|
||||
await expect(selectionMenu).not.toBeVisible()
|
||||
|
||||
await locate.graphNodeIcon(node1).click()
|
||||
await expect(node1).toBeSelected()
|
||||
await expect(node2).not.toBeSelected()
|
||||
await expect(selectionMenu).not.toBeVisible()
|
||||
|
||||
// Check that clicking an unselected node deselects replaces the previous selection.
|
||||
await locate.graphNodeIcon(node2).click()
|
||||
await expect(node1).not.toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
await expect(selectionMenu).not.toBeVisible()
|
||||
|
||||
await page.waitForTimeout(300) // Avoid double clicks
|
||||
await locate.graphNodeIcon(node1).click({ modifiers: ['Shift'] })
|
||||
await expect(node1).toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
await expect(selectionMenu).toBeVisible()
|
||||
|
||||
// Check that when two nodes are selected, clicking a selected node replaces the previous selection.
|
||||
await locate.graphNodeIcon(node2).click()
|
||||
await expect(node1).not.toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
await expect(selectionMenu).not.toBeVisible()
|
||||
|
||||
// Check that clicking the background deselects all nodes.
|
||||
await page.mouse.click(600, 200)
|
||||
await expect(node1).not.toBeSelected()
|
||||
await expect(node2).not.toBeSelected()
|
||||
await expect(selectionMenu).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Selecting nodes by area drag', async ({ page }) => {
|
||||
|
@ -1,28 +1,30 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test'
|
||||
import assert from 'assert'
|
||||
import { test, type Locator, type Page } from '@playwright/test'
|
||||
import * as actions from './actions'
|
||||
import { expect } from './customExpect'
|
||||
import { mockExpressionUpdate } from './expressionUpdates'
|
||||
import * as locate from './locate'
|
||||
|
||||
const DUMMY_INT_TYPE = 'Standard.Base.Data.Numbers.Integer'
|
||||
const DUMMY_STRING_TYPE = 'Standard.Base.Data.Text.Text'
|
||||
const DUMMY_FLOAT_TYPE = 'Standard.Base.Data.Numbers.Float'
|
||||
const UNKNOWN_TYPE = 'Unknown'
|
||||
async function assertTypeLabelOnNode(page: Page, node: Locator, type: string) {
|
||||
const targetLabel = node.locator('.outputPortLabel').first()
|
||||
await expect(targetLabel).toHaveText(type)
|
||||
await expect(targetLabel).toHaveCSS('opacity', '0')
|
||||
|
||||
const outputPortArea = await node.locator('.outputPortHoverArea').boundingBox()
|
||||
assert(outputPortArea, 'The outputPortArea of the node is null')
|
||||
const outputPortX = outputPortArea.x + outputPortArea.width / 2.0
|
||||
const outputPortY = outputPortArea.y + outputPortArea.height - 2.0
|
||||
await page.mouse.move(outputPortX, outputPortY)
|
||||
await expect(targetLabel).toBeVisible()
|
||||
await expect(targetLabel).toHaveCSS('opacity', '1')
|
||||
const DUMMY_INT_TYPE = { full: 'Standard.Base.Data.Numbers.Integer', short: 'Integer' }
|
||||
const DUMMY_STRING_TYPE = { full: 'Standard.Base.Data.Text.Text', short: 'Text' }
|
||||
const DUMMY_FLOAT_TYPE = { full: 'Standard.Base.Data.Numbers.Float', short: 'Float' }
|
||||
const UNKNOWN_TYPE = { full: 'Unknown', short: 'Unknown' }
|
||||
async function assertTypeLabelOnNode(
|
||||
page: Page,
|
||||
node: Locator,
|
||||
type: { full: string; short: string },
|
||||
) {
|
||||
await node.hover({ position: { x: 8, y: 8 } })
|
||||
await locate.toggleVisualizationButton(node).click()
|
||||
const targetLabel = node.locator('.node-type').first()
|
||||
await expect(targetLabel).toHaveText(type.short)
|
||||
await expect(targetLabel).toHaveAttribute('title', type.full)
|
||||
}
|
||||
|
||||
async function assertTypeLabelOnNodeByBinding(page: Page, label: string, type: string) {
|
||||
async function assertTypeLabelOnNodeByBinding(
|
||||
page: Page,
|
||||
label: string,
|
||||
type: { full: string; short: string },
|
||||
) {
|
||||
const node = locate.graphNodeByBinding(page, label)
|
||||
await assertTypeLabelOnNode(page, node, type)
|
||||
}
|
||||
@ -31,10 +33,10 @@ test('shows the correct type when hovering a node', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
|
||||
// Note that the types don't have to make sense, they just have to be applied.
|
||||
await mockExpressionUpdate(page, 'five', { type: DUMMY_INT_TYPE })
|
||||
await mockExpressionUpdate(page, 'ten', { type: DUMMY_STRING_TYPE })
|
||||
await mockExpressionUpdate(page, 'sum', { type: DUMMY_FLOAT_TYPE })
|
||||
await mockExpressionUpdate(page, 'prod', { type: DUMMY_INT_TYPE })
|
||||
await mockExpressionUpdate(page, 'five', { type: DUMMY_INT_TYPE.full })
|
||||
await mockExpressionUpdate(page, 'ten', { type: DUMMY_STRING_TYPE.full })
|
||||
await mockExpressionUpdate(page, 'sum', { type: DUMMY_FLOAT_TYPE.full })
|
||||
await mockExpressionUpdate(page, 'prod', { type: DUMMY_INT_TYPE.full })
|
||||
|
||||
await assertTypeLabelOnNodeByBinding(page, 'five', DUMMY_INT_TYPE)
|
||||
await assertTypeLabelOnNodeByBinding(page, 'ten', DUMMY_STRING_TYPE)
|
||||
|
@ -440,3 +440,39 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
// '"',
|
||||
// ])
|
||||
})
|
||||
|
||||
// Test that autoscoped constructors provide argument placeholders.
|
||||
// This test can be removed when `aggregate` inserts autoscoped constructors by default,
|
||||
// so this behavior will be tested in regular `aggregate` tests.
|
||||
test('Autoscoped constructors', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
await mockMethodCallInfo(page, 'autoscoped', {
|
||||
methodPointer: {
|
||||
module: 'Standard.Table.Table',
|
||||
definedOnType: 'Standard.Table.Table.Table',
|
||||
name: 'aggregate',
|
||||
},
|
||||
notAppliedArguments: [2, 3],
|
||||
})
|
||||
await mockMethodCallInfo(
|
||||
page,
|
||||
{ binding: 'autoscoped', expr: '..Group_By' },
|
||||
{
|
||||
methodPointer: {
|
||||
module: 'Standard.Table.Aggregate_Column',
|
||||
definedOnType: 'Standard.Table.Aggregate_Column.Aggregate_Column',
|
||||
name: 'Group_By',
|
||||
},
|
||||
notAppliedArguments: [0, 1],
|
||||
},
|
||||
)
|
||||
const node = locate.graphNodeByBinding(page, 'autoscoped')
|
||||
const topLevelArgs = node.locator('.WidgetTopLevelArgument')
|
||||
// Wait for hidden arguments to appear after selecting the node.
|
||||
await node.click()
|
||||
await expect(topLevelArgs).toHaveCount(3)
|
||||
|
||||
const groupBy = node.locator('.item').nth(0)
|
||||
await expect(groupBy).toBeVisible()
|
||||
await expect(groupBy.locator('.WidgetArgumentName')).toContainText(['column', 'new_name'])
|
||||
})
|
||||
|
1
app/gui2/env.d.ts
vendored
1
app/gui2/env.d.ts
vendored
@ -1,6 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const PROJECT_MANAGER_URL: string
|
||||
declare const YDOC_SERVER_URL: string
|
||||
declare const RUNNING_VITEST: boolean
|
||||
declare const IS_CLOUD_BUILD: boolean
|
||||
|
||||
|
@ -17,6 +17,8 @@ const conf = [
|
||||
'templates',
|
||||
'.histoire',
|
||||
'playwright-report',
|
||||
'test-results',
|
||||
'vite.ydoc-server-polyglot.config.ts',
|
||||
],
|
||||
},
|
||||
...compat.extends('plugin:vue/vue3-recommended'),
|
||||
|
@ -70,6 +70,7 @@ main =
|
||||
data = Data.read
|
||||
filtered = data.filter
|
||||
aggregated = data.aggregate
|
||||
autoscoped = data.aggregate [..Group_By]
|
||||
selected = data.select_columns
|
||||
`
|
||||
|
||||
@ -495,6 +496,7 @@ export const mockLSHandler: MockTransportData = async (method, data, transport)
|
||||
case 'executionContext/push':
|
||||
case 'executionContext/pop':
|
||||
case 'executionContext/recompute':
|
||||
case 'executionContext/setExecutionEnvironment':
|
||||
case 'capability/acquire':
|
||||
return {}
|
||||
case 'file/list': {
|
||||
|
@ -29,6 +29,7 @@ export function guiConfig(app: App) {
|
||||
engine: {
|
||||
rpcUrl: 'mock://engine',
|
||||
dataUrl: 'mock://data',
|
||||
ydocUrl: '',
|
||||
namespace: 'local',
|
||||
projectManagerUrl: '',
|
||||
},
|
||||
|
@ -19,6 +19,7 @@
|
||||
"story:build": "histoire build",
|
||||
"story:preview": "histoire preview",
|
||||
"build-only": "vite build",
|
||||
"build-ydoc-server-polyglot": "vite build --config vite.ydoc-server-polyglot.config.ts",
|
||||
"compile-server": "tsc -p tsconfig.server.json",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
|
||||
"lint": "eslint .",
|
||||
|
@ -48,7 +48,7 @@ export default defineConfig({
|
||||
use: {
|
||||
headless: !DEBUG,
|
||||
trace: 'retain-on-failure',
|
||||
viewport: { width: 1920, height: 1600 },
|
||||
viewport: { width: 1920, height: 1750 },
|
||||
...(DEBUG ?
|
||||
{}
|
||||
: {
|
||||
|
@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file Provides the Rust ffi interface. The interface should be kept in sync with polyglot ffi inteface {@link module:ffiPolyglot}.
|
||||
*
|
||||
* @module ffi
|
||||
*/
|
||||
|
||||
import { createXXHash128 } from 'hash-wasm'
|
||||
import type { IDataType } from 'hash-wasm/dist/lib/util'
|
||||
import init, { is_ident_or_operator, parse, parse_doc_to_json } from '../../rust-ffi/pkg/rust_ffi'
|
||||
|
21
app/gui2/shared/ast/ffiPolyglot.ts
Normal file
21
app/gui2/shared/ast/ffiPolyglot.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @file This file is used as ffi {@link module:ffi} interface for building the polyglot ydoc server.
|
||||
* All the exported methods are provided by the ydoc server implementation.
|
||||
* The interface should be kept in sync with Rust ffi inteface {@link module:ffi}.
|
||||
*
|
||||
* @module ffiPolyglot
|
||||
*/
|
||||
|
||||
import type { IDataType } from 'hash-wasm/dist/lib/util'
|
||||
|
||||
declare global {
|
||||
function parse_tree(code: string): Uint8Array
|
||||
function parse_doc_to_json(docs: string): string
|
||||
function is_ident_or_operator(code: string): number
|
||||
function xxHash128(input: IDataType): string
|
||||
}
|
||||
|
||||
export async function initializeFFI(_path?: string | undefined) {}
|
||||
|
||||
/* eslint-disable-next-line camelcase */
|
||||
export const { is_ident_or_operator, parse_doc_to_json, parse_tree, xxHash128 } = globalThis
|
@ -39,6 +39,7 @@ import {
|
||||
App,
|
||||
Assignment,
|
||||
Ast,
|
||||
AutoscopedIdentifier,
|
||||
BodyBlock,
|
||||
Documented,
|
||||
Function,
|
||||
@ -203,6 +204,12 @@ class Abstractor {
|
||||
}
|
||||
break
|
||||
}
|
||||
case RawAst.Tree.Type.AutoscopedIdentifier: {
|
||||
const opr = this.abstractToken(tree.opr)
|
||||
const ident = this.abstractToken(tree.ident)
|
||||
node = AutoscopedIdentifier.concrete(this.module, opr, ident)
|
||||
break
|
||||
}
|
||||
case RawAst.Tree.Type.OprApp: {
|
||||
const lhs = tree.lhs ? this.abstractTree(tree.lhs) : undefined
|
||||
const opr =
|
||||
|
@ -77,6 +77,7 @@ export interface IdentifierToken extends Token {
|
||||
|
||||
declare const qualifiedNameBrand: unique symbol
|
||||
declare const identifierBrand: unique symbol
|
||||
declare const typeOrConsIdentifierBrand: unique symbol
|
||||
declare const operatorBrand: unique symbol
|
||||
|
||||
/** A string representing a valid qualified name of our language.
|
||||
@ -89,6 +90,9 @@ export type QualifiedName = string & { [qualifiedNameBrand]: never }
|
||||
/** A string representing a lexical identifier. */
|
||||
export type Identifier = string & { [identifierBrand]: never; [qualifiedNameBrand]: never }
|
||||
|
||||
/** A specific subtype of capitalized identifier, used for type and constructor names. */
|
||||
export type TypeOrConstructorIdentifier = Identifier & { [typeOrConsIdentifierBrand]: never }
|
||||
|
||||
/** A string representing a lexical operator. */
|
||||
export type Operator = string & { [operatorBrand]: never; [qualifiedNameBrand]: never }
|
||||
|
||||
@ -121,6 +125,11 @@ export function isIdentifier(code: string): code is Identifier {
|
||||
return is_ident_or_operator(code) === 1
|
||||
}
|
||||
|
||||
export function isTypeOrConsIdentifier(code: string): code is TypeOrConstructorIdentifier {
|
||||
const isUppercase = (s: string) => s.toUpperCase() === s && s.toLowerCase() !== s
|
||||
return isIdentifier(code) && code.length > 0 && isUppercase(code[0]!)
|
||||
}
|
||||
|
||||
export function identifier(code: string): Identifier | undefined {
|
||||
if (isIdentifier(code)) return code
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import type {
|
||||
RawNodeChild,
|
||||
SpanMap,
|
||||
SyncTokenId,
|
||||
TypeOrConstructorIdentifier,
|
||||
} from '.'
|
||||
import {
|
||||
MutableModule,
|
||||
@ -763,6 +764,61 @@ export interface MutableUnaryOprApp extends UnaryOprApp, MutableAst {
|
||||
}
|
||||
applyMixins(MutableUnaryOprApp, [MutableAst])
|
||||
|
||||
interface AutoscopedIdentifierFields {
|
||||
operator: NodeChild<SyncTokenId>
|
||||
identifier: NodeChild<SyncTokenId>
|
||||
}
|
||||
export class AutoscopedIdentifier extends Ast {
|
||||
declare fields: FixedMapView<AstFields & AutoscopedIdentifierFields>
|
||||
constructor(module: Module, fields: FixedMapView<AstFields & AutoscopedIdentifierFields>) {
|
||||
super(module, fields)
|
||||
}
|
||||
|
||||
static tryParse(
|
||||
source: string,
|
||||
module?: MutableModule,
|
||||
): Owned<MutableAutoscopedIdentifier> | undefined {
|
||||
const parsed = parse(source, module)
|
||||
if (parsed instanceof MutableAutoscopedIdentifier) return parsed
|
||||
}
|
||||
|
||||
static concrete(module: MutableModule, operator: NodeChild<Token>, identifier: NodeChild<Token>) {
|
||||
const base = module.baseObject('AutoscopedIdentifier')
|
||||
const fields = composeFieldData(base, {
|
||||
operator,
|
||||
identifier,
|
||||
})
|
||||
return asOwned(new MutableAutoscopedIdentifier(module, fields))
|
||||
}
|
||||
|
||||
static new(
|
||||
identifier: TypeOrConstructorIdentifier,
|
||||
module?: MutableModule,
|
||||
): Owned<MutableAutoscopedIdentifier> {
|
||||
const module_ = module || MutableModule.Transient()
|
||||
const operator = Token.new('..')
|
||||
const ident = Token.new(identifier, RawAst.Token.Type.Ident)
|
||||
return this.concrete(module_, unspaced(operator), unspaced(ident))
|
||||
}
|
||||
|
||||
*concreteChildren(_verbatim?: boolean): IterableIterator<RawNodeChild> {
|
||||
const { operator, identifier } = getAll(this.fields)
|
||||
yield operator
|
||||
yield identifier
|
||||
}
|
||||
}
|
||||
export class MutableAutoscopedIdentifier extends AutoscopedIdentifier implements MutableAst {
|
||||
declare readonly module: MutableModule
|
||||
declare readonly fields: FixedMap<AstFields & AutoscopedIdentifierFields>
|
||||
|
||||
setIdentifier(value: TypeOrConstructorIdentifier) {
|
||||
const token = Token.new(value, RawAst.Token.Type.Ident)
|
||||
this.fields.set('identifier', unspaced(token))
|
||||
}
|
||||
}
|
||||
export interface MutableAutoscopedIdentifier extends AutoscopedIdentifier, MutableAst {}
|
||||
applyMixins(MutableAutoscopedIdentifier, [MutableAst])
|
||||
|
||||
interface NegationAppFields {
|
||||
operator: NodeChild<SyncTokenId>
|
||||
argument: NodeChild<AstId>
|
||||
@ -2369,6 +2425,8 @@ export function materializeMutable(module: MutableModule, fields: FixedMap<AstFi
|
||||
return new MutableTextLiteral(module, fieldsForType)
|
||||
case 'UnaryOprApp':
|
||||
return new MutableUnaryOprApp(module, fieldsForType)
|
||||
case 'AutoscopedIdentifier':
|
||||
return new MutableAutoscopedIdentifier(module, fieldsForType)
|
||||
case 'Vector':
|
||||
return new MutableVector(module, fieldsForType)
|
||||
case 'Wildcard':
|
||||
@ -2413,6 +2471,8 @@ export function materialize(module: Module, fields: FixedMapView<AstFields>): As
|
||||
return new TextLiteral(module, fields_)
|
||||
case 'UnaryOprApp':
|
||||
return new UnaryOprApp(module, fields_)
|
||||
case 'AutoscopedIdentifier':
|
||||
return new AutoscopedIdentifier(module, fields_)
|
||||
case 'Vector':
|
||||
return new Vector(module, fields_)
|
||||
case 'Wildcard':
|
||||
|
@ -3,3 +3,6 @@
|
||||
export function allKeys<T>(keys: { [P in keyof T]-?: any }): ReadonlySet<string> {
|
||||
return Object.freeze(new Set(Object.keys(keys)))
|
||||
}
|
||||
|
||||
/** Static check that type `T` extends type `U`. */
|
||||
export function mustExtend<T extends U, U>() {} // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
nonDictatedPlacement,
|
||||
previousNodeDictatedPlacement,
|
||||
type Environment,
|
||||
type Placement,
|
||||
} from '@/components/ComponentBrowser/placement'
|
||||
import * as iterable from '@/util/data/iterable'
|
||||
import { chain, map, range } from '@/util/data/iterable'
|
||||
@ -154,13 +153,10 @@ describe('Non dictated placement', () => {
|
||||
pos: new Vec2(1090, 1531),
|
||||
pan: new Vec2(0, 841),
|
||||
},
|
||||
])('$desc', ({ nodes, pos, gap, pan }) => {
|
||||
])('$desc', ({ nodes, pos, gap }) => {
|
||||
expect(
|
||||
nonDictatedPlacement(nodeSize, nonDictatedEnvironment(nodes), {
|
||||
horizontalGap: gap ?? 24,
|
||||
verticalGap: gap ?? 24,
|
||||
}),
|
||||
).toEqual({ position: pos, pan })
|
||||
nonDictatedPlacement(nodeSize, nonDictatedEnvironment(nodes), new Vec2(gap ?? 24, gap ?? 24)),
|
||||
).toEqual(pos)
|
||||
expect(getSelectedNodeRects, 'Should not depend on `selectedNodeRects`').not.toHaveBeenCalled()
|
||||
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
||||
})
|
||||
@ -180,7 +176,7 @@ describe('Non dictated placement', () => {
|
||||
({ left, top, width, height }) => new Rect(new Vec2(left, top), new Vec2(width, height)),
|
||||
)
|
||||
const newNodeRect = new Rect(
|
||||
nonDictatedPlacement(nodeSize, nonDictatedEnvironment(nodes)).position,
|
||||
nonDictatedPlacement(nodeSize, nonDictatedEnvironment(nodes)),
|
||||
nodeSize,
|
||||
)
|
||||
for (const node of nodes) {
|
||||
@ -339,14 +335,14 @@ describe('Previous node dictated placement', () => {
|
||||
pos: new Vec2(2071, 734),
|
||||
pan: new Vec2(981, 44),
|
||||
},
|
||||
])('$desc', ({ nodes, gap, pos, pan }) => {
|
||||
])('$desc', ({ nodes, gap, pos }) => {
|
||||
expect(
|
||||
previousNodeDictatedPlacement(
|
||||
nodeSize,
|
||||
previousNodeDictatedEnvironment([...nodes, rectAt(1090, 690)]),
|
||||
{ horizontalGap: gap ?? 24, verticalGap: gap ?? 24 },
|
||||
new Vec2(gap ?? 24, gap ?? 24),
|
||||
),
|
||||
).toEqual({ position: pos, pan })
|
||||
).toEqual(pos)
|
||||
expect(getMousePosition, 'Should not depend on `mousePosition`').not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@ -371,7 +367,7 @@ describe('Previous node dictated placement', () => {
|
||||
screenBounds,
|
||||
nodeRects,
|
||||
selectedNodeRects,
|
||||
}).position,
|
||||
}),
|
||||
nodeSize,
|
||||
)
|
||||
expect(newNodeRect.top, {
|
||||
@ -403,10 +399,10 @@ describe('Mouse dictated placement', () => {
|
||||
x: fc.nat(1000),
|
||||
y: fc.nat(1000),
|
||||
})('prop testing', ({ x, y }) => {
|
||||
expect(mouseDictatedPlacement(nodeSize, new Vec2(x, y))).toEqual<Placement>({
|
||||
expect(mouseDictatedPlacement(new Vec2(x, y), nodeSize)).toEqual(
|
||||
// Note: Currently, this is a reimplementation of the entire mouse dictated placement algorithm.
|
||||
position: new Vec2(x + radius, y + radius),
|
||||
})
|
||||
new Vec2(x + radius, y + radius),
|
||||
)
|
||||
// Non-overlap test omitted, as mouse-dictated node placement MAY overlap existing nodes.
|
||||
expect(getScreenBounds, 'Should not depend on `screenBounds`').not.toHaveBeenCalled()
|
||||
expect(getNodeRects, 'Should not depend on `nodeRects`').not.toHaveBeenCalled()
|
||||
@ -425,15 +421,23 @@ describe('Collapsed node placement', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function options(): { horizontalGap: number; verticalGap: number } {
|
||||
return {
|
||||
get horizontalGap() {
|
||||
return getHorizontalGap()
|
||||
},
|
||||
get verticalGap() {
|
||||
return getVerticalGap()
|
||||
},
|
||||
function options(): Vec2 {
|
||||
class MockVec2 {
|
||||
constructor(
|
||||
private getX: () => number,
|
||||
private getY: () => number,
|
||||
) {}
|
||||
get x() {
|
||||
return this.getX()
|
||||
}
|
||||
get y() {
|
||||
return this.getY()
|
||||
}
|
||||
reflectXY(): MockVec2 {
|
||||
return new MockVec2(this.getY, this.getX)
|
||||
}
|
||||
}
|
||||
return new MockVec2(getHorizontalGap, getVerticalGap) as any
|
||||
}
|
||||
|
||||
test('One selected, no other nodes', () => {
|
||||
@ -441,13 +445,13 @@ describe('Collapsed node placement', () => {
|
||||
const Y = 700
|
||||
const selectedNodeRects = [rectAt(X, Y)]
|
||||
const result = collapsedNodePlacement(nodeSize, environment(selectedNodeRects, []), options())
|
||||
expect(result).toEqual({ position: new Vec2(X, Y), pan: undefined })
|
||||
expect(result).toEqual(new Vec2(X, Y))
|
||||
})
|
||||
|
||||
test('Multiple selected, no other nodes', () => {
|
||||
const selectedNodeRects = [rectAt(1000, 600), rectAt(1300, 800)]
|
||||
const result = collapsedNodePlacement(nodeSize, environment(selectedNodeRects, []), options())
|
||||
expect(result).toEqual({ position: new Vec2(1000, 700), pan: undefined })
|
||||
expect(result).toEqual(new Vec2(1000, 700))
|
||||
})
|
||||
|
||||
test('Average position occupied', () => {
|
||||
@ -457,7 +461,7 @@ describe('Collapsed node placement', () => {
|
||||
environment(selectedNodeRects, [rectAt(1000, 700)]),
|
||||
options(),
|
||||
)
|
||||
expect(result).toEqual({ position: new Vec2(1000, 744), pan: undefined })
|
||||
expect(result).toEqual(new Vec2(1000, 744))
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -5,34 +5,27 @@ import theme from '@/util/theme.json'
|
||||
import type { ComputedRef, MaybeRefOrGetter } from 'vue'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
// Assumed size of a newly created node. This is used to place the component browser and creating a node before other
|
||||
// recently-created nodes have rendered and computed their real sizes.
|
||||
export const DEFAULT_NODE_SIZE = new Vec2(240, 24)
|
||||
// Assumed size of a newly created node. This is used to place the component browser and when creating a node before
|
||||
// other recently-created nodes have rendered and computed their real sizes.
|
||||
export const DEFAULT_NODE_SIZE = new Vec2(300, 32)
|
||||
|
||||
export interface PlacementOptions {
|
||||
horizontalGap?: number
|
||||
verticalGap?: number
|
||||
}
|
||||
const orDefaultSize = (rect: Rect) =>
|
||||
rect.width !== 0 ? rect : new Rect(rect.pos, DEFAULT_NODE_SIZE)
|
||||
|
||||
type ToValue<T> = MaybeRefOrGetter<T> | ComputedRef<T>
|
||||
|
||||
export function usePlacement(nodeRects: ToValue<Iterable<Rect>>, screenBounds: ToValue<Rect>) {
|
||||
const orDefaultSize = (rect: Rect) =>
|
||||
rect.width !== 0 ? rect : new Rect(rect.pos, DEFAULT_NODE_SIZE)
|
||||
const options = { horizontalGap: theme.node.horizontal_gap, verticalGap: theme.node.vertical_gap }
|
||||
const gap = themeGap()
|
||||
const environment = (selectedNodeRects: Iterable<Rect>) => ({
|
||||
selectedNodeRects: Array.from(selectedNodeRects, orDefaultSize),
|
||||
screenBounds: toValue(screenBounds),
|
||||
nodeRects: Array.from(toValue(nodeRects), orDefaultSize),
|
||||
})
|
||||
return {
|
||||
place: (
|
||||
selectedNodeRects: Iterable<Rect> = [],
|
||||
nodeSize: Vec2 = DEFAULT_NODE_SIZE,
|
||||
): Placement =>
|
||||
previousNodeDictatedPlacement(nodeSize, environment(selectedNodeRects), options),
|
||||
collapse: (selectedNodeRects: Iterable<Rect>, nodeSize: Vec2 = DEFAULT_NODE_SIZE): Placement =>
|
||||
collapsedNodePlacement(nodeSize, environment(selectedNodeRects), options),
|
||||
place: (selectedNodeRects: Iterable<Rect> = [], nodeSize: Vec2 = DEFAULT_NODE_SIZE): Vec2 =>
|
||||
previousNodeDictatedPlacement(nodeSize, environment(selectedNodeRects), gap),
|
||||
collapse: (selectedNodeRects: Iterable<Rect>, nodeSize: Vec2 = DEFAULT_NODE_SIZE): Vec2 =>
|
||||
collapsedNodePlacement(nodeSize, environment(selectedNodeRects), gap),
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,9 +38,8 @@ export interface Environment extends NonDictatedEnvironment {
|
||||
selectedNodeRects: Iterable<Rect>
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
position: Vec2
|
||||
pan?: Vec2
|
||||
function themeGap(): Vec2 {
|
||||
return new Vec2(theme.node.horizontal_gap, theme.node.vertical_gap)
|
||||
}
|
||||
|
||||
/** The new node should appear at the center of the screen if there is enough space for the new node.
|
||||
@ -64,24 +56,10 @@ export interface Placement {
|
||||
export function nonDictatedPlacement(
|
||||
nodeSize: Vec2,
|
||||
{ screenBounds, nodeRects }: NonDictatedEnvironment,
|
||||
{ verticalGap = theme.node.vertical_gap }: PlacementOptions = {},
|
||||
): Placement {
|
||||
gap: Vec2 = themeGap(),
|
||||
): Vec2 {
|
||||
const initialPosition = screenBounds.center().sub(new Vec2(nodeSize.y / 2, nodeSize.y / 2))
|
||||
const initialRect = new Rect(initialPosition, nodeSize)
|
||||
let top = initialPosition.y
|
||||
const height = nodeSize.y
|
||||
const bottom = () => top + height
|
||||
const nodeRectsSorted = Array.from(nodeRects).sort((a, b) => a.top - b.top)
|
||||
for (const rect of nodeRectsSorted) {
|
||||
if (initialRect.intersectsX(rect) && rect.bottom + verticalGap > top) {
|
||||
if (rect.top - bottom() < verticalGap) {
|
||||
top = rect.bottom + verticalGap
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalPosition = new Vec2(initialPosition.x, top)
|
||||
if (new Rect(finalPosition, nodeSize).within(screenBounds)) return { position: finalPosition }
|
||||
else return { position: finalPosition, pan: finalPosition.sub(initialPosition) }
|
||||
return seekVertical(new Rect(initialPosition, nodeSize), nodeRects, gap)
|
||||
}
|
||||
|
||||
/** The new node should be left aligned to the first selected node (order of selection matters).
|
||||
@ -108,40 +86,20 @@ export function nonDictatedPlacement(
|
||||
export function previousNodeDictatedPlacement(
|
||||
nodeSize: Vec2,
|
||||
{ screenBounds, selectedNodeRects, nodeRects }: Environment,
|
||||
{
|
||||
horizontalGap = theme.node.horizontal_gap,
|
||||
verticalGap = theme.node.vertical_gap,
|
||||
}: PlacementOptions = {},
|
||||
): Placement {
|
||||
gap: Vec2 = themeGap(),
|
||||
): Vec2 {
|
||||
let initialLeft: number | undefined
|
||||
let top = -Infinity
|
||||
for (const rect of selectedNodeRects) {
|
||||
initialLeft ??= rect.left
|
||||
const newTop = rect.bottom + verticalGap
|
||||
const newTop = rect.bottom + gap.y
|
||||
if (newTop > top) top = newTop
|
||||
}
|
||||
if (initialLeft == null) {
|
||||
return nonDictatedPlacement(nodeSize, { screenBounds, nodeRects }, { verticalGap })
|
||||
}
|
||||
let left = initialLeft
|
||||
const width = nodeSize.x
|
||||
const right = () => left + width
|
||||
const initialPosition = new Vec2(left, top)
|
||||
const initialRect = new Rect(initialPosition, nodeSize)
|
||||
const sortedNodeRects = Array.from(nodeRects).sort((a, b) => a.left - b.left)
|
||||
for (const rect of sortedNodeRects) {
|
||||
if (initialRect.intersectsY(rect) && rect.right + horizontalGap > left) {
|
||||
if (rect.left - right() < horizontalGap) {
|
||||
left = rect.right + horizontalGap
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalPosition = new Vec2(left, top)
|
||||
if (new Rect(finalPosition, nodeSize).within(screenBounds)) return { position: finalPosition }
|
||||
else {
|
||||
const screenCenter = screenBounds.center().sub(new Vec2(nodeSize.y / 2, nodeSize.y / 2))
|
||||
return { position: finalPosition, pan: finalPosition.sub(screenCenter) }
|
||||
return nonDictatedPlacement(nodeSize, { screenBounds, nodeRects }, gap)
|
||||
}
|
||||
const initialPosition = new Vec2(initialLeft, top)
|
||||
return seekHorizontal(new Rect(initialPosition, nodeSize), nodeRects, gap)
|
||||
}
|
||||
|
||||
/** The new node should appear exactly below the mouse.
|
||||
@ -151,9 +109,12 @@ export function previousNodeDictatedPlacement(
|
||||
* positions.
|
||||
*
|
||||
* [Documentation](https://github.com/enso-org/design/blob/main/epics/component-browser/design.md#placement-of-newly-opened-component-browser) */
|
||||
export function mouseDictatedPlacement(nodeSize: Vec2, mousePosition: Vec2): Placement {
|
||||
export function mouseDictatedPlacement(
|
||||
mousePosition: Vec2,
|
||||
nodeSize: Vec2 = DEFAULT_NODE_SIZE,
|
||||
): Vec2 {
|
||||
const nodeRadius = nodeSize.y / 2
|
||||
return { position: mousePosition.add(new Vec2(nodeRadius, nodeRadius)) }
|
||||
return mousePosition.add(new Vec2(nodeRadius, nodeRadius))
|
||||
}
|
||||
|
||||
/** The new node should appear at the average Y-position of selected nodes and with the X-position of the leftmost node.
|
||||
@ -170,45 +131,51 @@ export function mouseDictatedPlacement(nodeSize: Vec2, mousePosition: Vec2): Pla
|
||||
*/
|
||||
export function collapsedNodePlacement(
|
||||
nodeSize: Vec2,
|
||||
{ screenBounds, selectedNodeRects, nodeRects }: Environment,
|
||||
{ verticalGap = theme.node.vertical_gap }: PlacementOptions = {},
|
||||
): Placement {
|
||||
{ selectedNodeRects, nodeRects }: Environment,
|
||||
gap = themeGap(),
|
||||
): Vec2 {
|
||||
let leftMostX
|
||||
let y = 0
|
||||
let selectedNodeRectsCount = 0
|
||||
const selectedRectKeys = new Set<string>()
|
||||
for (const rect of selectedNodeRects) {
|
||||
leftMostX = leftMostX == null ? rect.pos.x : Math.min(leftMostX, rect.pos.x)
|
||||
y += rect.pos.y
|
||||
selectedNodeRectsCount++
|
||||
selectedRectKeys.add(rect.key())
|
||||
}
|
||||
assert(
|
||||
selectedNodeRectsCount > 0 && leftMostX != null,
|
||||
'averagePositionPlacement works only if at least one node is selected.',
|
||||
)
|
||||
const initialPosition = new Vec2(leftMostX, y / selectedNodeRectsCount)
|
||||
const nonSelectedNodeRects = []
|
||||
outer: for (const rect of nodeRects) {
|
||||
for (const sel of selectedNodeRects) {
|
||||
if (sel.equals(rect)) {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
nonSelectedNodeRects.push(rect)
|
||||
}
|
||||
let top = initialPosition.y
|
||||
const initialRect = new Rect(initialPosition, nodeSize)
|
||||
const nodeRectsSorted = Array.from(nonSelectedNodeRects).sort((a, b) => a.top - b.top)
|
||||
for (const rect of nodeRectsSorted) {
|
||||
if (initialRect.intersectsX(rect) && rect.bottom + verticalGap > top) {
|
||||
if (rect.top - (top + nodeSize.y) < verticalGap) {
|
||||
top = rect.bottom + verticalGap
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalPosition = new Vec2(initialPosition.x, top)
|
||||
if (new Rect(finalPosition, nodeSize).within(screenBounds)) {
|
||||
return { position: finalPosition }
|
||||
} else {
|
||||
return { position: finalPosition, pan: finalPosition.sub(initialPosition) }
|
||||
}
|
||||
const nonSelectedNodeRects = [...nodeRects].filter((rect) => !selectedRectKeys.has(rect.key()))
|
||||
return seekVertical(new Rect(initialPosition, nodeSize), nonSelectedNodeRects, gap)
|
||||
}
|
||||
|
||||
/** Given a preferred location for a node, adjust the top as much as necessary for it not to collide with any of the
|
||||
* provided `otherRects`. */
|
||||
export function seekVertical(preferredRect: Rect, otherRects: Iterable<Rect>, gap = themeGap()) {
|
||||
const initialRect = orDefaultSize(preferredRect)
|
||||
const nodeRectsSorted = Array.from(otherRects, orDefaultSize).sort((a, b) => a.top - b.top)
|
||||
const bottom = () => top + initialRect.height
|
||||
let top = initialRect.top
|
||||
for (const rect of nodeRectsSorted) {
|
||||
if (initialRect.intersectsX(rect) && rect.bottom + gap.y > top) {
|
||||
if (rect.top - bottom() < gap.y) {
|
||||
top = rect.bottom + gap.y
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Vec2(initialRect.left, top)
|
||||
}
|
||||
|
||||
/** Given a preferred location for a node, adjust the left edge as much as necessary for it not to collide with any of
|
||||
* the provided `otherRects`. */
|
||||
export function seekHorizontal(initialRect: Rect, otherRects: Iterable<Rect>, gap = themeGap()) {
|
||||
return seekVertical(
|
||||
orDefaultSize(initialRect).reflectXY(),
|
||||
Array.from(otherRects, (rect) => orDefaultSize(rect).reflectXY()),
|
||||
gap.reflectXY(),
|
||||
).reflectXY()
|
||||
}
|
||||
|
@ -4,11 +4,7 @@ import CodeEditor from '@/components/CodeEditor.vue'
|
||||
import ColorPicker from '@/components/ColorPicker.vue'
|
||||
import ComponentBrowser from '@/components/ComponentBrowser.vue'
|
||||
import { type Usage } from '@/components/ComponentBrowser/input'
|
||||
import {
|
||||
DEFAULT_NODE_SIZE,
|
||||
mouseDictatedPlacement,
|
||||
usePlacement,
|
||||
} from '@/components/ComponentBrowser/placement'
|
||||
import { usePlacement } from '@/components/ComponentBrowser/placement'
|
||||
import GraphEdges from '@/components/GraphEditor/GraphEdges.vue'
|
||||
import GraphNodes from '@/components/GraphEditor/GraphNodes.vue'
|
||||
import { useGraphEditorClipboard } from '@/components/GraphEditor/clipboard'
|
||||
@ -23,8 +19,10 @@ import TopBar from '@/components/TopBar.vue'
|
||||
import { useDoubleClick } from '@/composables/doubleClick'
|
||||
import { keyboardBusy, keyboardBusyExceptIn, useEvent } from '@/composables/events'
|
||||
import { useNavigatorStorage } from '@/composables/navigatorStorage'
|
||||
import type { PlacementStrategy } from '@/composables/nodeCreation'
|
||||
import { useStackNavigator } from '@/composables/stackNavigator'
|
||||
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { provideNodeCreation } from '@/providers/graphNodeCreation'
|
||||
import { provideGraphSelection } from '@/providers/graphSelection'
|
||||
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
||||
import { provideKeyboard } from '@/providers/keyboard'
|
||||
@ -33,9 +31,8 @@ import { useGraphStore, type NodeId } from '@/stores/graph'
|
||||
import type { RequiredImport } from '@/stores/graph/imports'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { groupColorVar, useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import { assertNever, bail } from '@/util/assert'
|
||||
import { bail } from '@/util/assert'
|
||||
import type { AstId } from '@/util/ast/abstract'
|
||||
import type { Pattern } from '@/util/ast/match'
|
||||
import { colorFromString } from '@/util/colors'
|
||||
import { partition } from '@/util/data/array'
|
||||
import { filterDefined } from '@/util/data/iterable'
|
||||
@ -66,9 +63,8 @@ useNavigatorStorage(graphNavigator, (enc) => {
|
||||
if (methodPtr != null) encodeMethodPointer(enc, methodPtr)
|
||||
})
|
||||
|
||||
function zoomToSelected() {
|
||||
function selectionBounds() {
|
||||
if (!viewportNode.value) return
|
||||
|
||||
const allNodes = graphStore.db.nodeIdToNode
|
||||
const validSelected = [...nodeSelection.selected].filter((id) => allNodes.has(id))
|
||||
const nodesToCenter = validSelected.length === 0 ? allNodes.keys() : validSelected
|
||||
@ -77,8 +73,18 @@ function zoomToSelected() {
|
||||
const rect = graphStore.visibleArea(id)
|
||||
if (rect) bounds = Rect.Bounding(bounds, rect)
|
||||
}
|
||||
if (bounds.isFinite())
|
||||
graphNavigator.panAndZoomTo(bounds, 0.1, Math.max(1, graphNavigator.targetScale))
|
||||
if (bounds.isFinite()) return bounds
|
||||
}
|
||||
|
||||
function zoomToSelected() {
|
||||
const bounds = selectionBounds()
|
||||
if (bounds) graphNavigator.panAndZoomTo(bounds, 0.1, Math.max(1, graphNavigator.targetScale))
|
||||
}
|
||||
|
||||
function panToSelected() {
|
||||
const bounds = selectionBounds()
|
||||
if (bounds)
|
||||
graphNavigator.panTo([new Vec2(bounds.left, bounds.top), new Vec2(bounds.right, bounds.bottom)])
|
||||
}
|
||||
|
||||
// == Breadcrumbs ==
|
||||
@ -87,7 +93,7 @@ const stackNavigator = useStackNavigator()
|
||||
|
||||
// === Toasts ===
|
||||
|
||||
useGraphEditorToasts()
|
||||
const toasts = useGraphEditorToasts()
|
||||
|
||||
// === Selection ===
|
||||
|
||||
@ -109,11 +115,28 @@ watch(
|
||||
() => nodeSelection.deselectAll(),
|
||||
)
|
||||
|
||||
// === Node creation ===
|
||||
|
||||
const { place: nodePlacement, collapse: collapsedNodePlacement } = usePlacement(
|
||||
toRef(graphStore, 'visibleNodeAreas'),
|
||||
toRef(graphNavigator, 'viewport'),
|
||||
)
|
||||
|
||||
const { createNode, createNodes, placeNode } = provideNodeCreation(
|
||||
toRef(graphNavigator, 'viewport'),
|
||||
toRef(graphNavigator, 'sceneMousePos'),
|
||||
(nodes) => {
|
||||
clearFocus()
|
||||
nodeSelection.setSelection(nodes)
|
||||
panToSelected()
|
||||
},
|
||||
)
|
||||
|
||||
// === Clipboard Copy/Paste ===
|
||||
|
||||
const { copySelectionToClipboard, createNodesFromClipboard } = useGraphEditorClipboard(
|
||||
nodeSelection,
|
||||
graphNavigator,
|
||||
createNodes,
|
||||
)
|
||||
|
||||
// === Interactions ===
|
||||
@ -123,19 +146,28 @@ const interactionBindingsHandler = interactionBindings.handler({
|
||||
cancel: () => interaction.handleCancel(),
|
||||
})
|
||||
|
||||
const { place: nodePlacement, collapse: collapsedNodePlacement } = usePlacement(
|
||||
toRef(graphStore, 'visibleNodeAreas'),
|
||||
toRef(graphNavigator, 'viewport'),
|
||||
)
|
||||
|
||||
useEvent(window, 'keydown', (event) => {
|
||||
interactionBindingsHandler(event) ||
|
||||
(!keyboardBusy() && graphBindingsHandler(event)) ||
|
||||
(!keyboardBusyExceptIn(codeEditorArea.value) && codeEditorHandler(event))
|
||||
})
|
||||
useEvent(window, 'pointerdown', (e) => interaction.handlePointerDown(e, graphNavigator), {
|
||||
capture: true,
|
||||
})
|
||||
useEvent(
|
||||
window,
|
||||
'pointerdown',
|
||||
(e) => interaction.handlePointerEvent(e, 'pointerdown', graphNavigator),
|
||||
{
|
||||
capture: true,
|
||||
},
|
||||
)
|
||||
|
||||
useEvent(
|
||||
window,
|
||||
'pointerup',
|
||||
(e) => interaction.handlePointerEvent(e, 'pointerup', graphNavigator),
|
||||
{
|
||||
capture: true,
|
||||
},
|
||||
)
|
||||
|
||||
// === Keyboard/Mouse bindings ===
|
||||
|
||||
@ -155,22 +187,10 @@ const graphBindingsHandler = graphBindings.handler({
|
||||
openComponentBrowser() {
|
||||
if (keyboardBusy()) return false
|
||||
if (graphNavigator.sceneMousePos != null && !componentBrowserVisible.value) {
|
||||
createWithComponentBrowser(
|
||||
fromSelection() ?? {
|
||||
placement: [
|
||||
'fixed',
|
||||
mouseDictatedPlacement(DEFAULT_NODE_SIZE, graphNavigator.sceneMousePos).position,
|
||||
],
|
||||
},
|
||||
)
|
||||
createWithComponentBrowser(fromSelection() ?? { placement: { type: 'mouse' } })
|
||||
}
|
||||
},
|
||||
deleteSelected() {
|
||||
graphStore.transact(() => {
|
||||
graphStore.deleteNodes([...nodeSelection.selected])
|
||||
nodeSelection.selected.clear()
|
||||
})
|
||||
},
|
||||
deleteSelected,
|
||||
zoomToSelected() {
|
||||
zoomToSelected()
|
||||
},
|
||||
@ -206,41 +226,7 @@ const graphBindingsHandler = graphBindings.handler({
|
||||
},
|
||||
collapse() {
|
||||
if (keyboardBusy()) return false
|
||||
const selected = new Set(nodeSelection.selected)
|
||||
if (selected.size == 0) return
|
||||
try {
|
||||
const info = prepareCollapsedInfo(selected, graphStore.db)
|
||||
const currentMethod = projectStore.executionContext.getStackTop()
|
||||
const currentMethodName = graphStore.db.stackItemToMethodName(currentMethod)
|
||||
if (currentMethodName == null) {
|
||||
bail(`Cannot get the method name for the current execution stack item. ${currentMethod}`)
|
||||
}
|
||||
const topLevel = graphStore.topLevel
|
||||
if (!topLevel) {
|
||||
bail('BUG: no top level, collapsing not possible.')
|
||||
}
|
||||
const selectedNodeRects = filterDefined(Array.from(selected, graphStore.visibleArea))
|
||||
graphStore.edit((edit) => {
|
||||
const { refactoredNodeId, collapsedNodeIds, outputNodeId } = performCollapse(
|
||||
info,
|
||||
edit.getVersion(topLevel),
|
||||
graphStore.db,
|
||||
currentMethodName,
|
||||
)
|
||||
const { position } = collapsedNodePlacement(selectedNodeRects)
|
||||
edit.get(refactoredNodeId).mutableNodeMetadata().set('position', position.xy())
|
||||
if (outputNodeId != null) {
|
||||
const collapsedNodeRects = filterDefined(
|
||||
Array.from(collapsedNodeIds, graphStore.visibleArea),
|
||||
)
|
||||
const { place } = usePlacement(collapsedNodeRects, graphNavigator.viewport)
|
||||
const { position } = place(collapsedNodeRects)
|
||||
edit.get(outputNodeId).mutableNodeMetadata().set('position', position.xy())
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.log('Error while collapsing, this is not normal.', err)
|
||||
}
|
||||
collapseNodes()
|
||||
},
|
||||
enterNode() {
|
||||
if (keyboardBusy()) return false
|
||||
@ -271,6 +257,13 @@ const { handleClick } = useDoubleClick(
|
||||
},
|
||||
)
|
||||
|
||||
function deleteSelected() {
|
||||
graphStore.transact(() => {
|
||||
graphStore.deleteNodes([...nodeSelection.selected])
|
||||
nodeSelection.selected.clear()
|
||||
})
|
||||
}
|
||||
|
||||
// === Code Editor ===
|
||||
|
||||
const codeEditorArea = ref<HTMLElement>()
|
||||
@ -301,7 +294,7 @@ function onRecordOnceButtonPress() {
|
||||
watch(
|
||||
() => projectStore.executionMode,
|
||||
(modeValue) => {
|
||||
projectStore.executionContext.setExecutionEnvironment(modeValue === 'live' ? 'Live' : 'Design')
|
||||
projectStore.executionContext.executionEnvironment = modeValue === 'live' ? 'Live' : 'Design'
|
||||
},
|
||||
)
|
||||
|
||||
@ -332,26 +325,23 @@ function editWithComponentBrowser(node: NodeId, cursorPos: number) {
|
||||
function createWithComponentBrowser(options: NewNodeOptions) {
|
||||
openComponentBrowser(
|
||||
{ type: 'newNode', sourcePort: options.sourcePort },
|
||||
placeNode(options.placement),
|
||||
placeNode(options.placement, nodePlacement),
|
||||
)
|
||||
}
|
||||
|
||||
function commitComponentBrowser(content: string, requiredImports: RequiredImport[]) {
|
||||
if (content != null) {
|
||||
if (graphStore.editedNodeInfo) {
|
||||
// We finish editing a node.
|
||||
graphStore.setNodeContent(graphStore.editedNodeInfo.id, content, requiredImports)
|
||||
} else if (content != '') {
|
||||
// We finish creating a new node.
|
||||
const metadata = undefined
|
||||
const createdNode = graphStore.createNode(
|
||||
componentBrowserNodePosition.value,
|
||||
content,
|
||||
metadata,
|
||||
requiredImports,
|
||||
)
|
||||
if (createdNode) nodeSelection.setSelection(new Set([createdNode]))
|
||||
}
|
||||
if (graphStore.editedNodeInfo) {
|
||||
// We finish editing a node.
|
||||
graphStore.setNodeContent(graphStore.editedNodeInfo.id, content, requiredImports)
|
||||
} else if (content != '') {
|
||||
// We finish creating a new node.
|
||||
createNode(
|
||||
{ type: 'fixed', position: componentBrowserNodePosition.value },
|
||||
content,
|
||||
undefined,
|
||||
undefined,
|
||||
requiredImports,
|
||||
)
|
||||
}
|
||||
hideComponentBrowser()
|
||||
}
|
||||
@ -371,61 +361,48 @@ watch(
|
||||
// === Node Creation ===
|
||||
|
||||
interface NewNodeOptions {
|
||||
placement: PlacementType
|
||||
placement: PlacementStrategy
|
||||
sourcePort?: AstId | undefined
|
||||
}
|
||||
type PlacementType = 'viewport' | ['source', NodeId] | ['fixed', Vec2]
|
||||
|
||||
const placeNode = (placement: PlacementType): Vec2 =>
|
||||
placement === 'viewport' ? nodePlacement().position
|
||||
: placement[0] === 'source' ?
|
||||
nodePlacement(filterDefined([graphStore.visibleArea(placement[1])])).position
|
||||
: placement[0] === 'fixed' ? placement[1]
|
||||
: assertNever(placement)
|
||||
|
||||
/**
|
||||
* Start creating a node, basing its inputs and position on the current selection, if any;
|
||||
* or the current viewport, otherwise.
|
||||
*/
|
||||
function addNodeAuto() {
|
||||
createWithComponentBrowser(fromSelection() ?? { placement: 'viewport' })
|
||||
createWithComponentBrowser(fromSelection() ?? { placement: { type: 'viewport' } })
|
||||
}
|
||||
|
||||
function fromSelection(): NewNodeOptions | undefined {
|
||||
if (graphStore.editedNodeInfo != null) return undefined
|
||||
const firstSelectedNode = set.first(nodeSelection.selected)
|
||||
return {
|
||||
placement: ['source', firstSelectedNode],
|
||||
placement: { type: 'source', node: firstSelectedNode },
|
||||
sourcePort: graphStore.db.getNodeFirstOutputPort(firstSelectedNode),
|
||||
}
|
||||
}
|
||||
|
||||
function createNode(placement: PlacementType, sourcePort: AstId, pattern: Pattern) {
|
||||
const position = placeNode(placement)
|
||||
const content = pattern.instantiateCopied([graphStore.viewModule.get(sourcePort)]).code()
|
||||
return graphStore.createNode(position, content, undefined, []) ?? undefined
|
||||
}
|
||||
|
||||
function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[]) {
|
||||
const sourcePort = graphStore.db.getNodeFirstOutputPort(sourceNode)
|
||||
const [toCommit, toEdit] = partition(options, (opts) => opts.commit)
|
||||
const [withPos, withoutPos] = partition(toCommit, (opts) => !!opts.position)
|
||||
function clearFocus() {
|
||||
if (
|
||||
document.activeElement instanceof HTMLElement ||
|
||||
document.activeElement instanceof SVGElement
|
||||
) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
const placementForOptions = (options: NodeCreationOptions): PlacementType =>
|
||||
options.position ? ['fixed', options.position] : ['source', sourceNode]
|
||||
const createWithoutEditing = (options: NodeCreationOptions) =>
|
||||
createNode(placementForOptions(options), sourcePort, options.content!)
|
||||
const created = new Set<NodeId>(
|
||||
filterDefined([...withPos.map(createWithoutEditing), ...withoutPos.map(createWithoutEditing)]),
|
||||
}
|
||||
|
||||
function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[]) {
|
||||
const sourcePort = graphStore.db.getNodeFirstOutputPort(sourceNode)
|
||||
const sourcePortAst = graphStore.viewModule.get(sourcePort)
|
||||
const [toCommit, toEdit] = partition(options, (opts) => opts.commit)
|
||||
createNodes(
|
||||
toCommit.map((options: NodeCreationOptions) => ({
|
||||
placement: { type: 'source', node: sourceNode },
|
||||
expression: options.content!.instantiateCopied([sourcePortAst]).code(),
|
||||
})),
|
||||
)
|
||||
if (created.size) nodeSelection.setSelection(created)
|
||||
for (const options of toEdit)
|
||||
createWithComponentBrowser({ placement: placementForOptions(options), sourcePort })
|
||||
if (toEdit.length)
|
||||
createWithComponentBrowser({ placement: { type: 'source', node: sourceNode }, sourcePort })
|
||||
}
|
||||
|
||||
function handleNodeOutputPortDoubleClick(id: AstId) {
|
||||
@ -434,11 +411,55 @@ function handleNodeOutputPortDoubleClick(id: AstId) {
|
||||
console.error('Impossible happened: Double click on port not belonging to any node: ', id)
|
||||
return
|
||||
}
|
||||
createWithComponentBrowser({ placement: ['source', srcNode], sourcePort: id })
|
||||
createWithComponentBrowser({ placement: { type: 'source', node: srcNode }, sourcePort: id })
|
||||
}
|
||||
|
||||
function handleEdgeDrop(source: AstId, position: Vec2) {
|
||||
createWithComponentBrowser({ placement: ['fixed', position], sourcePort: source })
|
||||
createWithComponentBrowser({ placement: { type: 'fixed', position }, sourcePort: source })
|
||||
}
|
||||
|
||||
// === Node Collapsing ===
|
||||
|
||||
function collapseNodes() {
|
||||
const selected = nodeSelection.selected
|
||||
if (selected.size == 0) return
|
||||
try {
|
||||
const info = prepareCollapsedInfo(selected, graphStore.db)
|
||||
if (!info.ok) {
|
||||
toasts.userActionFailed.show(`Unable to group nodes: ${info.error.payload}.`)
|
||||
return
|
||||
}
|
||||
const currentMethod = projectStore.executionContext.getStackTop()
|
||||
const currentMethodName = graphStore.db.stackItemToMethodName(currentMethod)
|
||||
if (currentMethodName == null) {
|
||||
bail(`Cannot get the method name for the current execution stack item. ${currentMethod}`)
|
||||
}
|
||||
const topLevel = graphStore.topLevel
|
||||
if (!topLevel) {
|
||||
bail('BUG: no top level, collapsing not possible.')
|
||||
}
|
||||
const selectedNodeRects = filterDefined(Array.from(selected, graphStore.visibleArea))
|
||||
graphStore.edit((edit) => {
|
||||
const { refactoredNodeId, collapsedNodeIds, outputNodeId } = performCollapse(
|
||||
info.value,
|
||||
edit.getVersion(topLevel),
|
||||
graphStore.db,
|
||||
currentMethodName,
|
||||
)
|
||||
const position = collapsedNodePlacement(selectedNodeRects)
|
||||
edit.get(refactoredNodeId).mutableNodeMetadata().set('position', position.xy())
|
||||
if (outputNodeId != null) {
|
||||
const collapsedNodeRects = filterDefined(
|
||||
Array.from(collapsedNodeIds, graphStore.visibleArea),
|
||||
)
|
||||
const { place } = usePlacement(collapsedNodeRects, graphNavigator.viewport)
|
||||
const position = place(collapsedNodeRects)
|
||||
edit.get(outputNodeId).mutableNodeMetadata().set('position', position.xy())
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.log('Error while collapsing, this is not normal.', err)
|
||||
}
|
||||
}
|
||||
|
||||
// === Drag and drop ===
|
||||
@ -468,7 +489,7 @@ async function handleFileDrop(event: DragEvent) {
|
||||
)
|
||||
const uploadResult = await uploader.upload()
|
||||
if (uploadResult.ok) {
|
||||
graphStore.createNode(pos, uploadedExpression(uploadResult.value))
|
||||
createNode({ type: 'mouseEvent', position: pos }, uploadedExpression(uploadResult.value))
|
||||
} else {
|
||||
uploadResult.error.log(`Uploading file failed`)
|
||||
}
|
||||
@ -581,6 +602,7 @@ const groupColors = computed(() => {
|
||||
:allowNavigationLeft="stackNavigator.allowNavigationLeft.value"
|
||||
:allowNavigationRight="stackNavigator.allowNavigationRight.value"
|
||||
:zoomLevel="100.0 * graphNavigator.targetScale"
|
||||
:componentsSelected="nodeSelection.selected.size"
|
||||
@breadcrumbClick="stackNavigator.handleBreadcrumbClick"
|
||||
@back="stackNavigator.exitNode"
|
||||
@forward="stackNavigator.enterNextNodeFromHistory"
|
||||
@ -589,6 +611,9 @@ const groupColors = computed(() => {
|
||||
@zoomIn="graphNavigator.stepZoom(+1)"
|
||||
@zoomOut="graphNavigator.stepZoom(-1)"
|
||||
@toggleCodeEditor="toggleCodeEditor"
|
||||
@collapseNodes="collapseNodes"
|
||||
@toggleColorPicker="toggleColorPicker"
|
||||
@removeNodes="deleteSelected"
|
||||
/>
|
||||
<PlusButton @pointerdown.stop @click.stop="addNodeAuto()" @pointerup.stop />
|
||||
<Transition>
|
||||
|
@ -416,11 +416,11 @@ const targetEndIsDimmed = computed(() => {
|
||||
|
||||
const baseStyle = computed(() => ({ '--node-base-color': edgeColor.value ?? 'tan' }))
|
||||
|
||||
function click() {
|
||||
function click(event: PointerEvent) {
|
||||
const distances = mouseLocationOnEdge.value
|
||||
if (distances == null) return
|
||||
if (distances.sourceToMouse < distances.mouseToTarget) graph.disconnectTarget(props.edge)
|
||||
else graph.disconnectSource(props.edge)
|
||||
if (distances.sourceToMouse < distances.mouseToTarget) graph.disconnectTarget(props.edge, event)
|
||||
else graph.disconnectSource(props.edge, event)
|
||||
}
|
||||
|
||||
function svgTranslate(offset: Vec2): string {
|
||||
|
@ -22,39 +22,55 @@ const emits = defineEmits<{
|
||||
createNodeFromEdge: [source: AstId, position: Vec2]
|
||||
}>()
|
||||
|
||||
const MIN_DRAG_MOVE = 10
|
||||
|
||||
const editingEdge: Interaction = {
|
||||
cancel() {
|
||||
graph.clearUnconnected()
|
||||
},
|
||||
pointerdown(_e: PointerEvent, graphNavigator: GraphNavigator): boolean {
|
||||
if (graph.unconnectedEdge == null) return false
|
||||
let source: AstId | undefined
|
||||
let sourceNode: NodeId | undefined
|
||||
if (graph.unconnectedEdge.source) {
|
||||
source = graph.unconnectedEdge.source
|
||||
sourceNode = graph.db.getPatternExpressionNodeId(source)
|
||||
} else if (selection?.hoveredNode) {
|
||||
sourceNode = selection.hoveredNode
|
||||
source = graph.db.getNodeFirstOutputPort(sourceNode)
|
||||
}
|
||||
const target = graph.unconnectedEdge.target ?? selection?.hoveredPort
|
||||
const targetNode = target && graph.getPortNodeId(target)
|
||||
graph.transact(() => {
|
||||
if (source != null && sourceNode != targetNode) {
|
||||
if (target == null) {
|
||||
if (graph.unconnectedEdge?.disconnectedEdgeTarget != null)
|
||||
disconnectEdge(graph.unconnectedEdge.disconnectedEdgeTarget)
|
||||
emits('createNodeFromEdge', source, graphNavigator.sceneMousePos ?? Vec2.Zero)
|
||||
} else {
|
||||
createEdge(source, target)
|
||||
}
|
||||
} else if (source == null && target != null) {
|
||||
disconnectEdge(target)
|
||||
}
|
||||
graph.clearUnconnected()
|
||||
})
|
||||
return true
|
||||
return edgeInteractionClick(graphNavigator)
|
||||
},
|
||||
pointerup(e: PointerEvent, graphNavigator: GraphNavigator): boolean {
|
||||
const originEvent = graph.unconnectedEdge?.event
|
||||
if (originEvent?.type === 'pointerdown') {
|
||||
const delta = new Vec2(e.screenX, e.screenY).sub(
|
||||
new Vec2(originEvent.screenX, originEvent.screenY),
|
||||
)
|
||||
if (delta.lengthSquared() >= MIN_DRAG_MOVE ** 2) return edgeInteractionClick(graphNavigator)
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
function edgeInteractionClick(graphNavigator: GraphNavigator) {
|
||||
if (graph.unconnectedEdge == null) return false
|
||||
let source: AstId | undefined
|
||||
let sourceNode: NodeId | undefined
|
||||
if (graph.unconnectedEdge.source) {
|
||||
source = graph.unconnectedEdge.source
|
||||
sourceNode = graph.db.getPatternExpressionNodeId(source)
|
||||
} else if (selection?.hoveredNode) {
|
||||
sourceNode = selection.hoveredNode
|
||||
source = graph.db.getNodeFirstOutputPort(sourceNode)
|
||||
}
|
||||
const target = graph.unconnectedEdge.target ?? selection?.hoveredPort
|
||||
const targetNode = target && graph.getPortNodeId(target)
|
||||
graph.transact(() => {
|
||||
if (source != null && sourceNode != targetNode) {
|
||||
if (target == null) {
|
||||
if (graph.unconnectedEdge?.disconnectedEdgeTarget != null)
|
||||
disconnectEdge(graph.unconnectedEdge.disconnectedEdgeTarget)
|
||||
emits('createNodeFromEdge', source, graphNavigator.sceneMousePos ?? Vec2.Zero)
|
||||
} else {
|
||||
createEdge(source, target)
|
||||
}
|
||||
} else if (source == null && target != null) {
|
||||
disconnectEdge(target)
|
||||
}
|
||||
graph.clearUnconnected()
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
interaction.setWhen(() => graph.unconnectedEdge != null, editingEdge)
|
||||
|
@ -54,8 +54,8 @@ const emit = defineEmits<{
|
||||
draggingCommited: []
|
||||
delete: []
|
||||
replaceSelection: []
|
||||
outputPortClick: [portId: AstId]
|
||||
outputPortDoubleClick: [portId: AstId]
|
||||
outputPortClick: [event: PointerEvent, portId: AstId]
|
||||
outputPortDoubleClick: [event: PointerEvent, portId: AstId]
|
||||
doubleClick: []
|
||||
createNodes: [options: NodeCreationOptions[]]
|
||||
toggleColorPicker: []
|
||||
@ -161,7 +161,9 @@ const selected = computed(() => nodeSelection?.isSelected(nodeId.value) ?? false
|
||||
const selectionVisible = ref(false)
|
||||
|
||||
const isOnlyOneSelected = computed(
|
||||
() => selected.value && nodeSelection?.selected.size === 1 && !nodeSelection.isChanging,
|
||||
() =>
|
||||
nodeSelection?.committedSelection.size === 1 &&
|
||||
nodeSelection?.committedSelection.has(nodeId.value),
|
||||
)
|
||||
|
||||
const menuVisible = computed(() => menuEnabledByHover.value || isOnlyOneSelected.value)
|
||||
@ -360,8 +362,8 @@ function getRelatedSpanOffset(domNode: globalThis.Node, domOffset: number): numb
|
||||
}
|
||||
|
||||
const handlePortClick = useDoubleClick(
|
||||
(portId: AstId) => emit('outputPortClick', portId),
|
||||
(portId: AstId) => emit('outputPortDoubleClick', portId),
|
||||
(event: PointerEvent, portId: AstId) => emit('outputPortClick', event, portId),
|
||||
(event: PointerEvent, portId: AstId) => emit('outputPortDoubleClick', event, portId),
|
||||
).handleClick
|
||||
|
||||
const handleNodeClick = useDoubleClick(
|
||||
@ -371,7 +373,7 @@ const handleNodeClick = useDoubleClick(
|
||||
|
||||
interface PortData {
|
||||
clipRange: [number, number]
|
||||
label: string
|
||||
label: string | undefined
|
||||
portId: AstId
|
||||
}
|
||||
|
||||
@ -379,12 +381,9 @@ const outputPorts = computed((): PortData[] => {
|
||||
const ports = outputPortsSet.value
|
||||
const numPorts = ports.size
|
||||
return Array.from(ports, (portId, index): PortData => {
|
||||
const labelIdent = numPorts > 1 ? graph.db.getOutputPortIdentifier(portId) + ': ' : ''
|
||||
const labelType =
|
||||
graph.db.getExpressionInfo(numPorts > 1 ? portId : nodeId.value)?.typename ?? 'Unknown'
|
||||
return {
|
||||
clipRange: [index / numPorts, (index + 1) / numPorts],
|
||||
label: labelIdent + labelType,
|
||||
label: numPorts > 1 ? graph.db.getOutputPortIdentifier(portId) : undefined,
|
||||
portId,
|
||||
}
|
||||
})
|
||||
@ -583,7 +582,7 @@ const documentation = computed<string | undefined>({
|
||||
class="outputPortHoverArea"
|
||||
@pointerenter="outputHovered = port.portId"
|
||||
@pointerleave="outputHovered = undefined"
|
||||
@pointerdown.stop.prevent="handlePortClick(port.portId)"
|
||||
@pointerdown.stop.prevent="handlePortClick($event, port.portId)"
|
||||
/>
|
||||
<rect class="outputPort" />
|
||||
</g>
|
||||
|
@ -59,8 +59,8 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
|
||||
@delete="graphStore.deleteNodes([id])"
|
||||
@dragging="nodeIsDragged(id, $event)"
|
||||
@draggingCommited="dragging.finishDrag()"
|
||||
@outputPortClick="graphStore.createEdgeFromOutput($event)"
|
||||
@outputPortDoubleClick="emit('nodeOutputPortDoubleClick', $event)"
|
||||
@outputPortClick="(event, port) => graphStore.createEdgeFromOutput(port, event)"
|
||||
@outputPortDoubleClick="(_event, port) => emit('nodeOutputPortDoubleClick', port)"
|
||||
@doubleClick="emit('nodeDoubleClick', id)"
|
||||
@createNodes="emit('createNodes', id, $event)"
|
||||
@toggleColorPicker="emit('toggleColorPicker')"
|
||||
|
@ -293,6 +293,9 @@ provideVisualizationConfig({
|
||||
get icon() {
|
||||
return icon.value
|
||||
},
|
||||
get nodeType() {
|
||||
return props.typename
|
||||
},
|
||||
hide: () => emit('update:visible', false),
|
||||
updateType: (id) => emit('update:id', id),
|
||||
createNodes: (...options) => emit('createNodes', options),
|
||||
|
@ -169,7 +169,7 @@ test.each(testCases)('Collapsing nodes, $description', (testCase) => {
|
||||
const selectedNodesCode = testCase.initialNodes.slice(range.start, range.end)
|
||||
const selectedNodes = new Set(selectedNodesCode.map((code) => nodeCodeToId.get(code)!))
|
||||
|
||||
const { extracted, refactored } = prepareCollapsedInfo(selectedNodes, graphDb)
|
||||
const { extracted, refactored } = unwrap(prepareCollapsedInfo(selectedNodes, graphDb))
|
||||
const expectedInputs = expectedExtracted.inputs.map((s) => unwrap(tryIdentifier(s)))
|
||||
const expectedOutput = unwrap(tryIdentifier(expectedExtracted.output))
|
||||
const expectedIds = expectedExtracted.nodes.map((code) => nodeCodeToId.get(code)!)
|
||||
@ -200,7 +200,9 @@ main =
|
||||
const graphDb = GraphDb.Mock()
|
||||
setupGraphDb(initialCode, graphDb)
|
||||
const nodes = Array.from(graphDb.nodeIdToNode.keys())
|
||||
const { extracted, refactored } = prepareCollapsedInfo(new Set(nodes.slice(1, 2)), graphDb)
|
||||
const { extracted, refactored } = unwrap(
|
||||
prepareCollapsedInfo(new Set(nodes.slice(1, 2)), graphDb),
|
||||
)
|
||||
expect(extracted.ids).toEqual(new Set(nodes.slice(1, 2)))
|
||||
expect(extracted.inputs).toEqual(['input', 'four'])
|
||||
expect(extracted.output).toEqual({
|
||||
|
@ -1,10 +1,9 @@
|
||||
import type { NavigatorComposable } from '@/composables/navigator'
|
||||
import type { NodeCreation } from '@/composables/nodeCreation'
|
||||
import type { GraphSelection } from '@/providers/graphSelection'
|
||||
import type { Node } from '@/stores/graph'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Pattern } from '@/util/ast/match'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import type { NodeMetadataFields } from 'shared/ast'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@ -121,7 +120,7 @@ function getClipboard() {
|
||||
|
||||
export function useGraphEditorClipboard(
|
||||
nodeSelection: GraphSelection,
|
||||
graphNavigator: NavigatorComposable,
|
||||
createNodes: NodeCreation['createNodes'],
|
||||
) {
|
||||
const graphStore = useGraphStore()
|
||||
|
||||
@ -147,16 +146,14 @@ export function useGraphEditorClipboard(
|
||||
console.warn('No valid node in clipboard.')
|
||||
return
|
||||
}
|
||||
for (const copiedNode of clipboardData) {
|
||||
const { expression, documentation, metadata } = copiedNode
|
||||
graphStore.createNode(
|
||||
(clipboardData.length === 1 ? graphNavigator.sceneMousePos : null) ?? Vec2.Zero,
|
||||
createNodes(
|
||||
clipboardData.map(({ expression, documentation, metadata }) => ({
|
||||
placement: { type: 'mouse' },
|
||||
expression,
|
||||
metadata,
|
||||
undefined,
|
||||
documentation,
|
||||
)
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -3,7 +3,7 @@ import { assert, assertDefined } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { autospaced, isIdentifier, moduleMethodNames, type Identifier } from '@/util/ast/abstract'
|
||||
import { nodeFromAst } from '@/util/ast/node'
|
||||
import { unwrap } from '@/util/data/result'
|
||||
import { Err, Ok, unwrap, type Result } from '@/util/data/result'
|
||||
import {
|
||||
isIdentifierOrOperatorIdentifier,
|
||||
tryIdentifier,
|
||||
@ -54,10 +54,13 @@ interface RefactoredInfo {
|
||||
/** Prepare the information necessary for collapsing nodes.
|
||||
* @throws errors in case of failures, but it should not happen in normal execution.
|
||||
*/
|
||||
export function prepareCollapsedInfo(selected: Set<NodeId>, graphDb: GraphDb): CollapsedInfo {
|
||||
export function prepareCollapsedInfo(
|
||||
selected: Set<NodeId>,
|
||||
graphDb: GraphDb,
|
||||
): Result<CollapsedInfo> {
|
||||
if (selected.size == 0) throw new Error('Collapsing requires at least a single selected node.')
|
||||
// Leaves are the nodes that have no outgoing connection.
|
||||
const leaves = new Set([...selected])
|
||||
const leaves = new Set(selected)
|
||||
const inputSet: Set<Identifier> = new Set()
|
||||
let output: Output | null = null
|
||||
for (const [targetExprId, sourceExprIds] of graphDb.allConnections.allReverse()) {
|
||||
@ -87,7 +90,7 @@ export function prepareCollapsedInfo(selected: Set<NodeId>, graphDb: GraphDb): C
|
||||
} else if (output.identifier == identifier) {
|
||||
// Ignore duplicate usage of the same identifier.
|
||||
} else {
|
||||
throw new Error(
|
||||
return Err(
|
||||
`More than one output from collapsed function: ${identifier} and ${output.identifier}. Collapsing is not supported.`,
|
||||
)
|
||||
}
|
||||
@ -98,8 +101,7 @@ export function prepareCollapsedInfo(selected: Set<NodeId>, graphDb: GraphDb): C
|
||||
// the extracted function. In such we will return value from arbitrarily chosen leaf.
|
||||
if (output == null) {
|
||||
const arbitraryLeaf = set.first(leaves)
|
||||
if (arbitraryLeaf == null)
|
||||
throw new Error('Cannot select the output node, no leaf nodes found.')
|
||||
if (arbitraryLeaf == null) throw Error('Cannot select the output node, no leaf nodes found.')
|
||||
const outputNode = graphDb.nodeIdToNode.get(arbitraryLeaf)
|
||||
if (outputNode == null) throw new Error(`The node with id ${arbitraryLeaf} not found.`)
|
||||
const identifier = unwrap(tryIdentifier(outputNode.pattern?.code() || ''))
|
||||
@ -109,7 +111,7 @@ export function prepareCollapsedInfo(selected: Set<NodeId>, graphDb: GraphDb): C
|
||||
const pattern = graphDb.nodeIdToNode.get(output.node)?.pattern?.code() ?? ''
|
||||
assert(isIdentifier(pattern))
|
||||
const inputs = Array.from(inputSet)
|
||||
return {
|
||||
return Ok({
|
||||
extracted: {
|
||||
ids: selected,
|
||||
output,
|
||||
@ -120,7 +122,7 @@ export function prepareCollapsedInfo(selected: Set<NodeId>, graphDb: GraphDb): C
|
||||
pattern,
|
||||
arguments: inputs,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Generate a safe method name for a collapsed function using `baseName` as a prefix. */
|
||||
|
@ -1,10 +1,6 @@
|
||||
import type { Pattern } from '@/util/ast/match'
|
||||
import type { Vec2 } from '@/util/data/vec2'
|
||||
|
||||
interface AllNodeCreationOptions {
|
||||
/** Where to place the node; if a value is specified, it will be used exactly.
|
||||
* If unspecified, a location will be chosen based on the source node. */
|
||||
position?: Vec2 | undefined
|
||||
/** If false, the Component Browser will be opened to edit the node.
|
||||
* If true, the node will be created without further interaction. */
|
||||
commit: boolean
|
||||
|
@ -9,6 +9,7 @@ export function useGraphEditorToasts() {
|
||||
const toastConnectionLost = useToast.error({ autoClose: false })
|
||||
const toastLspError = useToast.error()
|
||||
const toastExecutionFailed = useToast.error()
|
||||
const toastUserActionFailed = useToast.error()
|
||||
|
||||
toastStartup.show('Initializing the project. This can take up to one minute.')
|
||||
projectStore.firstExecution.then(toastStartup.dismiss)
|
||||
@ -24,4 +25,6 @@ export function useGraphEditorToasts() {
|
||||
projectStore.executionContext.on('executionFailed', (e) =>
|
||||
toastExecutionFailed.show(`Execution Failed: ${JSON.stringify(e)}`),
|
||||
)
|
||||
|
||||
return { userActionFailed: toastUserActionFailed }
|
||||
}
|
||||
|
@ -125,6 +125,9 @@ const selfArgumentExternalId = computed<Opt<ExternalId>>(() => {
|
||||
const visualizationConfig = computed<Opt<NodeVisualizationConfiguration>>(() => {
|
||||
// Even if we inherit dynamic config in props.input.dynamicConfig, we should also read it for
|
||||
// the current call and then merge them.
|
||||
|
||||
let m = ArgumentApplication.collectArgumentNamesAndUuids(interpreted.value, methodCallInfo.value)
|
||||
|
||||
const expressionId = selfArgumentExternalId.value
|
||||
const astId = props.input.value.id
|
||||
if (astId == null || expressionId == null) return null
|
||||
@ -144,6 +147,7 @@ const visualizationConfig = computed<Opt<NodeVisualizationConfiguration>>(() =>
|
||||
positionalArgumentsExpressions: [
|
||||
`.${name}`,
|
||||
Ast.Vector.build(args, Ast.TextLiteral.new).code(),
|
||||
Ast.TextLiteral.new(JSON.stringify(m)).code(),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
53
app/gui2/src/components/SelectionMenu.vue
Normal file
53
app/gui2/src/components/SelectionMenu.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
|
||||
const _props = defineProps<{
|
||||
selectedComponents: number
|
||||
}>()
|
||||
const emit = defineEmits<{ collapseNodes: []; toggleColorPicker: []; removeNodes: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="SelectionMenu" @pointerdown.stop @pointerup.stop @click.stop>
|
||||
<span
|
||||
v-text="`${selectedComponents} component${selectedComponents === 1 ? '' : 's'} selected`"
|
||||
/>
|
||||
<SvgIcon
|
||||
name="group"
|
||||
draggable="false"
|
||||
class="icon button"
|
||||
alt="Group components"
|
||||
@click.stop="emit('collapseNodes')"
|
||||
/>
|
||||
<SvgIcon
|
||||
name="paint_palette"
|
||||
draggable="false"
|
||||
class="icon button"
|
||||
alt="Change components' colors"
|
||||
@click.stop="emit('toggleColorPicker')"
|
||||
/>
|
||||
<SvgIcon
|
||||
name="trash"
|
||||
draggable="false"
|
||||
class="icon button"
|
||||
alt="Delete components"
|
||||
@click.stop="emit('removeNodes')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.SelectionMenu {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-frame-bg);
|
||||
backdrop-filter: var(--blur-app-bg);
|
||||
place-items: center;
|
||||
gap: 12px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
</style>
|
@ -1,35 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
createNodes: [options: NodeCreationOptions[]]
|
||||
}>()
|
||||
|
||||
const navigator = injectGraphNavigator(true)
|
||||
|
||||
const addNodeButton = ref<HTMLElement>()
|
||||
|
||||
function addNode() {
|
||||
const clientRect = addNodeButton.value?.getBoundingClientRect()
|
||||
const position =
|
||||
clientRect && navigator?.clientToScenePos(new Vec2(clientRect.left, clientRect.top))
|
||||
emit('createNodes', [{ position, commit: false, content: undefined }])
|
||||
emit('createNodes', [{ commit: false, content: undefined }])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="addNodeButton"
|
||||
class="SmallPlusButton add-node"
|
||||
@click.stop
|
||||
class="SmallPlusButton add-node button"
|
||||
@click.stop="addNode"
|
||||
@pointerdown.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<SvgIcon name="add" class="icon button" @click.stop="addNode" />
|
||||
<SvgIcon name="add" class="icon" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -37,21 +26,15 @@ function addNode() {
|
||||
.SmallPlusButton {
|
||||
width: var(--node-height);
|
||||
height: var(--node-height);
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
backdrop-filter: var(--blur-app-bg);
|
||||
background: var(--color-app-bg);
|
||||
border-radius: 16px;
|
||||
width: var(--node-height);
|
||||
height: var(--node-height);
|
||||
}
|
||||
&:hover:before {
|
||||
|
||||
backdrop-filter: var(--blur-app-bg);
|
||||
background: var(--color-app-bg);
|
||||
border-radius: 16px;
|
||||
|
||||
&:hover {
|
||||
background: rgb(230, 230, 255);
|
||||
}
|
||||
&:active:before {
|
||||
&:active {
|
||||
background: rgb(158, 158, 255);
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import ExtendedMenu from '@/components/ExtendedMenu.vue'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import type { BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
|
||||
import RecordControl from '@/components/RecordControl.vue'
|
||||
import SelectionMenu from '@/components/SelectionMenu.vue'
|
||||
import { injectGuiConfig } from '@/providers/guiConfig'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@ -12,6 +13,7 @@ const props = defineProps<{
|
||||
allowNavigationLeft: boolean
|
||||
allowNavigationRight: boolean
|
||||
zoomLevel: number
|
||||
componentsSelected: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
recordOnce: []
|
||||
@ -23,6 +25,9 @@ const emit = defineEmits<{
|
||||
zoomIn: []
|
||||
zoomOut: []
|
||||
toggleCodeEditor: []
|
||||
collapseNodes: []
|
||||
toggleColorPicker: []
|
||||
removeNodes: []
|
||||
}>()
|
||||
|
||||
const LEFT_PADDING_PX = 11
|
||||
@ -52,6 +57,15 @@ const barStyle = computed(() => {
|
||||
@forward="emit('forward')"
|
||||
@breadcrumbClick="emit('breadcrumbClick', $event)"
|
||||
/>
|
||||
<Transition name="selection-menu">
|
||||
<SelectionMenu
|
||||
v-if="componentsSelected > 1"
|
||||
:selectedComponents="componentsSelected"
|
||||
@collapseNodes="emit('collapseNodes')"
|
||||
@toggleColorPicker="emit('toggleColorPicker')"
|
||||
@removeNodes="emit('removeNodes')"
|
||||
/>
|
||||
</Transition>
|
||||
<ExtendedMenu
|
||||
:zoomLevel="props.zoomLevel"
|
||||
@fitToAllClicked="emit('fitToAllClicked')"
|
||||
@ -72,4 +86,14 @@ const barStyle = computed(() => {
|
||||
left: 9px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selection-menu-enter-active,
|
||||
.selection-menu-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.selection-menu-enter-from,
|
||||
.selection-menu-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
@ -4,7 +4,8 @@ import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import VisualizationSelector from '@/components/VisualizationSelector.vue'
|
||||
import { PointerButtonMask, isTriggeredByKeyboard, usePointer } from '@/composables/events'
|
||||
import { useVisualizationConfig } from '@/providers/visualizationConfig'
|
||||
import { onMounted, ref, watchEffect } from 'vue'
|
||||
import { isQualifiedName, qnLastSegment } from '@/util/qualifiedName'
|
||||
import { computed, onMounted, ref, watchEffect } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** If true, the visualization should be `overflow: visible` instead of `overflow: hidden`. */
|
||||
@ -55,39 +56,34 @@ function hideSelector() {
|
||||
requestAnimationFrame(() => (isSelectorVisible.value = false))
|
||||
}
|
||||
|
||||
const resizeRight = usePointer((pos, _, type) => {
|
||||
if (type !== 'move' || pos.delta.x === 0) {
|
||||
return
|
||||
}
|
||||
const width =
|
||||
(pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)) / config.scale
|
||||
config.width = Math.max(width, MIN_WIDTH_PX)
|
||||
}, PointerButtonMask.Main)
|
||||
function resizeHandler(resizeX: boolean, resizeY: boolean) {
|
||||
return usePointer((pos, _, type) => {
|
||||
if (type !== 'move') {
|
||||
return
|
||||
}
|
||||
if (resizeX && pos.delta.x !== 0) {
|
||||
const width =
|
||||
(pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)) / config.scale
|
||||
config.width = Math.max(0, width)
|
||||
}
|
||||
if (resizeY && pos.delta.y !== 0) {
|
||||
const height =
|
||||
(pos.absolute.y - (contentNode.value?.getBoundingClientRect().top ?? 0)) / config.scale
|
||||
config.height = Math.max(0, height)
|
||||
}
|
||||
}, PointerButtonMask.Main)
|
||||
}
|
||||
|
||||
const resizeBottom = usePointer((pos, _, type) => {
|
||||
if (type !== 'move' || pos.delta.y === 0) {
|
||||
return
|
||||
}
|
||||
const height =
|
||||
(pos.absolute.y - (contentNode.value?.getBoundingClientRect().top ?? 0)) / config.scale
|
||||
config.height = Math.max(0, height)
|
||||
}, PointerButtonMask.Main)
|
||||
const resizeRight = resizeHandler(true, false)
|
||||
const resizeBottom = resizeHandler(false, true)
|
||||
const resizeBottomRight = resizeHandler(true, true)
|
||||
|
||||
const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
if (type !== 'move') {
|
||||
return
|
||||
}
|
||||
if (pos.delta.x !== 0) {
|
||||
const width =
|
||||
(pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)) / config.scale
|
||||
config.width = Math.max(0, width)
|
||||
}
|
||||
if (pos.delta.y !== 0) {
|
||||
const height =
|
||||
(pos.absolute.y - (contentNode.value?.getBoundingClientRect().top ?? 0)) / config.scale
|
||||
config.height = Math.max(0, height)
|
||||
}
|
||||
}, PointerButtonMask.Main)
|
||||
const UNKNOWN_TYPE = 'Unknown'
|
||||
const nodeShortType = computed(() =>
|
||||
config.nodeType != null && isQualifiedName(config.nodeType) ?
|
||||
qnLastSegment(config.nodeType)
|
||||
: UNKNOWN_TYPE,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -108,9 +104,9 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
@pointerup.stop
|
||||
@click.stop
|
||||
>
|
||||
<div class="resizer-right" v-on="resizeRight.stop.events"></div>
|
||||
<div class="resizer-bottom" v-on="resizeBottom.stop.events"></div>
|
||||
<div class="resizer-bottom-right" v-on="resizeBottomRight.stop.events"></div>
|
||||
<div class="resizer-right" v-on="resizeRight.events"></div>
|
||||
<div class="resizer-bottom" v-on="resizeBottom.events"></div>
|
||||
<div class="resizer-bottom-right" v-on="resizeBottomRight.events"></div>
|
||||
<SmallPlusButton
|
||||
v-if="config.isCircularMenuVisible"
|
||||
class="below-viz"
|
||||
@ -186,6 +182,11 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
<div v-if="$slots.toolbar" class="visualization-defined-toolbars">
|
||||
<div class="toolbar"><slot name="toolbar"></slot></div>
|
||||
</div>
|
||||
<div
|
||||
class="after-toolbars node-type"
|
||||
:title="config.nodeType ?? UNKNOWN_TYPE"
|
||||
v-text="nodeShortType"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
@ -232,7 +233,6 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
}
|
||||
|
||||
.toolbars {
|
||||
width: 100%;
|
||||
transition-duration: 100ms;
|
||||
transition-property: padding-left;
|
||||
}
|
||||
@ -250,6 +250,7 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
}
|
||||
|
||||
.toolbars {
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
@ -257,6 +258,15 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
top: calc(var(--node-height) + 4px);
|
||||
}
|
||||
|
||||
.after-toolbars {
|
||||
margin-left: auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.node-type {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.VisualizationContainer.fullscreen .toolbars {
|
||||
top: 4px;
|
||||
}
|
||||
@ -264,7 +274,6 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
.below-viz {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
|
@ -117,6 +117,7 @@ const agGridOptions: Ref<GridOptions & Required<Pick<GridOptions, 'defaultColDef
|
||||
onColumnResized: lockColumnSize,
|
||||
suppressFieldDotNotation: true,
|
||||
enableRangeSelection: true,
|
||||
popupParent: document.body,
|
||||
})
|
||||
|
||||
const isRowCountSelectorVisible = computed(() => rowCount.value >= 1000)
|
||||
@ -134,6 +135,12 @@ const selectableRowLimits = computed(() => {
|
||||
})
|
||||
const wasAutomaticallyAutosized = ref(false)
|
||||
|
||||
const numberFormat = new Intl.NumberFormat(undefined, {
|
||||
style: 'decimal',
|
||||
maximumFractionDigits: 12,
|
||||
useGrouping: 'min2' as any,
|
||||
})
|
||||
|
||||
function setRowLimit(newRowLimit: number) {
|
||||
if (newRowLimit !== rowLimit.value) {
|
||||
rowLimit.value = newRowLimit
|
||||
@ -158,12 +165,29 @@ function escapeHTML(str: string) {
|
||||
}
|
||||
|
||||
function cellRenderer(params: ICellRendererParams) {
|
||||
// Convert's the value into a display string.
|
||||
if (params.value === null) return '<span style="color:grey; font-style: italic;">Nothing</span>'
|
||||
else if (params.value === undefined) return ''
|
||||
else if (params.value === '') return '<span style="color:grey; font-style: italic;">Empty</span>'
|
||||
else if (typeof params.value === 'number')
|
||||
return params.value.toLocaleString(undefined, { maximumFractionDigits: 12 })
|
||||
else return escapeHTML(params.value.toString())
|
||||
else if (typeof params.value === 'number') return numberFormat.format(params.value)
|
||||
else if (Array.isArray(params.value)) {
|
||||
const content = params.value
|
||||
if (isMatrix({ json: content })) {
|
||||
return `[Vector ${content.length} rows x ${content[0].length} cols]`
|
||||
} else if (isObjectMatrix({ json: content })) {
|
||||
return `[Table ${content.length} rows x ${Object.keys(content[0]).length} cols]`
|
||||
} else {
|
||||
return `[Vector ${content.length} items]`
|
||||
}
|
||||
} else if (typeof params.value === 'object') {
|
||||
const valueType = params.value?.type
|
||||
if (valueType === 'BigInt') return numberFormat.format(BigInt(params.value?.value))
|
||||
else if (valueType === 'Float')
|
||||
return `<span style="color:grey; font-style: italic;">${params.value?.value ?? 'Unknown'}</span>`
|
||||
else if ('_display_text_' in params.value && params.value['_display_text_'])
|
||||
return String(params.value['_display_text_'])
|
||||
else return `{ ${valueType} Object }`
|
||||
} else return escapeHTML(params.value.toString())
|
||||
}
|
||||
|
||||
function addRowIndex(data: object[]): object[] {
|
||||
@ -217,25 +241,6 @@ function indexField(): ColDef {
|
||||
|
||||
/** Return a human-readable representation of an object. */
|
||||
function toRender(content: unknown) {
|
||||
if (Array.isArray(content)) {
|
||||
if (isMatrix({ json: content })) {
|
||||
return `[Vector ${content.length} rows x ${content[0].length} cols]`
|
||||
} else if (isObjectMatrix({ json: content })) {
|
||||
return `[Table ${content.length} rows x ${Object.keys(content[0]).length} cols]`
|
||||
} else {
|
||||
return `[Vector ${content.length} items]`
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof content === 'object' && content != null) {
|
||||
const type = 'type' in content ? content.type : undefined
|
||||
if ('_display_text_' in content && content['_display_text_']) {
|
||||
return String(content['_display_text_'])
|
||||
} else {
|
||||
return `{ ${type} Object }`
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
@ -406,6 +411,7 @@ onMounted(() => {
|
||||
origValidateLicense.call(this)
|
||||
}
|
||||
}
|
||||
// TODO: consider using Vue component instead: https://ag-grid.com/vue-data-grid/getting-started/
|
||||
new Grid(tableNode.value!, agGridOptions.value)
|
||||
updateColumnWidths()
|
||||
})
|
||||
|
@ -4,7 +4,7 @@ import { assert } from '@/util/assert'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { isPointer, pointerButtonToEventInfo, type BindingInfo } from '@/util/shortcuts'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
import { beforeAll, expect, test, vi } from 'vitest'
|
||||
import { proxyRefs, ref, type Ref } from 'vue'
|
||||
|
||||
function selectionWithMockData(sceneMousePos?: Ref<Vec2>) {
|
||||
@ -104,7 +104,7 @@ test.each`
|
||||
dragCase(new Vec2(area.right, area.top), new Vec2(area.left, area.bottom))
|
||||
})
|
||||
|
||||
// There is no PointerEvent class in jsdom (yet).
|
||||
// See https://github.com/thymikee/jest-preset-angular/issues/245#issuecomment-576296325
|
||||
class MockPointerEvent extends MouseEvent {
|
||||
readonly pointerId: number
|
||||
constructor(type: string, options: MouseEventInit & { currentTarget?: Element | undefined }) {
|
||||
@ -116,6 +116,10 @@ class MockPointerEvent extends MouseEvent {
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
;(window as any).PointerEvent = MockPointerEvent
|
||||
})
|
||||
|
||||
function mockPointerEvent(type: string, pos: Vec2, binding: BindingInfo): PointerEvent {
|
||||
const modifiersSet = new Set(binding.modifiers)
|
||||
assert(isPointer(binding.key))
|
||||
|
@ -305,7 +305,7 @@ export function usePointer(
|
||||
|
||||
if (trackedElement != null && initialGrabPos != null && lastPos != null) {
|
||||
if (handler(computePosition(e, initialGrabPos, lastPos), e, 'stop') !== false) {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
lastPos = null
|
||||
@ -317,7 +317,7 @@ export function usePointer(
|
||||
function doMove(e: PointerEvent) {
|
||||
if (trackedElement != null && initialGrabPos != null && lastPos != null) {
|
||||
if (handler(computePosition(e, initialGrabPos, lastPos), e, 'move') !== false) {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
lastPos = new Vec2(e.clientX, e.clientY)
|
||||
}
|
||||
@ -339,7 +339,7 @@ export function usePointer(
|
||||
initialGrabPos = new Vec2(e.clientX, e.clientY)
|
||||
lastPos = initialGrabPos
|
||||
if (handler(computePosition(e, initialGrabPos, lastPos), e, 'start') !== false) {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -362,24 +362,8 @@ export function usePointer(
|
||||
},
|
||||
}
|
||||
|
||||
const stopEvents = {
|
||||
pointerdown(e: PointerEvent) {
|
||||
e.stopImmediatePropagation()
|
||||
events.pointerdown(e)
|
||||
},
|
||||
pointerup(e: PointerEvent) {
|
||||
e.stopImmediatePropagation()
|
||||
events.pointerup(e)
|
||||
},
|
||||
pointermove(e: PointerEvent) {
|
||||
e.stopImmediatePropagation()
|
||||
events.pointermove(e)
|
||||
},
|
||||
}
|
||||
|
||||
return proxyRefs({
|
||||
events,
|
||||
stop: { events: stopEvents },
|
||||
dragging,
|
||||
})
|
||||
}
|
||||
|
176
app/gui2/src/composables/nodeCreation.ts
Normal file
176
app/gui2/src/composables/nodeCreation.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import {
|
||||
DEFAULT_NODE_SIZE,
|
||||
mouseDictatedPlacement,
|
||||
seekHorizontal,
|
||||
usePlacement,
|
||||
} from '@/components/ComponentBrowser/placement'
|
||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
||||
import { useGraphStore, type NodeId } from '@/stores/graph'
|
||||
import { asNodeId } from '@/stores/graph/graphDatabase'
|
||||
import type { RequiredImport } from '@/stores/graph/imports'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { partition } from '@/util/data/array'
|
||||
import { filterDefined } from '@/util/data/iterable'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { assertNever } from 'shared/util/assert'
|
||||
import { mustExtend } from 'shared/util/types'
|
||||
import { toValue, type ComputedRef, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
export type NodeCreation = ReturnType<typeof useNodeCreation>
|
||||
|
||||
type GraphIndependentPlacement =
|
||||
| { type: 'fixed'; position: Vec2 }
|
||||
| { type: 'mouse' }
|
||||
| { type: 'mouseEvent'; position: Vec2 }
|
||||
type GraphAwarePlacement = { type: 'viewport' } | { type: 'source'; node: NodeId }
|
||||
export type PlacementStrategy = GraphIndependentPlacement | GraphAwarePlacement
|
||||
|
||||
function isIndependent(
|
||||
strategy: GraphIndependentPlacement | GraphAwarePlacement,
|
||||
): strategy is GraphIndependentPlacement {
|
||||
if (strategy.type === 'viewport' || strategy.type === 'source') {
|
||||
mustExtend<typeof strategy, GraphAwarePlacement>()
|
||||
return false
|
||||
} else {
|
||||
mustExtend<typeof strategy, GraphIndependentPlacement>()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export interface NodeCreationOptions<Placement extends PlacementStrategy = PlacementStrategy> {
|
||||
placement: Placement
|
||||
expression: string
|
||||
documentation?: string | undefined
|
||||
metadata?: Ast.NodeMetadataFields | undefined
|
||||
requiredImports?: RequiredImport[] | undefined
|
||||
}
|
||||
|
||||
type ToValue<T> = MaybeRefOrGetter<T> | ComputedRef<T>
|
||||
|
||||
export function useNodeCreation(
|
||||
viewport: ToValue<GraphNavigator['viewport']>,
|
||||
sceneMousePos: ToValue<GraphNavigator['sceneMousePos']>,
|
||||
onCreated: (nodes: Set<NodeId>) => void,
|
||||
) {
|
||||
const graphStore = useGraphStore()
|
||||
|
||||
function tryMouse() {
|
||||
const pos = toValue(sceneMousePos)
|
||||
return pos ? mouseDictatedPlacement(pos) : undefined
|
||||
}
|
||||
|
||||
function placeNode(placement: PlacementStrategy, place: (nodes?: Iterable<Rect>) => Vec2): Vec2 {
|
||||
return (
|
||||
placement.type === 'viewport' ? place()
|
||||
: placement.type === 'mouse' ? tryMouse() ?? place()
|
||||
: placement.type === 'mouseEvent' ? mouseDictatedPlacement(placement.position)
|
||||
: placement.type === 'source' ? place(filterDefined([graphStore.visibleArea(placement.node)]))
|
||||
: placement.type === 'fixed' ? placement.position
|
||||
: assertNever(placement)
|
||||
)
|
||||
}
|
||||
|
||||
function identity<T>(value: T): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function placeNodes(nodesOptions: Iterable<NodeCreationOptions>) {
|
||||
const rects = new Array<Rect>()
|
||||
const { place } = usePlacement(rects, viewport)
|
||||
const [independentNodesOptions, dependentNodesOptions] = partition(nodesOptions, (options) =>
|
||||
isIndependent(options.placement),
|
||||
)
|
||||
const doPlace =
|
||||
(adjust: (pos: Vec2) => Vec2 = identity) =>
|
||||
({
|
||||
placement,
|
||||
expression,
|
||||
documentation,
|
||||
metadata,
|
||||
requiredImports,
|
||||
}: NodeCreationOptions) => {
|
||||
const position = adjust(placeNode(placement, place)).xy()
|
||||
rects.push(new Rect(Vec2.FromXY(position), Vec2.Zero))
|
||||
return {
|
||||
metadata: { ...metadata, position },
|
||||
expression,
|
||||
documentation,
|
||||
withImports: requiredImports ?? [],
|
||||
}
|
||||
}
|
||||
const placedOptions = []
|
||||
// Graph-independent placement strategies normally specify an exact position for the node, regardless of other
|
||||
// nodes. However, when creating multiple nodes at once, the newly-created nodes should never overlap with each
|
||||
// other; so, after determining the intended position of each graph-independent placement its position is adjusted
|
||||
// if necessary, considering only the other uncommitted nodes already placed in the same batch.
|
||||
const adjust = (pos: Vec2) => seekHorizontal(new Rect(pos, DEFAULT_NODE_SIZE), rects)
|
||||
placedOptions.push(...Array.from(independentNodesOptions, doPlace(adjust)))
|
||||
rects.push(...graphStore.visibleNodeAreas)
|
||||
placedOptions.push(...Array.from(dependentNodesOptions, doPlace()))
|
||||
return placedOptions
|
||||
}
|
||||
|
||||
function createNodes(nodesOptions: Iterable<NodeCreationOptions>) {
|
||||
const placedNodes = placeNodes(nodesOptions)
|
||||
if (placedNodes.length === 0) return new Set()
|
||||
const methodAst = graphStore.method
|
||||
if (!methodAst) {
|
||||
console.error(`BUG: Cannot add node: No current function.`)
|
||||
return new Set()
|
||||
}
|
||||
const created = new Set<NodeId>()
|
||||
graphStore.edit((edit) => {
|
||||
const bodyBlock = edit.getVersion(methodAst).bodyAsBlock()
|
||||
for (const options of placedNodes) {
|
||||
const { rootExpression, id } = newAssignmentNode(
|
||||
edit,
|
||||
graphStore.generateUniqueIdent(),
|
||||
options.expression,
|
||||
options.metadata,
|
||||
options.withImports,
|
||||
options.documentation,
|
||||
)
|
||||
bodyBlock.push(rootExpression)
|
||||
created.add(id)
|
||||
graphStore.nodeRects.set(id, new Rect(Vec2.FromXY(options.metadata.position), Vec2.Zero))
|
||||
}
|
||||
})
|
||||
onCreated(created)
|
||||
}
|
||||
|
||||
function createNode(
|
||||
placement: PlacementStrategy,
|
||||
expression: string,
|
||||
documentation?: string | undefined,
|
||||
metadata?: Ast.NodeMetadataFields | undefined,
|
||||
requiredImports?: RequiredImport[] | undefined,
|
||||
) {
|
||||
createNodes([{ placement, expression, documentation, metadata, requiredImports }])
|
||||
}
|
||||
|
||||
function newAssignmentNode(
|
||||
edit: Ast.MutableModule,
|
||||
ident: Ast.Identifier,
|
||||
expression: string,
|
||||
metadata: Ast.NodeMetadataFields,
|
||||
withImports: RequiredImport[],
|
||||
documentation: string | undefined,
|
||||
) {
|
||||
const conflicts = graphStore.addMissingImports(edit, withImports) ?? []
|
||||
const rhs = Ast.parse(expression, edit)
|
||||
rhs.setNodeMetadata(metadata)
|
||||
const assignment = Ast.Assignment.new(edit, ident, rhs)
|
||||
for (const _conflict of conflicts) {
|
||||
// TODO: Substitution does not work, because we interpret imports wrongly. To be fixed in
|
||||
// https://github.com/enso-org/enso/issues/9356
|
||||
// substituteQualifiedName(edit, assignment, conflict.pattern, conflict.fullyQualified)
|
||||
}
|
||||
const id = asNodeId(rhs.id)
|
||||
const rootExpression =
|
||||
documentation != null ? Ast.Documented.new(documentation, assignment) : assignment
|
||||
return { rootExpression, id }
|
||||
}
|
||||
|
||||
return { createNode, createNodes, placeNode }
|
||||
}
|
@ -25,6 +25,9 @@ export function useSelection<T>(
|
||||
const hoveredNode = ref<NodeId>()
|
||||
const hoveredElement = ref<Element>()
|
||||
|
||||
const isChanging = computed(() => anchor.value != null)
|
||||
const committedSelection = computed(() => (isChanging.value ? initiallySelected : selected))
|
||||
|
||||
useEvent(document, 'pointerover', (event) => {
|
||||
hoveredElement.value = event.target instanceof Element ? event.target : undefined
|
||||
})
|
||||
@ -171,7 +174,8 @@ export function useSelection<T>(
|
||||
},
|
||||
deselectAll: () => selected.clear(),
|
||||
isSelected: (element: T) => selected.has(element),
|
||||
isChanging: computed(() => anchor.value != null),
|
||||
isChanging,
|
||||
committedSelection,
|
||||
setSelection,
|
||||
handleSelectionOf,
|
||||
hoveredNode,
|
||||
|
@ -43,23 +43,7 @@ export function useStackNavigator() {
|
||||
}
|
||||
|
||||
function handleBreadcrumbClick(index: number) {
|
||||
const activeStack = projectStore.executionContext.desiredStack
|
||||
// Number of items in desired stack should be index + 1
|
||||
if (index + 1 < activeStack.length) {
|
||||
for (let i = activeStack.length; i > index + 1; i--) {
|
||||
projectStore.executionContext.pop()
|
||||
}
|
||||
} else if (index + 1 > activeStack.length) {
|
||||
for (let i = activeStack.length; i <= index; i++) {
|
||||
const stackItem = breadcrumbs.value[i]
|
||||
if (stackItem?.type === 'LocalCall') {
|
||||
const exprId = stackItem.expressionId
|
||||
projectStore.executionContext.push(exprId)
|
||||
} else {
|
||||
console.warn('Cannot enter non-local call.')
|
||||
}
|
||||
}
|
||||
}
|
||||
projectStore.executionContext.desiredStack = breadcrumbs.value.slice(0, index + 1)
|
||||
graphStore.updateState()
|
||||
}
|
||||
|
||||
|
@ -46,6 +46,11 @@
|
||||
"value": "",
|
||||
"primary": false
|
||||
},
|
||||
"ydocUrl": {
|
||||
"description": "The address of the Ydoc Server endpoint.",
|
||||
"value": "",
|
||||
"primary": false
|
||||
},
|
||||
"namespace": {
|
||||
"description": "The namespace of the opened project. It can be used when connecting to an existing Language Server process.",
|
||||
"value": "local",
|
||||
|
@ -68,6 +68,7 @@ function main() {
|
||||
const supportsVibrancy = config.window.vibrancy
|
||||
const shouldUseAuthentication = config.authentication.enabled
|
||||
const projectManagerUrl = config.engine.projectManagerUrl || PROJECT_MANAGER_URL
|
||||
const ydocUrl = config.engine.ydocUrl === '' ? YDOC_SERVER_URL : config.engine.ydocUrl
|
||||
const initialProjectName = config.startup.project || null
|
||||
|
||||
dashboard.run({
|
||||
@ -77,6 +78,7 @@ function main() {
|
||||
supportsLocalBackend: !IS_CLOUD_BUILD,
|
||||
supportsDeepLinks: !isDevMode && !isOnLinux(),
|
||||
projectManagerUrl,
|
||||
ydocUrl,
|
||||
isAuthenticationDisabled: !shouldUseAuthentication,
|
||||
shouldShowDashboard: true,
|
||||
initialProjectName,
|
||||
|
5
app/gui2/src/providers/graphNodeCreation.ts
Normal file
5
app/gui2/src/providers/graphNodeCreation.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { useNodeCreation } from '@/composables/nodeCreation'
|
||||
import { createContextStore } from '@/providers'
|
||||
|
||||
export { injectFn as injectNodeCreation, provideFn as provideNodeCreation }
|
||||
const { provideFn, injectFn } = createContextStore('node creation', useNodeCreation)
|
@ -53,9 +53,16 @@ export class InteractionHandler {
|
||||
return hasCurrent
|
||||
}
|
||||
|
||||
handlePointerDown(event: PointerEvent, graphNavigator: GraphNavigator): boolean {
|
||||
if (!this.currentInteraction?.pointerdown) return false
|
||||
const handled = this.currentInteraction.pointerdown(event, graphNavigator) !== false
|
||||
handlePointerEvent<HandlerName extends keyof Interaction>(
|
||||
event: PointerEvent,
|
||||
handlerName: Interaction[HandlerName] extends InteractionEventHandler | undefined ? HandlerName
|
||||
: never,
|
||||
graphNavigator: GraphNavigator,
|
||||
): boolean {
|
||||
if (!this.currentInteraction) return false
|
||||
const handler = this.currentInteraction[handlerName]
|
||||
if (!handler) return false
|
||||
const handled = handler.bind(this.currentInteraction)(event, graphNavigator) !== false
|
||||
if (handled) {
|
||||
event.stopImmediatePropagation()
|
||||
event.preventDefault()
|
||||
@ -64,8 +71,14 @@ export class InteractionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
type InteractionEventHandler = (event: PointerEvent, navigator: GraphNavigator) => boolean | void
|
||||
|
||||
export interface Interaction {
|
||||
cancel(): void
|
||||
/** Uses a `capture` event handler to allow an interaction to respond to clicks over any element. */
|
||||
pointerdown?(event: PointerEvent, navigator: GraphNavigator): boolean | void
|
||||
pointerdown?: InteractionEventHandler
|
||||
/** Uses a `capture` event handler to allow an interaction to respond to mouse button release
|
||||
* over any element. It is useful for interactions happening during mouse press (like dragging
|
||||
* edges) */
|
||||
pointerup?: InteractionEventHandler
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ export interface VisualizationConfig {
|
||||
readonly nodeSize: Vec2
|
||||
readonly scale: number
|
||||
readonly isFocused: boolean
|
||||
readonly nodeType: string | undefined
|
||||
isBelowToolbar: boolean
|
||||
width: number | null
|
||||
height: number
|
||||
|
@ -65,7 +65,8 @@ export namespace WidgetInput {
|
||||
input.value instanceof Ast.App ||
|
||||
input.value instanceof Ast.Ident ||
|
||||
input.value instanceof Ast.PropertyAccess ||
|
||||
input.value instanceof Ast.OprApp
|
||||
input.value instanceof Ast.OprApp ||
|
||||
input.value instanceof Ast.AutoscopedIdentifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -160,7 +160,7 @@ test.each`
|
||||
widgetTree,
|
||||
)
|
||||
handlers.get(edited)?.handler.start()
|
||||
interactionHandler.handlePointerDown(event, navigator)
|
||||
interactionHandler.handlePointerEvent(event, 'pointerdown', navigator)
|
||||
const handlersCalled = new Set<string>()
|
||||
for (const [id, { interaction }] of handlers)
|
||||
if (interaction.pointerdown?.mock.lastCall) handlersCalled.add(id)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { usePlacement } from '@/components/ComponentBrowser/placement'
|
||||
import type { PortId } from '@/providers/portInfo'
|
||||
import type { WidgetUpdate } from '@/providers/widgetRegistry'
|
||||
import { GraphDb, asNodeId, type NodeId } from '@/stores/graph/graphDatabase'
|
||||
import { GraphDb, type NodeId } from '@/stores/graph/graphDatabase'
|
||||
import {
|
||||
addImports,
|
||||
detectImportConflicts,
|
||||
@ -15,17 +15,10 @@ import { useProjectStore } from '@/stores/project'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import { assert, bail } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import type {
|
||||
AstId,
|
||||
Module,
|
||||
ModuleUpdate,
|
||||
NodeMetadata,
|
||||
NodeMetadataFields,
|
||||
} from '@/util/ast/abstract'
|
||||
import type { AstId } from '@/util/ast/abstract'
|
||||
import { MutableModule, isIdentifier } from '@/util/ast/abstract'
|
||||
import { RawAst, visitRecursive } from '@/util/ast/raw'
|
||||
import { partition } from '@/util/data/array'
|
||||
import type { Opt } from '@/util/data/opt'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { map, set } from 'lib0'
|
||||
@ -126,7 +119,11 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
})
|
||||
|
||||
let toRaw = new Map<SourceRangeKey, RawAst.Tree.Function>()
|
||||
function handleModuleUpdate(module: Module, moduleChanged: boolean, update: ModuleUpdate) {
|
||||
function handleModuleUpdate(
|
||||
module: Ast.Module,
|
||||
moduleChanged: boolean,
|
||||
update: Ast.ModuleUpdate,
|
||||
) {
|
||||
const root = module.root()
|
||||
if (!root) return
|
||||
if (moduleRoot.value != root) {
|
||||
@ -138,7 +135,7 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
// We can cast maps of unknown metadata fields to `NodeMetadata` because all `NodeMetadata` fields are optional.
|
||||
const nodeMetadataUpdates = update.metadataUpdated as any as {
|
||||
id: AstId
|
||||
changes: NodeMetadata
|
||||
changes: Ast.NodeMetadata
|
||||
}[]
|
||||
const dirtyNodeSet = new Set(
|
||||
(function* () {
|
||||
@ -187,7 +184,7 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function methodAstInModule(mod: Module) {
|
||||
function methodAstInModule(mod: Ast.Module) {
|
||||
const topLevel = mod.root()
|
||||
if (!topLevel) return
|
||||
assert(topLevel instanceof Ast.BodyBlock)
|
||||
@ -223,25 +220,27 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
return edges.value.filter<ConnectedEdge>(isConnected)
|
||||
})
|
||||
|
||||
function createEdgeFromOutput(source: Ast.AstId) {
|
||||
unconnectedEdge.value = { source, target: undefined }
|
||||
function createEdgeFromOutput(source: Ast.AstId, event: PointerEvent | undefined) {
|
||||
unconnectedEdge.value = { source, target: undefined, event }
|
||||
}
|
||||
|
||||
function disconnectSource(edge: Edge) {
|
||||
function disconnectSource(edge: Edge, event: PointerEvent | undefined) {
|
||||
if (!edge.target) return
|
||||
unconnectedEdge.value = {
|
||||
source: undefined,
|
||||
target: edge.target,
|
||||
disconnectedEdgeTarget: edge.target,
|
||||
event,
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectTarget(edge: Edge) {
|
||||
function disconnectTarget(edge: Edge, event: PointerEvent | undefined) {
|
||||
if (!edge.source || !edge.target) return
|
||||
unconnectedEdge.value = {
|
||||
source: edge.source,
|
||||
target: undefined,
|
||||
disconnectedEdgeTarget: edge.target,
|
||||
event,
|
||||
}
|
||||
}
|
||||
|
||||
@ -249,74 +248,9 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
unconnectedEdge.value = undefined
|
||||
}
|
||||
|
||||
function createNodes(
|
||||
nodeOptions: {
|
||||
position: Vec2
|
||||
expression: string
|
||||
metadata?: NodeMetadataFields | undefined
|
||||
withImports?: RequiredImport[] | undefined
|
||||
documentation?: string | undefined
|
||||
}[],
|
||||
): NodeId[] {
|
||||
const method = syncModule.value ? methodAstInModule(syncModule.value) : undefined
|
||||
if (!method) {
|
||||
console.error(`BUG: Cannot add node: No current function.`)
|
||||
return []
|
||||
}
|
||||
const created = new Array<NodeId>()
|
||||
edit((edit) => {
|
||||
const bodyBlock = edit.getVersion(method).bodyAsBlock()
|
||||
for (const options of nodeOptions) {
|
||||
const ident = generateUniqueIdent()
|
||||
const metadata = { ...options.metadata, position: options.position.xy() }
|
||||
const { rootExpression, id } = newAssignmentNode(
|
||||
edit,
|
||||
ident,
|
||||
options.expression,
|
||||
metadata,
|
||||
options.withImports ?? [],
|
||||
options.documentation,
|
||||
)
|
||||
bodyBlock.push(rootExpression)
|
||||
created.push(id)
|
||||
nodeRects.set(id, new Rect(options.position, Vec2.Zero))
|
||||
}
|
||||
})
|
||||
return created
|
||||
}
|
||||
|
||||
function newAssignmentNode(
|
||||
edit: MutableModule,
|
||||
ident: Ast.Identifier,
|
||||
expression: string,
|
||||
metadata: NodeMetadataFields,
|
||||
withImports: RequiredImport[],
|
||||
documentation: string | undefined,
|
||||
) {
|
||||
const conflicts = addMissingImports(edit, withImports) ?? []
|
||||
const rhs = Ast.parse(expression, edit)
|
||||
rhs.setNodeMetadata(metadata)
|
||||
const assignment = Ast.Assignment.new(edit, ident, rhs)
|
||||
for (const _conflict of conflicts) {
|
||||
// TODO: Substitution does not work, because we interpret imports wrongly. To be fixed in
|
||||
// https://github.com/enso-org/enso/issues/9356
|
||||
// substituteQualifiedName(edit, assignment, conflict.pattern, conflict.fullyQualified)
|
||||
}
|
||||
const id = asNodeId(rhs.id)
|
||||
const rootExpression =
|
||||
documentation != null ? Ast.Documented.new(documentation, assignment) : assignment
|
||||
return { rootExpression, id }
|
||||
}
|
||||
|
||||
function createNode(
|
||||
position: Vec2,
|
||||
expression: string,
|
||||
metadata: NodeMetadataFields = {},
|
||||
withImports: RequiredImport[] | undefined = undefined,
|
||||
documentation?: string | undefined,
|
||||
): Opt<NodeId> {
|
||||
return createNodes([{ position, expression, metadata, withImports, documentation }])[0]
|
||||
}
|
||||
const method = computed(() =>
|
||||
syncModule.value ? methodAstInModule(syncModule.value) : undefined,
|
||||
)
|
||||
|
||||
/* Try adding imports. Does nothing if conflict is detected, and returns `DectedConflict` in such case. */
|
||||
function addMissingImports(
|
||||
@ -480,7 +414,7 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
const nodeAst = syncModule.value?.get(nodeId)
|
||||
const rect = nodeRects.get(nodeId)
|
||||
if (!rect || !nodeAst || nodeAst.nodeMetadata.get('position') != null) continue
|
||||
const { position } = placeNode([], rect.size)
|
||||
const position = placeNode([], rect.size)
|
||||
editNodeMetadata(nodeAst, (metadata) =>
|
||||
metadata.set('position', { x: position.x, y: position.y }),
|
||||
)
|
||||
@ -636,7 +570,11 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
exprId = nodeId
|
||||
}
|
||||
|
||||
if (exprId == null) bail(`Cannot find expression located by ${locator}`)
|
||||
if (exprId == null) {
|
||||
const locatorStr =
|
||||
typeof locator === 'string' ? locator : `${locator.binding}/${locator.expr}`
|
||||
bail(`Cannot find expression located by ${locatorStr}`)
|
||||
}
|
||||
|
||||
const update_: ExpressionUpdate = {
|
||||
expressionId: db.idToExternal(exprId)!,
|
||||
@ -723,13 +661,13 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
visibleArea,
|
||||
unregisterNodeRect,
|
||||
methodAst,
|
||||
generateUniqueIdent,
|
||||
createEdgeFromOutput,
|
||||
disconnectSource,
|
||||
disconnectTarget,
|
||||
clearUnconnected,
|
||||
moduleRoot,
|
||||
createNodes,
|
||||
createNode,
|
||||
method,
|
||||
deleteNodes,
|
||||
ensureCorrectNodeOrder,
|
||||
batchEdits,
|
||||
@ -788,6 +726,8 @@ export function isConnected(edge: Edge): edge is ConnectedEdge {
|
||||
interface UnconnectedEdge extends Edge {
|
||||
/** If this edge represents an in-progress edit of a connected edge, it is identified by its target expression. */
|
||||
disconnectedEdgeTarget?: PortId
|
||||
/** A pointer event which caused the unconnected edge */
|
||||
event: PointerEvent | undefined
|
||||
}
|
||||
|
||||
function getExecutedMethodAst(
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { findIndexOpt } from '@/util/data/array'
|
||||
import { isSome, type Opt } from '@/util/data/opt'
|
||||
import { Err, Ok, type Result } from '@/util/data/result'
|
||||
import { AsyncQueue, type AbortScope } from '@/util/net'
|
||||
@ -6,16 +7,17 @@ import * as object from 'lib0/object'
|
||||
import { ObservableV2 } from 'lib0/observable'
|
||||
import * as random from 'lib0/random'
|
||||
import type { LanguageServer } from 'shared/languageServer'
|
||||
import type {
|
||||
ContextId,
|
||||
Diagnostic,
|
||||
ExecutionEnvironment,
|
||||
ExplicitCall,
|
||||
ExpressionId,
|
||||
ExpressionUpdate,
|
||||
StackItem,
|
||||
Uuid,
|
||||
VisualizationConfiguration,
|
||||
import {
|
||||
stackItemsEqual,
|
||||
type ContextId,
|
||||
type Diagnostic,
|
||||
type ExecutionEnvironment,
|
||||
type ExplicitCall,
|
||||
type ExpressionId,
|
||||
type ExpressionUpdate,
|
||||
type StackItem,
|
||||
type Uuid,
|
||||
type VisualizationConfiguration,
|
||||
} from 'shared/languageServerTypes'
|
||||
import { exponentialBackoff } from 'shared/util/net'
|
||||
import type { ExternalId } from 'shared/yjsModel'
|
||||
@ -46,12 +48,14 @@ function visualizationConfigEqual(
|
||||
)
|
||||
}
|
||||
|
||||
interface ExecutionContextState {
|
||||
lsRpc: LanguageServer
|
||||
created: boolean
|
||||
visualizations: Map<Uuid, NodeVisualizationConfiguration>
|
||||
stack: StackItem[]
|
||||
}
|
||||
type ExecutionContextState =
|
||||
| { status: 'not-created' }
|
||||
| {
|
||||
status: 'created'
|
||||
visualizations: Map<Uuid, NodeVisualizationConfiguration>
|
||||
stack: StackItem[]
|
||||
environment?: ExecutionEnvironment
|
||||
} // | { status: 'broken'} TODO[ao] think about it
|
||||
|
||||
type EntryPoint = Omit<ExplicitCall, 'type'>
|
||||
|
||||
@ -80,146 +84,79 @@ type ExecutionContextNotification = {
|
||||
* run only when the previous call is done.
|
||||
*/
|
||||
export class ExecutionContext extends ObservableV2<ExecutionContextNotification> {
|
||||
id: ContextId = random.uuidv4() as ContextId
|
||||
queue: AsyncQueue<ExecutionContextState>
|
||||
taskRunning = false
|
||||
visSyncScheduled = false
|
||||
desiredStack: StackItem[] = reactive([])
|
||||
visualizationConfigs: Map<Uuid, NodeVisualizationConfiguration> = new Map()
|
||||
readonly id: ContextId = random.uuidv4() as ContextId
|
||||
private queue: AsyncQueue<ExecutionContextState>
|
||||
private syncScheduled = false
|
||||
private clearScheduled = false
|
||||
private _desiredStack: StackItem[] = reactive([])
|
||||
private visualizationConfigs: Map<Uuid, NodeVisualizationConfiguration> = new Map()
|
||||
private _executionEnvironment: ExecutionEnvironment = 'Design'
|
||||
|
||||
constructor(
|
||||
lsRpc: LanguageServer,
|
||||
private lsRpc: LanguageServer,
|
||||
entryPoint: EntryPoint,
|
||||
private abort: AbortScope,
|
||||
) {
|
||||
super()
|
||||
this.abort.handleDispose(this)
|
||||
|
||||
this.queue = new AsyncQueue<ExecutionContextState>(
|
||||
Promise.resolve({
|
||||
lsRpc,
|
||||
created: false,
|
||||
visualizations: new Map(),
|
||||
stack: [],
|
||||
}),
|
||||
)
|
||||
this.lsRpc.retain()
|
||||
this.queue = new AsyncQueue<ExecutionContextState>(Promise.resolve({ status: 'not-created' }))
|
||||
this.registerHandlers()
|
||||
this.create()
|
||||
this.pushItem({ type: 'ExplicitCall', ...entryPoint })
|
||||
this.recompute()
|
||||
}
|
||||
|
||||
private async withBackoff<T>(f: () => Promise<Result<T>>, message: string): Promise<T> {
|
||||
const result = await exponentialBackoff(f, {
|
||||
onBeforeRetry: (error, _, delay) => {
|
||||
if (this.abort.signal.aborted) return false
|
||||
console.warn(`${error.message(message)}. Retrying after ${delay}ms...\n`)
|
||||
},
|
||||
private registerHandlers() {
|
||||
this.abort.handleObserve(this.lsRpc, 'executionContext/expressionUpdates', (event) => {
|
||||
if (event.contextId == this.id) this.emit('expressionUpdates', [event.updates])
|
||||
})
|
||||
if (result.ok) return result.value
|
||||
else throw result.error
|
||||
}
|
||||
|
||||
private syncVisualizations() {
|
||||
if (this.visSyncScheduled || this.abort.signal.aborted) return
|
||||
this.visSyncScheduled = true
|
||||
this.queue.pushTask(async (state) => {
|
||||
this.visSyncScheduled = false
|
||||
if (!state.created || this.abort.signal.aborted) return state
|
||||
this.emit('newVisualizationConfiguration', [new Set(this.visualizationConfigs.keys())])
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
const attach = (id: Uuid, config: NodeVisualizationConfiguration) => {
|
||||
return this.withBackoff(
|
||||
() =>
|
||||
state.lsRpc.attachVisualization(id, config.expressionId, {
|
||||
executionContextId: this.id,
|
||||
expression: config.expression,
|
||||
visualizationModule: config.visualizationModule,
|
||||
...(config.positionalArgumentsExpressions ?
|
||||
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
|
||||
: {}),
|
||||
}),
|
||||
'Failed to attach visualization',
|
||||
).then(() => {
|
||||
state.visualizations.set(id, config)
|
||||
})
|
||||
}
|
||||
|
||||
const modify = (id: Uuid, config: NodeVisualizationConfiguration) => {
|
||||
return this.withBackoff(
|
||||
() =>
|
||||
state.lsRpc.modifyVisualization(id, {
|
||||
executionContextId: this.id,
|
||||
expression: config.expression,
|
||||
visualizationModule: config.visualizationModule,
|
||||
...(config.positionalArgumentsExpressions ?
|
||||
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
|
||||
: {}),
|
||||
}),
|
||||
'Failed to modify visualization',
|
||||
).then(() => {
|
||||
state.visualizations.set(id, config)
|
||||
})
|
||||
}
|
||||
|
||||
const detach = (id: Uuid, config: NodeVisualizationConfiguration) => {
|
||||
return this.withBackoff(
|
||||
() => state.lsRpc.detachVisualization(id, config.expressionId, this.id),
|
||||
'Failed to detach visualization',
|
||||
).then(() => {
|
||||
state.visualizations.delete(id)
|
||||
})
|
||||
}
|
||||
|
||||
// Attach new and update existing visualizations.
|
||||
for (const [id, config] of this.visualizationConfigs) {
|
||||
const previousConfig = state.visualizations.get(id)
|
||||
if (previousConfig == null) {
|
||||
promises.push(attach(id, config))
|
||||
} else if (!visualizationConfigEqual(previousConfig, config)) {
|
||||
if (previousConfig.expressionId === config.expressionId) {
|
||||
promises.push(modify(id, config))
|
||||
} else {
|
||||
promises.push(detach(id, previousConfig).then(() => attach(id, config)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detach removed visualizations.
|
||||
for (const [id, config] of state.visualizations) {
|
||||
if (!this.visualizationConfigs.get(id)) {
|
||||
promises.push(detach(id, config))
|
||||
}
|
||||
}
|
||||
const settled = await Promise.allSettled(promises)
|
||||
|
||||
// Emit errors for failed requests.
|
||||
const errors = settled
|
||||
.map((result) => (result.status === 'rejected' ? result.reason : null))
|
||||
.filter(isSome)
|
||||
if (errors.length > 0) {
|
||||
console.error('Failed to synchronize visualizations:', errors)
|
||||
}
|
||||
|
||||
this.emit('visualizationsConfigured', [new Set(this.visualizationConfigs.keys())])
|
||||
|
||||
// State object was updated in-place in each successful promise.
|
||||
return state
|
||||
this.abort.handleObserve(this.lsRpc, 'executionContext/executionFailed', (event) => {
|
||||
if (event.contextId == this.id) this.emit('executionFailed', [event.message])
|
||||
})
|
||||
this.abort.handleObserve(this.lsRpc, 'executionContext/executionComplete', (event) => {
|
||||
if (event.contextId == this.id) this.emit('executionComplete', [])
|
||||
})
|
||||
this.abort.handleObserve(this.lsRpc, 'executionContext/executionStatus', (event) => {
|
||||
if (event.contextId == this.id) this.emit('executionStatus', [event.diagnostics])
|
||||
})
|
||||
this.abort.handleObserve(
|
||||
this.lsRpc,
|
||||
'executionContext/visualizationEvaluationFailed',
|
||||
(event) => {
|
||||
if (event.contextId == this.id)
|
||||
this.emit('visualizationEvaluationFailed', [
|
||||
event.visualizationId,
|
||||
event.expressionId,
|
||||
event.message,
|
||||
event.diagnostic,
|
||||
])
|
||||
},
|
||||
)
|
||||
this.lsRpc.on('transport/closed', () => {
|
||||
// Connection closed: the created execution context is no longer available
|
||||
// There is no point in any scheduled action until resynchronization
|
||||
this.queue.clear()
|
||||
this.syncScheduled = false
|
||||
this.queue.pushTask(() => {
|
||||
this.clearScheduled = false
|
||||
this.sync()
|
||||
return Promise.resolve({ status: 'not-created' })
|
||||
})
|
||||
this.clearScheduled = true
|
||||
})
|
||||
}
|
||||
|
||||
private pushItem(item: StackItem) {
|
||||
this.desiredStack.push(item)
|
||||
this.queue.pushTask(async (state) => {
|
||||
if (!state.created) return state
|
||||
await this.withBackoff(
|
||||
() => state.lsRpc.pushExecutionContextItem(this.id, item),
|
||||
'Failed to push item to execution context stack',
|
||||
)
|
||||
state.stack.push(item)
|
||||
return state
|
||||
})
|
||||
this._desiredStack.push(item)
|
||||
this.sync()
|
||||
}
|
||||
|
||||
get desiredStack() {
|
||||
return this._desiredStack
|
||||
}
|
||||
|
||||
set desiredStack(stack: StackItem[]) {
|
||||
this._desiredStack = stack
|
||||
this.sync()
|
||||
}
|
||||
|
||||
push(expressionId: ExpressionId) {
|
||||
@ -227,79 +164,21 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
|
||||
}
|
||||
|
||||
pop() {
|
||||
if (this.desiredStack.length === 1) {
|
||||
if (this._desiredStack.length === 1) {
|
||||
console.debug('Cannot pop last item from execution context stack')
|
||||
return
|
||||
}
|
||||
this.desiredStack.pop()
|
||||
this.queue.pushTask(async (state) => {
|
||||
if (!state.created) return state
|
||||
if (state.stack.length === 1) {
|
||||
console.debug('Cannot pop last item from execution context stack')
|
||||
return state
|
||||
}
|
||||
await this.withBackoff(
|
||||
() => state.lsRpc.popExecutionContextItem(this.id),
|
||||
'Failed to pop item from execution context stack',
|
||||
)
|
||||
state.stack.pop()
|
||||
return state
|
||||
})
|
||||
this._desiredStack.pop()
|
||||
this.sync()
|
||||
}
|
||||
|
||||
async setVisualization(id: Uuid, configuration: Opt<NodeVisualizationConfiguration>) {
|
||||
setVisualization(id: Uuid, configuration: Opt<NodeVisualizationConfiguration>) {
|
||||
if (configuration == null) {
|
||||
this.visualizationConfigs.delete(id)
|
||||
} else {
|
||||
this.visualizationConfigs.set(id, configuration)
|
||||
}
|
||||
this.syncVisualizations()
|
||||
}
|
||||
|
||||
private create() {
|
||||
this.queue.pushTask(async (state) => {
|
||||
if (state.created) return state
|
||||
return this.withBackoff(async () => {
|
||||
const result = await state.lsRpc.createExecutionContext(this.id)
|
||||
if (!result.ok) return result
|
||||
if (result.value.contextId !== this.id) {
|
||||
return Err('Unexpected Context ID returned by the language server.')
|
||||
}
|
||||
state.lsRpc.retain()
|
||||
return Ok({ ...state, created: true })
|
||||
}, 'Failed to create execution context')
|
||||
})
|
||||
}
|
||||
|
||||
private registerHandlers() {
|
||||
this.queue.pushTask(async (state) => {
|
||||
this.abort.handleObserve(state.lsRpc, 'executionContext/expressionUpdates', (event) => {
|
||||
if (event.contextId == this.id) this.emit('expressionUpdates', [event.updates])
|
||||
})
|
||||
this.abort.handleObserve(state.lsRpc, 'executionContext/executionFailed', (event) => {
|
||||
if (event.contextId == this.id) this.emit('executionFailed', [event.message])
|
||||
})
|
||||
this.abort.handleObserve(state.lsRpc, 'executionContext/executionComplete', (event) => {
|
||||
if (event.contextId == this.id) this.emit('executionComplete', [])
|
||||
})
|
||||
this.abort.handleObserve(state.lsRpc, 'executionContext/executionStatus', (event) => {
|
||||
if (event.contextId == this.id) this.emit('executionStatus', [event.diagnostics])
|
||||
})
|
||||
this.abort.handleObserve(
|
||||
state.lsRpc,
|
||||
'executionContext/visualizationEvaluationFailed',
|
||||
(event) => {
|
||||
if (event.contextId == this.id)
|
||||
this.emit('visualizationEvaluationFailed', [
|
||||
event.visualizationId,
|
||||
event.expressionId,
|
||||
event.message,
|
||||
event.diagnostic,
|
||||
])
|
||||
},
|
||||
)
|
||||
return state
|
||||
})
|
||||
this.sync()
|
||||
}
|
||||
|
||||
recompute(
|
||||
@ -307,36 +186,220 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
|
||||
executionEnvironment?: ExecutionEnvironment,
|
||||
) {
|
||||
this.queue.pushTask(async (state) => {
|
||||
if (!state.created) return state
|
||||
await state.lsRpc.recomputeExecutionContext(this.id, expressionIds, executionEnvironment)
|
||||
if (state.status !== 'created') {
|
||||
this.sync()
|
||||
return state
|
||||
}
|
||||
await this.lsRpc.recomputeExecutionContext(this.id, expressionIds, executionEnvironment)
|
||||
return state
|
||||
})
|
||||
}
|
||||
|
||||
getStackBottom(): StackItem {
|
||||
return this.desiredStack[0]!
|
||||
return this._desiredStack[0]!
|
||||
}
|
||||
|
||||
getStackTop(): StackItem {
|
||||
return this.desiredStack[this.desiredStack.length - 1]!
|
||||
return this._desiredStack[this._desiredStack.length - 1]!
|
||||
}
|
||||
|
||||
setExecutionEnvironment(mode: ExecutionEnvironment) {
|
||||
this.queue.pushTask(async (state) => {
|
||||
await state.lsRpc.setExecutionEnvironment(this.id, mode)
|
||||
return state
|
||||
})
|
||||
get executionEnvironment() {
|
||||
return this._executionEnvironment
|
||||
}
|
||||
|
||||
set executionEnvironment(env: ExecutionEnvironment) {
|
||||
this._executionEnvironment = env
|
||||
this.sync()
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.queue.pushTask(async (state) => {
|
||||
if (!state.created) return state
|
||||
const result = await state.lsRpc.destroyExecutionContext(this.id)
|
||||
if (!result.ok) {
|
||||
result.error.log('Failed to destroy execution context')
|
||||
if (state.status === 'created') {
|
||||
const result = await this.withBackoff(
|
||||
() => this.lsRpc.destroyExecutionContext(this.id),
|
||||
'Destroying execution context',
|
||||
)
|
||||
if (!result.ok) {
|
||||
result.error.log('Failed to destroy execution context')
|
||||
}
|
||||
}
|
||||
state.lsRpc.release()
|
||||
return { ...state, created: false }
|
||||
this.lsRpc.release()
|
||||
return { status: 'not-created' }
|
||||
})
|
||||
}
|
||||
|
||||
private sync() {
|
||||
if (this.syncScheduled || this.abort.signal.aborted) return
|
||||
this.syncScheduled = true
|
||||
this.queue.pushTask(this.syncTask())
|
||||
}
|
||||
|
||||
private withBackoff<T>(f: () => Promise<Result<T>>, message: string): Promise<Result<T>> {
|
||||
return exponentialBackoff(f, {
|
||||
onBeforeRetry: (error, _, delay) => {
|
||||
if (this.abort.signal.aborted || this.clearScheduled) return false
|
||||
console.warn(`${error.message(message)}. Retrying after ${delay}ms...\n`)
|
||||
},
|
||||
onFailure(error) {
|
||||
error.log(message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private syncTask() {
|
||||
return async (state: ExecutionContextState) => {
|
||||
this.syncScheduled = false
|
||||
if (this.abort.signal.aborted) return state
|
||||
let newState = { ...state }
|
||||
|
||||
const create = () => {
|
||||
if (newState.status === 'created') return Ok()
|
||||
// if (newState.status === 'broken') {
|
||||
// this.withBackoff(() => this.lsRpc.destroyExecutionContext(this.id), 'Failed to destroy broken execution context')
|
||||
// }
|
||||
return this.withBackoff(async () => {
|
||||
const result = await this.lsRpc.createExecutionContext(this.id)
|
||||
if (!result.ok) return result
|
||||
if (result.value.contextId !== this.id) {
|
||||
return Err('Unexpected Context ID returned by the language server.')
|
||||
}
|
||||
newState = { status: 'created', visualizations: new Map(), stack: [] }
|
||||
return Ok()
|
||||
}, 'Failed to create execution context')
|
||||
}
|
||||
|
||||
const syncEnvironment = async () => {
|
||||
const state = newState
|
||||
if (state.status !== 'created')
|
||||
return Err('Cannot sync execution environment when context is not created')
|
||||
if (state.environment === this._executionEnvironment) return Ok()
|
||||
const result = await this.lsRpc.setExecutionEnvironment(this.id, this._executionEnvironment)
|
||||
if (!result.ok) return result
|
||||
state.environment = this._executionEnvironment
|
||||
return Ok()
|
||||
}
|
||||
|
||||
const syncStack = async () => {
|
||||
const state = newState
|
||||
if (state.status !== 'created')
|
||||
return Err('Cannot sync stack when execution context is not created')
|
||||
const firstDifferent =
|
||||
findIndexOpt(this._desiredStack, (item, index) => {
|
||||
const stateStack = state.stack[index]
|
||||
return stateStack == null || !stackItemsEqual(item, stateStack)
|
||||
}) ?? this._desiredStack.length
|
||||
for (let i = state.stack.length; i > firstDifferent; --i) {
|
||||
const popResult = await this.withBackoff(
|
||||
() => this.lsRpc.popExecutionContextItem(this.id),
|
||||
'Failed to pop execution stack frame',
|
||||
)
|
||||
if (popResult.ok) state.stack.pop()
|
||||
else return popResult
|
||||
}
|
||||
for (let i = state.stack.length; i < this._desiredStack.length; ++i) {
|
||||
const newItem = this._desiredStack[i]!
|
||||
const pushResult = await this.withBackoff(
|
||||
() => this.lsRpc.pushExecutionContextItem(this.id, newItem),
|
||||
'Failed to push execution stack frame',
|
||||
)
|
||||
if (pushResult.ok) state.stack.push(newItem)
|
||||
else return pushResult
|
||||
}
|
||||
return Ok()
|
||||
}
|
||||
|
||||
const syncVisualizations = async () => {
|
||||
const state = newState
|
||||
if (state.status !== 'created')
|
||||
return Err('Cannot sync visualizations when execution context is not created')
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
const attach = (id: Uuid, config: NodeVisualizationConfiguration) => {
|
||||
return this.withBackoff(
|
||||
() =>
|
||||
this.lsRpc.attachVisualization(id, config.expressionId, {
|
||||
executionContextId: this.id,
|
||||
expression: config.expression,
|
||||
visualizationModule: config.visualizationModule,
|
||||
...(config.positionalArgumentsExpressions ?
|
||||
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
|
||||
: {}),
|
||||
}),
|
||||
'Failed to attach visualization',
|
||||
).then((result) => {
|
||||
if (result.ok) state.visualizations.set(id, config)
|
||||
})
|
||||
}
|
||||
|
||||
const modify = (id: Uuid, config: NodeVisualizationConfiguration) => {
|
||||
return this.withBackoff(
|
||||
() =>
|
||||
this.lsRpc.modifyVisualization(id, {
|
||||
executionContextId: this.id,
|
||||
expression: config.expression,
|
||||
visualizationModule: config.visualizationModule,
|
||||
...(config.positionalArgumentsExpressions ?
|
||||
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
|
||||
: {}),
|
||||
}),
|
||||
'Failed to modify visualization',
|
||||
).then((result) => {
|
||||
if (result.ok) state.visualizations.set(id, config)
|
||||
})
|
||||
}
|
||||
|
||||
const detach = (id: Uuid, config: NodeVisualizationConfiguration) => {
|
||||
return this.withBackoff(
|
||||
() => this.lsRpc.detachVisualization(id, config.expressionId, this.id),
|
||||
'Failed to detach visualization',
|
||||
).then((result) => {
|
||||
if (result.ok) state.visualizations.delete(id)
|
||||
})
|
||||
}
|
||||
|
||||
// Attach new and update existing visualizations.
|
||||
for (const [id, config] of this.visualizationConfigs) {
|
||||
const previousConfig = state.visualizations.get(id)
|
||||
if (previousConfig == null) {
|
||||
promises.push(attach(id, config))
|
||||
} else if (!visualizationConfigEqual(previousConfig, config)) {
|
||||
if (previousConfig.expressionId === config.expressionId) {
|
||||
promises.push(modify(id, config))
|
||||
} else {
|
||||
promises.push(detach(id, previousConfig).then(() => attach(id, config)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detach removed visualizations.
|
||||
for (const [id, config] of state.visualizations) {
|
||||
if (!this.visualizationConfigs.get(id)) {
|
||||
promises.push(detach(id, config))
|
||||
}
|
||||
}
|
||||
const settled = await Promise.allSettled(promises)
|
||||
|
||||
// Emit errors for failed requests.
|
||||
const errors = settled
|
||||
.map((result) => (result.status === 'rejected' ? result.reason : null))
|
||||
.filter(isSome)
|
||||
if (errors.length > 0) {
|
||||
console.error('Failed to synchronize visualizations:', errors)
|
||||
}
|
||||
}
|
||||
|
||||
const createResult = await create()
|
||||
if (!createResult.ok) return newState
|
||||
const syncStackResult = await syncStack()
|
||||
if (!syncStackResult.ok) return newState
|
||||
const syncEnvResult = await syncEnvironment()
|
||||
if (!syncEnvResult.ok) return newState
|
||||
this.emit('newVisualizationConfiguration', [new Set(this.visualizationConfigs.keys())])
|
||||
await syncVisualizations()
|
||||
this.emit('visualizationsConfigured', [
|
||||
new Set(state.status === 'created' ? state.visualizations.keys() : []),
|
||||
])
|
||||
return newState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -43,15 +43,29 @@ import * as Y from 'yjs'
|
||||
interface LsUrls {
|
||||
rpcUrl: string
|
||||
dataUrl: string
|
||||
ydocUrl: URL
|
||||
}
|
||||
|
||||
function resolveLsUrl(config: GuiConfig): LsUrls {
|
||||
const engine = config.engine
|
||||
if (engine == null) throw new Error('Missing engine configuration')
|
||||
if (engine.rpcUrl != null && engine.dataUrl != null) {
|
||||
const dataUrl = engine.dataUrl
|
||||
const rpcUrl = engine.rpcUrl
|
||||
let ydocUrl
|
||||
if (engine.ydocUrl == null || engine.ydocUrl === '') {
|
||||
ydocUrl = new URL(location.origin)
|
||||
ydocUrl.protocol = location.protocol.replace(/^http/, 'ws')
|
||||
} else {
|
||||
ydocUrl = new URL(engine.rpcUrl)
|
||||
ydocUrl.port = '1234'
|
||||
}
|
||||
ydocUrl.pathname = '/project'
|
||||
|
||||
return {
|
||||
rpcUrl: engine.rpcUrl,
|
||||
dataUrl: engine.dataUrl,
|
||||
rpcUrl,
|
||||
dataUrl,
|
||||
ydocUrl,
|
||||
}
|
||||
}
|
||||
throw new Error('Incomplete engine configuration')
|
||||
@ -132,13 +146,8 @@ export const useProjectStore = defineStore('project', () => {
|
||||
|
||||
let yDocsProvider: ReturnType<typeof attachProvider> | undefined
|
||||
watchEffect((onCleanup) => {
|
||||
// For now, let's assume that the websocket server is running on the same host as the web server.
|
||||
// Eventually, we can make this configurable, or even runtime variable.
|
||||
const socketUrl = new URL(location.origin)
|
||||
socketUrl.protocol = location.protocol.replace(/^http/, 'ws')
|
||||
socketUrl.pathname = '/project'
|
||||
yDocsProvider = attachProvider(
|
||||
socketUrl.href,
|
||||
lsUrls.ydocUrl.href,
|
||||
'index',
|
||||
{ ls: lsUrls.rpcUrl },
|
||||
doc,
|
||||
|
@ -3,7 +3,13 @@ import {
|
||||
defineKeybinds,
|
||||
normalizedKeyboardSegmentLookup,
|
||||
} from '@/util/shortcuts'
|
||||
import { expect, test, vi, type MockInstance } from 'vitest'
|
||||
import { beforeAll, expect, test, vi, type MockInstance } from 'vitest'
|
||||
|
||||
// See https://github.com/thymikee/jest-preset-angular/issues/245#issuecomment-576296325
|
||||
class MockPointerEvent extends MouseEvent {}
|
||||
beforeAll(() => {
|
||||
;(window as any).PointerEvent = MockPointerEvent
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ keybind: 'A', expected: { modifiers: [], key: 'A' } },
|
||||
|
@ -19,8 +19,10 @@ import {
|
||||
getMethodCallInfoRecursively,
|
||||
interpretCall,
|
||||
} from '@/util/callTree'
|
||||
import { fail } from 'assert'
|
||||
import { initializeFFI } from 'shared/ast/ffi'
|
||||
import type { ExpressionUpdatePayload, MethodCall } from 'shared/languageServerTypes'
|
||||
import { assertEqual, assertNotEqual } from 'shared/util/assert'
|
||||
import { assert, expect, test } from 'vitest'
|
||||
import type { AstId } from '../abstract'
|
||||
|
||||
@ -228,6 +230,165 @@ test.each([
|
||||
},
|
||||
)
|
||||
|
||||
interface ArgsTestCase extends TestCase {
|
||||
expectedSameIds: Array<[string, string]>
|
||||
}
|
||||
|
||||
test.each([
|
||||
{
|
||||
description: 'Base case',
|
||||
code: 'Aggregate_Column.Sum',
|
||||
subapplicationIndex: 0,
|
||||
notAppliedArguments: [0, 1],
|
||||
expectedNotAppliedArguments: [0, 1],
|
||||
expectedSameIds: [['0', undefined]],
|
||||
},
|
||||
{
|
||||
description: '1 arg, info on most inner subapplication.',
|
||||
code: 'Aggregate_Column.Sum x',
|
||||
subapplicationIndex: 1,
|
||||
notAppliedArguments: [0, 1],
|
||||
expectedNotAppliedArguments: [1],
|
||||
expectedSameIds: [['0', 'column']],
|
||||
},
|
||||
{
|
||||
description: '2 args, info on most inner subapplication.',
|
||||
code: 'Aggregate_Column.Sum x y',
|
||||
subapplicationIndex: 2,
|
||||
notAppliedArguments: [0, 1],
|
||||
expectedNotAppliedArguments: [],
|
||||
expectedSameIds: [
|
||||
['0', 'column'],
|
||||
['1', 'as'],
|
||||
],
|
||||
},
|
||||
{
|
||||
description: '2 args, info on inner subapplication.',
|
||||
code: 'Aggregate_Column.Sum x y',
|
||||
subapplicationIndex: 1,
|
||||
notAppliedArguments: [1],
|
||||
expectedNotAppliedArguments: [],
|
||||
expectedSameIds: [
|
||||
['0', 'column'],
|
||||
['1', 'as'],
|
||||
],
|
||||
},
|
||||
{
|
||||
description: '2 args, notAppliedArguments are incorrectly empty.',
|
||||
code: 'Aggregate_Column.Sum x y',
|
||||
subapplicationIndex: 2,
|
||||
notAppliedArguments: [],
|
||||
expectedNotAppliedArguments: [],
|
||||
expectedSameIds: [
|
||||
['0', 'column'],
|
||||
['1', 'as'],
|
||||
],
|
||||
},
|
||||
{
|
||||
description: '1 arg, notAppliedArguments unsorted.',
|
||||
code: 'Aggregate_Column.Sum x',
|
||||
subapplicationIndex: 1,
|
||||
notAppliedArguments: [1, 0],
|
||||
expectedNotAppliedArguments: [1],
|
||||
expectedSameIds: [
|
||||
['0', 'column'],
|
||||
['1', null],
|
||||
],
|
||||
},
|
||||
{
|
||||
description: '1 named arg.',
|
||||
code: 'Aggregate_Column.Sum as=x',
|
||||
subapplicationIndex: 1,
|
||||
notAppliedArguments: [0, 1],
|
||||
expectedNotAppliedArguments: [0],
|
||||
expectedSameIds: [
|
||||
['1', 'as'],
|
||||
['column', null],
|
||||
],
|
||||
},
|
||||
{
|
||||
description: '2 named args.',
|
||||
code: 'Aggregate_Column.Sum as=x column=y',
|
||||
subapplicationIndex: 2,
|
||||
notAppliedArguments: [0, 1],
|
||||
expectedNotAppliedArguments: [],
|
||||
expectedSameIds: [
|
||||
['0', 'as'],
|
||||
['1', 'column'],
|
||||
],
|
||||
},
|
||||
{
|
||||
description: '1 wrongly named arg.',
|
||||
code: 'Aggregate_Column.Sum bla=x',
|
||||
subapplicationIndex: 1,
|
||||
notAppliedArguments: [0, 1],
|
||||
expectedNotAppliedArguments: [0, 1],
|
||||
expectedSameIds: [['2', 'bla']],
|
||||
},
|
||||
{
|
||||
description: '1 named & 1 unnamed args.',
|
||||
code: 'Aggregate_Column.Sum as=x y',
|
||||
subapplicationIndex: 2,
|
||||
notAppliedArguments: [0, 1],
|
||||
expectedNotAppliedArguments: [],
|
||||
expectedSameIds: [['0', 'as']],
|
||||
},
|
||||
{
|
||||
description: '1 unnamed & 1 named args.',
|
||||
code: 'Aggregate_Column.Sum y as=x',
|
||||
subapplicationIndex: 2,
|
||||
notAppliedArguments: [0, 1],
|
||||
expectedNotAppliedArguments: [],
|
||||
expectedSameIds: [
|
||||
['1', 'as'],
|
||||
['0', 'column'],
|
||||
],
|
||||
},
|
||||
] as ArgsTestCase[])(
|
||||
'Computing IDs of arguments: $description',
|
||||
({ code, subapplicationIndex, notAppliedArguments, expectedSameIds }: ArgsTestCase) => {
|
||||
const { db, expectedMethodCall, setExpressionInfo } = prepareMocksForGetMethodCallTest()
|
||||
const ast = Ast.parse(code)
|
||||
const subApplication = nthSubapplication(ast, subapplicationIndex)
|
||||
assert(subApplication)
|
||||
db.updateExternalIds(ast)
|
||||
setExpressionInfo(subApplication.id, {
|
||||
typename: undefined,
|
||||
methodCall: { ...expectedMethodCall, notAppliedArguments },
|
||||
payload: { type: 'Pending' } as ExpressionUpdatePayload,
|
||||
profilingInfo: [],
|
||||
})
|
||||
|
||||
const info = getMethodCallInfoRecursively(ast, db)
|
||||
const interpreted = interpretCall(ast, true)
|
||||
const res = ArgumentApplication.collectArgumentNamesAndUuids(interpreted, info)
|
||||
|
||||
if (expectedSameIds) {
|
||||
for (const p of expectedSameIds) {
|
||||
if (p[1] === undefined) {
|
||||
const id = res[p[0]]
|
||||
assertEqual(undefined, id, `No ${[0]} ID found`)
|
||||
} else if (p[1] === null) {
|
||||
const id = res[p[0]]
|
||||
assertNotEqual(null, id, `One ${id} ID found`)
|
||||
for (const name in res) {
|
||||
if (name == p[0]) {
|
||||
continue
|
||||
}
|
||||
assertNotEqual(id, res[name], `No other ${id} found, testing ${name}`)
|
||||
}
|
||||
} else {
|
||||
const id1 = res[p[0]]
|
||||
const id2 = res[p[1]]
|
||||
assertEqual(id1, id2, `Checking ${p[0]} and ${p[1]}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fail('Undefined expectedSameIds')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function prepareMocksForGetMethodCallTest(): {
|
||||
db: GraphDb
|
||||
expectedMethodCall: MethodCall
|
||||
|
@ -7,6 +7,7 @@ import type { GraphDb, MethodCallInfo } from '@/stores/graph/graphDatabase'
|
||||
import type { SuggestionEntry, SuggestionEntryArgument } from '@/stores/suggestionDatabase/entry'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { findLastIndex, tryGetIndex } from '@/util/data/array'
|
||||
import type { ExternalId } from 'shared/yjsModel'
|
||||
import { assert } from './assert'
|
||||
|
||||
export const enum ApplicationKind {
|
||||
@ -397,6 +398,57 @@ export class ArgumentApplication {
|
||||
[ArgumentApplicationKey]: this,
|
||||
}
|
||||
}
|
||||
|
||||
static collectArgumentNamesAndUuids(
|
||||
value: InterpretedCall,
|
||||
mci: MethodCallInfo | undefined,
|
||||
): Record<string, ExternalId> {
|
||||
const namesAndExternalIds: Array<{
|
||||
name: string | null
|
||||
uuid: ExternalId | undefined
|
||||
}> = []
|
||||
|
||||
const args = ArgumentApplication.FromInterpretedWithInfo(value)
|
||||
if (args instanceof ArgumentApplication) {
|
||||
for (const n of args.iterApplications()) {
|
||||
const a = n.argument
|
||||
if (a instanceof ArgumentPlaceholder) {
|
||||
// pass thru
|
||||
} else {
|
||||
namesAndExternalIds.push({
|
||||
name: a.argInfo?.name.toString() ?? null,
|
||||
uuid: a.ast.externalId,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// don't process
|
||||
}
|
||||
namesAndExternalIds.reverse()
|
||||
|
||||
const argsExternalIds: Record<string, ExternalId> = {}
|
||||
let index = 'self' === mci?.suggestion.arguments[0]?.name ? 1 : 0
|
||||
for (const nameAndExtenalId of namesAndExternalIds) {
|
||||
const notApplied = mci?.methodCall.notAppliedArguments ?? []
|
||||
while (notApplied.indexOf(index) != -1) {
|
||||
index++
|
||||
}
|
||||
if (nameAndExtenalId.uuid) {
|
||||
argsExternalIds['' + index] = nameAndExtenalId.uuid
|
||||
}
|
||||
const suggestedName: string | undefined = mci?.suggestion.arguments[index]?.name
|
||||
if (suggestedName && nameAndExtenalId.uuid) {
|
||||
argsExternalIds[suggestedName] = nameAndExtenalId.uuid
|
||||
}
|
||||
index++
|
||||
}
|
||||
for (const nameAndExternalId of namesAndExternalIds) {
|
||||
if (nameAndExternalId.name && nameAndExternalId.uuid) {
|
||||
argsExternalIds[nameAndExternalId.name] = nameAndExternalId.uuid
|
||||
}
|
||||
}
|
||||
return argsExternalIds
|
||||
}
|
||||
}
|
||||
|
||||
const unknownArgInfoNamed = (name: string) => ({
|
||||
|
@ -6,7 +6,10 @@ import type { Opt } from '@/util/data/opt'
|
||||
export type NonEmptyArray<T> = [T, ...T[]]
|
||||
|
||||
/** An equivalent of `Array.prototype.findIndex` method, but returns null instead of -1. */
|
||||
export function findIndexOpt<T>(arr: T[], pred: (elem: T) => boolean): number | null {
|
||||
export function findIndexOpt<T>(
|
||||
arr: T[],
|
||||
pred: (elem: T, index: number) => boolean,
|
||||
): number | null {
|
||||
const index = arr.findIndex(pred)
|
||||
return index >= 0 ? index : null
|
||||
}
|
||||
@ -67,12 +70,12 @@ export function findLastIndex<T>(array: T[], pred: (elem: T) => boolean): number
|
||||
}
|
||||
|
||||
/**
|
||||
* Split array into two arrays based on predicate.
|
||||
* Split iterable into two arrays based on predicate.
|
||||
*
|
||||
* The predicate passed to `partition` can return true, or false. `partition` returns a pair, all of
|
||||
* the elements for which it returned true, and all of the elements for which it returned false.
|
||||
*/
|
||||
export function partition<T>(array: T[], pred: (elem: T) => boolean): [T[], T[]] {
|
||||
export function partition<T>(array: Iterable<T>, pred: (elem: T) => boolean): [T[], T[]] {
|
||||
const truthy: T[] = []
|
||||
const falsy: T[] = []
|
||||
|
||||
|
@ -149,6 +149,27 @@ export class Rect {
|
||||
if (newX == null && newY == null) return
|
||||
return new Rect(new Vec2(newX ?? this.pos.x, newY ?? this.pos.y), this.size)
|
||||
}
|
||||
|
||||
/** Returns a value that will compare equal for any two rects `a` and `b` if `a.equals(b)`, and
|
||||
* `a.isFinite() && b.isFinite()`. The result of comparing keys from two `Rect`s that don't satisfy `isFinite` are
|
||||
* unspecified, but a key returned from a non-finite `Rect` will never compare equal to a key return from any finite
|
||||
* Rect.
|
||||
* ---------------------------------------------------
|
||||
* | KEYS EQUAL | a is finite | a is not finite |
|
||||
* ---------------------------------------------------
|
||||
* | b is finite | a.equals(b) | false |
|
||||
* | b is not finite | false | unspecified |
|
||||
* ---------------------------------------------------
|
||||
*/
|
||||
key(): string {
|
||||
return [this.top, this.bottom, this.left, this.right].join(':')
|
||||
}
|
||||
|
||||
/** Return a `Rect` equal to this `Rect` reflected over the line `y=x`, i.e. with the x and y axes of all coordinates
|
||||
* swapped. */
|
||||
reflectXY() {
|
||||
return new Rect(this.pos.reflectXY(), this.size.reflectXY())
|
||||
}
|
||||
}
|
||||
|
||||
Rect.Zero = new Rect(Vec2.Zero, Vec2.Zero)
|
||||
|
@ -71,6 +71,11 @@ export class Vec2 {
|
||||
return new Vec2(1 / this.x, 1 / this.y)
|
||||
}
|
||||
|
||||
/** Return a `Vec2` equal to this `Vec2` reflected over the line `y=x`, i.e. with the x and y axes swapped. */
|
||||
reflectXY() {
|
||||
return new Vec2(this.y, this.x)
|
||||
}
|
||||
|
||||
add(other: Vec2): Vec2 {
|
||||
return new Vec2(this.x + other.x, this.y + other.y)
|
||||
}
|
||||
|
@ -415,7 +415,10 @@ export function defineKeybinds<
|
||||
}
|
||||
if (handled && stopAndPrevent) {
|
||||
event.stopImmediatePropagation()
|
||||
event.preventDefault()
|
||||
// We don't prevent default on PointerEvents, because it may prevent emitting
|
||||
// mousedown/mouseup events, on which external libraries may rely (like AGGrid for hiding
|
||||
// context menu).
|
||||
if (!(event instanceof PointerEvent)) event.preventDefault()
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { getDefines, readEnvironmentFromFile } from 'enso-common/src/appConfig'
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import postcssNesting from 'postcss-nesting'
|
||||
import tailwindcss from 'tailwindcss'
|
||||
@ -14,6 +16,9 @@ const localServerPort = 8080
|
||||
const projectManagerUrl = 'ws://127.0.0.1:30535'
|
||||
|
||||
const IS_CLOUD_BUILD = process.env.CLOUD_BUILD === 'true'
|
||||
const IS_POLYGLOT_YDOC_SERVER_DEBUG = process.env.POLYGLOT_YDOC_SERVER_DEBUG === 'true'
|
||||
const IS_POLYGLOT_YDOC_SERVER =
|
||||
process.env.POLYGLOT_YDOC_SERVER === 'true' || IS_POLYGLOT_YDOC_SERVER_DEBUG
|
||||
|
||||
await readEnvironmentFromFile()
|
||||
|
||||
@ -59,6 +64,7 @@ export default defineConfig({
|
||||
...getDefines(localServerPort),
|
||||
IS_CLOUD_BUILD: JSON.stringify(IS_CLOUD_BUILD),
|
||||
PROJECT_MANAGER_URL: JSON.stringify(projectManagerUrl),
|
||||
YDOC_SERVER_URL: IS_POLYGLOT_YDOC_SERVER ? JSON.stringify('defined') : undefined,
|
||||
RUNNING_VITEST: false,
|
||||
'import.meta.vitest': false,
|
||||
// Single hardcoded usage of `global` in aws-amplify.
|
||||
@ -94,13 +100,48 @@ export default defineConfig({
|
||||
},
|
||||
})
|
||||
|
||||
let ydocServer: ChildProcessWithoutNullStreams | null
|
||||
function gatewayServer(): Plugin {
|
||||
return {
|
||||
name: 'gateway-server',
|
||||
configureServer(server) {
|
||||
if (server.httpServer == null) return
|
||||
|
||||
createGatewayServer(server.httpServer, undefined)
|
||||
if (IS_POLYGLOT_YDOC_SERVER) {
|
||||
const ydocServerJar = fileURLToPath(
|
||||
new URL(
|
||||
'../../lib/java/ydoc-server/target/ydoc-server-assembly-0.1.0-SNAPSHOT.jar',
|
||||
import.meta.url,
|
||||
),
|
||||
)
|
||||
const runYdocServer = () => {
|
||||
const args = []
|
||||
if (IS_POLYGLOT_YDOC_SERVER_DEBUG) {
|
||||
args.push('-DinspectPort=34567')
|
||||
}
|
||||
args.push('-jar', ydocServerJar)
|
||||
ydocServer = spawn('java', args)
|
||||
if (IS_POLYGLOT_YDOC_SERVER_DEBUG) {
|
||||
ydocServer.stdout.on('data', (data) => console.log(`ydoc: ${data}`))
|
||||
}
|
||||
ydocServer.stderr.on('data', (data) => console.log(`ydoc: ${data}`))
|
||||
}
|
||||
if (!existsSync(ydocServerJar)) {
|
||||
const cwd = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const sbt = spawn('sbt', ['ydoc-server/assembly'], { cwd })
|
||||
sbt.stdout.on('data', (data) => console.log(`sbt: ${data}`))
|
||||
sbt.on('exit', runYdocServer)
|
||||
} else {
|
||||
runYdocServer()
|
||||
}
|
||||
} else {
|
||||
createGatewayServer(server.httpServer, undefined)
|
||||
}
|
||||
},
|
||||
buildEnd() {
|
||||
if (ydocServer == null) return
|
||||
|
||||
ydocServer.kill('SIGTERM')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
56
app/gui2/vite.ydoc-server-polyglot.config.ts
Normal file
56
app/gui2/vite.ydoc-server-polyglot.config.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import * as fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import defaultConfig from './vite.config'
|
||||
|
||||
const root = defaultConfig.root
|
||||
const cacheDir = defaultConfig.cacheDir
|
||||
const publicDir = defaultConfig.publicDir
|
||||
const envDir = defaultConfig.envDir
|
||||
const resolve = defaultConfig.resolve
|
||||
|
||||
export default defineConfig({
|
||||
root,
|
||||
cacheDir,
|
||||
publicDir,
|
||||
envDir,
|
||||
resolve,
|
||||
plugins: [usePolyglotFfi()],
|
||||
define: {
|
||||
...defaultConfig.define,
|
||||
self: 'globalThis',
|
||||
},
|
||||
build: {
|
||||
minify: false, // For debugging
|
||||
emptyOutDir: true,
|
||||
outDir: '../../lib/java/ydoc-server/target/classes/dist',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
ydocServer: fileURLToPath(new URL('ydoc-server/indexPolyglot.ts', import.meta.url)),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: `assets/[name].js`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Use `ffiPolyglot` module as `ffi` interface during the build.
|
||||
*/
|
||||
function usePolyglotFfi(): Plugin {
|
||||
const ffiPolyglot = fileURLToPath(new URL('./shared/ast/ffiPolyglot.ts', import.meta.url))
|
||||
const ffiBackup = fileURLToPath(new URL('./shared/ast/ffiBackup.ts', import.meta.url))
|
||||
const ffi = fileURLToPath(new URL('./shared/ast/ffi.ts', import.meta.url))
|
||||
|
||||
return {
|
||||
name: 'use-polyglot-ffi',
|
||||
options: () => {
|
||||
fs.renameSync(ffi, ffiBackup)
|
||||
fs.copyFileSync(ffiPolyglot, ffi)
|
||||
},
|
||||
buildEnd: () => {
|
||||
fs.renameSync(ffiBackup, ffi)
|
||||
},
|
||||
}
|
||||
}
|
22
app/gui2/ydoc-server/auth.ts
Normal file
22
app/gui2/ydoc-server/auth.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @file Utility methods for ydoc server authentication.
|
||||
*/
|
||||
|
||||
export type ConnectionData = {
|
||||
lsUrl: string
|
||||
doc: string
|
||||
user: string
|
||||
}
|
||||
|
||||
const docNameRegex = /^[a-z0-9/-]+$/i
|
||||
|
||||
export function docName(pathname: string) {
|
||||
const prefix = '/project/'
|
||||
if (pathname != null && pathname.startsWith(prefix)) {
|
||||
const docName = pathname.slice(prefix.length)
|
||||
if (docNameRegex.test(docName)) {
|
||||
return docName
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
@ -13,14 +13,9 @@ import { IncomingMessage } from 'node:http'
|
||||
import { parse } from 'url'
|
||||
import { WebSocket, WebSocketServer } from 'ws'
|
||||
import { initializeFFI } from '../shared/ast/ffi'
|
||||
import { ConnectionData, docName } from './auth'
|
||||
import { setupGatewayClient } from './ydoc'
|
||||
|
||||
type ConnectionData = {
|
||||
lsUrl: string
|
||||
doc: string
|
||||
user: string
|
||||
}
|
||||
|
||||
export async function createGatewayServer(httpServer: Server, rustFFIPath: string | undefined) {
|
||||
await initializeFFI(rustFFIPath)
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
@ -71,15 +66,3 @@ function authenticate(
|
||||
const data = doc != null && typeof lsUrl === 'string' ? { lsUrl, doc, user } : null
|
||||
callback(null, data)
|
||||
}
|
||||
|
||||
const docNameRegex = /^[a-z0-9/-]+$/i
|
||||
function docName(pathname: string) {
|
||||
const prefix = '/project/'
|
||||
if (pathname != null && pathname.startsWith(prefix)) {
|
||||
const docName = pathname.slice(prefix.length)
|
||||
if (docNameRegex.test(docName)) {
|
||||
return docName
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
29
app/gui2/ydoc-server/indexPolyglot.ts
Normal file
29
app/gui2/ydoc-server/indexPolyglot.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file An entry point for polyglot Yjs gateway server.
|
||||
*/
|
||||
|
||||
import { docName } from './auth'
|
||||
import { setupGatewayClient } from './ydoc'
|
||||
|
||||
declare global {
|
||||
class WebSocketServer {
|
||||
constructor(config: any)
|
||||
onconnect: ((socket: any, url: any) => any) | null
|
||||
start(): void
|
||||
}
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ host: 'localhost', port: 1234 })
|
||||
|
||||
wss.onconnect = (socket, url) => {
|
||||
const doc = docName(url.pathname)
|
||||
const ls = url.searchParams.get('ls')
|
||||
if (doc != null && ls != null) {
|
||||
console.log('setupGatewayClient', ls, doc)
|
||||
setupGatewayClient(socket, ls, doc)
|
||||
} else {
|
||||
console.log('Failed to authenticate user', ls, doc)
|
||||
}
|
||||
}
|
||||
|
||||
wss.start()
|
@ -7,10 +7,10 @@ import {
|
||||
import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync'
|
||||
import * as Y from 'yjs'
|
||||
|
||||
import WebSocket from 'isomorphic-ws'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import { ObservableV2 } from 'lib0/observable'
|
||||
import { WebSocket } from 'ws'
|
||||
import { LanguageServerSession } from './languageServerSession'
|
||||
|
||||
const pingTimeout = 30000
|
||||
|
@ -8,6 +8,7 @@
|
||||
"./src/appConfig": "./src/appConfig.js",
|
||||
"./src/buildUtils": "./src/buildUtils.js",
|
||||
"./src/detect": "./src/detect.ts",
|
||||
"./src/gtag": "./src/gtag.ts"
|
||||
"./src/gtag": "./src/gtag.ts",
|
||||
"./src/load": "./src/load.ts"
|
||||
}
|
||||
}
|
||||
|
@ -12,5 +12,7 @@ export function readEnvironmentFromFile(): Promise<void>
|
||||
* - the WebSocket URL for the chatbot
|
||||
* - the unique identifier for the cloud environment, for use in Sentry logs
|
||||
* - Stripe, Sentry and Amplify public keys */
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
export function getDefines(serverPort?: number): Record<string, string>
|
||||
|
||||
/** Load test environment variables, useful for when the Cloud backend is mocked or unnecessary. */
|
||||
export function loadTestEnvironmentVariables(): void
|
||||
|
@ -15,6 +15,14 @@ export async function readEnvironmentFromFile() {
|
||||
const isProduction = environment == null || environment === '' || environment === 'production'
|
||||
const fileName = isProduction ? '.env' : `.${environment}.env`
|
||||
const filePath = path.join(url.fileURLToPath(new URL('../../..', import.meta.url)), fileName)
|
||||
const expectedKeys = Object.keys(DUMMY_DEFINES).map(key => key.replace(/^process[.]env[.]/, ''))
|
||||
/** @type {string[]} */
|
||||
const missingKeys = []
|
||||
for (const key of expectedKeys) {
|
||||
if (!(key in process.env)) {
|
||||
missingKeys.push(key)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const file = await fs.readFile(filePath, { encoding: 'utf-8' })
|
||||
// eslint-disable-next-line jsdoc/valid-types
|
||||
@ -27,21 +35,21 @@ export async function readEnvironmentFromFile() {
|
||||
return [[key, value]]
|
||||
}
|
||||
})
|
||||
if (environment == null) {
|
||||
if (isProduction) {
|
||||
entries = entries.filter(kv => {
|
||||
const [k] = kv
|
||||
return process.env[k] == null
|
||||
})
|
||||
}
|
||||
const variables = Object.fromEntries(entries)
|
||||
Object.assign(process.env, variables)
|
||||
if (!isProduction || entries.length > 0) {
|
||||
Object.assign(process.env, variables)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isProduction) {
|
||||
if (missingKeys.length !== 0) {
|
||||
console.warn('Could not load `.env` file; disabling cloud backend.')
|
||||
console.warn(`Missing keys: ${missingKeys.map(key => `'${key}'`).join(', ')}`)
|
||||
console.error(error)
|
||||
return
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -89,6 +97,34 @@ export function getDefines(serverPort = 8080) {
|
||||
),
|
||||
'process.env.ENSO_CLOUD_COGNITO_DOMAIN': stringify(process.env.ENSO_CLOUD_COGNITO_DOMAIN),
|
||||
'process.env.ENSO_CLOUD_COGNITO_REGION': stringify(process.env.ENSO_CLOUD_COGNITO_REGION),
|
||||
'process.env.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG': stringify(
|
||||
process.env.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG
|
||||
),
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_PORT = 8080
|
||||
const DUMMY_DEFINES = {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
'process.env.NODE_ENV': 'production',
|
||||
'process.env.ENSO_CLOUD_REDIRECT': `http://localhost:${SERVER_PORT}`,
|
||||
'process.env.ENSO_CLOUD_ENVIRONMENT': 'production',
|
||||
'process.env.ENSO_CLOUD_API_URL': 'https://mock',
|
||||
'process.env.ENSO_CLOUD_SENTRY_DSN':
|
||||
'https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@o0000000000000000.ingest.sentry.io/0000000000000000',
|
||||
'process.env.ENSO_CLOUD_STRIPE_KEY': '',
|
||||
'process.env.ENSO_CLOUD_CHAT_URL': '',
|
||||
'process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID': '',
|
||||
'process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID': '',
|
||||
'process.env.ENSO_CLOUD_COGNITO_DOMAIN': '',
|
||||
'process.env.ENSO_CLOUD_COGNITO_REGION': '',
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
|
||||
/** Load test environment variables, useful for when the Cloud backend is mocked or unnecessary. */
|
||||
export function loadTestEnvironmentVariables() {
|
||||
for (const [k, v] of Object.entries(DUMMY_DEFINES)) {
|
||||
process.env[k.replace(/^process[.]env[.]/, '')] = v
|
||||
}
|
||||
}
|
||||
|
@ -17,15 +17,27 @@ export enum Platform {
|
||||
windows = 'Windows',
|
||||
macOS = 'macOS',
|
||||
linux = 'Linux',
|
||||
windowsPhone = 'Windows Phone',
|
||||
iPhoneOS = 'iPhone OS',
|
||||
android = 'Android',
|
||||
}
|
||||
|
||||
/** Return the platform the app is currently running on.
|
||||
/** The platform the app is currently running on.
|
||||
* This is used to determine whether `metaKey` or `ctrlKey` is used in shortcuts. */
|
||||
export function platform(): Platform {
|
||||
if (isOnWindows()) {
|
||||
export function platform() {
|
||||
if (isOnWindowsPhone()) {
|
||||
// MUST be before Android and Windows.
|
||||
return Platform.windowsPhone
|
||||
} else if (isOnWindows()) {
|
||||
return Platform.windows
|
||||
} else if (isOnIPhoneOS()) {
|
||||
// MUST be before macOS.
|
||||
return Platform.iPhoneOS
|
||||
} else if (isOnMacOS()) {
|
||||
return Platform.macOS
|
||||
} else if (isOnAndroid()) {
|
||||
// MUST be before Linux.
|
||||
return Platform.android
|
||||
} else if (isOnLinux()) {
|
||||
return Platform.linux
|
||||
} else {
|
||||
@ -33,22 +45,37 @@ export function platform(): Platform {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return whether the device is running Windows. */
|
||||
/** Whether the device is running Windows. */
|
||||
export function isOnWindows() {
|
||||
return /windows/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/** Return whether the device is running macOS. */
|
||||
/** Whether the device is running macOS. */
|
||||
export function isOnMacOS() {
|
||||
return /mac os/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/** Return whether the device is running Linux. */
|
||||
/** Whether the device is running Linux. */
|
||||
export function isOnLinux() {
|
||||
return /linux/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/** Return whether the device is running an unknown OS. */
|
||||
/** Whether the device is running Windows Phone. */
|
||||
export function isOnWindowsPhone() {
|
||||
return /windows phone/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/** Whether the device is running iPhone OS. */
|
||||
export function isOnIPhoneOS() {
|
||||
return /iPhone/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/** Whether the device is running Android. */
|
||||
export function isOnAndroid() {
|
||||
return /android/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
/** Whether the device is running an unknown OS. */
|
||||
export function isOnUnknownOS() {
|
||||
return platform() === Platform.unknown
|
||||
}
|
||||
@ -126,3 +153,73 @@ export function isOnSafari() {
|
||||
export function isOnUnknownBrowser() {
|
||||
return browser() === Browser.unknown
|
||||
}
|
||||
|
||||
// ====================
|
||||
// === Architecture ===
|
||||
// ====================
|
||||
|
||||
let detectedArchitecture: string | null = null
|
||||
// Only implemented by Chromium.
|
||||
// @ts-expect-error This API exists, but no typings exist for it yet.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
navigator.userAgentData
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
?.getHighEntropyValues(['architecture'])
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
.then((values: unknown) => {
|
||||
if (
|
||||
typeof values === 'object' &&
|
||||
values != null &&
|
||||
'architecture' in values &&
|
||||
typeof values.architecture === 'string'
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
detectedArchitecture = String(values.architecture)
|
||||
}
|
||||
})
|
||||
|
||||
/** Possible processor architectures. */
|
||||
export enum Architecture {
|
||||
intel64 = 'x86_64',
|
||||
arm64 = 'arm64',
|
||||
}
|
||||
|
||||
/** The processor architecture of the current system. */
|
||||
export function architecture() {
|
||||
if (detectedArchitecture != null) {
|
||||
switch (detectedArchitecture) {
|
||||
case 'arm': {
|
||||
return Architecture.arm64
|
||||
}
|
||||
default: {
|
||||
return Architecture.intel64
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (platform()) {
|
||||
case Platform.windows:
|
||||
case Platform.linux:
|
||||
case Platform.unknown: {
|
||||
return Architecture.intel64
|
||||
}
|
||||
case Platform.macOS:
|
||||
case Platform.iPhoneOS:
|
||||
case Platform.android:
|
||||
case Platform.windowsPhone: {
|
||||
// Assume the macOS device is on a M-series CPU.
|
||||
// This is highly unreliable, but operates under the assumption that all
|
||||
// new macOS devices will be ARM64.
|
||||
return Architecture.arm64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the device has an Intel 64-bit CPU. */
|
||||
export function isIntel64() {
|
||||
return architecture() === Architecture.intel64
|
||||
}
|
||||
|
||||
/** Whether the device has an ARM 64-bit CPU. */
|
||||
export function isArm64() {
|
||||
return architecture() === Architecture.arm64
|
||||
}
|
||||
|
@ -1,4 +1,11 @@
|
||||
/** @file Google Analytics tag. */
|
||||
import * as load from './load'
|
||||
|
||||
const GOOGLE_ANALYTICS_TAG = process.env.ENSO_CLOUD_GOOGLE_ANALYTICS_TAG
|
||||
|
||||
if (GOOGLE_ANALYTICS_TAG != null) {
|
||||
void load.loadScript(`https://www.googletagmanager.com/gtag/js?id=${GOOGLE_ANALYTICS_TAG}`)
|
||||
}
|
||||
|
||||
// @ts-expect-error This is explicitly not given types as it is a mistake to acess this
|
||||
// anywhere else.
|
||||
@ -22,5 +29,7 @@ export function event(name: string, params?: object) {
|
||||
gtag('js', new Date())
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
gtag('set', 'linker', { accept_incoming: true })
|
||||
gtag('config', 'G-CLTBJ37MDM')
|
||||
gtag('config', 'G-DH47F649JC')
|
||||
gtag('config', GOOGLE_ANALYTICS_TAG)
|
||||
if (GOOGLE_ANALYTICS_TAG === 'G-CLTBJ37MDM') {
|
||||
gtag('config', 'G-DH47F649JC')
|
||||
}
|
||||
|
32
app/ide-desktop/lib/common/src/load.ts
Normal file
32
app/ide-desktop/lib/common/src/load.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/** @file Utilities for loading resources. */
|
||||
|
||||
/** Add a script to the DOM. */
|
||||
export function loadScript(url: string) {
|
||||
const script = document.createElement('script')
|
||||
script.crossOrigin = 'anonymous'
|
||||
script.src = url
|
||||
document.head.appendChild(script)
|
||||
return new Promise<HTMLScriptElement>((resolve, reject) => {
|
||||
script.onload = () => {
|
||||
resolve(script)
|
||||
}
|
||||
script.onerror = reject
|
||||
})
|
||||
}
|
||||
|
||||
/** Add a CSS stylesheet to the DOM. */
|
||||
export function loadStyle(url: string) {
|
||||
const style = document.createElement('link')
|
||||
style.crossOrigin = 'anonymous'
|
||||
style.href = url
|
||||
style.rel = 'stylesheet'
|
||||
style.media = 'screen'
|
||||
style.type = 'text/css'
|
||||
document.head.appendChild(style)
|
||||
return new Promise<HTMLLinkElement>((resolve, reject) => {
|
||||
style.onload = () => {
|
||||
resolve(style)
|
||||
}
|
||||
style.onerror = reject
|
||||
})
|
||||
}
|
@ -42,10 +42,5 @@
|
||||
<noscript>
|
||||
This page requires JavaScript to run. Please enable it in your browser.
|
||||
</noscript>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-CLTBJ37MDM"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -43,10 +43,5 @@
|
||||
<noscript>
|
||||
This page requires JavaScript to run. Please enable it in your browser.
|
||||
</noscript>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-CLTBJ37MDM"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -6,6 +6,10 @@
|
||||
* default fonts. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as appConfig from 'enso-common/src/appConfig'
|
||||
|
||||
appConfig.loadTestEnvironmentVariables()
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-magic-numbers, @typescript-eslint/strict-boolean-expressions */
|
||||
|
||||
export default test.defineConfig({
|
||||
|
@ -139,6 +139,7 @@ export interface AppProps {
|
||||
readonly initialProjectName: string | null
|
||||
readonly onAuthenticated: (accessToken: string | null) => void
|
||||
readonly projectManagerUrl: string | null
|
||||
readonly ydocUrl: string | null
|
||||
readonly appRunner: AppRunner
|
||||
}
|
||||
|
||||
|
@ -70,6 +70,9 @@ const MODIFIER_JSX: Readonly<
|
||||
</aria.Text>
|
||||
),
|
||||
},
|
||||
[detect.Platform.iPhoneOS]: {},
|
||||
[detect.Platform.android]: {},
|
||||
[detect.Platform.windowsPhone]: {},
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
|
||||
|
@ -24,6 +24,7 @@ main.run({
|
||||
},
|
||||
/** The cloud frontend is not capable of running a Project Manager. */
|
||||
projectManagerUrl: null,
|
||||
ydocUrl: null,
|
||||
// This cannot be `appRunner: window.enso` as `window.enso` is set to a new value
|
||||
// every time a new project is opened.
|
||||
appRunner: {
|
||||
|
@ -5,9 +5,9 @@ import * as gtag from 'enso-common/src/gtag'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
|
||||
// ===================
|
||||
// === useGtag ===
|
||||
// ===================
|
||||
// ====================
|
||||
// === useGtagEvent ===
|
||||
// ====================
|
||||
|
||||
/** A hook that returns a no-op if the user is offline, otherwise it returns
|
||||
* a transparent wrapper around `gtag.event`. */
|
||||
@ -22,3 +22,28 @@ export function useGtagEvent() {
|
||||
[sessionType]
|
||||
)
|
||||
}
|
||||
|
||||
// =============================
|
||||
// === gtagOpenCloseCallback ===
|
||||
// =============================
|
||||
|
||||
/** Send an event indicating that something has been opened, and return a cleanup function
|
||||
* sending an event indicating that it has been closed.
|
||||
*
|
||||
* Also sends the close event when the window is unloaded. */
|
||||
export function gtagOpenCloseCallback(
|
||||
gtagEventRef: React.MutableRefObject<ReturnType<typeof useGtagEvent>>,
|
||||
openEvent: string,
|
||||
closeEvent: string
|
||||
) {
|
||||
const gtagEventCurrent = gtagEventRef.current
|
||||
gtagEventCurrent(openEvent)
|
||||
const onBeforeUnload = () => {
|
||||
gtagEventCurrent(closeEvent)
|
||||
}
|
||||
window.addEventListener('beforeunload', onBeforeUnload)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', onBeforeUnload)
|
||||
gtagEventCurrent(closeEvent)
|
||||
}
|
||||
}
|
||||
|
@ -243,7 +243,6 @@ interface InternalChatHeaderProps {
|
||||
function ChatHeader(props: InternalChatHeaderProps) {
|
||||
const { threads, setThreads, threadId, threadTitle, setThreadTitle } = props
|
||||
const { switchThread, sendMessage, doClose } = props
|
||||
const gtagEvent = gtagHooks.useGtagEvent()
|
||||
const [isThreadListVisible, setIsThreadListVisible] = React.useState(false)
|
||||
// These will never be `null` as their values are set immediately.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@ -258,12 +257,10 @@ function ChatHeader(props: InternalChatHeaderProps) {
|
||||
setIsThreadListVisible(false)
|
||||
}
|
||||
document.addEventListener('click', onClick)
|
||||
gtagEvent('cloud_open_chat')
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick)
|
||||
gtagEvent('cloud_close_chat')
|
||||
}
|
||||
}, [gtagEvent])
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -394,6 +391,17 @@ export default function Chat(props: ChatProps) {
|
||||
}
|
||||
},
|
||||
})
|
||||
const gtagEvent = gtagHooks.useGtagEvent()
|
||||
const gtagEventRef = React.useRef(gtagEvent)
|
||||
gtagEventRef.current = gtagEvent
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
} else {
|
||||
return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'cloud_open_chat', 'cloud_close_chat')
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
/** This is SAFE, because this component is only rendered when `accessToken` is present.
|
||||
* See `dashboard.tsx` for its sole usage. */
|
||||
|
@ -1,14 +1,15 @@
|
||||
/** @file The container that launches the IDE. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as load from 'enso-common/src/load'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as gtagHooks from '#/hooks/gtagHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as load from '#/utilities/load'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -31,14 +32,18 @@ const JS_EXTENSION: Readonly<Record<backendModule.BackendType, string>> = {
|
||||
export interface EditorProps {
|
||||
readonly hidden: boolean
|
||||
readonly supportsLocalBackend: boolean
|
||||
readonly ydocUrl: string | null
|
||||
readonly projectStartupInfo: backendModule.ProjectStartupInfo | null
|
||||
readonly appRunner: AppRunner
|
||||
}
|
||||
|
||||
/** The container that launches the IDE. */
|
||||
export default function Editor(props: EditorProps) {
|
||||
const { hidden, supportsLocalBackend, projectStartupInfo, appRunner } = props
|
||||
const { hidden, supportsLocalBackend, ydocUrl, projectStartupInfo, appRunner } = props
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const gtagEvent = gtagHooks.useGtagEvent()
|
||||
const gtagEventRef = React.useRef(gtagEvent)
|
||||
gtagEventRef.current = gtagEvent
|
||||
const [initialized, setInitialized] = React.useState(supportsLocalBackend)
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -48,6 +53,14 @@ export default function Editor(props: EditorProps) {
|
||||
}
|
||||
}, [hidden])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hidden) {
|
||||
return
|
||||
} else {
|
||||
return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'open_workflow', 'close_workflow')
|
||||
}
|
||||
}, [projectStartupInfo, hidden])
|
||||
|
||||
let hasEffectRun = false
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -65,6 +78,7 @@ export default function Editor(props: EditorProps) {
|
||||
void (async () => {
|
||||
const jsonAddress = project.jsonAddress
|
||||
const binaryAddress = project.binaryAddress
|
||||
const ydocAddress = ydocUrl ?? ''
|
||||
if (jsonAddress == null) {
|
||||
toastAndLog('noJSONEndpointError')
|
||||
} else if (binaryAddress == null) {
|
||||
@ -91,6 +105,7 @@ export default function Editor(props: EditorProps) {
|
||||
const engineConfig = {
|
||||
rpcUrl: jsonAddress,
|
||||
dataUrl: binaryAddress,
|
||||
ydocUrl: ydocAddress,
|
||||
}
|
||||
const originalUrl = window.location.href
|
||||
if (backendType === backendModule.BackendType.remote) {
|
||||
|
@ -118,13 +118,14 @@ export interface DashboardProps {
|
||||
readonly appRunner: AppRunner
|
||||
readonly initialProjectName: string | null
|
||||
readonly projectManagerUrl: string | null
|
||||
readonly ydocUrl: string | null
|
||||
readonly projectManagerRootDirectory: projectManager.Path | null
|
||||
}
|
||||
|
||||
/** The component that contains the entire UI. */
|
||||
export default function Dashboard(props: DashboardProps) {
|
||||
const { supportsLocalBackend, appRunner, initialProjectName } = props
|
||||
const { projectManagerUrl, projectManagerRootDirectory } = props
|
||||
const { ydocUrl, projectManagerUrl, projectManagerRootDirectory } = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const session = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
@ -546,6 +547,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
<Editor
|
||||
hidden={page !== pageSwitcher.Page.editor}
|
||||
supportsLocalBackend={supportsLocalBackend}
|
||||
ydocUrl={ydocUrl}
|
||||
projectStartupInfo={projectStartupInfo}
|
||||
appRunner={appRunner}
|
||||
/>
|
||||
|
@ -6,6 +6,8 @@ import type * as stripeTypes from '@stripe/stripe-js'
|
||||
import * as stripe from '@stripe/stripe-js/pure'
|
||||
import * as toast from 'react-toastify'
|
||||
|
||||
import * as load from 'enso-common/src/load'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
import type * as text from '#/text'
|
||||
|
||||
@ -20,7 +22,6 @@ import UnstyledButton from '#/components/UnstyledButton'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as load from '#/utilities/load'
|
||||
import * as string from '#/utilities/string'
|
||||
|
||||
// =================
|
||||
|
@ -10,10 +10,13 @@ import isNetworkError from 'is-network-error'
|
||||
import * as router from 'react-router-dom'
|
||||
import * as toast from 'react-toastify'
|
||||
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
import * as gtag from 'enso-common/src/gtag'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as gtagHooks from '#/hooks/gtagHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
@ -238,6 +241,16 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
},
|
||||
[userSession?.type]
|
||||
)
|
||||
const gtagEventRef = React.useRef(gtagEvent)
|
||||
gtagEventRef.current = gtagEvent
|
||||
|
||||
React.useEffect(() => {
|
||||
gtag.gtag('set', {
|
||||
platform: detect.platform(),
|
||||
architecture: detect.architecture(),
|
||||
})
|
||||
return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'open_app', 'close_app')
|
||||
}, [])
|
||||
|
||||
// This is identical to `hooks.useOnlineCheck`, however it is inline here to avoid any possible
|
||||
// circular dependency.
|
||||
|
@ -1,32 +0,0 @@
|
||||
/** @file Utilities for loading resources. */
|
||||
|
||||
/** Add a script to the DOM. */
|
||||
export function loadScript(url: string) {
|
||||
const script = document.createElement('script')
|
||||
script.crossOrigin = 'anonymous'
|
||||
script.src = url
|
||||
document.head.appendChild(script)
|
||||
return new Promise<HTMLScriptElement>((resolve, reject) => {
|
||||
script.onload = () => {
|
||||
resolve(script)
|
||||
}
|
||||
script.onerror = reject
|
||||
})
|
||||
}
|
||||
|
||||
/** Add a CSS stylesheet to the DOM. */
|
||||
export function loadStyle(url: string) {
|
||||
const style = document.createElement('link')
|
||||
style.crossOrigin = 'anonymous'
|
||||
style.href = url
|
||||
style.rel = 'stylesheet'
|
||||
style.media = 'screen'
|
||||
style.type = 'text/css'
|
||||
document.head.appendChild(style)
|
||||
return new Promise<HTMLLinkElement>((resolve, reject) => {
|
||||
style.onload = () => {
|
||||
resolve(style)
|
||||
}
|
||||
style.onerror = reject
|
||||
})
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
/** @file Configuration for vite. */
|
||||
import * as vite from 'vite'
|
||||
|
||||
import config from './vite.config'
|
||||
import * as appConfig from 'enso-common/src/appConfig'
|
||||
|
||||
// =====================
|
||||
// === Configuration ===
|
||||
@ -9,10 +9,12 @@ import config from './vite.config'
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
const SERVER_PORT = 8080
|
||||
appConfig.loadTestEnvironmentVariables()
|
||||
|
||||
const CONFIG = (await import('./vite.config')).default
|
||||
|
||||
export default vite.mergeConfig(
|
||||
config,
|
||||
CONFIG,
|
||||
vite.defineConfig({
|
||||
resolve: {
|
||||
extensions: [
|
||||
@ -32,18 +34,5 @@ export default vite.mergeConfig(
|
||||
'.json',
|
||||
],
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
'process.env.ENSO_CLOUD_REDIRECT': JSON.stringify(`http://localhost:${SERVER_PORT}`),
|
||||
'process.env.ENSO_CLOUD_ENVIRONMENT': JSON.stringify('production'),
|
||||
'process.env.ENSO_CLOUD_API_URL': JSON.stringify('https://mock'),
|
||||
'process.env.ENSO_CLOUD_SENTRY_DSN': JSON.stringify(''),
|
||||
'process.env.ENSO_CLOUD_STRIPE_KEY': JSON.stringify(''),
|
||||
'process.env.ENSO_CLOUD_CHAT_URL': JSON.stringify(''),
|
||||
'process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID': JSON.stringify(''),
|
||||
'process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID': JSON.stringify(''),
|
||||
'process.env.ENSO_CLOUD_COGNITO_DOMAIN': JSON.stringify(''),
|
||||
'process.env.ENSO_CLOUD_COGNITO_REGION': JSON.stringify(''),
|
||||
},
|
||||
})
|
||||
)
|
||||
|
@ -3,10 +3,19 @@ import * as url from 'node:url'
|
||||
|
||||
import * as vitestConfig from 'vitest/config'
|
||||
|
||||
import viteConfig from './vite.config'
|
||||
import * as appConfig from 'enso-common/src/appConfig'
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
|
||||
appConfig.loadTestEnvironmentVariables()
|
||||
// @ts-expect-error This is required, otherwise importing node modules is broken.
|
||||
// This is required for `dataLinkSchema.test.ts`.
|
||||
process.env.NODE_ENV = 'development'
|
||||
|
||||
const VITE_CONFIG = (await import('./vite.config')).default
|
||||
|
||||
export default vitestConfig.mergeConfig(
|
||||
viteConfig,
|
||||
VITE_CONFIG,
|
||||
vitestConfig.defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user