[Gui2] Opening projects and language server connection (#7813)

# Important Notes
- Binary LS endpoint is not yet handled.
- The parsing of provided source is not entirely correct, as each line (including imports) is treated as node. The usage of actual enso AST for nodes is not yet implemented.
- Modifications to the graph state are not yet synchronized back to the language server.
This commit is contained in:
Paweł Grabarz 2023-09-22 05:43:25 +02:00 committed by GitHub
parent 3ba2f6f391
commit 42a7cb2d23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
161 changed files with 17023 additions and 42866 deletions

View File

@ -24,7 +24,7 @@ jobs:
conda-channels: anaconda, conda-forge
- if: startsWith(runner.name, 'GitHub Actions') || startsWith(runner.name, 'Hosted Agent')
name: Installing wasm-pack
uses: jetli/wasm-pack-action@v0.3.0
uses: jetli/wasm-pack-action@v0.4.0
with:
version: v0.10.2
- name: Expose Artifact API and context information.

3
.gitignore vendored
View File

@ -70,6 +70,7 @@ node_modules/
.enso-sources*
.metals
tools/performance/engine-benchmarks/generated_site
*.tsbuildinfo
############################
## Rendered Documentation ##
@ -108,7 +109,7 @@ bench-report*.xml
.bloop/
.bsp/
project/metals.sbt
/app/ide-desktop/build.json
/build.json
/app/ide-desktop/lib/client/electron-builder-config.json

1
.npmrc Normal file
View File

@ -0,0 +1 @@
legacy-peer-deps=true

View File

@ -26,11 +26,13 @@ test/**/data
**/msdfgen_wasm.js
# Generated files
app/ide-desktop/build.json
app/ide-desktop/lib/client/electron-builder-config.json
app/ide-desktop/lib/content-config/src/config.json
app/gui/view/documentation/assets/stylesheet.css
app/gui2/rust-ffi/pkg
Cargo.lock
build.json
app/gui2/playwright-report/
# Engine Builds can leave these nested working copies.
# TODO [mwu]: Adjust Engine build to not leave them.

10
.vscode/settings.json vendored
View File

@ -6,6 +6,16 @@
"auto-snippets.snippets": [
{ "language": "vue", "snippet": "Vue single-file component" }
],
"typescript.tsdk": "node_modules/typescript/lib",
"eslint.experimental.useFlatConfig": true,
"eslint.useESLintClass": true,
"[javascript][typescript][typescriptreact][vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
},
"eslint.workingDirectories": [
"./app/gui2",
"./app/gui2/ide-desktop"
],
"files.watcherExclude": {
"**/target": true
}

34
Cargo.lock generated
View File

@ -5995,6 +5995,16 @@ dependencies = [
"xmlparser",
]
[[package]]
name = "rust-ffi"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"enso-parser",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-demangle"
version = "0.1.21"
@ -7425,9 +7435,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasm-bindgen"
version = "0.2.84"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b"
checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342"
dependencies = [
"cfg-if 1.0.0",
"wasm-bindgen-macro",
@ -7435,16 +7445,16 @@ dependencies = [
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.84"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9"
checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn 1.0.107",
"syn 2.0.15",
"wasm-bindgen-shared",
]
@ -7462,9 +7472,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.84"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5"
checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -7472,22 +7482,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.84"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6"
checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.107",
"syn 2.0.15",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.84"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d"
checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
[[package]]
name = "wasm-bindgen-test"

View File

@ -10,6 +10,7 @@ members = [
"app/gui",
"app/gui/language/parser",
"app/gui/enso-profiler-enso-data",
"app/gui2/rust-ffi",
"build/cli",
"build/macros/proc-macro",
"build/ci-gen",
@ -93,7 +94,7 @@ serde-wasm-bindgen = { version = "0.4.5" }
tokio = { version = "1.23.0", features = ["full", "tracing"] }
tokio-stream = { version = "0.1.12", features = ["fs"] }
tokio-util = { version = "0.7.4", features = ["full"] }
wasm-bindgen = { version = "0.2.84", features = [] }
wasm-bindgen = { version = "0.2.87", features = [] }
wasm-bindgen-test = { version = "0.3.34" }
anyhow = { version = "1.0.66" }
failure = { version = "0.1.8" }

View File

@ -60,52 +60,43 @@ impl BackendService {
/// Read backend configuration from the web arguments. See also [`web::Arguments`]
/// documentation.
pub fn from_web_arguments(args: &Args) -> FallibleResult<Self> {
let endpoint = args.groups.engine.options.project_manager_url.value.as_str();
let rpc_url_option = &args.groups.engine.options.rpc_url;
let data_url_option = &args.groups.engine.options.data_url;
let rpc_url = rpc_url_option.value.as_str();
let data_url = data_url_option.value.as_str();
if !endpoint.is_empty() {
if !rpc_url.is_empty() || !data_url.is_empty() {
Err(MutuallyExclusiveOptions.into())
} else {
let endpoint = endpoint.to_owned();
Ok(Self::ProjectManager { endpoint })
}
} else {
match (rpc_url, data_url) {
("", "") => Ok(default()),
("", _) => Err(MissingOption(rpc_url_option.__name__.to_owned()).into()),
(_, "") => Err(MissingOption(data_url_option.__name__.to_owned()).into()),
(json_endpoint, binary_endpoint) => {
let json_endpoint = json_endpoint.to_owned();
let binary_endpoint = binary_endpoint.to_owned();
let def_namespace = || constants::DEFAULT_PROJECT_NAMESPACE.to_owned();
let namespace = args.groups.engine.options.namespace.value.clone();
let namespace = if namespace.is_empty() { def_namespace() } else { namespace };
let project_name_option = &args.groups.startup.options.project;
let project_name = project_name_option.value.as_str();
let no_project_name = || MissingOption(project_name_option.__name__.to_owned());
let project_name = if project_name.is_empty() {
Err(no_project_name())
} else {
Ok(project_name.to_owned())
}?;
let displayed_name_option = &args.groups.startup.options.displayed_project_name;
let displayed_name = displayed_name_option.value.as_str();
let displayed_name = if displayed_name.is_empty() {
project_name.clone()
} else {
displayed_name.to_owned()
};
Ok(Self::LanguageServer {
json_endpoint,
binary_endpoint,
namespace,
project_name,
displayed_name,
})
}
match (rpc_url, data_url) {
("", "") => Ok(default()),
("", _) => Err(MissingOption(rpc_url_option.__name__.to_owned()).into()),
(_, "") => Err(MissingOption(data_url_option.__name__.to_owned()).into()),
(json_endpoint, binary_endpoint) => {
let json_endpoint = json_endpoint.to_owned();
let binary_endpoint = binary_endpoint.to_owned();
let def_namespace = || constants::DEFAULT_PROJECT_NAMESPACE.to_owned();
let namespace = args.groups.engine.options.namespace.value.clone();
let namespace = if namespace.is_empty() { def_namespace() } else { namespace };
let project_name_option = &args.groups.startup.options.project;
let project_name = project_name_option.value.as_str();
let no_project_name = || MissingOption(project_name_option.__name__.to_owned());
let project_name = if project_name.is_empty() {
Err(no_project_name())
} else {
Ok(project_name.to_owned())
}?;
let displayed_name_option = &args.groups.startup.options.displayed_project_name;
let displayed_name = displayed_name_option.value.as_str();
let displayed_name = if displayed_name.is_empty() {
project_name.clone()
} else {
displayed_name.to_owned()
};
Ok(Self::LanguageServer {
json_endpoint,
binary_endpoint,
namespace,
project_name,
displayed_name,
})
}
}
}

View File

@ -69,7 +69,8 @@
.enso-internal-templates-view .enso-internal-card {
border-radius: 20px;
color: #fcfeff;
box-shadow: 0px 36px 51px rgba(0, 0, 0, 0.03),
box-shadow:
0px 36px 51px rgba(0, 0, 0, 0.03),
0px 15.0399px 21.3066px rgba(0, 0, 0, 0.0232911),
0px 8.04107px 11.3915px rgba(0, 0, 0, 0.0197608),
0px 4.50776px 6.38599px rgba(0, 0, 0, 0.0166035),

View File

@ -1,33 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-recommended',
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier/skip-formatting',
],
parserOptions: {
ecmaVersion: 'latest',
},
ignorePatterns: ['rust-ffi/pkg/**'],
rules: {
camelcase: [
1,
{
ignoreImports: true,
},
],
'no-inner-declarations': 0,
'vue/v-on-event-hyphenation': [2, 'never'],
'@typescript-eslint/no-unused-vars': [
1,
{
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
},
],
},
}

View File

@ -1,8 +1,10 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"plugins": ["prettier-plugin-organize-imports"],
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all"
"trailingComma": "all",
"organizeImportsSkipDestructiveCodeActions": true
}

View File

@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test'
import { expect, test } from '@playwright/test'
// See here how to get started:
// https://playwright.dev/docs/intro

5
app/gui2/env.d.ts vendored
View File

@ -1,6 +1,3 @@
/// <reference types="vite/client" />
module 'y-websocket' {
// hack for bad module resolution
export * from 'node_modules/y-websocket/dist/src/y-websocket'
}
declare const PROJECT_MANAGER_URL: string

41
app/gui2/eslint.config.js Normal file
View File

@ -0,0 +1,41 @@
import { FlatCompat } from '@eslint/eslintrc'
import eslintJs from '@eslint/js'
import * as path from 'node:path'
import * as url from 'node:url'
const compat = new FlatCompat()
const DIR_NAME = path.dirname(url.fileURLToPath(import.meta.url))
const conf = [
{
ignores: ['rust-ffi/pkg', 'dist'],
},
...compat.extends('plugin:vue/vue3-recommended'),
eslintJs.configs.recommended,
...compat.extends('@vue/eslint-config-typescript', '@vue/eslint-config-prettier'),
{
// files: ['{**,src}/*.{vue,js,jsx,cjs,mjs,ts,tsx,cts,mts}'],
languageOptions: {
parserOptions: {
tsconfigRootDir: DIR_NAME,
ecmaVersion: 'latest',
project: ['./tsconfig.app.json', './tsconfig.node.json', './tsconfig.vitest.json'],
},
},
rules: {
camelcase: [1, { ignoreImports: true }],
'no-inner-declarations': 0,
'vue/v-on-event-hyphenation': [2, 'never'],
'@typescript-eslint/no-unused-vars': [
1,
{
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
},
],
},
},
]
export default conf

View File

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
@ -8,6 +8,7 @@
</head>
<body>
<div id="app"></div>
<div id="enso-dashboard" class="enso-dashboard"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5
app/gui2/node.env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
module 'tailwindcss/nesting' {
import { PluginCreator } from 'postcss'
declare const plugin: PluginCreator<unknown>
export default plugin
}

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +1,35 @@
{
"name": "enso-ide",
"version": "0.0.0",
"version": "0.1.0",
"name": "enso-gui2",
"private": true,
"type": "module",
"author": {
"name": "Enso Team",
"email": "contact@enso.org"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"build": "run-p typecheck build-only",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"test": "vitest run",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint .",
"format": "prettier --write src/ && eslint . --fix",
"build-rust-ffi": "cd rust-ffi && wasm-pack build --release --target web",
"preinstall": "npm run build-rust-ffi"
},
"dependencies": {
"@open-rpc/client-js": "^1.8.1",
"@vueuse/core": "^10.4.1",
"enso-authentication": "^1.0.0",
"isomorphic-ws": "^5.0.0",
"lib0": "^0.2.83",
"pinia": "^2.1.6",
"postcss-nesting": "^12.0.1",
"vite-plugin-top-level-await": "^1.3.1",
"postcss-inline-svg": "^6.0.0",
"sha3": "^2.1.4",
"vue": "^3.3.4",
"ws": "^8.13.0",
"y-protocols": "^1.0.5",
@ -29,6 +38,8 @@
"yjs": "^13.6.7"
},
"devDependencies": {
"@eslint/eslintrc": "^2.1.2",
"@eslint/js": "^8.49.0",
"@playwright/test": "^1.37.0",
"@rushstack/eslint-patch": "^1.3.2",
"@tsconfig/node18": "^18.2.0",
@ -36,19 +47,26 @@
"@types/node": "^18.17.5",
"@types/shuffle-seed": "^1.1.0",
"@types/ws": "^8.5.5",
"@vitejs/plugin-react": "^4.0.4",
"@vitejs/plugin-vue": "^4.3.1",
"@volar/vue-typescript": "^1.6.5",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/test-utils": "^2.4.1",
"@vue/tsconfig": "^0.4.0",
"eslint": "^8.46.0",
"esbuild": "^0.19.3",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.16.1",
"jsdom": "^22.1.0",
"npm-run-all": "^4.1.5",
"postcss-nesting": "^12.0.1",
"prettier": "^3.0.0",
"prettier-plugin-organize-imports": "^3.2.3",
"shuffle-seed": "^1.1.6",
"typescript": "~5.1.6",
"tailwindcss": "^3.2.7",
"typescript": "~5.2.2",
"vite": "^4.4.9",
"vite-plugin-inspect": "^0.7.38",
"vite-plugin-top-level-await": "^1.3.1",
"vitest": "^0.34.2",
"vue-tsc": "^1.8.8"
}

View File

@ -1,6 +0,0 @@
/* eslint-env node */
module.exports = {
plugins: {
autoprefixer: {},
},
}

View File

@ -1,904 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
dependencies = [
"lazy_static",
"regex",
]
[[package]]
name = "addr2line"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
dependencies = [
"gimli",
]
[[package]]
name = "adler"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "aho-corasick"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c378d78423fdad8089616f827526ee33c19f2fddbd5de1629152c9593ba4783"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
[[package]]
name = "approx"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278"
dependencies = [
"num-traits",
]
[[package]]
name = "assert_approx_eq"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c07dab4369547dbe5114677b33fbbf724971019f3818172d59a97a61c774ffd"
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "backtrace"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837"
dependencies = [
"addr2line",
"cc",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
]
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "boolinator"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9"
[[package]]
name = "bumpalo"
version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
[[package]]
name = "bytemuck"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdde5c9cd29ebd706ce1b35600920a33550e402fc998a2e53ad3b42c3c47a192"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.31",
]
[[package]]
name = "cc"
version = "1.0.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
dependencies = [
"libc",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "convert_case"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "derivative"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "derive_more"
version = "0.99.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 1.0.109",
]
[[package]]
name = "either"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
[[package]]
name = "enso-data-structures"
version = "0.2.0"
dependencies = [
"bytemuck",
"enso-prelude",
"failure",
"rustversion",
"serde",
"typenum",
]
[[package]]
name = "enso-logging"
version = "0.3.1"
dependencies = [
"enso-logging-macros",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "enso-logging-macros"
version = "0.1.0"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.31",
]
[[package]]
name = "enso-macro-utils"
version = "0.2.0"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "enso-metamodel"
version = "0.1.0"
dependencies = [
"derivative",
"derive_more",
"enso-zst",
]
[[package]]
name = "enso-parser"
version = "0.1.0"
dependencies = [
"bincode",
"enso-data-structures",
"enso-parser-syntax-tree-visitor",
"enso-prelude",
"enso-reflect",
"enso-shapely-macros",
"enso-types",
"serde",
"serde_json",
"uuid",
]
[[package]]
name = "enso-parser-syntax-tree-visitor"
version = "0.1.0"
dependencies = [
"enso-macro-utils",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "enso-prelude"
version = "0.2.6"
dependencies = [
"anyhow",
"assert_approx_eq",
"backtrace",
"boolinator",
"bytemuck",
"derivative",
"derive_more",
"enso-logging",
"enso-reflect",
"enso-shapely",
"enso-zst",
"failure",
"futures",
"gen-iter",
"itertools",
"lazy_static",
"paste",
"serde",
"serde_json",
"smallvec",
"wasm-bindgen",
"weak-table",
"web-sys",
]
[[package]]
name = "enso-reflect"
version = "0.1.0"
dependencies = [
"derivative",
"enso-metamodel",
"enso-reflect-macros",
]
[[package]]
name = "enso-reflect-macros"
version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "enso-shapely"
version = "0.2.0"
dependencies = [
"derivative",
"enso-shapely-macros",
"enso-zst",
"paste",
"rustversion",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "enso-shapely-macros"
version = "0.2.1"
dependencies = [
"Inflector",
"boolinator",
"enso-macro-utils",
"itertools",
"paste",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "enso-types"
version = "0.1.0"
dependencies = [
"enso-prelude",
"enso-reflect",
"nalgebra",
"num-traits",
"paste",
"serde",
]
[[package]]
name = "enso-zst"
version = "0.1.0"
dependencies = [
"bytemuck",
"paste",
"serde",
]
[[package]]
name = "failure"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86"
dependencies = [
"backtrace",
"failure_derive",
]
[[package]]
name = "failure_derive"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
"synstructure",
]
[[package]]
name = "futures"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23342abe12aba583913b2e62f22225ff9c950774065e4bfb61a19cd9770fec40"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "955518d47e09b25bbebc7a18df10b81f0c766eaf4c4f1cccef2fca5f2a4fb5f2"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bca583b7e26f571124fe5b7561d49cb2868d79116cfa0eefce955557c6fee8c"
[[package]]
name = "futures-executor"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccecee823288125bd88b4d7f565c9e58e41858e47ab72e8ea2d64e93624386e0"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fff74096e71ed47f8e023204cfd0aa1289cd54ae5430a9523be060cdb849964"
[[package]]
name = "futures-macro"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.31",
]
[[package]]
name = "futures-sink"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f43be4fe21a13b9781a69afa4985b0f6ee0e1afab2c6f454a8cf30e2b2237b6e"
[[package]]
name = "futures-task"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76d3d132be6c0e6aa1534069c705a74a5997a356c0dc2f86a47765e5617c5b65"
[[package]]
name = "futures-util"
version = "0.3.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b01e40b772d54cf6c6d721c1d1abd0647a0106a12ecaa1c186273392a69533"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "gen-iter"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1668ac3c7b8cc5f1e31565ed509d8d70aa1a81bd7f508b620725b78c6e1d7049"
[[package]]
name = "gimli"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0"
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
[[package]]
name = "js-sys"
version = "0.3.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a"
dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "libc"
version = "0.2.147"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3"
[[package]]
name = "log"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
[[package]]
name = "matrixmultiply"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "090126dc04f95dc0d1c1c91f61bdd474b3930ca064c1edc8a849da2c6cbe1e77"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c"
[[package]]
name = "miniz_oxide"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7"
dependencies = [
"adler",
]
[[package]]
name = "nalgebra"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "476d1d59fe02fe54c86356e91650cd892f392782a1cb9fc524ec84f7aa9e1d06"
dependencies = [
"approx",
"matrixmultiply",
"num-complex",
"num-rational",
"num-traits",
"serde",
"simba",
"typenum",
]
[[package]]
name = "num-complex"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "747d632c0c558b87dbabbe6a82f3b4ae03720d0646ac5b7b4dae89394be5f2c5"
dependencies = [
"num-traits",
"serde",
]
[[package]]
name = "num-integer"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9"
dependencies = [
"autocfg",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12ac428b1cb17fce6f731001d307d351ec70a6d202fc2e60f7d4c5e42d8f4f07"
dependencies = [
"autocfg",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f30b0abd723be7e2ffca1272140fac1a2f084c77ec3e123c192b66af1ee9e6c2"
dependencies = [
"autocfg",
]
[[package]]
name = "object"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
name = "paste"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
[[package]]
name = "pin-project-lite"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "proc-macro2"
version = "1.0.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18fb31db3f9bddb2ea821cde30a9f70117e3f119938b5ee630b7403aa6e2ead9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "regex"
version = "1.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "697061221ea1b4a94a624f67d0ae2bfe4e22b8a17b6a192afb11046542cc8c47"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2f401f4955220693b56f8ec66ee9c78abffd8d1c4f23dc41a23839eb88f0795"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
[[package]]
name = "rust-ffi"
version = "0.1.0"
dependencies = [
"enso-parser",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-demangle"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
[[package]]
name = "rustc_version"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366"
dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
[[package]]
name = "ryu"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
[[package]]
name = "semver"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0293b4b29daaf487284529cc2f5675b8e57c61f70167ba415a463651fd6a918"
[[package]]
name = "serde"
version = "1.0.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.31",
]
[[package]]
name = "serde_json"
version = "1.0.105"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "simba"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5132a955559188f3d13c9ba831e77c802ddc8782783f050ed0c52f5988b95f4c"
dependencies = [
"approx",
"num-complex",
"num-traits",
"paste",
]
[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
"autocfg",
]
[[package]]
name = "smallvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "718fa2415bcb8d8bd775917a1bf12a7931b6dfa890753378538118181e0cb398"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
"unicode-xid",
]
[[package]]
name = "typenum"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba"
[[package]]
name = "unicode-ident"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c"
[[package]]
name = "unicode-xid"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c"
[[package]]
name = "uuid"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
dependencies = [
"serde",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342"
dependencies = [
"cfg-if",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd"
dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.31",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.31",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.87"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
[[package]]
name = "weak-table"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549"
[[package]]
name = "web-sys"
version = "0.3.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b"
dependencies = [
"js-sys",
"wasm-bindgen",
]

View File

@ -8,18 +8,7 @@ authors = ["Enso Team <contact@enso.org>"]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = { version = "0.2.84", features = [] }
enso-parser = { path = "../../../lib/rust/parser" }
serde_json = "1.0"
[workspace]
[profile.release]
debug = false
strip = true
lto = true
codegen-units = 1
opt-level = "z"
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Os']
wasm-bindgen = { workspace = true }
serde_json = { workspace = true }
console_error_panic_hook = { workspace = true }

View File

@ -19,3 +19,8 @@ pub fn parse_to_json(code: &str) -> String {
let ast = PARSER.with(|parser| parser.run(code));
serde_json::to_string(&ast).expect("Failed to serialize AST to JSON")
}
#[wasm_bindgen(start)]
fn main() {
console_error_panic_hook::set_once();
}

61
app/gui2/shared/event.ts Normal file
View File

@ -0,0 +1,61 @@
import * as array from 'lib0/array'
import * as map from 'lib0/map'
import * as set from 'lib0/set'
type EventMap = { [name: PropertyKey]: any[] }
export type EventHandler<Args extends any[] = any[]> = (...args: Args) => void
export class Emitter<Events extends EventMap = EventMap> {
private _observers: Map<PropertyKey, Set<EventHandler<any[]>>>
constructor() {
this._observers = map.create()
}
on<N extends keyof Events>(name: N, f: EventHandler<Events[N]>) {
map.setIfUndefined(this._observers, name as PropertyKey, set.create).add(f)
}
once<N extends keyof Events>(name: N, f: EventHandler<Events[N]>) {
const _f = (...args: Events[N]) => {
this.off(name, _f)
f(...args)
}
this.on(name, _f)
}
off<N extends keyof Events>(name: N, f: EventHandler<Events[N]>) {
const observers = this._observers.get(name)
if (observers !== undefined) {
observers.delete(f as EventHandler<any[]>)
if (observers.size === 0) {
this._observers.delete(name)
}
}
}
emit<N extends keyof Events>(name: N, args: Events[N]) {
// copy all listeners to an array first to make sure that no event is emitted to listeners that
// are subscribed while the event handler is called.
return array
.from((this._observers.get(name) || map.create()).values())
.forEach((f) => f(...args))
}
destroy() {
this._observers = map.create()
}
}
// Partial compatibility with node 'events' module, for the purposes of @open-rpc/client-js.
// See vite.config.ts for details.
export class EventEmitter extends Emitter<EventMap> {
addListener(name: string, f: EventHandler) {
this.on(name, f)
}
removeListener(name: string, f: EventHandler) {
this.off(name, f)
}
removeAllListeners() {
this.destroy()
}
}

View File

@ -0,0 +1,94 @@
import { Client } from '@open-rpc/client-js'
import * as map from 'lib0/map'
import * as set from 'lib0/set'
import { SHA3 } from 'sha3'
import { Emitter } from './event'
import type {
Checksum,
FileEdit,
Notifications,
Path,
RegisterOptions,
response,
} from './languageServerTypes'
import type { Uuid } from './yjsModel'
export class LanguageServer extends Emitter<Notifications> {
client: Client
handlers: Map<string, Set<(...params: any[]) => void>>
constructor(client: Client) {
super()
this.client = client
this.handlers = new Map()
client.onNotification((notification) => {
this.emit(notification.method as keyof Notifications, [notification.params])
})
}
addEventListener<K extends keyof Notifications>(
type: K,
listener: (params: Notifications[K]) => void,
) {
const listeners = map.setIfUndefined(this.handlers, type as string, set.create)
listeners.add(listener)
}
removeEventListener<K extends keyof Notifications>(
type: K,
listener: (params: Notifications[K]) => void,
) {
const listeners = this.handlers.get(type as string)
if (listeners) {
listeners.delete(listener)
}
}
private request(method: string, params: object): Promise<any> {
return this.client.request({ method, params })
}
acquireCapability(method: string, registerOptions: RegisterOptions): Promise<void> {
return this.request('capability/acquire', { method, registerOptions })
}
acquireReceivesTreeUpdates(path: Path): Promise<void> {
return this.acquireCapability('file/receivesTreeUpdates', { path })
}
initProtocolConnection(clientId: Uuid): Promise<response.InitProtocolConnection> {
return this.request('session/initProtocolConnection', { clientId })
}
openTextFile(path: Path): Promise<response.OpenTextFile> {
return this.request('text/openFile', { path })
}
closeTextFile(path: Path): Promise<void> {
return this.request('text/closeFile', { path })
}
saveTextFile(path: Path, currentVersion: Checksum): Promise<void> {
return this.request('text/save', { path, currentVersion })
}
applyEdit(edit: FileEdit, execute: boolean): Promise<void> {
return this.request('text/applyEdit', { edit, execute })
}
listFiles(path: Path): Promise<response.FileList> {
return this.request('file/list', { path })
}
dispose() {
this.client.close()
}
}
export function computeTextChecksum(text: string): Checksum {
const hash = new SHA3(224)
hash.update(text)
return hash.digest('hex') as Checksum
}

View File

@ -0,0 +1,301 @@
import type { Uuid } from './yjsModel'
/** Version checksum of a text file - Sha3_224 */
declare const brandChecksum: unique symbol
export type Checksum = string & { [brandChecksum]: never }
export type ContextId = Uuid
export type ExpressionId = Uuid
export type ContentRoot =
| { type: 'Project'; id: Uuid }
| { type: 'FileSystemRoot'; id: Uuid; path: string }
| { type: 'Home'; id: Uuid }
| { type: 'Library'; id: Uuid; namespace: string; name: string; version: string }
| { type: 'Custom'; id: Uuid }
/** A path is a representation of a path relative to a specified content root. */
export interface Path {
/** Path's root id. */
rootId: Uuid
/** Path's segments. */
segments: string[]
}
export interface FileEdit {
path: Path
edits: TextEdit[]
oldVersion: Checksum
newVersion: Checksum
}
export interface TextEdit {
range: TextRange
text: string
}
export interface TextRange {
start: Position
end: Position
}
export interface Position {
line: number
character: number
}
export type RegisterOptions = { path: Path } | { contextId: ContextId } | {}
export interface CapabilityRegistration {
method: string
register_options: RegisterOptions
}
export type FileEventKind = 'Added' | 'Removed' | 'Modified'
export interface MethodCall {
/** The method pointer of a call. */
methodPointer: MethodPointer
/** Indexes of arguments that have not been applied to this method. */
notAppliedArguments: number[]
}
export type ExpressionUpdatePayload = Value | DataflowError | Panic | Pending
/**
* Indicates that the expression was computed to a value.
*/
export interface Value {
/**
* Information about attached warnings.
*/
warnings?: Warnings
/**
* The schema of returned function value.
*/
functionSchema?: FunctionSchema
}
/**
* Indicates that the expression was computed to an error.
*/
export interface DataflowError {
/**
* The list of expressions leading to the root error.
*/
trace: ExpressionId[]
}
/**
* Indicates that the expression failed with the runtime exception.
*/
export interface Panic {
/**
* The error message.
*/
message: string
/**
* The stack trace.
*/
trace: ExpressionId[]
}
/**
* Indicates the expression is currently being computed. Optionally it
* provides description and percentage (`0.0-1.0`) of completeness.
*/
export interface Pending {
/**
* Optional message describing current operation.
*/
message?: string
/**
* Optional amount of already done work as a number between `0.0` to `1.0`.
*/
progress?: Number
}
/**
* Information about warnings associated with the value.
*/
export interface Warnings {
/**
* The number of attached warnings.
*/
count: number
/**
* If the value has a single warning attached, this field contains textual
* representation of the attached warning. In general, warning values should
* be obtained by attaching an appropriate visualization to a value.
*/
value?: string
}
/**
* Contains a method pointer with information on the partially applied argument
* positions.
*/
export interface FunctionSchema {
/** The method pointer of this function. */
methodPointer: MethodPointer
/** Indexes of arguments that have not been applied to this function. */
notAppliedArguments: number[]
}
export interface MethodPointer {
/** The fully qualified module name. */
module: String
/** The type on which the method is defined. */
definedOnType: String
/** The method name. */
name: String
}
export type ProfilingInfo = ExecutionTime
interface ExecutionTime {
/** The time elapsed during the expression's evaluation, in nanoseconds */
nanoTime: Number
}
interface ExpressionUpdate {
/** The id of updated expression. */
expressionId: ExpressionId
/** The updated type of the expression. */
type?: String
/** The updated method call info. */
methodCall?: MethodCall
/** Profiling information about the expression. */
profilingInfo: ProfilingInfo[]
/** Wether or not the expression's value came from the cache. */
fromCache: boolean
/** An extra information about the computed value. */
payload: ExpressionUpdatePayload
}
interface StackTraceElement {
functionName: string
path?: Path
location?: TextRange
}
type DiagnosticType = 'Error' | 'Warning'
interface Diagnostic {
/**
* The type of diagnostic message.
*/
kind: DiagnosticType
/**
* The diagnostic message.
*/
message: String
/**
* The location of a file containing the diagnostic.
*/
path?: Path
/**
* The location of the diagnostic object in a file.
*/
location?: Range
/**
* The id of related expression.
*/
expressionId?: ExpressionId
/**
* The stack trace.
*/
stack: StackTraceElement[]
}
/** A representation of what kind of type a filesystem object can be. */
type FileSystemObject =
| {
type: 'Directory'
name: string
path: Path
}
/**
* A directory which contents have been truncated, i.e. with its subtree not listed any further
* due to depth limit being reached.
*/
| {
type: 'DirectoryTruncated'
name: string
path: Path
}
| {
type: 'File'
name: string
path: Path
}
/** Represents other, potentially unrecognized object. Example is a broken symbolic link. */
| {
type: 'Other'
name: string
path: Path
}
/** Represents a symbolic link that creates a loop. */
| {
type: 'SymlinkLoop'
name: string
path: Path
/** A target of the symlink. Since it is a loop, target is a subpath of the symlink. */
target: Path
}
interface VisualizationContext {}
export type Notifications = {
'file/event': [{ path: Path; kind: FileEventKind }]
'text/autoSave': [{ path: Path }]
'text/didChange': [{ edits: FileEdit[] }]
'text/fileModifiedOnDisk': [{ path: Path }]
'executionContext/expressionUpdates': [{ contextId: ContextId; updates: ExpressionUpdate[] }]
'executionContext/executionFailed': [{ contextId: ContextId; message: string }]
'executionContext/executionComplete': [{ contextId: ContextId }]
'executionContext/executionStatus': [{ contextId: ContextId; diagnostics: Diagnostic[] }]
'search/suggestionsDatabaseUpdate': [{}]
'file/rootAdded': [{}]
'file/rootRemoved': [{}]
'executionContext/visualizationEvaluationFailed': [
{
contextId: ContextId
visualizationId: Uuid
expressionId: ExpressionId
message: String
diagnostic?: Diagnostic
},
]
'refactoring/projectRenamed': [{}]
}
export namespace response {
export interface OpenTextFile {
writeCapability: CapabilityRegistration | null
content: string
currentVersion: Checksum
}
export interface InitProtocolConnection {
contentRoots: ContentRoot[]
}
export interface FileList {
paths: FileSystemObject[]
}
export interface VisualizationUpdate {
context: VisualizationContext
data: Uint8Array
}
}

View File

@ -1,361 +0,0 @@
import * as y from 'yjs'
import { setIfUndefined } from 'lib0/map'
import { isSome, type Opt } from '@/util/opt'
export type Uuid = ReturnType<typeof crypto.randomUUID>
declare const brandExprId: unique symbol
export type ExprId = Uuid & { [brandExprId]: never }
export const NULL_EXPR_ID: ExprId = '00000000-0000-0000-0000-000000000000' as ExprId
export interface NodeMetadata {
x: number
y: number
vis?: string
}
const enum NamedDocKey {
NAME = 'name',
DOC = 'doc',
}
interface NamedDoc {
name: y.Text
doc: y.Doc
}
type NamedDocMap = y.Map<y.Text | y.Doc>
export class NamedDocArray {
array: y.Array<NamedDocMap>
nameToIndex: Map<string, y.RelativePosition[]>
constructor(doc: y.Doc, name: string) {
this.array = doc.getArray(name)
this.nameToIndex = new Map()
this.array.forEach(this.integrateAddedItem.bind(this))
}
private integrateAddedItem(item: NamedDocMap, index: number): void {
const name = item.get(NamedDocKey.NAME)?.toString()
if (name == null) return
const indices = setIfUndefined(this.nameToIndex, name, () => [] as y.RelativePosition[])
indices.push(y.createRelativePositionFromTypeIndex(this.array, index))
}
names(): string[] {
return this.array.map((item) => item.get(NamedDocKey.NAME)?.toString()).filter(isSome)
}
createNew(name: string): NamedDoc {
const map = new y.Map<y.Text | y.Doc>()
const yName = map.set(NamedDocKey.NAME, new y.Text(name))
const doc = map.set(NamedDocKey.DOC, new y.Doc())
this.array.push([map])
this.integrateAddedItem(map, this.array.length - 1)
return { name: yName, doc }
}
open(name: string): NamedDoc | undefined {
const index = this.names().indexOf(name)
if (index < 0) return
const item = this.array.get(index)
if (item == null) return
const yName = item.get(NamedDocKey.NAME)
const doc = item.get(NamedDocKey.DOC)
if (!(yName instanceof y.Text && doc instanceof y.Doc)) return
return { name: yName, doc }
}
delete(name: string): void {
const relPos = this.nameToIndex.get(name)
if (relPos == null) return
const pos = y.createAbsolutePositionFromRelativePosition(relPos[0], this.array.doc!)
if (pos == null) return
this.array.delete(pos.index, 1)
this.nameToIndex.delete(name)
}
dispose(): void {}
}
export class DistributedModel {
doc: y.Doc
projects: NamedDocArray
constructor(doc: y.Doc) {
this.doc = doc
this.projects = new NamedDocArray(this.doc, 'projects')
}
projectNames(): string[] {
return this.projects.names()
}
async openProject(name: string): Promise<Opt<DistributedProject>> {
await this.doc.whenLoaded
const pair = this.projects.open(name)
if (pair == null) return null
return await DistributedProject.load(pair)
}
async createNewProject(name: string): Promise<DistributedProject> {
await this.doc.whenLoaded
const pair = this.projects.createNew(name)
return await DistributedProject.load(pair)
}
async openOrCreateProject(name: string): Promise<DistributedProject> {
return (await this.openProject(name)) ?? (await this.createNewProject(name))
}
deleteProject(name: string): void {
this.projects.delete(name)
}
dispose(): void {
this.projects.dispose()
}
}
export class DistributedProject {
doc: y.Doc
name: y.Text
modules: NamedDocArray
static async load(pair: NamedDoc) {
const project = new DistributedProject(pair)
project.doc.load()
await project.doc.whenLoaded
return project
}
private constructor(pair: NamedDoc) {
this.doc = pair.doc
this.name = pair.name
this.modules = new NamedDocArray(this.doc, 'modules')
}
moduleNames(): string[] {
return this.modules.names()
}
async openModule(name: string): Promise<DistributedModule | null> {
const pair = this.modules.open(name)
if (pair == null) return null
return await DistributedModule.load(pair)
}
async createNewModule(name: string): Promise<DistributedModule> {
const pair = this.modules.createNew(name)
return await DistributedModule.load(pair)
}
async openOrCreateModule(name: string): Promise<DistributedModule> {
return (await this.openModule(name)) ?? (await this.createNewModule(name))
}
deleteModule(name: string): void {
this.modules.delete(name)
}
}
class DistributedModule {
name: y.Text
doc: y.Doc
contents: y.Text
idMap: y.Map<[any, any]>
metadata: y.Map<NodeMetadata>
static async load(pair: NamedDoc) {
const module = new DistributedModule(pair)
module.doc.load()
await module.doc.whenLoaded
return module
}
private constructor(pair: NamedDoc) {
this.name = pair.name
this.doc = pair.doc
this.contents = this.doc.getText('contents')
this.idMap = this.doc.getMap('idMap')
this.metadata = this.doc.getMap('metadata')
}
insertNewNode(offset: number, content: string, meta: NodeMetadata): ExprId {
const range = [offset, offset + content.length]
const newId = crypto.randomUUID() as ExprId
this.doc.transact(() => {
this.contents.insert(offset, content + '\n')
const start = y.createRelativePositionFromTypeIndex(this.contents, range[0])
const end = y.createRelativePositionFromTypeIndex(this.contents, range[1])
const startJson = y.relativePositionToJSON(start)
const endJson = y.relativePositionToJSON(end)
this.idMap.set(newId, [startJson, endJson])
this.metadata.set(newId, meta)
})
return newId
}
replaceExpressionContent(id: ExprId, content: string, range?: ContentRange): void {
const exprRangeJson = this.idMap.get(id)
if (exprRangeJson == null) return
const exprRange = [
y.createRelativePositionFromJSON(exprRangeJson[0]),
y.createRelativePositionFromJSON(exprRangeJson[1]),
]
const exprStart = y.createAbsolutePositionFromRelativePosition(exprRange[0], this.doc)?.index
const exprEnd = y.createAbsolutePositionFromRelativePosition(exprRange[1], this.doc)?.index
if (exprStart == null || exprEnd == null) return
const start = range == null ? exprStart : exprStart + range[0]
const end = range == null ? exprEnd : exprStart + range[1]
if (start > end) throw new Error('Invalid range')
if (start < exprStart || end > exprEnd) throw new Error('Range out of bounds')
this.doc.transact(() => {
if (content.length > 0) {
this.contents.insert(start, content)
}
if (start !== end) {
this.contents.delete(start + content.length, end - start)
}
})
}
updateNodeMetadata(id: ExprId, meta: Partial<NodeMetadata>): void {
const existing = this.metadata.get(id) ?? { x: 0, y: 0 }
this.metadata.set(id, { ...existing, ...meta })
}
getIdMap(): IdMap {
return new IdMap(this.idMap, this.contents)
}
dispose(): void {
this.doc.destroy()
}
}
export interface RelativeRange {
start: y.RelativePosition
end: y.RelativePosition
}
/**
* Accessor for the ID map stored in shared yjs map as relative ranges. Synchronizes the ranges
* that were accessed during parsing, throws away stale ones. The text contents is used to translate
* the relative ranges to absolute ranges, but it is not modified.
*/
export class IdMap {
private contents: y.Text
private doc: y.Doc
private yMap: y.Map<[any, any]>
private rangeToExpr: Map<string, ExprId>
private accessed: Set<ExprId>
private finished: boolean
constructor(yMap: y.Map<[any, any]>, contents: y.Text) {
if (yMap.doc == null) {
throw new Error('IdMap must be associated with a document')
}
this.doc = yMap.doc
this.contents = contents
this.yMap = yMap
this.rangeToExpr = new Map()
this.accessed = new Set()
yMap.forEach((range, expr) => {
if (!isUuid(expr)) return
const indices = this.modelToIndices(range)
if (indices == null) return
this.rangeToExpr.set(IdMap.keyForRange(indices), expr as ExprId)
})
this.finished = false
}
private static keyForRange(range: [number, number]): string {
return `${range[0]}:${range[1]}`
}
private modelToIndices(range: [any, any]): [number, number] | null {
const relStart = y.createRelativePositionFromJSON(range[0])
const relEnd = y.createRelativePositionFromJSON(range[1])
const start = y.createAbsolutePositionFromRelativePosition(relStart, this.doc)
const end = y.createAbsolutePositionFromRelativePosition(relEnd, this.doc)
if (start == null || end == null) return null
return [start.index, end.index]
}
getOrInsertUniqueId(range: [number, number]): ExprId {
if (this.finished) {
throw new Error('IdMap already finished')
}
const key = IdMap.keyForRange(range)
const val = this.rangeToExpr.get(key)
if (val !== undefined) {
this.accessed.add(val)
return val
} else {
const newId = crypto.randomUUID() as ExprId
this.rangeToExpr.set(key, newId)
this.accessed.add(newId)
return newId
}
}
accessedSoFar(): ReadonlySet<ExprId> {
return this.accessed
}
/**
* Finish accessing or modifying ID map. Synchronizes the accessed keys back to the shared map,
* removes keys that were present previously, but were not accessed.
*
* Can be called at most once. After calling this method, the ID map is no longer usable.
*/
finishAndSynchronize(): void {
if (this.finished) {
throw new Error('IdMap already finished')
}
this.finished = true
const doc = this.doc
doc.transact(() => {
this.yMap.forEach((currentRange, expr) => {
// Expressions that were accessed and present in the map are guaranteed to match. There is
// no mechanism for modifying them, so we don't need to check for equality. We only need to
// delete the expressions ones that are not used anymore.
if (!this.accessed.delete(expr as ExprId)) {
this.yMap.delete(expr)
}
})
this.rangeToExpr.forEach((expr, key) => {
// For all remaining expressions, we need to write them into the map.
if (!this.accessed.has(expr)) return
const range = key.split(':').map((x) => parseInt(x, 10)) as [number, number]
const start = y.createRelativePositionFromTypeIndex(this.contents, range[0])
const end = y.createRelativePositionFromTypeIndex(this.contents, range[1])
const startJson = y.relativePositionToJSON(start)
const endJson = y.relativePositionToJSON(end)
this.yMap.set(expr, [startJson, endJson])
})
})
}
}
const uuidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/
export function isUuid(x: any): x is Uuid {
return typeof x === 'string' && x.length === 36 && uuidRegex.test(x)
}
export type ContentRange = [number, number]
export function rangeEncloses(a: ContentRange, b: ContentRange): boolean {
return a[0] <= b[0] && a[1] >= b[1]
}
export function rangeIntersects(a: ContentRange, b: ContentRange): boolean {
return a[0] <= b[1] && a[1] >= b[0]
}

355
app/gui2/shared/yjsModel.ts Normal file
View File

@ -0,0 +1,355 @@
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import * as Y from 'yjs'
export type Uuid = `${string}-${string}-${string}-${string}-${string}`
declare const brandExprId: unique symbol
export type ExprId = Uuid & { [brandExprId]: never }
export const NULL_EXPR_ID: ExprId = '00000000-0000-0000-0000-000000000000' as ExprId
export interface NodeMetadata {
x: number
y: number
vis?: string
}
export class DistributedProject {
doc: Y.Doc
name: Y.Text
modules: Y.Map<Y.Doc>
constructor(doc: Y.Doc) {
this.doc = doc
this.name = this.doc.getText('name')
this.modules = this.doc.getMap('modules')
}
moduleNames(): string[] {
return Array.from(this.modules.keys())
}
findModuleByDocId(id: string): string | null {
for (const [name, doc] of this.modules.entries()) {
if (doc.guid === id) return name
}
return null
}
async openModule(name: string): Promise<DistributedModule | null> {
const doc = this.modules.get(name)
if (doc == null) return null
return await DistributedModule.load(doc)
}
openUnloadedModule(name: string): DistributedModule | null {
const doc = this.modules.get(name)
if (doc == null) return null
return new DistributedModule(doc)
}
createUnloadedModule(name: string, doc: Y.Doc): DistributedModule {
this.modules.set(name, doc)
return new DistributedModule(doc)
}
async createNewModule(name: string): Promise<DistributedModule> {
return this.createUnloadedModule(name, new Y.Doc())
}
async openOrCreateModule(name: string): Promise<DistributedModule> {
return (await this.openModule(name)) ?? (await this.createNewModule(name))
}
deleteModule(name: string): void {
this.modules.delete(name)
}
dispose(): void {
this.doc.destroy()
}
}
export class DistributedModule {
doc: Y.Doc
contents: Y.Text
idMap: Y.Map<Uint8Array>
metadata: Y.Map<NodeMetadata>
static async load(doc: Y.Doc) {
doc.load()
await doc.whenLoaded
return new DistributedModule(doc)
}
constructor(doc: Y.Doc) {
this.doc = doc
this.contents = this.doc.getText('contents')
this.idMap = this.doc.getMap('idMap')
this.metadata = this.doc.getMap('metadata')
}
insertNewNode(offset: number, content: string, meta: NodeMetadata): ExprId {
const range = [offset, offset + content.length]
const newId = crypto.randomUUID() as ExprId
this.doc.transact(() => {
this.contents.insert(offset, content + '\n')
const start = Y.createRelativePositionFromTypeIndex(this.contents, range[0])
const end = Y.createRelativePositionFromTypeIndex(this.contents, range[1])
this.idMap.set(newId, encodeRange([start, end]))
this.metadata.set(newId, meta)
})
return newId
}
replaceExpressionContent(id: ExprId, content: string, range?: ContentRange): void {
const rangeBuffer = this.idMap.get(id)
if (rangeBuffer == null) return
const [relStart, relEnd] = decodeRange(rangeBuffer)
const exprStart = Y.createAbsolutePositionFromRelativePosition(relStart, this.doc)?.index
const exprEnd = Y.createAbsolutePositionFromRelativePosition(relEnd, this.doc)?.index
if (exprStart == null || exprEnd == null) return
const start = range == null ? exprStart : exprStart + range[0]
const end = range == null ? exprEnd : exprStart + range[1]
if (start > end) throw new Error('Invalid range')
if (start < exprStart || end > exprEnd) throw new Error('Range out of bounds')
this.doc.transact(() => {
if (content.length > 0) {
this.contents.insert(start, content)
}
if (start !== end) {
this.contents.delete(start + content.length, end - start)
}
})
}
updateNodeMetadata(id: ExprId, meta: Partial<NodeMetadata>): void {
const existing = this.metadata.get(id) ?? { x: 0, y: 0 }
this.metadata.set(id, { ...existing, ...meta })
}
getIdMap(): IdMap {
return new IdMap(this.idMap, this.contents)
}
dispose(): void {
this.doc.destroy()
}
}
export type RelativeRange = [Y.RelativePosition, Y.RelativePosition]
/**
* Accessor for the ID map stored in shared yjs map as relative ranges. Synchronizes the ranges
* that were accessed during parsing, throws away stale ones. The text contents is used to translate
* the relative ranges to absolute ranges, but it is not modified.
*/
export class IdMap {
private contents: Y.Text
private doc: Y.Doc
private yMap: Y.Map<Uint8Array>
private rangeToExpr: Map<string, ExprId>
private accessed: Set<ExprId>
private finished: boolean
constructor(yMap: Y.Map<Uint8Array>, contents: Y.Text) {
if (yMap.doc == null) {
throw new Error('IdMap must be associated with a document')
}
this.doc = yMap.doc
this.contents = contents
this.yMap = yMap
this.rangeToExpr = new Map()
this.accessed = new Set()
yMap.forEach((rangeBuffer, expr) => {
if (!(isUuid(expr) && rangeBuffer instanceof Uint8Array)) return
const indices = this.modelToIndices(rangeBuffer)
if (indices == null) return
this.rangeToExpr.set(IdMap.keyForRange(indices), expr as ExprId)
})
this.finished = false
}
private static keyForRange(range: [number, number]): string {
return `${range[0].toString(16)}:${range[1].toString(16)}`
}
private static rangeForKey(key: string): [number, number] {
return key.split(':').map((x) => parseInt(x, 16)) as [number, number]
}
private modelToIndices(rangeBuffer: Uint8Array): [number, number] | null {
const [relStart, relEnd] = decodeRange(rangeBuffer)
const start = Y.createAbsolutePositionFromRelativePosition(relStart, this.doc)
const end = Y.createAbsolutePositionFromRelativePosition(relEnd, this.doc)
if (start == null || end == null) return null
return [start.index, end.index]
}
insertKnownId(range: [number, number], id: ExprId) {
if (this.finished) {
throw new Error('IdMap already finished')
}
const key = IdMap.keyForRange(range)
this.rangeToExpr.set(key, id)
this.accessed.add(id)
}
getOrInsertUniqueId(range: [number, number]): ExprId {
if (this.finished) {
throw new Error('IdMap already finished')
}
const key = IdMap.keyForRange(range)
const val = this.rangeToExpr.get(key)
if (val !== undefined) {
this.accessed.add(val)
return val
} else {
const newId = crypto.randomUUID() as ExprId
this.rangeToExpr.set(key, newId)
this.accessed.add(newId)
return newId
}
}
accessedSoFar(): ReadonlySet<ExprId> {
return this.accessed
}
toRawRanges(): Record<string, [number, number]> {
const ranges: Record<string, [number, number]> = {}
for (const [key, expr] of this.rangeToExpr.entries()) {
ranges[expr] = IdMap.rangeForKey(key)
}
return ranges
}
/**
* Finish accessing or modifying ID map. Synchronizes the accessed keys back to the shared map,
* removes keys that were present previously, but were not accessed.
*
* Can be called at most once. After calling this method, the ID map is no longer usable.
*/
finishAndSynchronize(): void {
if (this.finished) {
throw new Error('IdMap already finished')
}
this.finished = true
const doc = this.doc
doc.transact(() => {
this.yMap.forEach((_, expr) => {
// Expressions that were accessed and present in the map are guaranteed to match. There is
// no mechanism for modifying them, so we don't need to check for equality. We only need to
// delete the expressions ones that are not used anymore.
if (!this.accessed.delete(expr as ExprId)) {
this.yMap.delete(expr)
}
})
this.rangeToExpr.forEach((expr, key) => {
// For all remaining expressions, we need to write them into the map.
if (!this.accessed.has(expr)) return
const range = IdMap.rangeForKey(key)
const start = Y.createRelativePositionFromTypeIndex(this.contents, range[0])
const end = Y.createRelativePositionFromTypeIndex(this.contents, range[1])
const encoded = encodeRange([start, end])
this.yMap.set(expr, encoded)
})
})
}
}
function encodeRange(range: RelativeRange): Uint8Array {
const encoder = encoding.createEncoder()
const start = Y.encodeRelativePosition(range[0])
const end = Y.encodeRelativePosition(range[1])
encoding.writeUint8(encoder, start.length)
encoding.writeUint8Array(encoder, start)
encoding.writeUint8(encoder, end.length)
encoding.writeUint8Array(encoder, end)
return encoding.toUint8Array(encoder)
}
function decodeRange(buffer: Uint8Array): RelativeRange {
const decoder = decoding.createDecoder(buffer)
const startLen = decoding.readUint8(decoder)
const start = decoding.readUint8Array(decoder, startLen)
const endLen = decoding.readUint8(decoder)
const end = decoding.readUint8Array(decoder, endLen)
return [Y.decodeRelativePosition(start), Y.decodeRelativePosition(end)]
}
const uuidRegex = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/
export function isUuid(x: any): x is Uuid {
return typeof x === 'string' && x.length === 36 && uuidRegex.test(x)
}
export type ContentRange = [number, number]
export function rangeEncloses(a: ContentRange, b: ContentRange): boolean {
return a[0] <= b[0] && a[1] >= b[1]
}
export function rangeIntersects(a: ContentRange, b: ContentRange): boolean {
return a[0] <= b[1] && a[1] >= b[0]
}
if (import.meta.vitest) {
const { test, expect } = import.meta.vitest
type RangeTest = { a: ContentRange; b: ContentRange }
const equalRanges: RangeTest[] = [
{ a: [0, 0], b: [0, 0] },
{ a: [0, 1], b: [0, 1] },
{ a: [-5, 5], b: [-5, 5] },
]
const totalOverlap: RangeTest[] = [
{ a: [0, 1], b: [0, 0] },
{ a: [0, 2], b: [2, 2] },
{ a: [-1, 1], b: [1, 1] },
{ a: [0, 2], b: [0, 1] },
{ a: [-10, 10], b: [-3, 7] },
{ a: [0, 5], b: [1, 2] },
{ a: [3, 5], b: [3, 4] },
]
const reverseTotalOverlap: RangeTest[] = totalOverlap.map(({ a, b }) => ({ a: b, b: a }))
const noOverlap: RangeTest[] = [
{ a: [0, 1], b: [2, 3] },
{ a: [0, 1], b: [-1, -1] },
{ a: [5, 6], b: [2, 3] },
{ a: [0, 2], b: [-2, -1] },
{ a: [-5, -3], b: [9, 10] },
{ a: [-3, 2], b: [3, 4] },
]
const partialOverlap: RangeTest[] = [
{ a: [0, 3], b: [-1, 1] },
{ a: [0, 1], b: [-1, 0] },
{ a: [0, 0], b: [-1, 0] },
{ a: [0, 2], b: [1, 4] },
{ a: [-8, 0], b: [0, 10] },
]
test.each([...equalRanges, ...totalOverlap])('Range $a should enclose $b', ({ a, b }) =>
expect(rangeEncloses(a, b)).toBe(true),
)
test.each([...noOverlap, ...partialOverlap, ...reverseTotalOverlap])(
'Range $a should not enclose $b',
({ a, b }) => expect(rangeEncloses(a, b)).toBe(false),
)
test.each([...equalRanges, ...totalOverlap, ...reverseTotalOverlap, ...partialOverlap])(
'Range $a should intersect $b',
({ a, b }) => expect(rangeIntersects(a, b)).toBe(true),
)
test.each([...noOverlap])('Range $a should not intersect $b', ({ a, b }) =>
expect(rangeIntersects(a, b)).toBe(false),
)
}

View File

@ -1,7 +1,14 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { provideGuiConfig, type GuiConfig } from '@/providers/guiConfig'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import ProjectView from '@/views/ProjectView.vue'
import { onMounted, toRef } from 'vue'
const props = defineProps<{
config: GuiConfig
}>()
provideGuiConfig(toRef(props, 'config'))
onMounted(() => useSuggestionDbStore().initializeDb())
</script>

View File

@ -71,9 +71,22 @@ body {
color: var(--color-text);
/* TEMPORARY. Will be replaced with actual background when it is integrated with the dashboard. */
background: #e4d4be;
transition: color 0.5s, background-color 0.5s;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
transition:
color 0.5s,
background-color 0.5s;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 11.5px;
font-weight: 500;
line-height: 174.5%;

View File

@ -9,4 +9,5 @@ body {
flex-direction: column;
flex: 1;
font-weight: normal;
cursor: default;
}

View File

@ -1,14 +1,14 @@
<script setup lang="ts">
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
import { Filtering } from '@/components/ComponentBrowser/filtering'
import { default as SvgIcon } from '@/components/SvgIcon.vue'
import { default as ToggleIcon } from '@/components/ToggleIcon.vue'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { useApproach } from '@/util/animation'
import { useResizeObserver } from '@/util/events'
import { type Component, makeComponentList } from '@/components/ComponentBrowser/component'
import type { useNavigator } from '@/util/navigator'
import { Vec2 } from '@/util/vec2'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import SvgIcon from '@/components/SvgIcon.vue'
import ToggleIcon from '@/components/ToggleIcon.vue'
import { useApproach } from '@/util/animation'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { Filtering } from '@/components/ComponentBrowser/filtering'
const ITEM_SIZE = 32
const TOP_BAR_HEIGHT = 32

View File

@ -1,5 +1,10 @@
import { expect, test } from 'vitest'
import {
compareSuggestions,
labelOfEntry,
type MatchedSuggestion,
} from '@/components/ComponentBrowser/component'
import {
makeCon,
makeMethod,
@ -7,13 +12,8 @@ import {
makeModuleMethod,
makeStaticMethod,
} from '@/stores/suggestionDatabase/entry'
import {
compareSuggestions,
labelOfEntry,
type MatchedSuggestion,
} from '@/components/ComponentBrowser/component'
import { Filtering } from '../filtering'
import shuffleSeed from 'shuffle-seed'
import { Filtering } from '../filtering'
test.each([
[makeModuleMethod('Standard.Base.Data.read'), 'Data.read'],

View File

@ -10,8 +10,8 @@ import {
makeStaticMethod,
makeType,
} from '@/stores/suggestionDatabase/entry'
import { Filtering } from '../filtering'
import type { QualifiedName } from '@/util/qualifiedName'
import { Filtering } from '../filtering'
test.each([
{ ...makeModuleMethod('Standard.Base.Data.read'), groupIndex: 0 },

View File

@ -1,13 +1,13 @@
import { SuggestionDb } from '@/stores/suggestionDatabase'
import {
SuggestionKind,
type SuggestionEntry,
type SuggestionId,
} from '@/stores/suggestionDatabase/entry'
import { SuggestionDb } from '@/stores/suggestionDatabase'
import { Filtering, type MatchResult } from './filtering'
import { qnIsTopElement, qnLastSegment } from '@/util/qualifiedName'
import { compareOpt } from '@/util/compare'
import { isSome } from '@/util/opt'
import { qnIsTopElement, qnLastSegment } from '@/util/qualifiedName'
import { Filtering, type MatchResult } from './filtering'
export interface Component {
suggestionId: SuggestionId

View File

@ -108,8 +108,8 @@ class FilteringWithPattern {
if (this.initialsMatchRegex.test(entry.name)) {
return { score: MatchTypeScore.NameInitialMatch }
}
const matchedAliasInitials = entry.aliases.find((alias) =>
this.initialsMatchRegex?.test(alias),
const matchedAliasInitials = entry.aliases.find(
(alias) => this.initialsMatchRegex?.test(alias),
)
if (matchedAliasInitials) {
return { matchedAlias: matchedAliasInitials, score: MatchTypeScore.AliasInitialMatch }

View File

@ -2,7 +2,7 @@
import type { Edge } from '@/stores/graph'
import type { Rect } from '@/stores/rect'
import { clamp } from '@vueuse/core'
import type { ExprId } from 'shared/yjs-model'
import type { ExprId } from 'shared/yjsModel'
import { computed } from 'vue'
const props = defineProps<{

View File

@ -5,12 +5,13 @@ import GraphNode from '@/components/GraphNode.vue'
import TopBar from '@/components/TopBar.vue'
import { useGraphStore } from '@/stores/graph'
import { useProjectStore } from '@/stores/project'
import type { Rect } from '@/stores/rect'
import { useWindowEvent } from '@/util/events'
import { modKey, useWindowEvent } from '@/util/events'
import { useNavigator } from '@/util/navigator'
import { Vec2 } from '@/util/vec2'
import type { ContentRange, ExprId } from 'shared/yjs-model'
import { reactive, ref, watchEffect } from 'vue'
import type { ContentRange, ExprId } from 'shared/yjsModel'
import { reactive, ref } from 'vue'
const EXECUTION_MODES = ['design', 'live']
@ -19,13 +20,10 @@ const mode = ref('design')
const viewportNode = ref<HTMLElement>()
const navigator = useNavigator(viewportNode)
const graphStore = useGraphStore()
const projectStore = useProjectStore()
const componentBrowserVisible = ref(false)
const componentBrowserPosition = ref(Vec2.Zero())
watchEffect(() => {
console.log(`execution mode changed to '${mode.value}'.`)
})
const nodeRects = reactive(new Map<ExprId, Rect>())
const exprRects = reactive(new Map<ExprId, Rect>())
@ -53,20 +51,28 @@ function keyboardBusy() {
useWindowEvent('keydown', (e) => {
if (keyboardBusy()) return
const pos = navigator.sceneMousePos
if (pos == null) return
switch (e.key) {
case 'Enter':
if (!componentBrowserVisible.value) {
componentBrowserPosition.value = pos
componentBrowserVisible.value = true
if (modKey(e)) {
switch (e.key) {
case 'z':
projectStore.undoManager.undo()
break
case 'y':
projectStore.undoManager.redo()
break
}
} else {
switch (e.key) {
case 'Enter':
if (pos != null && !componentBrowserVisible.value) {
componentBrowserPosition.value = pos
componentBrowserVisible.value = true
}
break
case 'n': {
if (pos != null) graphStore.createNode(pos, 'hello "world"! 123 + x')
break
}
break
case 'n': {
const n = graphStore.createNode(pos)
if (n == null) return
graphStore.setNodeContent(n, 'hello "world"! 123 + x')
break
}
}
})
@ -130,7 +136,7 @@ function moveNode(id: ExprId, delta: Vec2) {
.viewport {
position: relative;
contain: layout;
overflow: hidden;
overflow: clip;
}
svg {

View File

@ -1,12 +1,12 @@
<script setup lang="ts">
import NodeSpan from '@/components/NodeSpan.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import type { Node } from '@/stores/graph'
import { Rect } from '@/stores/rect'
import { usePointer, useResizeObserver } from '@/util/events'
import { computed, onUpdated, reactive, ref, watch, watchEffect } from 'vue'
import type { ContentRange, ExprId } from 'shared/yjs-model'
import type { Vec2 } from '@/util/vec2'
import type { ContentRange, ExprId } from 'shared/yjsModel'
import { computed, onUpdated, reactive, ref, watch, watchEffect } from 'vue'
const props = defineProps<{
node: Node
@ -261,7 +261,7 @@ function handleClick(e: PointerEvent) {
:class="{ dragging: dragPointer.dragging }"
v-on="dragPointer.events"
>
<div class="icon" @pointerdown="handleClick">@ &nbsp;</div>
<SvgIcon class="icon" name="number_input" @pointerdown="handleClick"></SvgIcon>
<div class="binding" @pointerdown.stop>{{ node.binding }}</div>
<div
ref="editableRootNode"
@ -283,6 +283,7 @@ function handleClick(e: PointerEvent) {
<style scoped>
.Node {
color: red;
position: absolute;
top: 0;
left: 0;
@ -300,6 +301,7 @@ function handleClick(e: PointerEvent) {
.binding {
margin-right: 10px;
color: black;
position: absolute;
right: 100%;
top: 50%;
@ -314,6 +316,7 @@ function handleClick(e: PointerEvent) {
.icon {
cursor: grab;
margin-right: 10px;
}
.Node.dragging,

View File

@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@ -4,7 +4,7 @@ const emit = defineEmits<{ click: [] }>()
</script>
<template>
<div class="NavBreadcrumb"><span @click="emit('click')" v-text="text"></span></div>
<div class="NavBreadcrumb"><span @click="emit('click')" v-text="props.text"></span></div>
</template>
<style scoped>

View File

@ -3,7 +3,7 @@ import { spanKindName, type Span } from '@/stores/graph'
import { Rect } from '@/stores/rect'
import { useResizeObserver } from '@/util/events'
import { Vec2 } from '@/util/vec2'
import type { ExprId } from 'shared/yjs-model'
import type { ExprId } from 'shared/yjsModel'
import { computed, onUpdated, ref, shallowRef, watch } from 'vue'
const props = defineProps<{

View File

@ -7,10 +7,10 @@ const emit = defineEmits<{ execute: []; 'update:mode': [mode: string] }>()
<template>
<div class="ProjectTitle">
<span class="title" v-text="title"></span>
<span class="title" v-text="props.title"></span>
<ExecutionModeSelector
:modes="modes"
:model-value="mode"
:modes="props.modes"
:model-value="props.mode"
@execute="emit('execute')"
@update:modelValue="emit('update:mode', $event)"
/>

View File

@ -1,6 +1,8 @@
<script setup lang="ts">
import NavBar from '@/components/NavBar.vue'
import ProjectTitle from '@/components/ProjectTitle.vue'
import { useGuiConfig } from '@/providers/guiConfig'
import { computed } from 'vue'
const props = defineProps<{ title: string; breadcrumbs: string[]; modes: string[]; mode: string }>()
const emit = defineEmits<{
@ -10,19 +12,28 @@ const emit = defineEmits<{
breadcrumbClick: [index: number]
'update:mode': [mode: string]
}>()
const config = useGuiConfig()
const barStyle = computed(() => {
const offset = config.value.window?.topBarOffset ?? '0'
return {
marginLeft: `${offset}px`,
}
})
</script>
<template>
<div class="TopBar">
<div class="TopBar" :style="barStyle">
<ProjectTitle
:title="title"
:modes="modes"
:mode="mode"
:title="props.title"
:modes="props.modes"
:mode="props.mode"
@update:mode="emit('update:mode', $event)"
@execute="emit('execute')"
/>
<NavBar
:breadcrumbs="breadcrumbs"
:breadcrumbs="props.breadcrumbs"
@back="emit('back')"
@forward="emit('forward')"
@breadcrumbClick="emit('breadcrumbClick', $event)"
@ -40,3 +51,4 @@ const emit = defineEmits<{
left: 9px;
}
</style>
@/providers/guiConfig

View File

@ -4,8 +4,8 @@ const emit = defineEmits<{ 'update:modelValue': [modelValue: boolean] }>()
</script>
<template>
<div class="Checkbox" @click="emit('update:modelValue', !modelValue)">
<div :class="{ hidden: !modelValue }"></div>
<div class="Checkbox" @click="emit('update:modelValue', !props.modelValue)">
<div :class="{ hidden: !props.modelValue }"></div>
</div>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { PointerButtonMask, usePointer } from '@/util/events'
import { computed, ref } from 'vue'
const props = defineProps<{ modelValue: number; min: number; max: number }>()
const emit = defineEmits<{ 'update:modelValue': [modelValue: number] }>()

View File

@ -1,11 +1,90 @@
import 'enso-dashboard/src/tailwind.css'
const INITIAL_URL_KEY = `Enso-initial-url`
import './assets/main.css'
import { createApp } from 'vue'
import { isMac } from 'lib0/environment'
import { decodeQueryParams } from 'lib0/url'
import { createPinia } from 'pinia'
import { createApp, type App } from 'vue'
import AppRoot from './App.vue'
import App from './App.vue'
const params = decodeQueryParams(location.href)
const app = createApp(App)
app.use(createPinia())
// Temporary hardcode
const config = {
supportsLocalBackend: true,
supportsDeepLinks: isMac,
shouldUseAuthentication: false,
projectManagerUrl: PROJECT_MANAGER_URL,
initialProjectName: params.project ?? null,
}
app.mount('#app')
let app: App | null = null
interface StringConfig {
[key: string]: StringConfig | string
}
async function runApp(config: StringConfig | null, accessToken: string | null, metadata?: object) {
if (app != null) stopApp()
const rootProps = { config, accessToken, metadata }
app = createApp(AppRoot, rootProps)
app.use(createPinia())
app.mount('#app')
}
function stopApp() {
if (app == null) return
app.unmount()
app = null
}
const appRunner = { runApp, stopApp }
const dashboard = await import('enso-authentication')
/** The entrypoint into the IDE. */
function main() {
/** Note: Signing out always redirects to `/`. It is impossible to make this work,
* as it is not possible to distinguish between having just logged out, and explicitly
* opening a page with no URL parameters set.
*
* Client-side routing endpoints are explicitly not supported for live-reload, as they are
* transitional pages that should not need live-reload when running `gui watch`. */
const url = new URL(location.href)
const isInAuthenticationFlow = url.searchParams.has('code') && url.searchParams.has('state')
const authenticationUrl = location.href
if (isInAuthenticationFlow) {
history.replaceState(null, '', localStorage.getItem(INITIAL_URL_KEY))
}
// const configOptions = OPTIONS.clone()
if (isInAuthenticationFlow) {
history.replaceState(null, '', authenticationUrl)
} else {
localStorage.setItem(INITIAL_URL_KEY, location.href)
}
dashboard.run({
appRunner,
logger: console,
supportsLocalBackend: true, // TODO
supportsDeepLinks: false, // TODO
projectManagerUrl: config.projectManagerUrl,
isAuthenticationDisabled: !config.shouldUseAuthentication,
shouldShowDashboard: true,
initialProjectName: config.initialProjectName,
onAuthenticated() {
if (isInAuthenticationFlow) {
const initialUrl = localStorage.getItem(INITIAL_URL_KEY)
if (initialUrl != null) {
// This is not used past this point, however it is set to the initial URL
// to make refreshing work as expected.
history.replaceState(null, '', initialUrl)
}
}
},
})
}
main()

View File

@ -0,0 +1,26 @@
import { inject, provide, type InjectionKey, type Ref } from 'vue'
export interface GuiConfig {
engine?: {
projectManagerUrl?: string
preferredVersion?: string
rpcUrl?: string
dataUrl?: string
}
startup?: {
project?: string
}
window?: { topBarOffset?: string }
}
const provideKey = Symbol('appConfig') as InjectionKey<Ref<GuiConfig>>
export function useGuiConfig(): Ref<GuiConfig> {
const injected = inject(provideKey)
if (injected == null) throw new Error('AppConfig not provided')
return injected
}
export function provideGuiConfig(appConfig: Ref<GuiConfig>) {
provide(provideKey, appConfig)
}

View File

@ -1,5 +1,5 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)

View File

@ -1,25 +1,25 @@
import { computed, reactive, ref, watch, watchEffect } from 'vue'
import { defineStore } from 'pinia'
import { map, set } from 'lib0'
import { Vec2 } from '@/util/vec2'
import { assertNever, assert } from '@/util/assert'
import { useProjectStore } from './project'
import * as Y from 'yjs'
import { assert, assertNever } from '@/util/assert'
import { useObserveYjs } from '@/util/crdt'
import { parseEnso } from '@/util/ffi'
import type { Opt } from '@/util/opt'
import { Vec2 } from '@/util/vec2'
import * as map from 'lib0/map'
import * as set from 'lib0/set'
import { defineStore } from 'pinia'
import {
rangeIntersects,
type ContentRange,
type ExprId,
type IdMap,
type NodeMetadata,
rangeIntersects,
} from 'shared/yjs-model'
import type { Opt } from '@/util/opt'
import { parseEnso } from '@/util/ffi'
} from 'shared/yjsModel'
import { computed, reactive, ref, watch, watchEffect } from 'vue'
import * as Y from 'yjs'
import { useProjectStore } from './project'
export const useGraphStore = defineStore('graph', () => {
const proj = useProjectStore()
proj.setProjectName('test')
proj.setObservedFileName('Main.enso')
const text = computed(() => proj.module?.contents)
@ -79,47 +79,50 @@ export const useGraphStore = defineStore('graph', () => {
watchEffect(() => {})
function updateState(affectedRanges?: ContentRange[]) {
if (proj.module == null) return
const idMap = proj.module.getIdMap()
const meta = proj.module.metadata
const text = proj.module.contents
const textContentLocal = textContent.value
const parsed = parseBlock(0, textContentLocal, idMap)
const module = proj.module
if (module == null) return
module.doc.transact(() => {
const idMap = module.getIdMap()
const meta = module.metadata
const text = module.contents
const textContentLocal = textContent.value
const parsed = parseBlock(0, textContentLocal, idMap)
_parsed.value = parsed
_parsedEnso.value = parseEnso(textContentLocal)
_parsed.value = parsed
_parsedEnso.value = parseEnso(textContentLocal)
const accessed = idMap.accessedSoFar()
const accessed = idMap.accessedSoFar()
for (const nodeId of nodes.keys()) {
if (!accessed.has(nodeId)) {
nodeDeleted(nodeId)
}
}
idMap.finishAndSynchronize()
for (const stmt of parsed) {
const id = stmt.id
const exprRange: ContentRange = [stmt.exprOffset, stmt.exprOffset + stmt.expression.length]
if (affectedRanges != null) {
while (affectedRanges[0]?.[1] < exprRange[0]) {
affectedRanges.shift()
for (const nodeId of nodes.keys()) {
if (!accessed.has(nodeId)) {
nodeDeleted(nodeId)
}
if (affectedRanges.length === 0) break
const nodeAffected = rangeIntersects(exprRange, affectedRanges[0])
if (!nodeAffected) continue
}
idMap.finishAndSynchronize()
const nodeMeta = meta.get(id)
const nodeContent = textContentLocal.substring(exprRange[0], exprRange[1])
const node = nodes.get(id)
if (node == null) {
nodeInserted(stmt, text, nodeContent, nodeMeta)
} else {
nodeUpdated(node, stmt, text, nodeContent, nodeMeta)
for (const stmt of parsed) {
const exprRange: ContentRange = [stmt.exprOffset, stmt.exprOffset + stmt.expression.length]
if (affectedRanges != null) {
while (affectedRanges[0]?.[1] < exprRange[0]) {
affectedRanges.shift()
}
if (affectedRanges.length === 0) break
const nodeAffected = rangeIntersects(exprRange, affectedRanges[0])
if (!nodeAffected) continue
}
const nodeId = stmt.expression.id
const node = nodes.get(nodeId)
const nodeMeta = meta.get(nodeId)
const nodeContent = textContentLocal.substring(exprRange[0], exprRange[1])
if (node == null) {
nodeInserted(stmt, text, nodeContent, nodeMeta)
} else {
nodeUpdated(node, stmt, text, nodeContent, nodeMeta)
}
}
}
})
}
useObserveYjs(metadata, (event) => {
@ -129,11 +132,13 @@ export const useGraphStore = defineStore('graph', () => {
const data = meta.get(id)
const node = nodes.get(id as ExprId)
if (data != null && node != null) {
const pos = new Vec2(data.x, data.y)
const pos = new Vec2(data.x, -data.y)
if (!node.position.equals(pos)) {
node.position = pos
}
}
} else {
console.log(op)
}
}
})
@ -142,20 +147,20 @@ export const useGraphStore = defineStore('graph', () => {
const identUsages = reactive(new Map<string, Set<ExprId>>())
function nodeInserted(stmt: Statement, text: Y.Text, content: string, meta: Opt<NodeMetadata>) {
console.log('nodeInserted', stmt.id)
const nodeId = stmt.expression.id
const node: Node = {
content,
binding: stmt.binding ?? '',
rootSpan: stmt.expression,
position: meta == null ? Vec2.Zero() : new Vec2(meta.x, meta.y),
position: meta == null ? Vec2.Zero() : new Vec2(meta.x, -meta.y),
docRange: [
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset),
Y.createRelativePositionFromTypeIndex(text, stmt.exprOffset + stmt.expression.length),
],
}
identDefinitions.set(node.binding, stmt.id)
addSpanUsages(stmt.id, node)
nodes.set(stmt.id, node)
identDefinitions.set(node.binding, nodeId)
addSpanUsages(nodeId, node)
nodes.set(nodeId, node)
}
function nodeUpdated(
@ -165,7 +170,6 @@ export const useGraphStore = defineStore('graph', () => {
content: string,
meta: Opt<NodeMetadata>,
) {
console.log('nodeUpdated', stmt.id)
clearSpanUsages(stmt.id, node)
node.content = content
if (node.binding !== stmt.binding) {
@ -178,8 +182,8 @@ export const useGraphStore = defineStore('graph', () => {
} else {
node.rootSpan = stmt.expression
}
if (meta != null && !node.position.equals(new Vec2(meta.x, meta.y))) {
node.position = new Vec2(meta.x, meta.y)
if (meta != null && !node.position.equals(new Vec2(meta.x, -meta.y))) {
node.position = new Vec2(meta.x, -meta.y)
}
}
@ -249,17 +253,17 @@ export const useGraphStore = defineStore('graph', () => {
return edges
})
function createNode(position: Vec2): Opt<ExprId> {
function createNode(position: Vec2, expression: string): Opt<ExprId> {
const mod = proj.module
if (mod == null) return
const { contents } = mod
const meta: NodeMetadata = {
x: position.x,
y: position.y,
y: -position.y,
}
const ident = generateUniqueIdent()
const content = `${ident} = x`
const content = `${ident} = ${expression}`
return mod.insertNewNode(contents.length, content, meta)
}
@ -288,7 +292,7 @@ export const useGraphStore = defineStore('graph', () => {
function setNodePosition(id: ExprId, position: Vec2) {
const node = nodes.get(id)
if (node == null) return
proj.module?.updateNodeMetadata(id, { x: position.x, y: position.y })
proj.module?.updateNodeMetadata(id, { x: position.x, y: -position.y })
}
return {
@ -398,10 +402,10 @@ interface Statement {
function parseBlock(offset: number, content: string, idMap: IdMap): Statement[] {
const stmtRegex = /^( *)(([a-zA-Z0-9_]+) *= *)?(.*)$/gm
const stmts: Statement[] = []
content.replace(stmtRegex, (stmt, ident, beforeExpr, binding, expr, index) => {
content.replace(stmtRegex, (stmt, indent, beforeExpr, binding, expr, index) => {
if (stmt.trim().length === 0) return stmt
const pos = offset + index + ident.length
const id = idMap.getOrInsertUniqueId([pos, pos + stmt.length])
const pos = offset + index + indent.length
const id = idMap.getOrInsertUniqueId([pos, pos + stmt.length - indent.length])
const exprOffset = pos + (beforeExpr?.length ?? 0)
stmts.push({
id,

View File

@ -1,10 +1,32 @@
import { ref, watchEffect } from 'vue'
import { defineStore } from 'pinia'
import * as Y from 'yjs'
import { useGuiConfig, type GuiConfig } from '@/providers/guiConfig'
import { attachProvider } from '@/util/crdt'
import { DistributedModel } from 'shared/yjs-model'
import { Client, RequestManager, WebSocketTransport } from '@open-rpc/client-js'
import { computedAsync } from '@vueuse/core'
import { defineStore } from 'pinia'
import { LanguageServer } from 'shared/languageServer'
import { DistributedProject } from 'shared/yjsModel'
import { markRaw, ref, watchEffect } from 'vue'
import { Awareness } from 'y-protocols/awareness'
import * as Y from 'yjs'
interface LsUrls {
rpcUrl: string
dataUrl: string
}
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) {
return {
rpcUrl: engine.rpcUrl,
dataUrl: engine.dataUrl,
}
}
throw new Error('Incomplete engine configuration')
}
/**
* The project store synchronizes and holds the open project-related data. The synchronization is
@ -12,44 +34,77 @@ import { Awareness } from 'y-protocols/awareness'
* client, it is submitted to the language server as a document update.
*/
export const useProjectStore = defineStore('project', () => {
// inputs
const projectName = ref<string>()
const observedFileName = ref<string>()
const doc = new Y.Doc()
const awareness = new Awareness(doc)
const config = useGuiConfig()
const projectId = config.value.startup?.project
if (projectId == null) throw new Error('Missing project ID')
const lsUrls = resolveLsUrl(config.value)
const rpcTransport = new WebSocketTransport(lsUrls.rpcUrl)
const rpcRequestManager = new RequestManager([rpcTransport])
const rpcClient = new Client(rpcRequestManager)
const lsRpcConnection = new LanguageServer(rpcClient)
const undoManager = new Y.UndoManager([], { doc })
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 = location.origin.replace(/^http/, 'ws') + '/room'
const provider = attachProvider(socketUrl, 'enso-projects', doc, awareness)
const socketUrl = new URL(location.origin)
socketUrl.protocol = location.protocol.replace(/^http/, 'ws')
socketUrl.pathname = '/project'
const provider = attachProvider(socketUrl.href, 'index', { ls: lsUrls.rpcUrl }, doc, awareness)
onCleanup(() => {
provider.dispose()
})
})
const model = new DistributedModel(doc)
const project = computedAsync(async () => {
const name = projectName.value
const projectModel = new DistributedProject(doc)
const moduleDocGuid = ref<string>()
function currentDocGuid() {
const name = observedFileName.value
if (name == null) return
return await model.openOrCreateProject(name)
})
return projectModel.modules.get(name)?.guid
}
function tryReadDocGuid() {
const guid = currentDocGuid()
if (guid === moduleDocGuid.value) return
moduleDocGuid.value = guid
}
projectModel.modules.observe((_) => tryReadDocGuid())
watchEffect(tryReadDocGuid)
const module = computedAsync(async () => {
const moduleName = observedFileName.value
const p = project.value
if (moduleName == null || p == null) return
return await p.openOrCreateModule(moduleName)
const guid = moduleDocGuid.value
if (guid == null) return null
const moduleName = projectModel.findModuleByDocId(guid)
if (moduleName == null) return null
return await projectModel.openModule(moduleName)
})
watchEffect((onCleanup) => {
const mod = module.value
if (mod == null) return
const scope: typeof undoManager.scope = [mod.contents, mod.idMap]
undoManager.scope.push(...scope)
onCleanup(() => {
undoManager.scope = undoManager.scope.filter((s) => !scope.includes(s))
})
})
return {
setProjectName(name: string) {
projectName.value = name
},
setObservedFileName(name: string) {
observedFileName.value = name
},
module,
undoManager,
lsRpcConnection: markRaw(lsRpcConnection),
}
})

View File

@ -1,12 +1,12 @@
import { assert } from '@/util/assert'
import {
isIdentifier,
isQualifiedName,
qnLastSegment,
qnParent,
qnSplit,
type Identifier,
type QualifiedName,
qnSplit,
isQualifiedName,
isIdentifier,
} from '@/util/qualifiedName'
export type SuggestionId = number

View File

@ -1,8 +1,8 @@
import { findIndexOpt } from '@/util/array'
import { isSome } from '@/util/opt'
import { defineStore } from 'pinia'
import { reactive, ref } from 'vue'
import { SuggestionKind, type SuggestionEntry, type SuggestionId } from './entry'
import { isSome } from '@/util/opt'
import { findIndexOpt } from '@/util/array'
export type SuggestionDb = Map<SuggestionId, SuggestionEntry>
export const SuggestionDb = Map<SuggestionId, SuggestionEntry>

View File

@ -1,13 +1,13 @@
export function assertNever(x: never): never {
throw new Error('Unexpected object: ' + x)
bail('Unexpected object: ' + x)
}
export function assert(condition: boolean): asserts condition {
if (!condition) throw new Error('Assertion failed')
if (!condition) bail('Assertion failed')
}
export function assertUnreachable(): never {
throw new Error('Unreachable code')
bail('Unreachable code')
}
/**

View File

@ -40,8 +40,22 @@ interface SubdocsEvent {
removed: Set<Y.Doc>
}
export function attachProvider(url: string, room: string, doc: Y.Doc, awareness: Awareness) {
const provider = new WebsocketProvider(url, room, doc, { awareness })
/**
* URL query parameters used in gateway server websocket connection.
*/
export type ProviderParams = {
/** URL for the project's language server RPC connection. */
ls: string
}
export function attachProvider(
url: string,
room: string,
params: ProviderParams,
doc: Y.Doc,
awareness: Awareness,
) {
const provider = new WebsocketProvider(url, room, doc, { awareness, params })
const onSync = () => doc.emit('sync', [true])
const onDrop = () => doc.emit('sync', [false])
@ -49,8 +63,7 @@ export function attachProvider(url: string, room: string, doc: Y.Doc, awareness:
function onSubdocs(e: SubdocsEvent) {
e.loaded.forEach((subdoc) => {
const subdocRoom = `${room}--${subdoc.guid}`
attachedSubdocs.set(subdoc, attachProvider(url, subdocRoom, subdoc, awareness))
attachedSubdocs.set(subdoc, attachProvider(url, subdoc.guid, params, subdoc, awareness))
})
e.removed.forEach((subdoc) => {
const subdocProvider = attachedSubdocs.get(subdoc)

View File

@ -4,10 +4,10 @@ import {
onUnmounted,
proxyRefs,
ref,
type Ref,
shallowRef,
watch,
watchEffect,
type Ref,
type WatchSource,
} from 'vue'
import { Vec2 } from './vec2'
@ -108,9 +108,13 @@ export function useDocumentEventConditional<K extends keyof DocumentEventMap>(
})
}
// const hasWindow = typeof window !== 'undefined'
// const platform = hasWindow ? window.navigator?.platform ?? '' : ''
// const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(platform)
const hasWindow = typeof window !== 'undefined'
const platform = hasWindow ? window.navigator?.platform ?? '' : ''
const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(platform)
export function modKey(e: KeyboardEvent): boolean {
return isMacLike ? e.metaKey : e.ctrlKey
}
/**
* Get DOM node size and keep it up to date.

View File

@ -1,7 +1,7 @@
import { Rect } from '@/stores/rect'
import { computed, proxyRefs, ref, type Ref } from 'vue'
import { PointerButtonMask, usePointer, useResizeObserver, useWindowEvent } from './events'
import { Vec2 } from './vec2'
import { Rect } from '@/stores/rect'
function elemRect(target: Element | undefined): Rect {
if (target != null && target instanceof Element) {

View File

@ -3,11 +3,18 @@
"include": ["env.d.ts", "src/**/*", "src/**/*.json", "src/**/*.vue", "shared/**/*"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"resolvePackageJsonExports": false,
"composite": true,
"outDir": "../../node_modules/.cache/tsc",
"baseUrl": ".",
"types": ["vitest/importMeta"],
"paths": {
"@/*": ["./src/*"]
},
"types": ["vitest/importMeta"]
}
}
},
"references": [
{
"path": "../ide-desktop/lib/dashboard/tsconfig.json"
}
]
}

View File

@ -1,6 +1,9 @@
{
"files": [],
"references": [
{
"path": "../ide-desktop/lib/dashboard/src/authentication/tsconfig.json"
},
{
"path": "./tsconfig.node.json"
},

View File

@ -1,8 +1,16 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"include": ["vite.config.*", "vitest.config.*", "playwright.config.*"],
"include": [
"vite.config.*",
"vitest.config.*",
"playwright.config.*",
"eslint.config.js",
"ydoc-server/**/*",
"shared/**/*",
"e2e/**/*",
"node.env.d.ts"
],
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]

View File

@ -5,5 +5,10 @@
"composite": true,
"lib": [],
"types": ["node", "jsdom", "vitest/importMeta"]
}
},
"references": [
{
"path": "../ide-desktop/lib/dashboard/tsconfig.json"
}
]
}

View File

@ -1,47 +1,55 @@
import { fileURLToPath } from 'node:url'
import { defineConfig, Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
import postcssNesting from 'postcss-nesting'
import { WebSocketServer } from 'ws'
import tailwindcss from 'tailwindcss'
import tailwindcssNesting from 'tailwindcss/nesting'
import { defineConfig, Plugin } from 'vite'
import topLevelAwait from 'vite-plugin-top-level-await'
import * as tailwindConfig from '../ide-desktop/lib/dashboard/tailwind.config'
import { createGatewayServer } from './ydoc-server'
const projectManagerUrl = 'ws://127.0.0.1:30535'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue(), yWebsocketServer(), topLevelAwait()],
cacheDir: '../../node_modules/.cache/vite',
plugins: [vue(), gatewayServer(), topLevelAwait()],
optimizeDeps: {
entries: 'index.html',
},
resolve: {
alias: {
shared: fileURLToPath(new URL('./shared', import.meta.url)),
'@': fileURLToPath(new URL('./src', import.meta.url)),
// workaround for @open-rpc/client-js bug: https://github.com/open-rpc/client-js/issues/310
events: 'shared/event.ts',
},
},
define: {
REDIRECT_OVERRIDE: JSON.stringify('http://localhost:8080'),
PROJECT_MANAGER_URL: JSON.stringify(projectManagerUrl),
global: 'window',
IS_DEV_MODE: JSON.stringify(process.env.NODE_ENV !== 'production'),
},
assetsInclude: ['**/*.yaml', '**/*.svg'],
css: {
postcss: {
plugins: [postcssNesting],
plugins: [tailwindcssNesting(postcssNesting()), tailwindcss({ config: tailwindConfig })],
},
},
build: {
// dashboard chunk size is larger than the default warning limit
chunkSizeWarningLimit: 700,
},
})
const roomNameRegex = /^[a-z0-9-]+$/i
function yWebsocketServer(): Plugin {
function gatewayServer(): Plugin {
return {
name: 'y-websocket-server',
name: 'gateway-server',
configureServer(server) {
if (server.httpServer == null) return
const { setupWSConnection } = require('./node_modules/y-websocket/bin/utils')
const wss = new WebSocketServer({ noServer: true })
wss.on('connection', setupWSConnection)
server.httpServer.on('upgrade', (request, socket, head) => {
if (request.url != null && request.url.startsWith('/room/')) {
const docName = request.url.slice(6)
if (roomNameRegex.test(docName)) {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request, { docName })
})
}
}
})
createGatewayServer(server.httpServer)
},
}
}

View File

@ -1,5 +1,5 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import { configDefaults, defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
@ -7,7 +7,7 @@ export default mergeConfig(
defineConfig({
test: {
environment: 'jsdom',
includeSource: ['./src/**/*.{ts,vue}'],
includeSource: ['./{src,shared}/**/*.{ts,vue}'],
exclude: [...configDefaults.exclude, 'e2e/*'],
root: fileURLToPath(new URL('./', import.meta.url)),
},

View File

@ -0,0 +1,83 @@
/**
* @file An entry point for the Yjs gateway server. The gateway server is a WebSocket server that
* synchronizes document requests and updates between language server and clients connected to the
* Yjs document mesh. It also serves as a central point for synchronizing document data and
* awareness updates between clients.
*
* Currently, this server is being run automatically in background as part of the vite development
* server. It is not yet deployed to any other environment.
*/
import { Server } from 'http'
import { IncomingMessage } from 'node:http'
import { parse } from 'url'
import { WebSocket, WebSocketServer } from 'ws'
import { setupGatewayClient } from './ydoc'
type ConnectionData = {
lsUrl: string
doc: string
user: string
}
export function createGatewayServer(httpServer: Server) {
const wss = new WebSocketServer({ noServer: true })
wss.on('connection', (ws: WebSocket, request: IncomingMessage, data: ConnectionData) => {
ws.on('error', onWebSocketError)
setupGatewayClient(ws, data.lsUrl, data.doc)
})
httpServer.on('upgrade', (request, socket, head) => {
socket.on('error', onHttpSocketError)
authenticate(request, function next(err, data) {
if (err != null) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
socket.destroy()
return
}
socket.removeListener('error', onHttpSocketError)
if (data != null) {
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request, data)
})
}
})
})
}
function onWebSocketError(err: Error) {
console.log('WebSocket error:', err)
}
function onHttpSocketError(err: Error) {
console.log('HTTP socket error:', err)
}
function authenticate(
request: IncomingMessage,
callback: (err: Error | null, authData: ConnectionData | null) => void,
) {
// FIXME: Stub. We don't implement authentication for now. Need to be implemented in combination
// with the language server.
const user = 'mock-user'
if (request.url == null) return callback(null, null)
const { pathname, query } = parse(request.url, true)
if (pathname == null) return callback(null, null)
const doc = docName(pathname)
const lsUrl = query.ls
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
}

View File

@ -0,0 +1,266 @@
import { Client, RequestManager, WebSocketTransport } from '@open-rpc/client-js'
import * as map from 'lib0/map'
import { createMutex } from 'lib0/mutex'
import * as random from 'lib0/random'
import * as Y from 'yjs'
import { Emitter } from '../shared/event'
import { LanguageServer } from '../shared/languageServer'
import { Checksum, Path, response } from '../shared/languageServerTypes'
import { DistributedModule, DistributedProject, NodeMetadata, Uuid } from '../shared/yjsModel'
import { WSSharedDoc } from './ydoc'
const sessions = new Map<string, LanguageServerSession>()
type Events = {
error: [error: Error]
}
export class LanguageServerSession extends Emitter<Events> {
clientId: Uuid
indexDoc: WSSharedDoc
docs: Map<string, WSSharedDoc>
retainCount: number
url: string
client: Client
ls: LanguageServer
model: DistributedProject
projectRootId: Uuid | null
authoritativeModules: Map<string, ModulePersistence>
constructor(url: string) {
super()
this.clientId = random.uuidv4() as Uuid
this.docs = new Map()
this.retainCount = 0
this.url = url
const transport = new WebSocketTransport(url)
const requestManager = new RequestManager([transport])
this.client = new Client(requestManager)
transport.connection.on('error', (error) => this.emit('error', [error]))
this.ls = new LanguageServer(this.client)
this.indexDoc = new WSSharedDoc()
this.docs.set('index', this.indexDoc)
this.model = new DistributedProject(this.indexDoc.doc)
this.projectRootId = null
this.readInitialState()
this.authoritativeModules = new Map()
this.indexDoc.doc.on('subdocs', (subdocs: { loaded: Set<Y.Doc> }) => {
for (const doc of subdocs.loaded) {
const name = this.model.findModuleByDocId(doc.guid)
if (name == null) continue
const persistence = this.authoritativeModules.get(name)
if (persistence == null) continue
persistence.load()
}
})
this.model.modules.observe((event, transaction) => {
if (transaction.origin === this) return
const changes = event.changes
console.log('index change', changes.delta)
// let index = 0
// const indicesToDelete: number[] = []
// for (const op of delta) {
// if (op.insert != null) {
// const mod = this.model.openUnloadedModule(index)
// if (mod == null || !this.isAuthoritative(mod)) {
// console.log(`index ${index} not authoritative, deleting`)
// indicesToDelete.push(index - indicesToDelete.length)
// }
// index++
// } else if (op.delete != null) {
// console.error('TODO: Module delete')
// } else if (op.retain != null) {
// index += op.retain
// }
// }
// this.indexDoc.doc.transact(() => {
// for (const index of indicesToDelete) {
// this.model.modules.delete(index)
// }
// }, this)
// console.log('index update', this.model.moduleNames())
})
this.ls.addEventListener('file/event', (event) => {
console.log('file/event', event)
})
}
private assertProjectRoot(): asserts this is { projectRootId: Uuid } {
if (this.projectRootId == null) throw new Error('Missing project root')
}
private async readInitialState() {
try {
const { contentRoots } = await this.ls.initProtocolConnection(this.clientId)
const projectRoot = contentRoots.find((root) => root.type === 'Project') ?? null
if (projectRoot == null) throw new Error('Missing project root')
this.projectRootId = projectRoot.id
await this.ls.acquireReceivesTreeUpdates({ rootId: this.projectRootId, segments: [] })
const srcFiles = await this.scanSrcFiles()
await Promise.all(
this.indexDoc.doc.transact(() => {
return srcFiles.map((file) => this.getModuleModel(pushPathSegment(file.path, file.name)))
}, this),
)
} catch (error) {
console.error('LS Initialization failed:', error)
if (error instanceof Error) {
this.emit('error', [error])
}
return
}
console.log('LS connection initialized.')
}
async scanSrcFiles() {
this.assertProjectRoot()
const srcModules = await this.ls.listFiles({ rootId: this.projectRootId, segments: ['src'] })
return srcModules.paths.filter((file) => file.type === 'File' && file.name.endsWith('.enso'))
}
getModuleModel(path: Path): ModulePersistence {
const name = pathToModuleName(path)
return map.setIfUndefined(this.authoritativeModules, name, () => {
const wsDoc = new WSSharedDoc()
this.docs.set(wsDoc.doc.guid, wsDoc)
const model = this.model.createUnloadedModule(name, wsDoc.doc)
const mod = new ModulePersistence(model, path, this.ls)
mod.once('removed', () => {
const index = this.model.findModuleByDocId(wsDoc.doc.guid)
this.authoritativeModules.delete(name)
if (index != null) {
this.model.deleteModule(index)
}
})
return mod
})
}
static get(url: string): LanguageServerSession {
const session = map.setIfUndefined(sessions, url, () => new LanguageServerSession(url))
session.retain()
return session
}
retain() {
this.retainCount++
}
release() {
this.retainCount--
if (this.retainCount === 0) {
this.model.doc.destroy()
this.ls.dispose()
sessions.delete(this.url)
}
}
getYDoc(guid: string): WSSharedDoc | undefined {
return this.docs.get(guid)
}
}
const pathToModuleName = (path: Path): string => {
if (path.segments[0] === 'src') {
return path.segments.slice(1).join('/')
} else {
return '//' + path.segments.join('/')
}
}
const pushPathSegment = (path: Path, segment: string): Path => {
return {
rootId: path.rootId,
segments: [...path.segments, segment],
}
}
type Mutex = ReturnType<typeof createMutex>
class ModulePersistence extends Emitter<{ removed: [] }> {
ls: LanguageServer
model: DistributedModule
path: Path
currentVersion: Checksum | null
mux: Mutex
constructor(model: DistributedModule, path: Path, ls: LanguageServer) {
super()
this.ls = ls
this.model = model
this.path = path
this.currentVersion = null
this.mux = createMutex()
}
async load() {
let loaded: response.OpenTextFile
try {
loaded = await this.ls.openTextFile(this.path)
} catch (_) {
return this.dispose()
}
this.model.doc.transact(() => {
let code = loaded.content
const idMap = this.model.getIdMap()
try {
const metaTag = '#### METADATA ####'
const splitPoint = loaded.content.lastIndexOf(metaTag)
if (splitPoint < 0) throw new Error('Metadata not found')
code = loaded.content.slice(0, splitPoint)
const metadataString = loaded.content.slice(splitPoint + metaTag.length)
const metaLines = metadataString.trim().split('\n')
const idMapMeta = JSON.parse(metaLines[0])
const ideMeta = JSON.parse(metaLines[1])?.ide
const nodeMeta = ideMeta?.node
for (const [{ index, size }, id] of idMapMeta) {
const range = [index.value, index.value + size.value]
if (typeof range[0] !== 'number' || typeof range[1] !== 'number') {
console.error(`Invalid range for id ${id}:`, range)
continue
}
idMap.insertKnownId([index.value, index.value + size.value], id)
}
for (const [id, rawMeta] of Object.entries(nodeMeta ?? {})) {
if (typeof id !== 'string') continue
const meta = rawMeta as any
const formattedMeta: NodeMetadata = {
x: meta?.position?.vector?.[0] ?? 0,
y: meta?.position?.vector?.[1] ?? 0,
vis: meta?.visualization ?? undefined,
}
this.model.metadata.set(id, formattedMeta)
}
} catch (e) {
console.log('Metadata parse failed:', e)
}
this.currentVersion = loaded.currentVersion
this.model.contents.delete(0, this.model.contents.length)
this.model.contents.insert(0, code)
idMap.finishAndSynchronize()
}, this)
}
/** A file was removed on the LS side. */
dispose() {
this.model.doc.destroy()
this.emit('removed', [])
}
handleLsUpdate() {
this.mux(() => {})
}
handleDocUpdate() {
this.mux(() => {})
}
}

View File

@ -0,0 +1,235 @@
import {
applyAwarenessUpdate,
Awareness,
encodeAwarenessUpdate,
removeAwarenessStates,
} from 'y-protocols/awareness'
import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync'
import * as Y from 'yjs'
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import { WebSocket } from 'ws'
import { Emitter } from '../shared/event'
import { LanguageServerSession } from './languageServerSession'
const pingTimeout = 30000
const messageSync = 0
const messageAwareness = 1
interface AwarenessUpdate {
added: number[]
updated: number[]
removed: number[]
}
type ConnectionId = YjsConnection | string
/**
* A Yjs document that is shared over multiple websocket connections.
*/
export class WSSharedDoc {
doc: Y.Doc
/**
* Maps from conn to set of controlled user ids. Delete all user ids from awareness when this conn
* is closed.
*/
conns: Map<ConnectionId, Set<number>>
awareness: Awareness
constructor(gc = true) {
this.doc = new Y.Doc({ gc })
// this.name = name
this.conns = new Map()
this.awareness = new Awareness(this.doc)
this.awareness.setLocalState(null)
this.awareness.on(
'update',
({ added, updated, removed }: AwarenessUpdate, conn: ConnectionId | null) => {
const changedClients = added.concat(updated, removed)
if (conn !== null) {
const connControlledIDs = this.conns.get(conn)
if (connControlledIDs !== undefined) {
added.forEach((clientID) => {
connControlledIDs.add(clientID)
})
removed.forEach((clientID) => {
connControlledIDs.delete(clientID)
})
}
}
// broadcast awareness update
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageAwareness)
encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(this.awareness, changedClients))
this.broadcast(encoding.toUint8Array(encoder))
},
)
this.doc.on('update', (update, origin) => this.updateHandler(update, origin))
}
broadcast(message: Uint8Array) {
this.conns.forEach((_, conn) => {
if (typeof conn !== 'string') {
conn.send(message)
}
})
}
updateHandler(update: Uint8Array, _origin: any) {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageSync)
writeUpdate(encoder, update)
this.broadcast(encoding.toUint8Array(encoder))
}
}
/**
* Handle servicing incoming websocket connection listening for given document updates.
* @param ws The newly connected websocket requesting Yjs document synchronization
* @param lsUrl Address of the language server to connect to. Each unique language server address
* will be assigned its own `DistributedProject` instance with a unique namespace of Yjs documents.
* @param docName The name of the document to synchronize. When the document name is `index`, the
* document is considered to be the root document of the `DistributedProject` data model.
*/
export function setupGatewayClient(ws: WebSocket, lsUrl: string, docName: string) {
const lsSession = LanguageServerSession.get(lsUrl)
const wsDoc = lsSession.getYDoc(docName)
if (wsDoc == null) {
console.log(`Document ${docName} not found in language server session ${lsUrl}`)
ws.close()
return
}
const connection = new YjsConnection(ws, wsDoc)
const doClose = () => connection.close()
lsSession.on('error', doClose)
connection.on('close', () => {
lsSession.off('error', doClose)
lsSession.release()
})
}
class YjsConnection extends Emitter<{ close: [] }> {
ws: WebSocket
wsDoc: WSSharedDoc
constructor(ws: WebSocket, wsDoc: WSSharedDoc) {
super()
this.ws = ws
this.wsDoc = wsDoc
const isLoaded = wsDoc.conns.size > 0
wsDoc.conns.set(this, new Set())
ws.binaryType = 'arraybuffer'
ws.on('message', (message: ArrayBuffer) => this.messageListener(new Uint8Array(message)))
ws.on('close', () => this.close())
if (!isLoaded) {
wsDoc.doc.load()
}
this.initPing()
this.sendSyncMessage()
}
private initPing() {
// Check if connection is still alive
let pongReceived = true
const pingInterval = setInterval(() => {
if (!pongReceived) {
if (this.wsDoc.conns.has(this)) {
this.close()
}
clearInterval(pingInterval)
} else if (this.wsDoc.conns.has(this)) {
pongReceived = false
try {
this.ws.ping()
} catch (e) {
this.close()
clearInterval(pingInterval)
}
}
}, pingTimeout)
this.ws.on('close', () => clearInterval(pingInterval))
this.ws.on('pong', () => {
pongReceived = true
})
}
sendSyncMessage() {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageSync)
writeSyncStep1(encoder, this.wsDoc.doc)
this.send(encoding.toUint8Array(encoder))
const awarenessStates = this.wsDoc.awareness.getStates()
if (awarenessStates.size > 0) {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, messageAwareness)
encoding.writeVarUint8Array(
encoder,
encodeAwarenessUpdate(this.wsDoc.awareness, Array.from(awarenessStates.keys())),
)
this.send(encoding.toUint8Array(encoder))
}
}
send(message: Uint8Array) {
if (this.ws.readyState !== WebSocket.CONNECTING && this.ws.readyState !== WebSocket.OPEN) {
this.close()
}
try {
this.ws.send(message, (error) => {
if (error != null) {
this.close()
}
})
} catch (e) {
this.close()
}
}
messageListener(message: Uint8Array) {
try {
const encoder = encoding.createEncoder()
const decoder = decoding.createDecoder(message)
const messageType = decoding.readVarUint(decoder)
switch (messageType) {
case messageSync:
encoding.writeVarUint(encoder, messageSync)
readSyncMessage(decoder, encoder, this.wsDoc.doc, this)
// If the `encoder` only contains the type of reply message and no
// message, there is no need to send the message. When `encoder` only
// contains the type of reply, its length is 1.
if (encoding.length(encoder) > 1) {
this.send(encoding.toUint8Array(encoder))
}
break
case messageAwareness: {
const update = decoding.readVarUint8Array(decoder)
applyAwarenessUpdate(this.wsDoc.awareness, update, this)
break
}
}
} catch (err) {
console.error(err)
this.wsDoc.doc.emit('error', [err])
}
}
close() {
const controlledIds = this.wsDoc.conns.get(this)
this.wsDoc.conns.delete(this)
if (controlledIds != null) {
removeAwarenessStates(this.wsDoc.awareness, Array.from(controlledIds), null)
}
this.ws.close()
this.emit('close', [])
if (this.wsDoc.conns.size === 0) {
this.wsDoc.doc.emit('unload', [])
}
}
}

View File

@ -8,7 +8,6 @@
import * as childProcess from 'node:child_process'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as electronBuilder from 'electron-builder'
import * as electronNotarize from 'electron-notarize'
@ -19,9 +18,10 @@ import * as common from 'enso-common'
import * as fileAssociations from './file-associations'
import * as paths from './paths'
import computeHashes from './tasks/computeHashes.mjs'
import signArchivesMacOs from './tasks/signArchivesMacOs'
import BUILD_INFO from '../../build.json' assert { type: 'json' }
import BUILD_INFO from '../../../../build.json' assert { type: 'json' }
// =============
// === Types ===
@ -213,8 +213,7 @@ export function createElectronBuilderConfig(passedArgs: Arguments): electronBuil
// https://kilianvalkhof.com/2019/electron/notarizing-your-electron-application/
sign: false,
},
afterAllArtifactBuild: path.join('tasks', 'computeHashes.cjs'),
afterAllArtifactBuild: computeHashes,
afterPack: ctx => {
if (passedArgs.platform === electronBuilder.Platform.MAC) {
// Make the subtree writable, so we can sign the binaries.

View File

@ -20,7 +20,7 @@
"@types/mime-types": "^2.1.1",
"@types/opener": "^1.4.0",
"chalk": "^5.2.0",
"create-servers": "^3.2.0",
"create-servers": "3.2.0",
"electron-is-dev": "^2.0.0",
"mime-types": "^2.1.35",
"opener": "^1.5.2",
@ -38,7 +38,7 @@
"electron-builder": "^22.14.13",
"electron-notarize": "1.2.2",
"enso-common": "^1.0.0",
"esbuild": "^0.17.15",
"esbuild": "^0.19.3",
"fast-glob": "^3.2.12",
"portfinder": "^1.0.32",
"tsx": "^3.12.6"
@ -51,7 +51,6 @@
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "npx --yes eslint src",
"start": "tsx start.ts",
"build": "tsx bundle.ts",
"dist": "tsx dist.ts",

View File

@ -26,7 +26,10 @@ const DEFAULT_PORT = 8080
export class WindowSize {
static separator = 'x'
/** Create a new {@link WindowSize}. */
constructor(public width: number, public height: number) {}
constructor(
public width: number,
public height: number
) {}
/** Constructor of the default window size. */
static default(): WindowSize {

View File

@ -3,15 +3,15 @@
import chalk from 'chalk'
import stringLength from 'string-length'
import yargs from 'yargs/yargs'
import yargsModule from 'yargs'
// eslint-disable-next-line no-restricted-syntax
import yargs, { Options } from 'yargs'
import * as contentConfig from 'enso-content-config'
import * as config from 'config'
import * as fileAssociations from 'file-associations'
import * as naming from 'naming'
import BUILD_INFO from '../../../../build.json' assert { type: 'json' }
import BUILD_INFO from '../../../../../../build.json' assert { type: 'json' }
const logger = contentConfig.logger
@ -206,7 +206,10 @@ function wordWrap(str: string, width: number): string[] {
/** Represents a command line option to be passed to the Chrome instance powering Electron. */
export class ChromeOption {
/** Create a {@link ChromeOption}. */
constructor(public name: string, public value?: string) {}
constructor(
public name: string,
public value?: string
) {}
/** Return the option as it would appear on the command line. */
display(): string {
@ -275,20 +278,18 @@ function argvAndChromeOptions(processArgs: string[]): ArgvAndChromeOptions {
export function parseArgs(clientArgs: string[] = fileAssociations.CLIENT_ARGUMENTS) {
const args = config.CONFIG
const { argv, chromeOptions } = argvAndChromeOptions(fixArgvNoPrefix(clientArgs))
const yargsOptions = args
.optionsRecursive()
.reduce((opts: Record<string, yargsModule.Options>, option) => {
opts[naming.camelToKebabCase(option.qualifiedName())] = {
...option,
requiresArg: ['string', 'array'].includes(option.type),
default: null,
// Required because yargs defines `defaultDescription`
// as `string | undefined`, not `string | null`.
// eslint-disable-next-line no-restricted-syntax
defaultDescription: option.defaultDescription ?? undefined,
}
return opts
}, {})
const yargsOptions = args.optionsRecursive().reduce((opts: Record<string, Options>, option) => {
opts[naming.camelToKebabCase(option.qualifiedName())] = {
...option,
requiresArg: ['string', 'array'].includes(option.type),
default: null,
// Required because yargs defines `defaultDescription`
// as `string | undefined`, not `string | null`.
// eslint-disable-next-line no-restricted-syntax
defaultDescription: option.defaultDescription ?? undefined,
}
return opts
}, {})
const optParser = yargs()
.version(false)

View File

@ -1,6 +1,6 @@
/** @file Application debug information. */
import BUILD_INFO from '../../../build.json' assert { type: 'json' }
import BUILD_INFO from '../../../../../build.json' assert { type: 'json' }
// =================
// === Constants ===

View File

@ -9,11 +9,11 @@
import * as fsSync from 'node:fs'
import * as pathModule from 'node:path'
import * as linkedDist from 'ensogl-runner/src/runner'
import * as contentConfig from 'enso-content-config'
import * as paths from 'paths'
import * as linkedDist from '../../../../../target/ensogl-pack/linked-dist'
// ================
// === Log File ===
// ================

View File

@ -1,11 +1,8 @@
/** @file Definition of hash computing functions. */
// Eslint is not (and should not be) set up to recognize CommonJS imports.
/* eslint-disable no-restricted-syntax */
const cryptoModule = require('crypto')
const fs = require('fs')
const pathModule = require('path')
/* eslint-enable no-restricted-syntax */
import * as cryptoModule from 'node:crypto'
import * as fs from 'node:fs'
import * as pathModule from 'node:path'
// =================
// === Constants ===
@ -63,8 +60,10 @@ async function writeFileChecksum(path, type) {
// ================
/** Generates checksums for all build artifacts.
* @param {import('electron-builder').BuildResult} context - Build information. */
exports.default = async function (context) {
* @param {import('electron-builder').BuildResult} context - Build information.
* @returns {Promise<string[]>} afterAllArtifactBuild hook result.
*/
export default async function (context) {
// `context` is BuildResult, see
// https://www.electron.build/configuration/configuration.html#buildresult
for (const file of context.artifactPaths) {

View File

@ -4,5 +4,12 @@
"baseUrl": "./src",
"esModuleInterop": true
},
"include": ["../types", "."]
"include": [
".",
"../content",
"../dashboard",
"../types",
"../../utils.ts",
"../../../../build.json"
]
}

View File

@ -86,6 +86,7 @@ const ALL_BUNDLES_READY = new Promise<Watches>((resolve, reject) => {
},
})
dashboardOpts.outdir = path.resolve(IDE_DIR_PATH, 'assets')
dashboardOpts.write = false
const dashboardBuilder = await esbuild.context(dashboardOpts)
const dashboard = await dashboardBuilder.rebuild()
console.log('Result of dashboard bundling: ', dashboard)

View File

@ -2,8 +2,8 @@
import * as semver from 'semver'
import * as linkedDist from '../../../../../target/ensogl-pack/linked-dist'
import BUILD_INFO from '../../../build.json' assert { type: 'json' }
import * as linkedDist from 'ensogl-runner/src/runner'
import BUILD_INFO from '../../../../../build.json' assert { type: 'json' }
// Aliases with the same name as the original.
// eslint-disable-next-line no-restricted-syntax

View File

@ -1,4 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["../types", "."]
"include": ["../types", "../../../../build.json", ".", "src/config.json"]
}

View File

@ -16,13 +16,12 @@ import * as url from 'node:url'
import * as esbuild from 'esbuild'
import * as esbuildPluginNodeGlobals from '@esbuild-plugins/node-globals-polyfill'
import * as esbuildPluginNodeModules from '@esbuild-plugins/node-modules-polyfill'
import esbuildPluginAlias from 'esbuild-plugin-alias'
import esbuildPluginCopyDirectories from 'esbuild-plugin-copy-directories'
import esbuildPluginTime from 'esbuild-plugin-time'
import esbuildPluginYaml from 'esbuild-plugin-yaml'
import * as utils from '../../utils'
import BUILD_INFO from '../../build.json' assert { type: 'json' }
import BUILD_INFO from '../../../../build.json' assert { type: 'json' }
// =================
// === Constants ===
@ -54,8 +53,6 @@ export interface Arguments extends PassthroughArguments {
assetsPath: string
/** Path where bundled files are output. */
outputPath: string
/** The main JS bundle to load WASM and JS wasm-pack bundles. */
ensoglAppPath: string
}
/** Get arguments from the environment. */
@ -63,8 +60,7 @@ export function argumentsFromEnv(passthroughArguments: PassthroughArguments): Ar
const wasmArtifacts = utils.requireEnv('ENSO_BUILD_GUI_WASM_ARTIFACTS')
const assetsPath = utils.requireEnv('ENSO_BUILD_GUI_ASSETS')
const outputPath = pathModule.resolve(utils.requireEnv('ENSO_BUILD_GUI'), 'assets')
const ensoglAppPath = utils.requireEnv('ENSO_BUILD_GUI_ENSOGL_APP')
return { ...passthroughArguments, wasmArtifacts, assetsPath, outputPath, ensoglAppPath }
return { ...passthroughArguments, wasmArtifacts, assetsPath, outputPath }
}
// ===================
@ -89,7 +85,6 @@ function git(command: string): string {
export function bundlerOptions(args: Arguments) {
const {
outputPath,
ensoglAppPath,
wasmArtifacts,
assetsPath,
devMode,
@ -156,7 +151,6 @@ export function bundlerOptions(args: Arguments) {
esbuildPluginYaml.yamlPlugin({}),
esbuildPluginNodeModules.NodeModulesPolyfillPlugin(),
esbuildPluginNodeGlobals.NodeGlobalsPolyfillPlugin({ buffer: true, process: true }),
esbuildPluginAlias({ ensogl_app: ensoglAppPath }),
esbuildPluginTime(),
],
define: {

View File

@ -16,7 +16,7 @@
},
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "npx --yes eslint src",
"lint": "eslint src",
"build": "tsx bundle.ts",
"watch": "tsx watch.ts",
"start": "tsx start.ts"
@ -29,27 +29,26 @@
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@eslint/js": "^8.36.0",
"@eslint/js": "^8.49.0",
"@types/connect": "^3.4.35",
"@types/morgan": "^1.9.4",
"@types/serve-static": "^1.15.1",
"@types/sharp": "^0.31.1",
"@types/to-ico": "^1.1.1",
"@types/ws": "^8.5.4",
"@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0",
"@typescript-eslint/eslint-plugin": "^6.7.2",
"@typescript-eslint/parser": "^6.7.2",
"enso-authentication": "^1.0.0",
"esbuild": "^0.17.15",
"esbuild-plugin-alias": "^0.2.1",
"esbuild": "^0.19.3",
"esbuild-plugin-copy-directories": "^1.0.0",
"esbuild-plugin-time": "^1.0.0",
"esbuild-plugin-yaml": "^0.0.1",
"eslint": "^8.36.0",
"eslint-plugin-jsdoc": "^40.0.2",
"eslint": "^8.49.0",
"eslint-plugin-jsdoc": "^46.8.1",
"globals": "^13.20.0",
"portfinder": "^1.0.32",
"tsx": "^3.12.6",
"typescript": "^4.9.3"
"typescript": "~5.2.2"
},
"optionalDependencies": {
"@esbuild/darwin-x64": "^0.17.15",

View File

@ -4,7 +4,7 @@
via a symlink. This is temporary, while the `content` and `dashboard` have separate entrypoints
for cloud and desktop. Once they are merged, the symlink must be removed.
-->
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />

View File

@ -5,12 +5,12 @@
import * as semver from 'semver'
import * as toastify from 'react-toastify'
import * as app from 'ensogl-runner/src/runner'
import * as common from 'enso-common'
import * as contentConfig from 'enso-content-config'
import * as dashboard from 'enso-authentication'
import * as detect from 'enso-common/src/detect'
import * as app from '../../../../../target/ensogl-pack/linked-dist'
import * as remoteLog from './remoteLog'
import GLOBAL_CONFIG from '../../../../gui/config.yaml' assert { type: 'yaml' }
@ -141,7 +141,7 @@ function displayDeprecatedVersionDialog() {
// ========================
/** Nested configuration options with `string` values. */
interface StringConfig {
export interface StringConfig {
[key: string]: StringConfig | string
}
@ -150,9 +150,7 @@ interface AuthenticationConfig {
projectManagerUrl: string | null
isInAuthenticationFlow: boolean
shouldUseAuthentication: boolean
shouldUseNewDashboard: boolean
initialProjectName: string | null
inputConfig: StringConfig | null
}
/** Contains the entrypoint into the IDE. */
@ -258,8 +256,6 @@ class Main implements AppRunner {
}
if (parseOk) {
const shouldUseAuthentication = configOptions.options.authentication.value
const shouldUseNewDashboard =
configOptions.groups.featurePreview.options.newDashboard.value
const isOpeningMainEntryPoint =
configOptions.groups.startup.options.entry.value ===
configOptions.groups.startup.options.entry.default
@ -275,14 +271,12 @@ class Main implements AppRunner {
url.searchParams.delete('startup.project')
history.replaceState(null, '', url.toString())
}
if ((shouldUseAuthentication || shouldUseNewDashboard) && isOpeningMainEntryPoint) {
if (shouldUseAuthentication && isOpeningMainEntryPoint) {
this.runAuthentication({
isInAuthenticationFlow,
projectManagerUrl,
shouldUseAuthentication,
shouldUseNewDashboard,
initialProjectName,
inputConfig: inputConfig ?? null,
})
} else {
void this.runApp(inputConfig ?? null, null)
@ -292,6 +286,12 @@ class Main implements AppRunner {
/** Begins the authentication UI flow. */
runAuthentication(config: AuthenticationConfig) {
const ideElement = document.getElementById('root')
if (ideElement) {
ideElement.style.top = '-100vh'
ideElement.style.display = 'fixed'
}
/** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345
* `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE
* should only have one entry point. Right now, we have two. One for the cloud
@ -308,9 +308,9 @@ class Main implements AppRunner {
supportsDeepLinks: SUPPORTS_DEEP_LINKS,
projectManagerUrl: config.projectManagerUrl,
isAuthenticationDisabled: !config.shouldUseAuthentication,
shouldShowDashboard: config.shouldUseNewDashboard,
shouldShowDashboard: true,
initialProjectName: config.initialProjectName,
onAuthenticated: (accessToken: string | null) => {
onAuthenticated: () => {
if (config.isInAuthenticationFlow) {
const initialUrl = localStorage.getItem(INITIAL_URL_KEY)
if (initialUrl != null) {
@ -319,17 +319,6 @@ class Main implements AppRunner {
history.replaceState(null, '', initialUrl)
}
}
if (!config.shouldUseNewDashboard) {
document.getElementById('enso-dashboard')?.remove()
const ideElement = document.getElementById('root')
if (ideElement) {
ideElement.style.top = ''
ideElement.style.display = ''
}
if (this.app == null) {
void this.runApp(config.inputConfig, accessToken)
}
}
},
})
}

View File

@ -1,7 +1,7 @@
/** @file Defines the {@link RemoteLogger} class and {@link remoteLog} function for sending logs to a remote server.
* {@link RemoteLogger} provides a convenient way to manage remote logging with access token authorization. */
import * as app from '../../../../../target/ensogl-pack/linked-dist'
import * as app from 'ensogl-runner/src/runner'
import * as authConfig from '../../dashboard/src/authentication/src/config'
const logger = app.log.logger

View File

@ -1,4 +1,10 @@
{
"extends": "../../tsconfig.json",
"include": ["../types", "."]
"include": [
"../types",
"../../utils.ts",
"../../../../build.json",
"../dashboard",
"."
]
}

View File

@ -12,6 +12,7 @@ import * as bundler from './esbuild-config'
// =================
export const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)))
export const ANALYZE = process.argv.includes('--analyze')
// ===============
// === Bundler ===
@ -33,8 +34,12 @@ async function bundle() {
path.resolve(THIS_PATH, 'src', 'index.html'),
path.resolve(THIS_PATH, 'src', 'index.tsx')
)
opts.metafile = ANALYZE
opts.loader['.html'] = 'copy'
await esbuild.build(opts)
const result = await esbuild.build(opts)
if (result.metafile) {
console.log(await esbuild.analyzeMetafile(result.metafile))
}
return
} catch (error) {
console.error(error)

View File

@ -12,6 +12,7 @@ import * as url from 'node:url'
import * as esbuild from 'esbuild'
import * as esbuildPluginNodeModules from '@esbuild-plugins/node-modules-polyfill'
import esbuildPluginInlineImage from 'esbuild-plugin-inline-image'
import esbuildPluginTime from 'esbuild-plugin-time'
import esbuildPluginYaml from 'esbuild-plugin-yaml'
@ -19,6 +20,7 @@ import postcss from 'postcss'
import tailwindcss from 'tailwindcss'
import tailwindcssNesting from 'tailwindcss/nesting/index.js'
import * as tailwindConfig from './tailwind.config'
import * as utils from '../../utils'
// =================
@ -26,7 +28,6 @@ import * as utils from '../../utils'
// =================
const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)))
const TAILWIND_CONFIG_PATH = path.resolve(THIS_PATH, 'tailwind.config.ts')
// =============================
// === Environment variables ===
@ -51,25 +52,25 @@ export function argumentsFromEnv(): Arguments {
// =======================
/** A plugin to process all CSS files with Tailwind CSS. */
function esbuildPluginGenerateTailwind(): esbuild.Plugin {
export function esbuildPluginGenerateTailwind(): esbuild.Plugin {
return {
name: 'enso-generate-tailwind',
setup: build => {
const cssProcessor = postcss([
tailwindcss({
config: TAILWIND_CONFIG_PATH,
config: tailwindConfig,
}),
tailwindcssNesting(),
])
build.onLoad({ filter: /tailwind\.css$/ }, async loadArgs => {
console.log(`Processing CSS file '${loadArgs.path}'.`)
// console.log(`Processing CSS file '${loadArgs.path}'.`)
const content = await fs.readFile(loadArgs.path, 'utf8')
const result = await cssProcessor.process(content, { from: loadArgs.path })
console.log(`Processed CSS file '${loadArgs.path}'.`)
// console.log(`Processed CSS file '${loadArgs.path}'.`)
return {
contents: result.content,
loader: 'css',
watchFiles: [loadArgs.path, TAILWIND_CONFIG_PATH],
watchFiles: [loadArgs.path],
}
})
},
@ -93,19 +94,20 @@ export function bundlerOptions(args: Arguments) {
outdir: outputPath,
outbase: 'src',
loader: {
// The CSS file needs to import a single SVG as a data URL.
// For `bundle.ts` and `watch.ts`, `index.js` also includes various SVG icons
// which need to be bundled.
// The `dataurl` loader replaces the import with the file, as a data URL. Using the
// `file` loader, which copies the file and replaces the import with the path,
// is an option, however this loader avoids adding extra files to the bundle.
/* eslint-disable @typescript-eslint/naming-convention */
'.svg': 'dataurl',
// The `file` loader copies the file, and replaces the import with the path to the file.
'.png': 'file',
/* eslint-enable @typescript-eslint/naming-convention */
},
plugins: [
// The CSS file needs to import a single SVG as a data URL.
// For `bundle.ts` and `watch.ts`, `index.js` also includes various SVG icons
// which need to be bundled.
// Depending on file size, choose between `dataurl` and `file` loaders.
// The `dataurl` loader replaces the import with the file, as a data URL. Using the
// `file` loader, which copies the file and replaces the import with the path.
/* eslint-disable @typescript-eslint/naming-convention */
esbuildPluginInlineImage({ extensions: ['svg'] }),
esbuildPluginNodeModules.NodeModulesPolyfillPlugin(),
esbuildPluginTime(),
// This is not strictly needed because the cloud frontend does not use
@ -125,7 +127,7 @@ export function bundlerOptions(args: Arguments) {
/* eslint-enable @typescript-eslint/naming-convention */
},
pure: ['assert'],
sourcemap: trueBoolean,
sourcemap: true,
minify: !devMode,
metafile: trueBoolean,
format: 'esm',

View File

@ -4,8 +4,8 @@
"type": "module",
"private": true,
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "npx --yes eslint src",
"typecheck": "tsc",
"lint": "eslint src",
"build": "tsx bundle.ts",
"watch": "tsx watch.ts",
"start": "tsx start.ts",
@ -13,10 +13,10 @@
},
"dependencies": {
"@heroicons/react": "^2.0.15",
"@types/node": "^16.18.11",
"@types/node": "^18.17.5",
"@types/react": "^18.0.27",
"@types/react-dom": "^18.0.10",
"esbuild": "^0.17.15",
"esbuild": "^0.19.3",
"esbuild-plugin-time": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -24,18 +24,19 @@
},
"devDependencies": {
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
"@typescript-eslint/eslint-plugin": "^6.7.2",
"@typescript-eslint/parser": "^6.7.2",
"chalk": "^5.3.0",
"enso-authentication": "^1.0.0",
"enso-chat": "git://github.com/enso-org/enso-bot",
"enso-content": "^1.0.0",
"eslint": "^8.32.0",
"eslint-plugin-jsdoc": "^39.6.8",
"eslint": "^8.49.0",
"eslint-plugin-jsdoc": "^46.8.1",
"eslint-plugin-react": "^7.32.1",
"react-toastify": "^9.1.3",
"tailwindcss": "^3.2.7",
"typescript": "^4.9.4"
"typescript": "~5.2.2",
"tsx": "^3.12.6"
},
"optionalDependencies": {
"@esbuild/darwin-x64": "^0.17.15",

View File

@ -5,15 +5,17 @@
"main": "./src/index.tsx",
"exports": {
".": "./src/index.tsx",
"./src/platform": "./src/platform.ts"
"./src/platform": "./src/platform.ts",
"./tailwind.css": "./tailwind.css"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-amplify/auth": "^5.1.8",
"@aws-amplify/core": "^5.0.14",
"@fortawesome/free-brands-svg-icons": "^6.3.0",
"@aws-amplify/auth": "^5.6.5",
"@aws-amplify/core": "^5.8.5",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"enso-common": "^1.0.0",
"react-toastify": "^9.1.3",
@ -21,6 +23,10 @@
"ts-results": "^3.3.0"
},
"devDependencies": {
"typescript": "^4.9.3"
"typescript": "~5.2.2"
},
"overrides": {
"@aws-amplify/auth": "../_IGNORED_",
"react-native-url-polyfill": "../_IGNORED_"
}
}

View File

@ -94,16 +94,15 @@ interface UserInfo {
*
* Some Amplify errors (e.g., network connectivity errors) can not be resolved within the
* application. Un-resolvable errors are allowed to flow up to the top-level error handler. Errors
* that can be resolved must be caught and handled as early as possible. The {@link KNOWN_ERRORS}
* map lists the Amplify errors that we want to catch and convert to typed responses.
* that can be resolved must be caught and handled as early as possible.
*
* # Handling Amplify Errors
*
* Use the {@link isAmplifyError} function to check if an `unknown` error is an
* {@link AmplifyError}. If it is, use the {@link intoAmplifyErrorOrThrow} function to convert it
* from `unknown` to a typed object. Then, use the {@link KNOWN_ERRORS} to see if the error is one
* that must be handled by the application (i.e., it is an error that is relevant to our business
* logic). */
* from `unknown` to a typed object. Then, use one of the response error handling functions (e.g.
* {@link intoSignUpErrorOrThrow}) to see if the error is one that must be handled by the
* application (i.e., it is an error that is relevant to our business logic). */
interface AmplifyError extends Error {
/** Error code for disambiguating the error. */
code: string
@ -396,9 +395,12 @@ const CURRENT_SESSION_NO_CURRENT_USER_ERROR = {
/** Internal IDs of errors that may occur when getting the current session. */
type CurrentSessionErrorKind = (typeof CURRENT_SESSION_NO_CURRENT_USER_ERROR)['kind']
/** Convert an {@link AmplifyError} into a {@link CurrentSessionErrorKind} if it is a known error,
/**
* Convert an {@link AmplifyError} into a {@link CurrentSessionErrorKind} if it is a known error,
* else re-throws the error.
* @throws {Error} If the error is not recognized. */
*
* @throws {Error} If the error is not recognized.
*/
function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind {
if (error === CURRENT_SESSION_NO_CURRENT_USER_ERROR.internalMessage) {
return CURRENT_SESSION_NO_CURRENT_USER_ERROR.kind
@ -467,9 +469,12 @@ export interface SignUpError extends CognitoError {
message: string
}
/** Convert an {@link AmplifyError} into a {@link SignUpError} if it is a known error,
/**
* Convert an {@link AmplifyError} into a {@link SignUpError} if it is a known error,
* else re-throws the error.
* @throws {Error} If the error is not recognized. */
*
* @throws {Error} If the error is not recognized.
*/
function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError {
if (error.code === SIGN_UP_USERNAME_EXISTS_ERROR.internalCode) {
return {

View File

@ -42,12 +42,12 @@ function isAuthEvent(value: string): value is AuthEvent {
/** Callback called in response to authentication state changes.
*
* @see {@link Api["listen"]}. */
* @see {@link amplify.Hub.listen}. */
export type ListenerCallback = (event: AuthEvent, data?: unknown) => void
/** Unsubscribe the {@link ListenerCallback} from authentication state changes.
*
* @see {@link Api["listen"]}. */
* @see {@link amplify.Hub.listen}. */
type UnsubscribeFunction = () => void
/** Used to subscribe to {@link AuthEvent}s.

View File

@ -108,7 +108,7 @@ export type UserSession = FullUserSession | OfflineUserSession | PartialUserSess
* signing out, etc. All interactions with the authentication API should be done through this
* interface.
*
* See {@link Cognito} for details on each of the authentication functions. */
* See {@link cognito.Cognito} for details on each of the authentication functions. */
interface AuthContextType {
goOffline: (shouldShowToast?: boolean) => Promise<boolean>
signUp: (email: string, password: string, organizationId: string | null) => Promise<boolean>

View File

@ -71,36 +71,6 @@ export const Subject = newtype.newtypeConstructor<Subject>()
/* eslint-enable @typescript-eslint/no-redeclare */
// ========================
// === PermissionAction ===
// ========================
/** Backend representation of user permission types. */
export enum PermissionAction {
own = 'Own',
admin = 'Admin',
edit = 'Edit',
read = 'Read',
readAndDocs = 'Read_docs',
readAndExec = 'Read_exec',
view = 'View',
viewAndDocs = 'View_docs',
viewAndExec = 'View_exec',
}
/** Whether each {@link PermissionAction} can execute a project. */
export const PERMISSION_ACTION_CAN_EXECUTE: Record<PermissionAction, boolean> = {
[PermissionAction.own]: true,
[PermissionAction.admin]: true,
[PermissionAction.edit]: true,
[PermissionAction.read]: false,
[PermissionAction.readAndDocs]: false,
[PermissionAction.readAndExec]: true,
[PermissionAction.view]: false,
[PermissionAction.viewAndDocs]: false,
[PermissionAction.viewAndExec]: true,
}
// =============
// === Types ===
// =============
@ -356,7 +326,7 @@ export interface SimpleUser {
/** User permission for a specific user. */
export interface UserPermission {
user: User
permission: PermissionAction
permission: permissions.PermissionAction
}
/** The type returned from the "update directory" endpoint. */
@ -571,7 +541,7 @@ export interface InviteUserRequestBody {
export interface CreatePermissionRequestBody {
userSubjects: Subject[]
resourceId: AssetId
action: PermissionAction | null
action: permissions.PermissionAction | null
}
/** HTTP request body for the "create directory" endpoint. */

View File

@ -17,6 +17,7 @@ import * as authProvider from '../authentication/providers/auth'
import * as backend from './backend'
import * as dateTime from './dateTime'
import * as modalProvider from '../providers/modal'
import * as permissions from './permissions'
import * as sorting from './sorting'
import * as tableColumn from './components/tableColumn'
import * as uniqueString from '../uniqueString'
@ -109,7 +110,7 @@ export const COLUMN_CSS_CLASS: Record<Column, string> = {
[Column.docs]: `min-w-96 ${NORMAL_COLUMN_CSS_CLASSES}`,
} as const
/** {@link table.ColumnProps} for an unknown variant of {@link backend.Asset}. */
/** {@link tableColumn.TableColumnProps} for an unknown variant of {@link backend.Asset}. */
export type AssetColumnProps = tableColumn.TableColumnProps<
assetTreeNode.AssetTreeNode,
assetsTable.AssetsTableState,
@ -165,8 +166,8 @@ function SharedWithColumn(props: AssetColumnProps) {
permission => permission.user.user_email === session.organization?.email
)
const managesThisAsset =
self?.permission === backend.PermissionAction.own ||
self?.permission === backend.PermissionAction.admin
self?.permission === permissions.PermissionAction.own ||
self?.permission === permissions.PermissionAction.admin
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<backend.AnyAsset>) => {
if (typeof valueOrUpdater === 'function') {

View File

@ -7,6 +7,7 @@ import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as hooks from '../../hooks'
import * as http from '../../http'
import * as permissions from '../permissions'
import * as remoteBackendModule from '../remoteBackend'
import * as shortcuts from '../shortcuts'
@ -67,14 +68,14 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
)
const managesThisAsset =
backend.type === backendModule.BackendType.local ||
self?.permission === backendModule.PermissionAction.own ||
self?.permission === backendModule.PermissionAction.admin
self?.permission === permissions.PermissionAction.own ||
self?.permission === permissions.PermissionAction.admin
const isRunningProject =
asset.type === backendModule.AssetType.project &&
backendModule.DOES_PROJECT_STATE_INDICATE_VM_EXISTS[asset.projectState.type]
const canExecute =
backend.type === backendModule.BackendType.local ||
(self?.permission != null && backendModule.PERMISSION_ACTION_CAN_EXECUTE[self.permission])
(self?.permission != null && permissions.PERMISSION_ACTION_CAN_EXECUTE[self.permission])
const isOtherUserUsingProject =
backend.type !== backendModule.BackendType.local &&
backendModule.assetIsProject(asset) &&

View File

@ -5,8 +5,6 @@ import * as backendModule from '../backend'
import * as hooks from '../../hooks'
import * as load from '../load'
import GLOBAL_CONFIG from '../../../../../../../../gui/config.yaml' assert { type: 'yaml' }
// =================
// === Constants ===
// =================
@ -114,15 +112,10 @@ export default function Editor(props: EditorProps) {
}
}
const runNewProject = async () => {
const engineConfig =
backendType === backendModule.BackendType.remote
? {
rpcUrl: jsonAddress,
dataUrl: binaryAddress,
}
: {
projectManagerUrl: GLOBAL_CONFIG.projectManagerEndpoint,
}
const engineConfig = {
rpcUrl: jsonAddress,
dataUrl: binaryAddress,
}
const originalUrl = window.location.href
if (backendType === backendModule.BackendType.remote) {
// The URL query contains commandline options when running in the desktop,

View File

@ -7,6 +7,7 @@ import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as hooks from '../../hooks'
import * as modalProvider from '../../providers/modal'
import * as permissionsModule from '../permissions'
import Autocomplete from './autocomplete'
import Modal from './modal'
@ -27,7 +28,7 @@ const TYPE_SELECTOR_Y_OFFSET_PX = 32
/** Props for a {@link ManagePermissionsModal}. */
export interface ManagePermissionsModalProps<
Asset extends backendModule.AnyAsset = backendModule.AnyAsset
Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
> {
item: Asset
setItem: React.Dispatch<React.SetStateAction<Asset>>
@ -43,7 +44,7 @@ export interface ManagePermissionsModalProps<
* @throws {Error} when the current backend is the local backend, or when the user is offline.
* This should never happen, as this modal should not be accessible in either case. */
export default function ManagePermissionsModal<
Asset extends backendModule.AnyAsset = backendModule.AnyAsset
Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
>(props: ManagePermissionsModalProps<Asset>) {
const { item, setItem, self, doRemoveSelf, eventTarget } = props
const { organization } = auth.useNonPartialUserSession()
@ -53,15 +54,15 @@ export default function ManagePermissionsModal<
const [permissions, setPermissions] = React.useState(item.permissions ?? [])
const [users, setUsers] = React.useState<backendModule.SimpleUser[]>([])
const [email, setEmail] = React.useState<string | null>(null)
const [action, setAction] = React.useState(backendModule.PermissionAction.view)
const [action, setAction] = React.useState(permissionsModule.PermissionAction.view)
const emailValidityRef = React.useRef<HTMLInputElement>(null)
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
const editablePermissions = React.useMemo(
() =>
self.permission === backendModule.PermissionAction.own
self.permission === permissionsModule.PermissionAction.own
? permissions
: permissions.filter(
permission => permission.permission !== backendModule.PermissionAction.own
permission => permission.permission !== permissionsModule.PermissionAction.own
),
[permissions, self.permission]
)
@ -78,10 +79,10 @@ export default function ManagePermissionsModal<
)
const isOnlyOwner = React.useMemo(
() =>
self.permission === backendModule.PermissionAction.own &&
self.permission === permissionsModule.PermissionAction.own &&
permissions.every(
permission =>
permission.permission !== backendModule.PermissionAction.own ||
permission.permission !== permissionsModule.PermissionAction.own ||
permission.user.user_email === organization?.email
),
[organization?.email, permissions, self.permission]
@ -278,7 +279,7 @@ export default function ManagePermissionsModal<
disabled={willInviteNewUser}
selfPermission={self.permission}
typeSelectorYOffsetPx={TYPE_SELECTOR_Y_OFFSET_PX}
action={backendModule.PermissionAction.view}
action={permissionsModule.PermissionAction.view}
assetType={item.type}
onChange={setAction}
/>

View File

@ -1,7 +1,6 @@
/** @file Colored border around icons and text indicating permissions. */
import * as React from 'react'
import * as backend from '../backend'
import * as permissionsModule from '../permissions'
// =================
@ -10,7 +9,7 @@ import * as permissionsModule from '../permissions'
/** Props for a {@link PermissionDisplay}. */
export interface PermissionDisplayProps extends React.PropsWithChildren {
action: backend.PermissionAction
action: permissionsModule.PermissionAction
className?: string
onClick?: React.MouseEventHandler<HTMLDivElement>
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>

View File

@ -2,6 +2,7 @@
import * as React from 'react'
import * as backend from '../backend'
import * as permissions from '../permissions'
import * as permissionsModule from '../permissions'
import Modal from './modal'
@ -34,12 +35,12 @@ export interface PermissionSelectorProps {
/** Overrides the vertical offset of the {@link PermissionTypeSelector}. */
typeSelectorYOffsetPx?: number
error?: string | null
selfPermission: backend.PermissionAction
selfPermission: permissions.PermissionAction
/** If this prop changes, the internal state will be updated too. */
action: backend.PermissionAction
action: permissions.PermissionAction
assetType: backend.AssetType
className?: string
onChange: (action: backend.PermissionAction) => void
onChange: (action: permissions.PermissionAction) => void
doDelete?: () => void
}
@ -61,7 +62,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
const [TheChild, setTheChild] = React.useState<(() => JSX.Element) | null>()
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
const setAction = (newAction: backend.PermissionAction) => {
const setAction = (newAction: permissions.PermissionAction) => {
setActionRaw(newAction)
onChange(newAction)
}

View File

@ -70,7 +70,7 @@ const PERMISSION_TYPE_DATA: PermissionTypeData[] = [
/** Props for a {@link PermissionTypeSelector}. */
export interface PermissionTypeSelectorProps {
showDelete?: boolean
selfPermission: backend.PermissionAction
selfPermission: permissions.PermissionAction
type: permissions.Permission
assetType: backend.AssetType
style?: React.CSSProperties
@ -93,7 +93,7 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
{PERMISSION_TYPE_DATA.filter(
data =>
(showDelete ? true : data.type !== permissions.Permission.delete) &&
(selfPermission === backend.PermissionAction.own
(selfPermission === permissions.PermissionAction.own
? true
: data.type !== permissions.Permission.owner)
).map(data => (

View File

@ -13,6 +13,7 @@ import * as errorModule from '../../error'
import * as eventModule from '../event'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as permissions from '../permissions'
import * as presence from '../presence'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
@ -66,7 +67,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const canExecute =
backend.type === backendModule.BackendType.local ||
(ownPermission != null &&
backendModule.PERMISSION_ACTION_CAN_EXECUTE[ownPermission.permission])
permissions.PERMISSION_ACTION_CAN_EXECUTE[ownPermission.permission])
const isOtherUserUsingProject =
backend.type !== backendModule.BackendType.local &&
asset.projectState.opened_by != null &&

View File

@ -77,7 +77,7 @@ export type TableProps<
T,
State = never,
RowState = never,
Key extends string = string
Key extends string = string,
> = InternalTableProps<T, State, RowState, Key> &
([RowState] extends [never] ? unknown : InitialRowStateProp<RowState>) &
([State] extends [never] ? unknown : StateProp<State>) &

View File

@ -4,7 +4,7 @@
// === Types ===
// =============
/** Props for a {@link Column}. */
/** Props for a {@link TableColumn}. */
export interface TableColumnProps<T, State = never, RowState = never, Key extends string = string> {
keyProp: Key
item: T
@ -16,7 +16,7 @@ export interface TableColumnProps<T, State = never, RowState = never, Key extend
setRowState: React.Dispatch<React.SetStateAction<RowState>>
}
/** Props for a {@link Column}. */
/** Props for a {@link TableColumn}. */
export interface TableColumnHeadingProps<State = never> {
state: State
}

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