mirror of
https://github.com/enso-org/enso.git
synced 2024-11-05 03:59:38 +03:00
Expose cloud event logging endpoint to GUI and render GUI editor as react component. (#9951)
fixes #9730 Added a `logEvent` method to remote backend implementation in dashboard. Added an always-present remote backend instance that can be used for logging even when running a local project (intentional behavior). # Important Notes Because the backend implementation requires access to always fresh session token, the logger needs to be periodically updated on GUI side, so it can continue to function. To accomplish that, I simplified the app loading logic to treat GUI as an ordinary react component, so its props can be updated with normal react rendering flow. That refactor also removed the dynamic GUI asset loading code that was only needed for Rust GUI.
This commit is contained in:
parent
2c060a2a92
commit
8edf49343f
@ -35,7 +35,7 @@ export async function mockExpressionUpdate(
|
||||
update: Partial<ExpressionUpdate>,
|
||||
) {
|
||||
await page.evaluate(
|
||||
({ expression, update }) => (window as any).mockExpressionUpdate(expression, update),
|
||||
({ expression, update }) => (window as any)._mockExpressionUpdate(expression, update),
|
||||
{ expression, update },
|
||||
)
|
||||
}
|
||||
|
4
app/gui2/env.d.ts
vendored
4
app/gui2/env.d.ts
vendored
@ -31,3 +31,7 @@ interface FileBrowserApi {
|
||||
kind: 'file' | 'directory' | 'default' | 'filePath',
|
||||
) => Promise<string[] | undefined>
|
||||
}
|
||||
|
||||
interface LogEvent {
|
||||
(message: string, projectId?: string | null, metadata?: object | null): void
|
||||
}
|
||||
|
@ -15,7 +15,6 @@
|
||||
<title>Enso</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="enso-dashboard" class="enso-dashboard"></div>
|
||||
<div id="enso-chat" class="enso-chat"></div>
|
||||
<script type="module" src="/src/entrypoint.ts"></script>
|
||||
|
@ -1,99 +0,0 @@
|
||||
import { provideGuiConfig, type GuiConfig } from '@/providers/guiConfig'
|
||||
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { GraphDb } from '@/stores/graph/graphDatabase'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { ComputedValueRegistry } from '@/stores/project/computedValueRegistry'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { MockTransport, MockWebSocket } from '@/util/net'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { ref, type App } from 'vue'
|
||||
import { mockDataHandler, mockLSHandler } from './engine'
|
||||
export * as vue from './vue'
|
||||
|
||||
export function languageServer() {
|
||||
MockTransport.addMock('engine', mockLSHandler)
|
||||
}
|
||||
|
||||
export function dataServer() {
|
||||
MockWebSocket.addMock('data', mockDataHandler)
|
||||
}
|
||||
|
||||
export function guiConfig(app: App) {
|
||||
return provideGuiConfig._mock(
|
||||
ref<GuiConfig>({
|
||||
startup: {
|
||||
project: 'Mock Project',
|
||||
displayedProjectName: 'Mock Project',
|
||||
},
|
||||
engine: {
|
||||
rpcUrl: 'mock://engine',
|
||||
dataUrl: 'mock://data',
|
||||
ydocUrl: '',
|
||||
namespace: 'local',
|
||||
projectManagerUrl: '',
|
||||
},
|
||||
window: {
|
||||
topBarOffset: 96,
|
||||
vibrancy: false,
|
||||
},
|
||||
authentication: {
|
||||
enabled: true,
|
||||
email: '',
|
||||
},
|
||||
}),
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
export const computedValueRegistry = ComputedValueRegistry.Mock
|
||||
export const graphDb = GraphDb.Mock
|
||||
|
||||
export function widgetRegistry(app: App) {
|
||||
return widgetRegistry.withGraphDb(graphDb())(app)
|
||||
}
|
||||
|
||||
widgetRegistry.withGraphDb = function widgetRegistryWithGraphDb(graphDb: GraphDb) {
|
||||
return (app: App) => provideWidgetRegistry._mock([graphDb], app)
|
||||
}
|
||||
|
||||
export function graphStore(): ReturnType<typeof useGraphStore> {
|
||||
return useGraphStore(getActivePinia())
|
||||
}
|
||||
|
||||
export function projectStore(): ReturnType<typeof useProjectStore> {
|
||||
const projectStore = useProjectStore(getActivePinia())
|
||||
const mod = projectStore.projectModel.createNewModule('Main.enso')
|
||||
mod.doc.ydoc.emit('load', [])
|
||||
const syncModule = new Ast.MutableModule(mod.doc.ydoc)
|
||||
syncModule.transact(() => {
|
||||
const root = Ast.parseBlock('main =\n', syncModule)
|
||||
syncModule.replaceRoot(root)
|
||||
})
|
||||
return projectStore
|
||||
}
|
||||
|
||||
/** The stores should be initialized in this order, as `graphStore` depends on `projectStore`. */
|
||||
export function projectStoreAndGraphStore(): readonly [
|
||||
ReturnType<typeof useProjectStore>,
|
||||
ReturnType<typeof useGraphStore>,
|
||||
] {
|
||||
return [projectStore(), graphStore()] as const
|
||||
}
|
||||
|
||||
export function waitForMainModule(projectStore?: ReturnType<typeof useProjectStore>) {
|
||||
const definedProjectStore = projectStore ?? useProjectStore(getActivePinia())
|
||||
return new Promise((resolve, reject) => {
|
||||
const handle1 = window.setInterval(() => {
|
||||
if (definedProjectStore.module != null) {
|
||||
window.clearInterval(handle1)
|
||||
window.clearTimeout(handle2)
|
||||
resolve(definedProjectStore.module)
|
||||
}
|
||||
}, 10)
|
||||
const handle2 = window.setTimeout(() => {
|
||||
window.clearInterval(handle1)
|
||||
reject()
|
||||
}, 5_000)
|
||||
})
|
||||
}
|
@ -56,7 +56,6 @@
|
||||
"@lezer/highlight": "^1.1.6",
|
||||
"@noble/hashes": "^1.3.2",
|
||||
"@open-rpc/client-js": "^1.8.1",
|
||||
"@pinia/testing": "^0.1.3",
|
||||
"@vueuse/core": "^10.4.1",
|
||||
"ag-grid-community": "^30.2.1",
|
||||
"ag-grid-enterprise": "^30.2.1",
|
||||
@ -73,12 +72,12 @@
|
||||
"magic-string": "^0.30.3",
|
||||
"murmurhash": "^2.0.1",
|
||||
"partysocket": "^1.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
"postcss-inline-svg": "^6.0.0",
|
||||
"postcss-nesting": "^12.0.1",
|
||||
"rimraf": "^5.0.5",
|
||||
"semver": "^7.5.4",
|
||||
"sucrase": "^3.34.0",
|
||||
"veaury": "^2.3.18",
|
||||
"vue": "^3.4.19",
|
||||
"ws": "^8.13.0",
|
||||
"y-codemirror.next": "^0.3.2",
|
||||
|
@ -19,6 +19,7 @@ export function xxHash128(input: IDataType) {
|
||||
}
|
||||
|
||||
export async function initializeFFI(path?: string | undefined) {
|
||||
if (xxHasher128 != null) return
|
||||
if (isNode) {
|
||||
const fs = await import('node:fs/promises')
|
||||
const { fileURLToPath, URL: nodeURL } = await import('node:url')
|
||||
|
@ -1,46 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import HelpScreen from '@/components/HelpScreen.vue'
|
||||
import { provideAppClassSet } from '@/providers/appClass'
|
||||
import { provideEventLogger } from '@/providers/eventLogging'
|
||||
import { provideGuiConfig } from '@/providers/guiConfig'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import { registerAutoBlurHandler } from '@/util/autoBlur'
|
||||
import { configValue, type ApplicationConfig, type ApplicationConfigValue } from '@/util/config'
|
||||
import {
|
||||
baseConfig,
|
||||
configValue,
|
||||
mergeConfig,
|
||||
type ApplicationConfigValue,
|
||||
type StringConfig,
|
||||
} from '@/util/config'
|
||||
import ProjectView from '@/views/ProjectView.vue'
|
||||
import { isDevMode } from 'shared/util/detect'
|
||||
import { computed, onMounted, onUnmounted, toRaw } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, toRef, watch } from 'vue'
|
||||
import { initializePrefixes } from './util/ast/node'
|
||||
import { urlParams } from './util/urlParams'
|
||||
|
||||
const props = defineProps<{
|
||||
config: ApplicationConfig
|
||||
accessToken: string | null
|
||||
unrecognizedOptions: string[]
|
||||
config: StringConfig
|
||||
projectId: string
|
||||
logEvent: LogEvent
|
||||
hidden: boolean
|
||||
ignoreParamsRegex?: RegExp
|
||||
}>()
|
||||
|
||||
const classSet = provideAppClassSet()
|
||||
|
||||
provideGuiConfig(computed((): ApplicationConfigValue => configValue(props.config)))
|
||||
initializePrefixes()
|
||||
|
||||
registerAutoBlurHandler()
|
||||
const logger = provideEventLogger(toRef(props, 'logEvent'), toRef(props, 'projectId'))
|
||||
watch(
|
||||
toRef(props, 'projectId'),
|
||||
(_id, _oldId, onCleanup) => {
|
||||
logger.send('ide_project_opened')
|
||||
onCleanup(() => logger.send('ide_project_closed'))
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Initialize suggestion db immediately, so it will be ready when user needs it.
|
||||
onMounted(() => {
|
||||
const suggestionDb = useSuggestionDbStore()
|
||||
if (isDevMode) {
|
||||
;(window as any).suggestionDb = toRaw(suggestionDb.entries)
|
||||
useEventListener(window, 'beforeunload', () => logger.send('ide_project_closed'))
|
||||
|
||||
const appConfig = computed(() => {
|
||||
const unrecognizedOptions: string[] = []
|
||||
const intermediateConfig = mergeConfig(
|
||||
baseConfig,
|
||||
urlParams({ ignoreKeysRegExp: props.ignoreParamsRegex }),
|
||||
{
|
||||
onUnrecognizedOption: (p) => unrecognizedOptions.push(p.join('.')),
|
||||
},
|
||||
)
|
||||
return {
|
||||
unrecognizedOptions,
|
||||
config: mergeConfig(intermediateConfig, props.config ?? {}),
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
useProjectStore().disposeYDocsProvider()
|
||||
})
|
||||
|
||||
provideGuiConfig(computed((): ApplicationConfigValue => configValue(appConfig.value.config)))
|
||||
|
||||
registerAutoBlurHandler()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpScreen
|
||||
v-if="unrecognizedOptions.length"
|
||||
:unrecognizedOptions="props.unrecognizedOptions"
|
||||
:config="props.config"
|
||||
v-if="appConfig.unrecognizedOptions.length"
|
||||
v-show="!props.hidden"
|
||||
:unrecognizedOptions="appConfig.unrecognizedOptions"
|
||||
:config="appConfig.config"
|
||||
/>
|
||||
<ProjectView v-else class="App" :class="[...classSet.keys()]" />
|
||||
<ProjectView v-else v-show="!props.hidden" class="App" :class="[...classSet.keys()]" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@ -48,11 +76,19 @@ onUnmounted(() => {
|
||||
flex: 1;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
pointer-events: all;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.enso-dashboard .App {
|
||||
/* Compensate for top bar, render the app below it. */
|
||||
margin-top: calc(0px - var(--row-height) - var(--top-level-gap) - var(--top-bar-margin));
|
||||
}
|
||||
</style>
|
||||
|
@ -1,45 +0,0 @@
|
||||
import { baseConfig, mergeConfig, type StringConfig } from '@/util/config'
|
||||
import { urlParams } from '@/util/urlParams'
|
||||
import type { Pinia } from 'pinia'
|
||||
|
||||
let unmount: null | (() => void) = null
|
||||
let running = false
|
||||
|
||||
async function runApp(
|
||||
config: StringConfig | null,
|
||||
accessToken: string | null,
|
||||
_metadata?: object | undefined,
|
||||
pinia?: Pinia | undefined,
|
||||
) {
|
||||
const ignoreParamsRegex = (() => {
|
||||
if (_metadata)
|
||||
if ('ignoreParamsRegex' in _metadata)
|
||||
if (_metadata['ignoreParamsRegex'] instanceof RegExp) return _metadata['ignoreParamsRegex']
|
||||
|
||||
return null
|
||||
})()
|
||||
|
||||
running = true
|
||||
const { mountProjectApp } = await import('./createApp')
|
||||
if (!running) return
|
||||
unmount?.()
|
||||
const unrecognizedOptions: string[] = []
|
||||
function onUnrecognizedOption(path: string[]) {
|
||||
unrecognizedOptions.push(path.join('.'))
|
||||
}
|
||||
const intermediateConfig = mergeConfig(
|
||||
baseConfig,
|
||||
urlParams({ ignoreKeysRegExp: ignoreParamsRegex }),
|
||||
{ onUnrecognizedOption },
|
||||
)
|
||||
const appConfig = mergeConfig(intermediateConfig, config ?? {})
|
||||
unmount = await mountProjectApp({ config: appConfig, accessToken, unrecognizedOptions }, pinia)
|
||||
}
|
||||
|
||||
function stopApp() {
|
||||
running = false
|
||||
unmount?.()
|
||||
unmount = null
|
||||
}
|
||||
|
||||
export const appRunner = { runApp, stopApp }
|
@ -69,18 +69,10 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button.disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Scrollbar style definitions for textual visualizations which need support for scrolling.
|
||||
*
|
||||
* The 11px width/height (depending on scrollbar orientation)
|
||||
|
@ -2,30 +2,9 @@
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
color: var(--color-text);
|
||||
min-height: 100vh;
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
/* FIXME [ao]: https://github.com/enso-org/cloud-v2/issues/777
|
||||
The cursor is set to none as workaround for GUI1, here we bring it back.
|
||||
Should be removed once GUI1 will be gone (with all workarounds */
|
||||
cursor: auto;
|
||||
[data-use-vue-component-wrap] {
|
||||
display: contents !important;
|
||||
}
|
||||
|
9
app/gui2/src/asyncApp.ts
Normal file
9
app/gui2/src/asyncApp.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import '@/assets/main.css'
|
||||
|
||||
export async function AsyncApp() {
|
||||
const [_, app] = await Promise.all([
|
||||
import('shared/ast/ffi').then((mod) => mod.initializeFFI()),
|
||||
import('@/App.vue'),
|
||||
])
|
||||
return app
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChangeSet, Diagnostic, Highlighter } from '@/components/CodeEditor/codemirror'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import { usePointer } from '@/composables/events'
|
||||
import { useGraphStore, type NodeId } from '@/stores/graph'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
@ -347,12 +347,7 @@ const editorStyle = computed(() => {
|
||||
<circle cx="14" cy="14" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<SvgIcon
|
||||
name="close"
|
||||
class="closeButton button"
|
||||
title="Close Code Editor"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
<SvgButton name="close" class="closeButton" title="Close Code Editor" @click="emit('close')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -43,11 +43,12 @@ import { provideGraphSelection } from '@/providers/graphSelection'
|
||||
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
||||
import { provideKeyboard } from '@/providers/keyboard'
|
||||
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
|
||||
import { useGraphStore, type NodeId } from '@/stores/graph'
|
||||
import { provideGraphStore, type NodeId } from '@/stores/graph'
|
||||
import type { RequiredImport } from '@/stores/graph/imports'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import { provideProjectStore } from '@/stores/project'
|
||||
import { provideSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import type { Typename } from '@/stores/suggestionDatabase/entry'
|
||||
import { provideVisualizationStore } from '@/stores/visualization'
|
||||
import { bail } from '@/util/assert'
|
||||
import type { AstId } from '@/util/ast/abstract'
|
||||
import { colorFromString } from '@/util/colors'
|
||||
@ -60,14 +61,26 @@ import { computedFallback } from '@/util/reactivity'
|
||||
import { until } from '@vueuse/core'
|
||||
import { encoding, set } from 'lib0'
|
||||
import { encodeMethodPointer } from 'shared/languageServerTypes'
|
||||
import { computed, onMounted, ref, shallowRef, toRef, watch } from 'vue'
|
||||
import { isDevMode } from 'shared/util/detect'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, toRaw, toRef, watch } from 'vue'
|
||||
|
||||
const keyboard = provideKeyboard()
|
||||
const graphStore = useGraphStore()
|
||||
const projectStore = provideProjectStore()
|
||||
const suggestionDb = provideSuggestionDbStore(projectStore)
|
||||
const graphStore = provideGraphStore(projectStore, suggestionDb)
|
||||
const widgetRegistry = provideWidgetRegistry(graphStore.db)
|
||||
const _visualizationStore = provideVisualizationStore(projectStore)
|
||||
|
||||
widgetRegistry.loadBuiltins()
|
||||
const projectStore = useProjectStore()
|
||||
const suggestionDb = useSuggestionDbStore()
|
||||
|
||||
onMounted(() => {
|
||||
if (isDevMode) {
|
||||
;(window as any).suggestionDb = toRaw(suggestionDb.entries)
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
projectStore.disposeYDocsProvider()
|
||||
})
|
||||
|
||||
// === Navigator ===
|
||||
|
||||
@ -174,11 +187,11 @@ function panToSelected() {
|
||||
|
||||
// == Breadcrumbs ==
|
||||
|
||||
const stackNavigator = useStackNavigator()
|
||||
const stackNavigator = useStackNavigator(projectStore, graphStore)
|
||||
|
||||
// === Toasts ===
|
||||
|
||||
const toasts = useGraphEditorToasts()
|
||||
const toasts = useGraphEditorToasts(projectStore)
|
||||
|
||||
// === Selection ===
|
||||
|
||||
@ -209,6 +222,7 @@ const { place: nodePlacement, collapse: collapsedNodePlacement } = usePlacement(
|
||||
)
|
||||
|
||||
const { createNode, createNodes, placeNode } = provideNodeCreation(
|
||||
graphStore,
|
||||
toRef(graphNavigator, 'viewport'),
|
||||
toRef(graphNavigator, 'sceneMousePos'),
|
||||
(nodes) => {
|
||||
@ -221,6 +235,7 @@ const { createNode, createNodes, placeNode } = provideNodeCreation(
|
||||
// === Clipboard Copy/Paste ===
|
||||
|
||||
const { copySelectionToClipboard, createNodesFromClipboard } = useGraphEditorClipboard(
|
||||
graphStore,
|
||||
toRef(nodeSelection, 'selected'),
|
||||
createNodes,
|
||||
)
|
||||
@ -374,7 +389,9 @@ const cssRightDockWidth = computed(() =>
|
||||
rightDockWidth.value != null ? `${rightDockWidth.value}px` : 'var(--right-dock-default-width)',
|
||||
)
|
||||
|
||||
const { documentation } = useAstDocumentation(() => unwrapOr(graphStore.methodAst, undefined))
|
||||
const { documentation } = useAstDocumentation(graphStore, () =>
|
||||
unwrapOr(graphStore.methodAst, undefined),
|
||||
)
|
||||
|
||||
// === Execution Mode ===
|
||||
|
||||
@ -605,13 +622,13 @@ async function handleFileDrop(event: DragEvent) {
|
||||
|
||||
// === Color Picker ===
|
||||
|
||||
provideNodeColors((variable) =>
|
||||
provideNodeColors(graphStore, (variable) =>
|
||||
viewportNode.value ? getComputedStyle(viewportNode.value).getPropertyValue(variable) : '',
|
||||
)
|
||||
|
||||
const showColorPicker = ref(false)
|
||||
|
||||
function setSelectedNodesColor(color: string) {
|
||||
function setSelectedNodesColor(color: string | undefined) {
|
||||
graphStore.transact(() =>
|
||||
nodeSelection.selected.forEach((id) => graphStore.overrideNodeColor(id, color)),
|
||||
)
|
||||
|
@ -59,7 +59,7 @@ const emit = defineEmits<{
|
||||
outputPortDoubleClick: [event: PointerEvent, portId: AstId]
|
||||
doubleClick: []
|
||||
createNodes: [options: NodeCreationOptions[]]
|
||||
setNodeColor: [color: string]
|
||||
setNodeColor: [color: string | undefined]
|
||||
'update:edited': [cursorPosition: number]
|
||||
'update:rect': [rect: Rect]
|
||||
'update:visualizationId': [id: Opt<VisualizationIdentifier>]
|
||||
|
@ -2,7 +2,7 @@
|
||||
import PlainTextEditor from '@/components/PlainTextEditor.vue'
|
||||
import { useAstDocumentation } from '@/composables/astDocumentation'
|
||||
import { useFocusDelayed } from '@/composables/focus'
|
||||
import { type Node } from '@/stores/graph'
|
||||
import { useGraphStore, type Node } from '@/stores/graph'
|
||||
import { syncRef } from '@vueuse/core'
|
||||
import { computed, ref, type ComponentInstance } from 'vue'
|
||||
|
||||
@ -11,7 +11,11 @@ const props = defineProps<{ node: Node }>()
|
||||
|
||||
const textEditor = ref<ComponentInstance<typeof PlainTextEditor>>()
|
||||
|
||||
const { documentation: astDocumentation } = useAstDocumentation(() => props.node.outerExpr)
|
||||
const graphStore = useGraphStore()
|
||||
const { documentation: astDocumentation } = useAstDocumentation(
|
||||
graphStore,
|
||||
() => props.node.outerExpr,
|
||||
)
|
||||
const documentation = computed({
|
||||
// This returns the same value as the `astDocumentation` getter, but with fewer reactive dependencies.
|
||||
get: () => props.node.documentation ?? '',
|
||||
|
@ -69,12 +69,12 @@ export const colorForMessageType: Record<MessageType, string> = {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
& > .button:hover {
|
||||
& > .SvgButton:hover {
|
||||
background-color: color-mix(in oklab, black, transparent 90%);
|
||||
color: color-mix(in oklab, var(--color-text-inversed), transparent 20%);
|
||||
}
|
||||
|
||||
& > .button:active {
|
||||
& > .SvgButton:active {
|
||||
background-color: color-mix(in oklab, black, transparent 70%);
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ const emit = defineEmits<{
|
||||
nodeOutputPortDoubleClick: [portId: AstId]
|
||||
nodeDoubleClick: [nodeId: NodeId]
|
||||
createNodes: [source: NodeId, options: NodeCreationOptions[]]
|
||||
setNodeColor: [color: string]
|
||||
setNodeColor: [color: string | undefined]
|
||||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
|
@ -1,6 +1,5 @@
|
||||
import type { NodeCreation } from '@/composables/nodeCreation'
|
||||
import type { Node, NodeId } from '@/stores/graph'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import type { GraphStore, Node, NodeId } from '@/stores/graph'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { Pattern } from '@/util/ast/match'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
@ -119,11 +118,10 @@ function getClipboard() {
|
||||
}
|
||||
|
||||
export function useGraphEditorClipboard(
|
||||
graphStore: GraphStore,
|
||||
selected: ToValue<Set<NodeId>>,
|
||||
createNodes: NodeCreation['createNodes'],
|
||||
) {
|
||||
const graphStore = useGraphStore()
|
||||
|
||||
/** Copy the content of the selected node to the clipboard. */
|
||||
function copySelectionToClipboard() {
|
||||
const nodes = new Array<Node>()
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { useEvent } from '@/composables/events'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { type ProjectStore } from '@/stores/project'
|
||||
import { useToast } from '@/util/toast'
|
||||
import { ProjectManagerEvents } from '../../../../ide-desktop/lib/dashboard/src/services/ProjectManager'
|
||||
|
||||
export function useGraphEditorToasts() {
|
||||
const projectStore = useProjectStore()
|
||||
export function useGraphEditorToasts(projectStore: ProjectStore) {
|
||||
const toastStartup = useToast.info({ autoClose: false })
|
||||
const toastConnectionLost = useToast.error({ autoClose: false })
|
||||
const toastLspError = useToast.error()
|
||||
|
@ -19,7 +19,7 @@ function onClick() {
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="MenuButton button"
|
||||
class="MenuButton"
|
||||
:class="{ toggledOn, toggledOff: toggledOn === false, disabled }"
|
||||
:disabled="disabled ?? false"
|
||||
@click.stop="onClick"
|
||||
@ -31,7 +31,7 @@ function onClick() {
|
||||
<style scoped>
|
||||
.MenuButton {
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: max-content;
|
||||
padding: 4px;
|
||||
@ -46,6 +46,7 @@ function onClick() {
|
||||
background-color: var(--color-menu-entry-active-bg);
|
||||
}
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import NavBreadcrumbs, { type BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import SvgButton from './SvgButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbs: BreadcrumbItem[]
|
||||
@ -14,19 +15,15 @@ const emit = defineEmits<{ back: []; forward: []; breadcrumbClick: [index: numbe
|
||||
<div class="NavBar">
|
||||
<SvgIcon name="graph_editor" draggable="false" />
|
||||
<div class="breadcrumbs-controls">
|
||||
<SvgIcon
|
||||
<SvgButton
|
||||
name="arrow_left"
|
||||
draggable="false"
|
||||
class="icon button"
|
||||
:class="{ inactive: !props.allowNavigationLeft }"
|
||||
:disabled="!props.allowNavigationLeft"
|
||||
title="Back"
|
||||
@click.stop="emit('back')"
|
||||
/>
|
||||
<SvgIcon
|
||||
<SvgButton
|
||||
name="arrow_right"
|
||||
draggable="false"
|
||||
class="icon button"
|
||||
:class="{ inactive: !props.allowNavigationRight }"
|
||||
:disabled="!props.allowNavigationRight"
|
||||
title="Forward"
|
||||
@click.stop="emit('forward')"
|
||||
/>
|
||||
@ -52,9 +49,5 @@ const emit = defineEmits<{ back: []; forward: []; breadcrumbClick: [index: numbe
|
||||
> .breadcrumbs-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& .inactive {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
|
||||
import NavBreadcrumb from '@/components/NavBreadcrumb.vue'
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string
|
||||
@ -15,10 +14,11 @@ const emit = defineEmits<{ selected: [index: number] }>()
|
||||
<template>
|
||||
<div class="NavBreadcrumbs">
|
||||
<template v-for="(breadcrumb, index) in props.breadcrumbs" :key="index">
|
||||
<SvgIcon
|
||||
<SvgButton
|
||||
v-if="index > 0"
|
||||
name="arrow_right_head_only"
|
||||
:class="['arrow', { inactive: !breadcrumb.active }]"
|
||||
:disabled="breadcrumb.active"
|
||||
class="arrow"
|
||||
/>
|
||||
<NavBreadcrumb
|
||||
:text="breadcrumb.label"
|
||||
|
@ -11,14 +11,14 @@ const emit = defineEmits<{ recordOnce: []; 'update:recordMode': [enabled: boolea
|
||||
<div class="control left-end" @click.stop="() => emit('update:recordMode', !props.recordMode)">
|
||||
<ToggleIcon
|
||||
icon="record"
|
||||
class="button"
|
||||
class="iconButton"
|
||||
title="Record"
|
||||
:modelValue="props.recordMode"
|
||||
@update:modelValue="emit('update:recordMode', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="control right-end" @click.stop="() => emit('recordOnce')">
|
||||
<SvgButton title="Record Once" class="button" name="record_once" draggable="false" />
|
||||
<SvgButton title="Record Once" class="iconButton" name="record_once" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -42,7 +42,7 @@ const emit = defineEmits<{ recordOnce: []; 'update:recordMode': [enabled: boolea
|
||||
.left-end {
|
||||
border-radius: var(--radius-full) 0 0 var(--radius-full);
|
||||
|
||||
.button {
|
||||
.iconButton {
|
||||
margin: 0 0 0 auto;
|
||||
}
|
||||
}
|
||||
@ -50,13 +50,10 @@ const emit = defineEmits<{ recordOnce: []; 'update:recordMode': [enabled: boolea
|
||||
.right-end {
|
||||
border-radius: 0 var(--radius-full) var(--radius-full) 0;
|
||||
|
||||
.button {
|
||||
.iconButton {
|
||||
position: relative;
|
||||
margin: 0 auto 0 0;
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
--icon-transform: scale(1.5) translateY(calc(-4px / 1.5));
|
||||
--icon-transform-origin: left top;
|
||||
--icon-width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,7 +61,7 @@ const emit = defineEmits<{ recordOnce: []; 'update:recordMode': [enabled: boolea
|
||||
color: #ba4c40;
|
||||
}
|
||||
|
||||
.button:active {
|
||||
.iconButton:active {
|
||||
color: #ba4c40;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
createNodes: [options: NodeCreationOptions[]]
|
||||
@ -12,9 +12,7 @@ function addNode() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="SmallPlusButton add-node button" title="Add Component" @click.stop="addNode">
|
||||
<SvgIcon name="add" class="icon" />
|
||||
</div>
|
||||
<SvgButton name="add" class="SmallPlusButton" title="Add Component" @click.stop="addNode" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
@ -15,7 +15,7 @@ const props = defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg>
|
||||
<svg viewBox="0 0 16 16" preserveAspectRatio="xMidYMid slice">
|
||||
<title v-if="title" v-text="title"></title>
|
||||
<use :href="props.name.includes(':') ? props.name : `${icons}#${props.name}`"></use>
|
||||
</svg>
|
||||
@ -23,11 +23,9 @@ const props = defineProps<{
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
min-height: 16px;
|
||||
width: var(--icon-width, var(--icon-size, 16px));
|
||||
height: var(--icon-height, var(--icon-size, 16px));
|
||||
transform: var(--icon-transform);
|
||||
transform-origin: var(--icon-transform-origin);
|
||||
transform-origin: var(--icon-transform-origin, center center);
|
||||
}
|
||||
</style>
|
||||
|
@ -11,7 +11,7 @@ import { isQualifiedName, qnLastSegment } from '@/util/qualifiedName'
|
||||
import { computed, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** If true, the visualization should be `overflow: visible` instead of `overflow: hidden`. */
|
||||
/** If true, the visualization should be `overflow: visible` instead of `overflow: auto`. */
|
||||
overflow?: boolean
|
||||
/** If true, the visualization should display below the node background. */
|
||||
belowNode?: boolean
|
||||
@ -83,6 +83,13 @@ const nodeShortType = computed(() =>
|
||||
qnLastSegment(config.nodeType)
|
||||
: UNKNOWN_TYPE,
|
||||
)
|
||||
|
||||
const contentStyle = computed(() => {
|
||||
return {
|
||||
width: config.fullscreen ? undefined : `${Math.max(config.width ?? 0, config.nodeSize.x)}px`,
|
||||
height: config.fullscreen ? undefined : `${Math.max(config.height ?? 0, config.nodeSize.y)}px`,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -109,12 +116,7 @@ const nodeShortType = computed(() =>
|
||||
ref="contentNode"
|
||||
class="content scrollable"
|
||||
:class="{ overflow: props.overflow }"
|
||||
:style="{
|
||||
width:
|
||||
config.fullscreen ? undefined : `${Math.max(config.width ?? 0, config.nodeSize.x)}px`,
|
||||
height:
|
||||
config.fullscreen ? undefined : `${Math.max(config.height ?? 0, config.nodeSize.y)}px`,
|
||||
}"
|
||||
:style="contentStyle"
|
||||
@wheel.passive="onWheel"
|
||||
>
|
||||
<slot></slot>
|
||||
|
@ -25,7 +25,7 @@ function entryTitle(index: number) {
|
||||
v-for="(child, index) in props.data"
|
||||
:key="index"
|
||||
:title="entryTitle(index)"
|
||||
class="button element"
|
||||
class="element"
|
||||
@click.stop="emit('createProjection', [$event.shiftKey ? [...props.data.keys()] : [index]])"
|
||||
>
|
||||
<JsonValueWidget
|
||||
@ -50,6 +50,7 @@ function entryTitle(index: number) {
|
||||
.block > .element {
|
||||
display: block;
|
||||
margin-left: 1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.element:not(:last-child)::after {
|
||||
display: inline;
|
||||
|
@ -29,7 +29,7 @@ function entryTitle(key: string) {
|
||||
v-for="[key, value] in Object.entries(props.data)"
|
||||
:key="key"
|
||||
:title="entryTitle(key)"
|
||||
class="button field"
|
||||
class="field"
|
||||
@click.stop="emit('createProjection', [$event.shiftKey ? Object.keys(props.data) : [key]])"
|
||||
>
|
||||
<span class="key" v-text="JSON.stringify(key)" />:
|
||||
@ -63,6 +63,7 @@ function entryTitle(key: string) {
|
||||
.block > .field {
|
||||
display: block;
|
||||
margin-left: 1em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field:not(:last-child)::after {
|
||||
display: inline;
|
||||
|
@ -65,16 +65,16 @@ export interface DropdownEntry {
|
||||
<ul class="list scrollable" @wheel.stop>
|
||||
<template v-for="entry in sortedValues" :key="entry.value">
|
||||
<li v-if="entry.selected">
|
||||
<div class="item selected button" @click.stop="emit('clickEntry', entry, $event.altKey)">
|
||||
<div class="item selected" @click.stop="emit('clickEntry', entry, $event.altKey)">
|
||||
<span v-text="entry.value"></span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-else class="item button" @click.stop="emit('clickEntry', entry, $event.altKey)">
|
||||
<li v-else class="item" @click.stop="emit('clickEntry', entry, $event.altKey)">
|
||||
<span v-text="entry.value"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div v-if="enableSortButton" class="sort button">
|
||||
<div v-if="enableSortButton" class="sort">
|
||||
<div class="sort-background"></div>
|
||||
<SvgIcon
|
||||
:name="ICON_LOOKUP[sortDirection]"
|
||||
@ -179,4 +179,9 @@ li {
|
||||
background-color: var(--color-port-connected);
|
||||
}
|
||||
}
|
||||
|
||||
.item,
|
||||
.sort {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { type GraphStore } from '@/stores/graph'
|
||||
import type { ToValue } from '@/util/reactivity'
|
||||
import type { Ast } from 'shared/ast'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
export function useAstDocumentation(ast: ToValue<Ast | undefined>) {
|
||||
const graphStore = useGraphStore()
|
||||
export function useAstDocumentation(graphStore: GraphStore, ast: ToValue<Ast | undefined>) {
|
||||
return {
|
||||
documentation: computed({
|
||||
get: () => toValue(ast)?.documentingAncestor()?.documentation() ?? '',
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { useGraphStore, type NodeId } from '@/stores/graph'
|
||||
import { type GraphStore, type NodeId } from '@/stores/graph'
|
||||
import { type Group } from '@/stores/suggestionDatabase'
|
||||
import { colorFromString } from '@/util/colors'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function useNodeColors(getCssValue: (variable: string) => string) {
|
||||
const graphStore = useGraphStore()
|
||||
|
||||
export function useNodeColors(graphStore: GraphStore, getCssValue: (variable: string) => string) {
|
||||
function getNodeColor(node: NodeId) {
|
||||
const color = graphStore.db.getNodeColorStyle(node)
|
||||
if (color.startsWith('var')) {
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
usePlacement,
|
||||
} from '@/components/ComponentBrowser/placement'
|
||||
import type { GraphNavigator } from '@/providers/graphNavigator'
|
||||
import { useGraphStore, type NodeId } from '@/stores/graph'
|
||||
import { type GraphStore, type NodeId } from '@/stores/graph'
|
||||
import { asNodeId } from '@/stores/graph/graphDatabase'
|
||||
import type { RequiredImport } from '@/stores/graph/imports'
|
||||
import type { Typename } from '@/stores/suggestionDatabase/entry'
|
||||
@ -51,12 +51,11 @@ export interface NodeCreationOptions<Placement extends PlacementStrategy = Place
|
||||
}
|
||||
|
||||
export function useNodeCreation(
|
||||
graphStore: GraphStore,
|
||||
viewport: ToValue<GraphNavigator['viewport']>,
|
||||
sceneMousePos: ToValue<GraphNavigator['sceneMousePos']>,
|
||||
onCreated: (nodes: Set<NodeId>) => void,
|
||||
) {
|
||||
const graphStore = useGraphStore()
|
||||
|
||||
function tryMouse() {
|
||||
const pos = toValue(sceneMousePos)
|
||||
return pos ? mouseDictatedPlacement(pos) : undefined
|
||||
|
@ -1,15 +1,12 @@
|
||||
import type { BreadcrumbItem } from '@/components/NavBreadcrumbs.vue'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { type GraphStore } from '@/stores/graph'
|
||||
import { type ProjectStore } from '@/stores/project'
|
||||
import type { AstId } from '@/util/ast/abstract.ts'
|
||||
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
|
||||
import { methodPointerEquals, type StackItem } from 'shared/languageServerTypes'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
export function useStackNavigator() {
|
||||
const projectStore = useProjectStore()
|
||||
const graphStore = useGraphStore()
|
||||
|
||||
export function useStackNavigator(projectStore: ProjectStore, graphStore: GraphStore) {
|
||||
const breadcrumbs = ref<StackItem[]>([])
|
||||
|
||||
const breadcrumbLabels = computed(() => {
|
||||
|
@ -1,44 +0,0 @@
|
||||
import { initializePrefixes } from '@/util/ast/node'
|
||||
import { initializeFFI } from 'shared/ast/ffi'
|
||||
|
||||
import App from '@/App.vue'
|
||||
import type { ApplicationConfig } from '@/util/config'
|
||||
import { createPinia, type Pinia } from 'pinia'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import '@/assets/main.css'
|
||||
|
||||
export async function mountProjectApp(
|
||||
rootProps: {
|
||||
config: ApplicationConfig
|
||||
accessToken: string | null
|
||||
unrecognizedOptions: string[]
|
||||
},
|
||||
pinia?: Pinia | undefined,
|
||||
) {
|
||||
await initializeFFI()
|
||||
initializePrefixes()
|
||||
|
||||
const usedPinia = pinia ?? createPinia()
|
||||
const app = createApp(App, rootProps)
|
||||
app.use(usedPinia)
|
||||
app.mount('#app')
|
||||
return () => {
|
||||
app.unmount()
|
||||
console.log('app unmounted')
|
||||
disposePinia(usedPinia)
|
||||
}
|
||||
}
|
||||
|
||||
// Hack: `disposePinia` is not yet officially released, but we desperately need this for correct app
|
||||
// cleanup. Pasted code from git version seems to work fine. This should be replaced with pinia
|
||||
// export once it is available. Code copied from:
|
||||
// https://github.com/vuejs/pinia/blob/8835e98173d9443531a7d65dfed09c2a8c19975d/packages/pinia/src/createPinia.ts#L74
|
||||
export function disposePinia(pinia: Pinia) {
|
||||
const anyPinia: any = pinia
|
||||
anyPinia._e.stop()
|
||||
anyPinia._s.clear()
|
||||
anyPinia._p.splice(0)
|
||||
anyPinia.state.value = {}
|
||||
anyPinia._a = null
|
||||
}
|
@ -4,13 +4,13 @@
|
||||
* providing mocks for connections with engine and to avoid running dashboard.
|
||||
*/
|
||||
|
||||
import { appRunner } from '@/appRunner'
|
||||
import { MockYdocProvider } from '@/util/crdt'
|
||||
import { MockTransport, MockWebSocket } from '@/util/net'
|
||||
import { createPinia } from 'pinia'
|
||||
import { mockDataHandler, mockLSHandler, mockYdocProvider } from '../mock/engine'
|
||||
|
||||
import 'enso-dashboard/src/tailwind.css'
|
||||
import { createApp } from 'vue'
|
||||
import { AsyncApp } from './asyncApp'
|
||||
|
||||
MockTransport.addMock('engine', mockLSHandler)
|
||||
MockWebSocket.addMock('data', mockDataHandler)
|
||||
@ -24,31 +24,26 @@ window_.fileBrowserApi = {
|
||||
},
|
||||
}
|
||||
|
||||
const pinia = createPinia()
|
||||
pinia.use((ctx) => {
|
||||
if (ctx.store.$id === 'graph') {
|
||||
window_.mockExpressionUpdate = ctx.store.mockExpressionUpdate
|
||||
}
|
||||
AsyncApp().then(({ default: App }) => {
|
||||
const app = createApp(App, {
|
||||
config: {
|
||||
startup: {
|
||||
project: 'Mock_Project',
|
||||
displayedProjectName: 'Mock Project',
|
||||
},
|
||||
engine: {
|
||||
rpcUrl: 'mock://engine',
|
||||
dataUrl: 'mock://data',
|
||||
namespace: 'local',
|
||||
projectManagerUrl: '',
|
||||
},
|
||||
window: {
|
||||
topBarOffset: '96',
|
||||
},
|
||||
},
|
||||
projectId: 'project-135af445-bcfb-42fe-aa74-96f95e99c28b',
|
||||
logEvent: () => {},
|
||||
hidden: false,
|
||||
})
|
||||
app.mount('body')
|
||||
})
|
||||
|
||||
// Instead of running through dashboard, setup the app immediately with mocked configuration.
|
||||
appRunner.runApp(
|
||||
{
|
||||
startup: {
|
||||
project: 'Mock_Project',
|
||||
displayedProjectName: 'Mock Project',
|
||||
},
|
||||
engine: {
|
||||
rpcUrl: 'mock://engine',
|
||||
dataUrl: 'mock://data',
|
||||
namespace: 'local',
|
||||
projectManagerUrl: '',
|
||||
},
|
||||
window: {
|
||||
topBarOffset: '96',
|
||||
},
|
||||
},
|
||||
null,
|
||||
undefined,
|
||||
pinia,
|
||||
)
|
||||
|
@ -3,9 +3,11 @@ import { urlParams } from '@/util/urlParams'
|
||||
import { isOnLinux } from 'enso-common/src/detect'
|
||||
import * as dashboard from 'enso-dashboard'
|
||||
import { isDevMode } from 'shared/util/detect'
|
||||
import { appRunner } from './appRunner'
|
||||
import { lazyVueInReact } from 'veaury'
|
||||
|
||||
import 'enso-dashboard/src/tailwind.css'
|
||||
import type { EditorRunner } from '../../ide-desktop/lib/types/types'
|
||||
import { AsyncApp } from './asyncApp'
|
||||
|
||||
const INITIAL_URL_KEY = `Enso-initial-url`
|
||||
const SCAM_WARNING_TIMEOUT = 1000
|
||||
@ -44,6 +46,8 @@ window.addEventListener('resize', () => {
|
||||
scamWarningHandle = window.setTimeout(printScamWarning, SCAM_WARNING_TIMEOUT)
|
||||
})
|
||||
|
||||
const appRunner = lazyVueInReact(AsyncApp as any /* async VueComponent */) as EditorRunner
|
||||
|
||||
/** The entrypoint into the IDE. */
|
||||
function main() {
|
||||
/** Note: Signing out always redirects to `/`. It is impossible to make this work,
|
||||
|
22
app/gui2/src/providers/eventLogging.ts
Normal file
22
app/gui2/src/providers/eventLogging.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import { createContextStore } from '.'
|
||||
|
||||
export type EventLogger = ReturnType<typeof injectFn>
|
||||
export { injectFn as injectEventLogger, provideFn as provideEventLogger }
|
||||
const { provideFn, injectFn } = createContextStore('event logger', eventLogger)
|
||||
|
||||
function eventLogger(logEvent: Ref<LogEvent>, projectId: Ref<string>) {
|
||||
const logProjectId = computed(() => {
|
||||
const id = projectId.value
|
||||
if (!id) return undefined
|
||||
const prefix = 'project-'
|
||||
const projectUuid = id.startsWith(prefix) ? id.substring(prefix.length) : id
|
||||
return `${prefix}${projectUuid.replace(/-/g, '')}`
|
||||
})
|
||||
|
||||
return {
|
||||
async send(message: string) {
|
||||
logEvent.value(message, logProjectId.value)
|
||||
},
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -155,7 +155,8 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
|
||||
}
|
||||
|
||||
set desiredStack(stack: StackItem[]) {
|
||||
this._desiredStack = stack
|
||||
this._desiredStack.length = 0
|
||||
this._desiredStack.push(...stack)
|
||||
this.sync()
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { createContextStore } from '@/providers'
|
||||
import { injectGuiConfig, type GuiConfig } from '@/providers/guiConfig'
|
||||
import { Awareness } from '@/stores/awareness'
|
||||
import { ComputedValueRegistry } from '@/stores/project/computedValueRegistry'
|
||||
@ -16,7 +17,6 @@ import { DataServer } from '@/util/net/dataServer'
|
||||
import { tryQualifiedName } from '@/util/qualifiedName'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import * as random from 'lib0/random'
|
||||
import { defineStore } from 'pinia'
|
||||
import { OutboundPayload, VisualizationUpdate } from 'shared/binaryProtocol'
|
||||
import { LanguageServer } from 'shared/languageServer'
|
||||
import type { Diagnostic, ExpressionId, MethodPointer } from 'shared/languageServerTypes'
|
||||
@ -31,6 +31,7 @@ import {
|
||||
computed,
|
||||
markRaw,
|
||||
onScopeDispose,
|
||||
proxyRefs,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
@ -86,288 +87,296 @@ function initializeDataConnection(clientId: Uuid, url: string, abort: AbortScope
|
||||
return connection
|
||||
}
|
||||
|
||||
export type ProjectStore = ReturnType<typeof useProjectStore>
|
||||
|
||||
/**
|
||||
* The project store synchronizes and holds the open project-related data. The synchronization is
|
||||
* performed using a CRDT data types from Yjs. Once the data is synchronized with a "LS bridge"
|
||||
* client, it is submitted to the language server as a document update.
|
||||
*/
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
const abort = useAbortScope()
|
||||
export const { provideFn: provideProjectStore, injectFn: useProjectStore } = createContextStore(
|
||||
'project',
|
||||
() => {
|
||||
const abort = useAbortScope()
|
||||
|
||||
const observedFileName = ref<string>()
|
||||
const observedFileName = ref<string>()
|
||||
|
||||
const doc = new Y.Doc()
|
||||
const awareness = new Awareness(doc)
|
||||
const doc = new Y.Doc()
|
||||
const awareness = new Awareness(doc)
|
||||
|
||||
const config = injectGuiConfig()
|
||||
const projectName = config.value.startup?.project
|
||||
if (projectName == null) throw new Error('Missing project name.')
|
||||
const projectDisplayName = config.value.startup?.displayedProjectName ?? projectName
|
||||
const config = injectGuiConfig()
|
||||
const projectName = config.value.startup?.project
|
||||
if (projectName == null) throw new Error('Missing project name.')
|
||||
const projectDisplayName = config.value.startup?.displayedProjectName ?? projectName
|
||||
|
||||
const clientId = random.uuidv4() as Uuid
|
||||
const lsUrls = resolveLsUrl(config.value)
|
||||
const lsRpcConnection = createLsRpcConnection(clientId, lsUrls.rpcUrl, abort)
|
||||
const contentRoots = lsRpcConnection.contentRoots
|
||||
const clientId = random.uuidv4() as Uuid
|
||||
const lsUrls = resolveLsUrl(config.value)
|
||||
const lsRpcConnection = createLsRpcConnection(clientId, lsUrls.rpcUrl, abort)
|
||||
const contentRoots = lsRpcConnection.contentRoots
|
||||
|
||||
const dataConnection = initializeDataConnection(clientId, lsUrls.dataUrl, abort)
|
||||
const rpcUrl = new URL(lsUrls.rpcUrl)
|
||||
const isOnLocalBackend =
|
||||
rpcUrl.protocol === 'mock:' ||
|
||||
rpcUrl.hostname === 'localhost' ||
|
||||
rpcUrl.hostname === '127.0.0.1' ||
|
||||
rpcUrl.hostname === '[::1]' ||
|
||||
rpcUrl.hostname === '0:0:0:0:0:0:0:1'
|
||||
const dataConnection = initializeDataConnection(clientId, lsUrls.dataUrl, abort)
|
||||
const rpcUrl = new URL(lsUrls.rpcUrl)
|
||||
const isOnLocalBackend =
|
||||
rpcUrl.protocol === 'mock:' ||
|
||||
rpcUrl.hostname === 'localhost' ||
|
||||
rpcUrl.hostname === '127.0.0.1' ||
|
||||
rpcUrl.hostname === '[::1]' ||
|
||||
rpcUrl.hostname === '0:0:0:0:0:0:0:1'
|
||||
|
||||
const name = computed(() => config.value.startup?.project)
|
||||
const namespace = computed(() => config.value.engine?.namespace)
|
||||
const fullName = computed(() => {
|
||||
const ns = namespace.value
|
||||
if (import.meta.env.PROD && ns == null) {
|
||||
console.warn(
|
||||
'Unknown project\'s namespace. Assuming "local", however it likely won\'t work in cloud',
|
||||
const name = computed(() => config.value.startup?.project)
|
||||
const namespace = computed(() => config.value.engine?.namespace)
|
||||
const fullName = computed(() => {
|
||||
const ns = namespace.value
|
||||
if (import.meta.env.PROD && ns == null) {
|
||||
console.warn(
|
||||
'Unknown project\'s namespace. Assuming "local", however it likely won\'t work in cloud',
|
||||
)
|
||||
}
|
||||
const projectName = name.value
|
||||
if (projectName == null) {
|
||||
console.error(
|
||||
"Unknown project's name. Cannot specify opened module's qualified path; many things may not work",
|
||||
)
|
||||
return null
|
||||
}
|
||||
return `${ns ?? 'local'}.${projectName}`
|
||||
})
|
||||
const modulePath = computed(() => {
|
||||
const filePath = observedFileName.value
|
||||
if (filePath == null) return undefined
|
||||
const withoutFileExt = filePath.replace(/\.enso$/, '')
|
||||
const withDotSeparators = withoutFileExt.replace(/\//g, '.')
|
||||
return tryQualifiedName(`${fullName.value}.${withDotSeparators}`)
|
||||
})
|
||||
|
||||
let yDocsProvider: ReturnType<typeof attachProvider> | undefined
|
||||
watchEffect((onCleanup) => {
|
||||
yDocsProvider = attachProvider(
|
||||
lsUrls.ydocUrl.href,
|
||||
'index',
|
||||
{ ls: lsUrls.rpcUrl },
|
||||
doc,
|
||||
awareness.internal,
|
||||
)
|
||||
onCleanup(disposeYDocsProvider)
|
||||
})
|
||||
|
||||
const projectModel = new DistributedProject(doc)
|
||||
const moduleDocGuid = ref<string>()
|
||||
|
||||
function currentDocGuid() {
|
||||
const name = observedFileName.value
|
||||
if (name == null) return
|
||||
return projectModel.modules.get(name)?.guid
|
||||
}
|
||||
const projectName = name.value
|
||||
if (projectName == null) {
|
||||
console.error(
|
||||
"Unknown project's name. Cannot specify opened module's qualified path; many things may not work",
|
||||
)
|
||||
return null
|
||||
function tryReadDocGuid() {
|
||||
const guid = currentDocGuid()
|
||||
if (guid === moduleDocGuid.value) return
|
||||
moduleDocGuid.value = guid
|
||||
}
|
||||
return `${ns ?? 'local'}.${projectName}`
|
||||
})
|
||||
const modulePath = computed(() => {
|
||||
const filePath = observedFileName.value
|
||||
if (filePath == null) return undefined
|
||||
const withoutFileExt = filePath.replace(/\.enso$/, '')
|
||||
const withDotSeparators = withoutFileExt.replace(/\//g, '.')
|
||||
return tryQualifiedName(`${fullName.value}.${withDotSeparators}`)
|
||||
})
|
||||
|
||||
let yDocsProvider: ReturnType<typeof attachProvider> | undefined
|
||||
watchEffect((onCleanup) => {
|
||||
yDocsProvider = attachProvider(
|
||||
lsUrls.ydocUrl.href,
|
||||
'index',
|
||||
{ ls: lsUrls.rpcUrl },
|
||||
doc,
|
||||
awareness.internal,
|
||||
)
|
||||
onCleanup(disposeYDocsProvider)
|
||||
})
|
||||
projectModel.modules.observe(tryReadDocGuid)
|
||||
watchEffect(tryReadDocGuid)
|
||||
|
||||
const projectModel = new DistributedProject(doc)
|
||||
const moduleDocGuid = ref<string>()
|
||||
const module = computedAsync(async () => {
|
||||
const guid = moduleDocGuid.value
|
||||
if (guid == null) return null
|
||||
const moduleName = projectModel.findModuleByDocId(guid)
|
||||
if (moduleName == null) return null
|
||||
const mod = await projectModel.openModule(moduleName)
|
||||
for (const origin of localUserActionOrigins) mod?.undoManager.addTrackedOrigin(origin)
|
||||
return mod
|
||||
})
|
||||
|
||||
function currentDocGuid() {
|
||||
const name = observedFileName.value
|
||||
if (name == null) return
|
||||
return projectModel.modules.get(name)?.guid
|
||||
}
|
||||
function tryReadDocGuid() {
|
||||
const guid = currentDocGuid()
|
||||
if (guid === moduleDocGuid.value) return
|
||||
moduleDocGuid.value = guid
|
||||
}
|
||||
const entryPoint = computed<MethodPointer>(() => {
|
||||
const projectName = fullName.value
|
||||
const mainModule = `${projectName}.Main`
|
||||
return { module: mainModule, definedOnType: mainModule, name: 'main' }
|
||||
})
|
||||
|
||||
projectModel.modules.observe(tryReadDocGuid)
|
||||
watchEffect(tryReadDocGuid)
|
||||
|
||||
const module = computedAsync(async () => {
|
||||
const guid = moduleDocGuid.value
|
||||
if (guid == null) return null
|
||||
const moduleName = projectModel.findModuleByDocId(guid)
|
||||
if (moduleName == null) return null
|
||||
const mod = await projectModel.openModule(moduleName)
|
||||
for (const origin of localUserActionOrigins) mod?.undoManager.addTrackedOrigin(origin)
|
||||
return mod
|
||||
})
|
||||
|
||||
const entryPoint = computed<MethodPointer>(() => {
|
||||
const projectName = fullName.value
|
||||
const mainModule = `${projectName}.Main`
|
||||
return { module: mainModule, definedOnType: mainModule, name: 'main' }
|
||||
})
|
||||
|
||||
function createExecutionContextForMain(): ExecutionContext {
|
||||
return new ExecutionContext(
|
||||
lsRpcConnection,
|
||||
{
|
||||
methodPointer: entryPoint.value,
|
||||
positionalArgumentsExpressions: [],
|
||||
},
|
||||
abort,
|
||||
)
|
||||
}
|
||||
|
||||
const firstExecution = nextEvent(lsRpcConnection, 'executionContext/executionComplete').catch(
|
||||
(error) => {
|
||||
console.error('First execution failed:', error)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
const executionContext = createExecutionContextForMain()
|
||||
const visualizationDataRegistry = new VisualizationDataRegistry(executionContext, dataConnection)
|
||||
const computedValueRegistry = ComputedValueRegistry.WithExecutionContext(executionContext)
|
||||
|
||||
const diagnostics = shallowRef<Diagnostic[]>([])
|
||||
executionContext.on('executionStatus', (newDiagnostics) => {
|
||||
diagnostics.value = newDiagnostics
|
||||
})
|
||||
|
||||
function useVisualizationData(configuration: WatchSource<Opt<NodeVisualizationConfiguration>>) {
|
||||
const id = random.uuidv4() as Uuid
|
||||
|
||||
watch(
|
||||
configuration,
|
||||
(config, _, onCleanup) => {
|
||||
executionContext.setVisualization(id, config)
|
||||
onCleanup(() => executionContext.setVisualization(id, null))
|
||||
},
|
||||
// Make sure to flush this watch in 'post', otherwise it might cause operations on stale
|
||||
// ASTs just before the widget tree renders and cleans up the associated widget instances.
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
return computed(() => parseVisualizationData(visualizationDataRegistry.getRawData(id)))
|
||||
}
|
||||
|
||||
const dataflowErrors = new ReactiveMapping(computedValueRegistry.db, (id, info) => {
|
||||
const config = computed(() =>
|
||||
info.payload.type === 'DataflowError' ?
|
||||
function createExecutionContextForMain(): ExecutionContext {
|
||||
return new ExecutionContext(
|
||||
lsRpcConnection,
|
||||
{
|
||||
expressionId: id,
|
||||
visualizationModule: 'Standard.Visualization.Preprocessor',
|
||||
expression: {
|
||||
module: 'Standard.Visualization.Preprocessor',
|
||||
definedOnType: 'Standard.Visualization.Preprocessor',
|
||||
name: 'error_preprocessor',
|
||||
},
|
||||
}
|
||||
: null,
|
||||
)
|
||||
const data = useVisualizationData(config)
|
||||
return computed<{ kind: 'Dataflow'; message: string } | undefined>(() => {
|
||||
const visResult = data.value
|
||||
if (!visResult) return
|
||||
if (!visResult.ok) {
|
||||
visResult.error.log('Dataflow Error visualization evaluation failed')
|
||||
return undefined
|
||||
} else if ('message' in visResult.value && typeof visResult.value.message === 'string') {
|
||||
if ('kind' in visResult.value && visResult.value.kind === 'Dataflow')
|
||||
return { kind: visResult.value.kind, message: visResult.value.message }
|
||||
// Other kinds of error are not handled here
|
||||
else return undefined
|
||||
} else {
|
||||
console.error('Invalid dataflow error payload:', visResult.value)
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const isRecordingEnabled = computed(() => executionMode.value === 'live')
|
||||
|
||||
function stopCapturingUndo() {
|
||||
module.value?.undoManager.stopCapturing()
|
||||
}
|
||||
|
||||
function executeExpression(
|
||||
expressionId: ExternalId,
|
||||
expression: string,
|
||||
): Promise<Result<any> | null> {
|
||||
return new Promise((resolve) => {
|
||||
const visualizationId = random.uuidv4() as Uuid
|
||||
const dataHandler = (visData: VisualizationUpdate, uuid: Uuid | null) => {
|
||||
if (uuid === visualizationId) {
|
||||
dataConnection.off(`${OutboundPayload.VISUALIZATION_UPDATE}`, dataHandler)
|
||||
executionContext.off('visualizationEvaluationFailed', errorHandler)
|
||||
const dataStr = Ok(visData.dataString())
|
||||
resolve(parseVisualizationData(dataStr))
|
||||
}
|
||||
}
|
||||
const errorHandler = (
|
||||
uuid: Uuid,
|
||||
_expressionId: ExpressionId,
|
||||
message: string,
|
||||
_diagnostic: Diagnostic | undefined,
|
||||
) => {
|
||||
if (uuid == visualizationId) {
|
||||
resolve(Err(message))
|
||||
dataConnection.off(`${OutboundPayload.VISUALIZATION_UPDATE}`, dataHandler)
|
||||
executionContext.off('visualizationEvaluationFailed', errorHandler)
|
||||
}
|
||||
}
|
||||
dataConnection.on(`${OutboundPayload.VISUALIZATION_UPDATE}`, dataHandler)
|
||||
executionContext.on('visualizationEvaluationFailed', errorHandler)
|
||||
return lsRpcConnection.executeExpression(
|
||||
executionContext.id,
|
||||
visualizationId,
|
||||
expressionId,
|
||||
expression,
|
||||
methodPointer: entryPoint.value,
|
||||
positionalArgumentsExpressions: [],
|
||||
},
|
||||
abort,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function parseVisualizationData(data: Result<string | null> | null): Result<any> | null {
|
||||
if (!data?.ok) return data
|
||||
if (data.value == null) return null
|
||||
try {
|
||||
return Ok(markRaw(JSON.parse(data.value)))
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError)
|
||||
return Err(`Parsing visualization result failed: ${error.message}`)
|
||||
else throw error
|
||||
}
|
||||
}
|
||||
|
||||
const { executionMode } = setupSettings(projectModel)
|
||||
const firstExecution = nextEvent(lsRpcConnection, 'executionContext/executionComplete').catch(
|
||||
(error) => {
|
||||
console.error('First execution failed:', error)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
const executionContext = createExecutionContextForMain()
|
||||
const visualizationDataRegistry = new VisualizationDataRegistry(
|
||||
executionContext,
|
||||
dataConnection,
|
||||
)
|
||||
const computedValueRegistry = ComputedValueRegistry.WithExecutionContext(executionContext)
|
||||
|
||||
function disposeYDocsProvider() {
|
||||
yDocsProvider?.dispose()
|
||||
yDocsProvider = undefined
|
||||
}
|
||||
const diagnostics = shallowRef<Diagnostic[]>([])
|
||||
executionContext.on('executionStatus', (newDiagnostics) => {
|
||||
diagnostics.value = newDiagnostics
|
||||
})
|
||||
|
||||
const recordMode = computed({
|
||||
get() {
|
||||
return executionMode.value === 'live'
|
||||
},
|
||||
set(value) {
|
||||
executionMode.value = value ? 'live' : 'design'
|
||||
},
|
||||
})
|
||||
function useVisualizationData(configuration: WatchSource<Opt<NodeVisualizationConfiguration>>) {
|
||||
const id = random.uuidv4() as Uuid
|
||||
|
||||
return {
|
||||
setObservedFileName(name: string) {
|
||||
observedFileName.value = name
|
||||
},
|
||||
get observedFileName() {
|
||||
return observedFileName.value
|
||||
},
|
||||
name: projectName,
|
||||
displayName: projectDisplayName,
|
||||
isOnLocalBackend,
|
||||
executionContext,
|
||||
firstExecution,
|
||||
diagnostics,
|
||||
module,
|
||||
modulePath,
|
||||
entryPoint,
|
||||
projectModel,
|
||||
contentRoots,
|
||||
awareness: markRaw(awareness),
|
||||
computedValueRegistry: markRaw(computedValueRegistry),
|
||||
lsRpcConnection: markRaw(lsRpcConnection),
|
||||
dataConnection: markRaw(dataConnection),
|
||||
useVisualizationData,
|
||||
isRecordingEnabled,
|
||||
stopCapturingUndo,
|
||||
executionMode,
|
||||
recordMode,
|
||||
dataflowErrors,
|
||||
executeExpression,
|
||||
disposeYDocsProvider,
|
||||
}
|
||||
})
|
||||
watch(
|
||||
configuration,
|
||||
(config, _, onCleanup) => {
|
||||
executionContext.setVisualization(id, config)
|
||||
onCleanup(() => executionContext.setVisualization(id, null))
|
||||
},
|
||||
// Make sure to flush this watch in 'post', otherwise it might cause operations on stale
|
||||
// ASTs just before the widget tree renders and cleans up the associated widget instances.
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
return computed(() => parseVisualizationData(visualizationDataRegistry.getRawData(id)))
|
||||
}
|
||||
|
||||
const dataflowErrors = new ReactiveMapping(computedValueRegistry.db, (id, info) => {
|
||||
const config = computed(() =>
|
||||
info.payload.type === 'DataflowError' ?
|
||||
{
|
||||
expressionId: id,
|
||||
visualizationModule: 'Standard.Visualization.Preprocessor',
|
||||
expression: {
|
||||
module: 'Standard.Visualization.Preprocessor',
|
||||
definedOnType: 'Standard.Visualization.Preprocessor',
|
||||
name: 'error_preprocessor',
|
||||
},
|
||||
}
|
||||
: null,
|
||||
)
|
||||
const data = useVisualizationData(config)
|
||||
return computed<{ kind: 'Dataflow'; message: string } | undefined>(() => {
|
||||
const visResult = data.value
|
||||
if (!visResult) return
|
||||
if (!visResult.ok) {
|
||||
visResult.error.log('Dataflow Error visualization evaluation failed')
|
||||
return undefined
|
||||
} else if ('message' in visResult.value && typeof visResult.value.message === 'string') {
|
||||
if ('kind' in visResult.value && visResult.value.kind === 'Dataflow')
|
||||
return { kind: visResult.value.kind, message: visResult.value.message }
|
||||
// Other kinds of error are not handled here
|
||||
else return undefined
|
||||
} else {
|
||||
console.error('Invalid dataflow error payload:', visResult.value)
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const isRecordingEnabled = computed(() => executionMode.value === 'live')
|
||||
|
||||
function stopCapturingUndo() {
|
||||
module.value?.undoManager.stopCapturing()
|
||||
}
|
||||
|
||||
function executeExpression(
|
||||
expressionId: ExternalId,
|
||||
expression: string,
|
||||
): Promise<Result<any> | null> {
|
||||
return new Promise((resolve) => {
|
||||
const visualizationId = random.uuidv4() as Uuid
|
||||
const dataHandler = (visData: VisualizationUpdate, uuid: Uuid | null) => {
|
||||
if (uuid === visualizationId) {
|
||||
dataConnection.off(`${OutboundPayload.VISUALIZATION_UPDATE}`, dataHandler)
|
||||
executionContext.off('visualizationEvaluationFailed', errorHandler)
|
||||
const dataStr = Ok(visData.dataString())
|
||||
resolve(parseVisualizationData(dataStr))
|
||||
}
|
||||
}
|
||||
const errorHandler = (
|
||||
uuid: Uuid,
|
||||
_expressionId: ExpressionId,
|
||||
message: string,
|
||||
_diagnostic: Diagnostic | undefined,
|
||||
) => {
|
||||
if (uuid == visualizationId) {
|
||||
resolve(Err(message))
|
||||
dataConnection.off(`${OutboundPayload.VISUALIZATION_UPDATE}`, dataHandler)
|
||||
executionContext.off('visualizationEvaluationFailed', errorHandler)
|
||||
}
|
||||
}
|
||||
dataConnection.on(`${OutboundPayload.VISUALIZATION_UPDATE}`, dataHandler)
|
||||
executionContext.on('visualizationEvaluationFailed', errorHandler)
|
||||
return lsRpcConnection.executeExpression(
|
||||
executionContext.id,
|
||||
visualizationId,
|
||||
expressionId,
|
||||
expression,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function parseVisualizationData(data: Result<string | null> | null): Result<any> | null {
|
||||
if (!data?.ok) return data
|
||||
if (data.value == null) return null
|
||||
try {
|
||||
return Ok(markRaw(JSON.parse(data.value)))
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError)
|
||||
return Err(`Parsing visualization result failed: ${error.message}`)
|
||||
else throw error
|
||||
}
|
||||
}
|
||||
|
||||
const { executionMode } = setupSettings(projectModel)
|
||||
|
||||
function disposeYDocsProvider() {
|
||||
yDocsProvider?.dispose()
|
||||
yDocsProvider = undefined
|
||||
}
|
||||
|
||||
const recordMode = computed({
|
||||
get() {
|
||||
return executionMode.value === 'live'
|
||||
},
|
||||
set(value) {
|
||||
executionMode.value = value ? 'live' : 'design'
|
||||
},
|
||||
})
|
||||
|
||||
return proxyRefs({
|
||||
setObservedFileName(name: string) {
|
||||
observedFileName.value = name
|
||||
},
|
||||
get observedFileName() {
|
||||
return observedFileName.value
|
||||
},
|
||||
name: projectName,
|
||||
displayName: projectDisplayName,
|
||||
isOnLocalBackend,
|
||||
executionContext,
|
||||
firstExecution,
|
||||
diagnostics,
|
||||
module,
|
||||
modulePath,
|
||||
entryPoint,
|
||||
projectModel,
|
||||
contentRoots,
|
||||
awareness: markRaw(awareness),
|
||||
computedValueRegistry: markRaw(computedValueRegistry),
|
||||
lsRpcConnection: markRaw(lsRpcConnection),
|
||||
dataConnection: markRaw(dataConnection),
|
||||
useVisualizationData,
|
||||
isRecordingEnabled,
|
||||
stopCapturingUndo,
|
||||
executionMode,
|
||||
recordMode,
|
||||
dataflowErrors,
|
||||
executeExpression,
|
||||
disposeYDocsProvider,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
type ExecutionMode = 'live' | 'design'
|
||||
type Settings = { executionMode: WritableComputedRef<ExecutionMode> }
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { createContextStore } from '@/providers'
|
||||
import { type ProjectStore } from '@/stores/project'
|
||||
import { entryQn, type SuggestionEntry, type SuggestionId } from '@/stores/suggestionDatabase/entry'
|
||||
import { applyUpdates, entryFromLs } from '@/stores/suggestionDatabase/lsUpdate'
|
||||
import { ReactiveDb, ReactiveIndex } from '@/util/database/reactiveDb'
|
||||
@ -10,11 +11,10 @@ import {
|
||||
tryQualifiedName,
|
||||
type QualifiedName,
|
||||
} from '@/util/qualifiedName'
|
||||
import { defineStore } from 'pinia'
|
||||
import { LanguageServer } from 'shared/languageServer'
|
||||
import type { MethodPointer } from 'shared/languageServerTypes'
|
||||
import { exponentialBackoff } from 'shared/util/net'
|
||||
import { markRaw, ref, type Ref } from 'vue'
|
||||
import { markRaw, proxyRefs, ref, type Ref } from 'vue'
|
||||
|
||||
export class SuggestionDb extends ReactiveDb<SuggestionId, SuggestionEntry> {
|
||||
nameToId = new ReactiveIndex(this, (id, entry) => [[entryQn(entry), id]])
|
||||
@ -56,10 +56,10 @@ class Synchronizer {
|
||||
queue: AsyncQueue<{ currentVersion: number }>
|
||||
|
||||
constructor(
|
||||
projectStore: ProjectStore,
|
||||
public entries: SuggestionDb,
|
||||
public groups: Ref<Group[]>,
|
||||
) {
|
||||
const projectStore = useProjectStore()
|
||||
const lsRpc = projectStore.lsRpcConnection
|
||||
const initState = exponentialBackoff(() =>
|
||||
lsRpc.acquireCapability('search/receivesSuggestionsDatabaseUpdates', {}),
|
||||
@ -67,8 +67,8 @@ class Synchronizer {
|
||||
if (!capability.ok) {
|
||||
capability.error.log('Will not receive database updates')
|
||||
}
|
||||
this.setupUpdateHandler(lsRpc)
|
||||
this.loadGroups(lsRpc, projectStore.firstExecution)
|
||||
this.#setupUpdateHandler(lsRpc)
|
||||
this.#loadGroups(lsRpc, projectStore.firstExecution)
|
||||
return Synchronizer.loadDatabase(entries, lsRpc, groups.value)
|
||||
})
|
||||
|
||||
@ -99,7 +99,7 @@ class Synchronizer {
|
||||
return { currentVersion: initialDb.value.currentVersion }
|
||||
}
|
||||
|
||||
private setupUpdateHandler(lsRpc: LanguageServer) {
|
||||
#setupUpdateHandler(lsRpc: LanguageServer) {
|
||||
lsRpc.on('search/suggestionsDatabaseUpdates', (param) => {
|
||||
this.queue.pushTask(async ({ currentVersion }) => {
|
||||
// There are rare cases where the database is updated twice in quick succession, with the
|
||||
@ -126,7 +126,7 @@ class Synchronizer {
|
||||
})
|
||||
}
|
||||
|
||||
private async loadGroups(lsRpc: LanguageServer, firstExecution: Promise<unknown>) {
|
||||
async #loadGroups(lsRpc: LanguageServer, firstExecution: Promise<unknown>) {
|
||||
this.queue.pushTask(async ({ currentVersion }) => {
|
||||
await firstExecution
|
||||
const groups = await exponentialBackoff(() => lsRpc.getComponentGroups())
|
||||
@ -146,10 +146,12 @@ class Synchronizer {
|
||||
}
|
||||
}
|
||||
|
||||
export const useSuggestionDbStore = defineStore('suggestionDatabase', () => {
|
||||
const entries = new SuggestionDb()
|
||||
const groups = ref<Group[]>([])
|
||||
export type SuggestionDbStore = ReturnType<typeof useSuggestionDbStore>
|
||||
export const { provideFn: provideSuggestionDbStore, injectFn: useSuggestionDbStore } =
|
||||
createContextStore('suggestionDatabase', (projectStore: ProjectStore) => {
|
||||
const entries = new SuggestionDb()
|
||||
const groups = ref<Group[]>([])
|
||||
|
||||
const _synchronizer = new Synchronizer(entries, groups)
|
||||
return { entries: markRaw(entries), groups, _synchronizer }
|
||||
})
|
||||
const _synchronizer = new Synchronizer(projectStore, entries, groups)
|
||||
return proxyRefs({ entries: markRaw(entries), groups, _synchronizer })
|
||||
})
|
||||
|
@ -7,7 +7,8 @@ import * as scatterplotVisualization from '@/components/visualizations/Scatterpl
|
||||
import * as sqlVisualization from '@/components/visualizations/SQLVisualization.vue'
|
||||
import * as tableVisualization from '@/components/visualizations/TableVisualization.vue'
|
||||
import * as warningsVisualization from '@/components/visualizations/WarningsVisualization.vue'
|
||||
import { useProjectStore } from '@/stores/project'
|
||||
import { createContextStore } from '@/providers'
|
||||
import { type ProjectStore } from '@/stores/project'
|
||||
import {
|
||||
compile,
|
||||
currentProjectProtocol,
|
||||
@ -24,7 +25,6 @@ import type { VisualizationModule } from '@/stores/visualization/runtimeTypes'
|
||||
import type { Opt } from '@/util/data/opt'
|
||||
import { isUrlString } from '@/util/data/urlString'
|
||||
import { isIconName } from '@/util/iconName'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ErrorCode, LsRpcError, RemoteRpcError } from 'shared/languageServer'
|
||||
import type { Event as LSEvent, VisualizationConfiguration } from 'shared/languageServerTypes'
|
||||
import type { ExternalId, VisualizationIdentifier } from 'shared/yjsModel'
|
||||
@ -78,202 +78,205 @@ const builtinVisualizationsByName = Object.fromEntries(
|
||||
builtinVisualizations.map((viz) => [viz.name, viz]),
|
||||
)
|
||||
|
||||
export const useVisualizationStore = defineStore('visualization', () => {
|
||||
const cache = reactive(new Map<VisualizationId, Promise<VisualizationModule>>())
|
||||
/** A map from file path to {@link AbortController}, so that a file change event can stop previous
|
||||
* file change event handlers for the same path. */
|
||||
const compilationAbortControllers = reactive(new Map<string, AbortController>())
|
||||
/** A map from file path in the current project, to visualization name. This is required so that
|
||||
* file delete events can remove the cached visualization. */
|
||||
const currentProjectVisualizationsByPath = new Map<string, string>()
|
||||
const metadata = new VisualizationMetadataDb()
|
||||
const proj = useProjectStore()
|
||||
const projectRoot = proj.contentRoots.then(
|
||||
(roots) => roots.find((root) => root.type === 'Project')?.id,
|
||||
)
|
||||
export const { provideFn: provideVisualizationStore, injectFn: useVisualizationStore } =
|
||||
createContextStore('visualization', (proj: ProjectStore) => {
|
||||
const cache = reactive(new Map<VisualizationId, Promise<VisualizationModule>>())
|
||||
/** A map from file path to {@link AbortController}, so that a file change event can stop previous
|
||||
* file change event handlers for the same path. */
|
||||
const compilationAbortControllers = reactive(new Map<string, AbortController>())
|
||||
/** A map from file path in the current project, to visualization name. This is required so that
|
||||
* file delete events can remove the cached visualization. */
|
||||
const currentProjectVisualizationsByPath = new Map<string, string>()
|
||||
const metadata = new VisualizationMetadataDb()
|
||||
const projectRoot = proj.contentRoots.then(
|
||||
(roots) => roots.find((root) => root.type === 'Project')?.id,
|
||||
)
|
||||
|
||||
for (const { name, inputType, icon } of builtinVisualizations) {
|
||||
metadata.set(toVisualizationId({ module: { kind: 'Builtin' }, name }), {
|
||||
name,
|
||||
inputType,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
for (const { name, inputType, icon } of builtinVisualizations) {
|
||||
metadata.set(toVisualizationId({ module: { kind: 'Builtin' }, name }), {
|
||||
name,
|
||||
inputType,
|
||||
icon,
|
||||
})
|
||||
}
|
||||
|
||||
const scriptsNode = document.head.appendChild(document.createElement('div'))
|
||||
scriptsNode.classList.add('visualization-scripts')
|
||||
const loadedScripts = new Set<string>()
|
||||
function loadScripts(module: VisualizationModule) {
|
||||
const promises: Promise<void>[] = []
|
||||
if ('scripts' in module && module.scripts) {
|
||||
if (!Array.isArray(module.scripts)) {
|
||||
console.warn('Visualiation scripts should be an array:', module.scripts)
|
||||
}
|
||||
const scripts = Array.isArray(module.scripts) ? module.scripts : [module.scripts]
|
||||
for (const url of scripts) {
|
||||
if (typeof url !== 'string') {
|
||||
console.warn('Visualization script should be a string, skipping URL:', url)
|
||||
} else if (!loadedScripts.has(url)) {
|
||||
loadedScripts.add(url)
|
||||
const node = document.createElement('script')
|
||||
node.src = url
|
||||
// Some resources still set only "Access-Control-Allow-Origin" in the response.
|
||||
// We need to explicitly make a request CORS - see https://resourcepolicy.fyi
|
||||
node.crossOrigin = 'anonymous'
|
||||
promises.push(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
node.addEventListener('load', () => {
|
||||
resolve()
|
||||
node.remove()
|
||||
})
|
||||
node.addEventListener('error', () => {
|
||||
reject()
|
||||
node.remove()
|
||||
})
|
||||
}),
|
||||
)
|
||||
scriptsNode.appendChild(node)
|
||||
const scriptsNode = document.head.appendChild(document.createElement('div'))
|
||||
scriptsNode.classList.add('visualization-scripts')
|
||||
const loadedScripts = new Set<string>()
|
||||
function loadScripts(module: VisualizationModule) {
|
||||
const promises: Promise<void>[] = []
|
||||
if ('scripts' in module && module.scripts) {
|
||||
if (!Array.isArray(module.scripts)) {
|
||||
console.warn('Visualiation scripts should be an array:', module.scripts)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.allSettled(promises)
|
||||
}
|
||||
|
||||
async function onFileEvent({ kind, path }: LSEvent<'file/event'>) {
|
||||
if (path.rootId !== (await projectRoot) || !/[.]vue$/.test(path.segments.at(-1) ?? '')) return
|
||||
const pathString = customVisualizationsDirectory + '/' + path.segments.join('/')
|
||||
const name = currentProjectVisualizationsByPath.get(pathString)
|
||||
let id: VisualizationIdentifier | undefined =
|
||||
name != null ? { module: { kind: 'CurrentProject' }, name } : undefined
|
||||
const key = id && toVisualizationId(id)
|
||||
compilationAbortControllers.get(pathString)?.abort()
|
||||
compilationAbortControllers.delete(pathString)
|
||||
// FIXME [sb]: Ideally these should be deleted as late as possible, instead of immediately.
|
||||
for (const el of document.querySelectorAll(
|
||||
`[${stylePathAttribute}="${CSS.escape(currentProjectProtocol + pathString)}"]`,
|
||||
)) {
|
||||
el.remove()
|
||||
}
|
||||
switch (kind) {
|
||||
case 'Added':
|
||||
case 'Modified': {
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
compilationAbortControllers.set(pathString, abortController)
|
||||
const vizPromise = compile(
|
||||
currentProjectProtocol + pathString,
|
||||
await projectRoot,
|
||||
await proj.dataConnection,
|
||||
).then(async (viz) => {
|
||||
await loadScripts(viz)
|
||||
return viz
|
||||
})
|
||||
if (key) cache.set(key, vizPromise)
|
||||
const viz = await vizPromise
|
||||
if (abortController.signal.aborted) break
|
||||
currentProjectVisualizationsByPath.set(pathString, viz.name)
|
||||
if (!id || viz.name !== id.name) {
|
||||
if (key && id && viz.name !== id.name) {
|
||||
cache.delete(key)
|
||||
metadata.delete(key)
|
||||
}
|
||||
id = { module: { kind: 'CurrentProject' }, name: viz.name }
|
||||
cache.set(toVisualizationId(id), vizPromise)
|
||||
}
|
||||
metadata.set(toVisualizationId(id), {
|
||||
name: viz.name,
|
||||
inputType: viz.inputType,
|
||||
icon: viz.icon,
|
||||
})
|
||||
} catch (error) {
|
||||
if (key) cache.delete(key)
|
||||
if (error instanceof InvalidVisualizationModuleError) {
|
||||
console.info(
|
||||
`Imported local file '${pathString}' which is not a visualization. ` +
|
||||
`If it is not a dependency, are you perhaps missing \`name\` or \`inputType\`?`,
|
||||
const scripts = Array.isArray(module.scripts) ? module.scripts : [module.scripts]
|
||||
for (const url of scripts) {
|
||||
if (typeof url !== 'string') {
|
||||
console.warn('Visualization script should be a string, skipping URL:', url)
|
||||
} else if (!loadedScripts.has(url)) {
|
||||
loadedScripts.add(url)
|
||||
const node = document.createElement('script')
|
||||
node.src = url
|
||||
// Some resources still set only "Access-Control-Allow-Origin" in the response.
|
||||
// We need to explicitly make a request CORS - see https://resourcepolicy.fyi
|
||||
node.crossOrigin = 'anonymous'
|
||||
promises.push(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
node.addEventListener('load', () => {
|
||||
resolve()
|
||||
node.remove()
|
||||
})
|
||||
node.addEventListener('error', () => {
|
||||
reject()
|
||||
node.remove()
|
||||
})
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
console.error('Could not load visualization:', error)
|
||||
scriptsNode.appendChild(node)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Removed': {
|
||||
currentProjectVisualizationsByPath.delete(pathString)
|
||||
if (key) {
|
||||
cache.delete(key)
|
||||
metadata.delete(key)
|
||||
return Promise.allSettled(promises)
|
||||
}
|
||||
|
||||
async function onFileEvent({ kind, path }: LSEvent<'file/event'>) {
|
||||
if (path.rootId !== (await projectRoot) || !/[.]vue$/.test(path.segments.at(-1) ?? '')) return
|
||||
const pathString = customVisualizationsDirectory + '/' + path.segments.join('/')
|
||||
const name = currentProjectVisualizationsByPath.get(pathString)
|
||||
let id: VisualizationIdentifier | undefined =
|
||||
name != null ? { module: { kind: 'CurrentProject' }, name } : undefined
|
||||
const key = id && toVisualizationId(id)
|
||||
compilationAbortControllers.get(pathString)?.abort()
|
||||
compilationAbortControllers.delete(pathString)
|
||||
// FIXME [sb]: Ideally these should be deleted as late as possible, instead of immediately.
|
||||
for (const el of document.querySelectorAll(
|
||||
`[${stylePathAttribute}="${CSS.escape(currentProjectProtocol + pathString)}"]`,
|
||||
)) {
|
||||
el.remove()
|
||||
}
|
||||
switch (kind) {
|
||||
case 'Added':
|
||||
case 'Modified': {
|
||||
try {
|
||||
const abortController = new AbortController()
|
||||
compilationAbortControllers.set(pathString, abortController)
|
||||
const vizPromise = compile(
|
||||
currentProjectProtocol + pathString,
|
||||
await projectRoot,
|
||||
await proj.dataConnection,
|
||||
).then(async (viz) => {
|
||||
await loadScripts(viz)
|
||||
return viz
|
||||
})
|
||||
if (key) cache.set(key, vizPromise)
|
||||
const viz = await vizPromise
|
||||
if (abortController.signal.aborted) break
|
||||
currentProjectVisualizationsByPath.set(pathString, viz.name)
|
||||
if (!id || viz.name !== id.name) {
|
||||
if (key && id && viz.name !== id.name) {
|
||||
cache.delete(key)
|
||||
metadata.delete(key)
|
||||
}
|
||||
id = { module: { kind: 'CurrentProject' }, name: viz.name }
|
||||
cache.set(toVisualizationId(id), vizPromise)
|
||||
}
|
||||
metadata.set(toVisualizationId(id), {
|
||||
name: viz.name,
|
||||
inputType: viz.inputType,
|
||||
icon: viz.icon,
|
||||
})
|
||||
} catch (error) {
|
||||
if (key) cache.delete(key)
|
||||
if (error instanceof InvalidVisualizationModuleError) {
|
||||
console.info(
|
||||
`Imported local file '${pathString}' which is not a visualization. ` +
|
||||
`If it is not a dependency, are you perhaps missing \`name\` or \`inputType\`?`,
|
||||
)
|
||||
} else {
|
||||
console.error('Could not load visualization:', error)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Removed': {
|
||||
currentProjectVisualizationsByPath.delete(pathString)
|
||||
if (key) {
|
||||
cache.delete(key)
|
||||
metadata.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all([proj.lsRpcConnection, projectRoot]).then(async ([ls, projectRoot]) => {
|
||||
if (!projectRoot) {
|
||||
console.error('Could not load custom visualizations: Project directory not found.')
|
||||
return
|
||||
}
|
||||
const watching = await ls.watchFiles(projectRoot, [customVisualizationsDirectory], onFileEvent)
|
||||
.promise
|
||||
if (!watching.ok) {
|
||||
if (
|
||||
watching.error.payload instanceof LsRpcError &&
|
||||
watching.error.payload.cause instanceof RemoteRpcError &&
|
||||
watching.error.payload.cause.code === ErrorCode.FILE_NOT_FOUND
|
||||
) {
|
||||
console.info(
|
||||
"'visualizations/' folder not found in project directory. " +
|
||||
"If you have custom visualizations, please put them under 'visualizations/'.",
|
||||
)
|
||||
} else {
|
||||
watching.error.log('Could not load custom visualizations')
|
||||
Promise.all([proj.lsRpcConnection, projectRoot]).then(async ([ls, projectRoot]) => {
|
||||
if (!projectRoot) {
|
||||
console.error('Could not load custom visualizations: Project directory not found.')
|
||||
return
|
||||
}
|
||||
const watching = await ls.watchFiles(
|
||||
projectRoot,
|
||||
[customVisualizationsDirectory],
|
||||
onFileEvent,
|
||||
).promise
|
||||
if (!watching.ok) {
|
||||
if (
|
||||
watching.error.payload instanceof LsRpcError &&
|
||||
watching.error.payload.cause instanceof RemoteRpcError &&
|
||||
watching.error.payload.cause.code === ErrorCode.FILE_NOT_FOUND
|
||||
) {
|
||||
console.info(
|
||||
"'visualizations/' folder not found in project directory. " +
|
||||
"If you have custom visualizations, please put them under 'visualizations/'.",
|
||||
)
|
||||
} else {
|
||||
watching.error.log('Could not load custom visualizations')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function* types(type: Opt<string>) {
|
||||
const types =
|
||||
type == null ?
|
||||
metadata.keys()
|
||||
: new Set([
|
||||
...(metadata.visualizationIdToType.reverseLookup(type) ?? []),
|
||||
...(metadata.visualizationIdToType.reverseLookup('Any') ?? []),
|
||||
])
|
||||
for (const type of types) yield fromVisualizationId(type)
|
||||
}
|
||||
|
||||
function icon(type: VisualizationIdentifier) {
|
||||
const icon = metadata.get(toVisualizationId(type))?.icon
|
||||
return icon && (isIconName(icon) || isUrlString(icon)) ? icon : undefined
|
||||
}
|
||||
|
||||
function get(meta: VisualizationIdentifier, ignoreCache = false) {
|
||||
const key = toVisualizationId(meta)
|
||||
if (!cache.get(key) || ignoreCache) {
|
||||
switch (meta.module.kind) {
|
||||
case 'Builtin': {
|
||||
cache.set(key, resolveBuiltinVisualization(meta.name))
|
||||
break
|
||||
}
|
||||
case 'CurrentProject': {
|
||||
// No special handling needed; updates are handled in an external event handler above.
|
||||
break
|
||||
}
|
||||
case 'Library': {
|
||||
console.warn('Library visualization support is not yet implemented:', meta.module)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return computed(() => cache.get(key))
|
||||
}
|
||||
|
||||
async function resolveBuiltinVisualization(type: string) {
|
||||
const module = builtinVisualizationsByName[type]
|
||||
if (!module) throw new Error(`Unknown visualization type: ${type}`)
|
||||
await loadScripts(module)
|
||||
return module
|
||||
}
|
||||
|
||||
return { types, get, icon }
|
||||
})
|
||||
|
||||
function* types(type: Opt<string>) {
|
||||
const types =
|
||||
type == null ?
|
||||
metadata.keys()
|
||||
: new Set([
|
||||
...(metadata.visualizationIdToType.reverseLookup(type) ?? []),
|
||||
...(metadata.visualizationIdToType.reverseLookup('Any') ?? []),
|
||||
])
|
||||
for (const type of types) yield fromVisualizationId(type)
|
||||
}
|
||||
|
||||
function icon(type: VisualizationIdentifier) {
|
||||
const icon = metadata.get(toVisualizationId(type))?.icon
|
||||
return icon && (isIconName(icon) || isUrlString(icon)) ? icon : undefined
|
||||
}
|
||||
|
||||
function get(meta: VisualizationIdentifier, ignoreCache = false) {
|
||||
const key = toVisualizationId(meta)
|
||||
if (!cache.get(key) || ignoreCache) {
|
||||
switch (meta.module.kind) {
|
||||
case 'Builtin': {
|
||||
cache.set(key, resolveBuiltinVisualization(meta.name))
|
||||
break
|
||||
}
|
||||
case 'CurrentProject': {
|
||||
// No special handling needed; updates are handled in an external event handler above.
|
||||
break
|
||||
}
|
||||
case 'Library': {
|
||||
console.warn('Library visualization support is not yet implemented:', meta.module)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return computed(() => cache.get(key))
|
||||
}
|
||||
|
||||
async function resolveBuiltinVisualization(type: string) {
|
||||
const module = builtinVisualizationsByName[type]
|
||||
if (!module) throw new Error(`Unknown visualization type: ${type}`)
|
||||
await loadScripts(module)
|
||||
return module
|
||||
}
|
||||
|
||||
return { types, get, icon }
|
||||
})
|
||||
|
@ -13,7 +13,7 @@ function makePrefixes() {
|
||||
|
||||
/** MUST be called after `initializeFFI`. */
|
||||
export function initializePrefixes() {
|
||||
prefixes = makePrefixes()
|
||||
prefixes = prefixes ?? makePrefixes()
|
||||
}
|
||||
|
||||
export function nodeFromAst(ast: Ast.Ast): NodeDataFromAst | undefined {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { StringConfig } from './config'
|
||||
|
||||
export interface UrlParamsProps {
|
||||
ignoreKeysRegExp?: RegExp | null
|
||||
ignoreKeysRegExp?: RegExp | undefined
|
||||
}
|
||||
|
||||
/** Returns the parameters passed in the URL query string. */
|
||||
|
@ -6,7 +6,6 @@ import { MockTransport } from '@/util/net'
|
||||
import type { QualifiedName } from '@/util/qualifiedName'
|
||||
import { defineSetupVue3 } from '@histoire/plugin-vue'
|
||||
import * as random from 'lib0/random'
|
||||
import { createPinia } from 'pinia'
|
||||
import type { LibraryComponentGroup, Uuid, response } from 'shared/languageServerTypes'
|
||||
import type {
|
||||
SuggestionEntry,
|
||||
@ -76,8 +75,6 @@ MockTransport.addMock('engine', async (method, data, transport) => {
|
||||
|
||||
export const setupVue3 = defineSetupVue3(({ app, addWrapper }) => {
|
||||
addWrapper(CustomBackground)
|
||||
|
||||
app.use(createPinia())
|
||||
provideGuiConfig._mock(
|
||||
ref({
|
||||
startup: {
|
||||
|
@ -1,4 +1,3 @@
|
||||
import * as fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import defaultConfig from './vite.config'
|
||||
@ -14,8 +13,14 @@ export default defineConfig({
|
||||
cacheDir,
|
||||
publicDir,
|
||||
envDir,
|
||||
resolve,
|
||||
plugins: [usePolyglotFfi()],
|
||||
resolve: {
|
||||
...resolve,
|
||||
alias: {
|
||||
...resolve?.alias,
|
||||
// Use `ffiPolyglot` module as `ffi` interface during the build.
|
||||
'shared/ast/ffi': fileURLToPath(new URL('./shared/ast/ffiPolyglot.ts', import.meta.url)),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
...defaultConfig.define,
|
||||
self: 'globalThis',
|
||||
@ -34,23 +39,3 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Use `ffiPolyglot` module as `ffi` interface during the build.
|
||||
*/
|
||||
function usePolyglotFfi(): Plugin {
|
||||
const ffiPolyglot = fileURLToPath(new URL('./shared/ast/ffiPolyglot.ts', import.meta.url))
|
||||
const ffiBackup = fileURLToPath(new URL('./shared/ast/ffiBackup.ts', import.meta.url))
|
||||
const ffi = fileURLToPath(new URL('./shared/ast/ffi.ts', import.meta.url))
|
||||
|
||||
return {
|
||||
name: 'use-polyglot-ffi',
|
||||
options: () => {
|
||||
fs.renameSync(ffi, ffiBackup)
|
||||
fs.copyFileSync(ffiPolyglot, ffi)
|
||||
},
|
||||
buildEnd: () => {
|
||||
fs.renameSync(ffiBackup, ffi)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -485,10 +485,8 @@ export function locateModalBackground(page: test.Locator | test.Page) {
|
||||
|
||||
/** Find an editor container (if any) on the current page. */
|
||||
export function locateEditor(page: test.Page) {
|
||||
// This is fine as this element is defined in `index.html`, rather than from React.
|
||||
// Using `data-testid` may be more correct though.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
return page.locator('#app')
|
||||
// Test ID of a placeholder editor component used during testing.
|
||||
return page.getByTestId('gui-editor-root')
|
||||
}
|
||||
|
||||
/** Find an assets table (if any) on the current page. */
|
||||
|
@ -442,6 +442,9 @@ export async function mockApi({ page }: MockParams) {
|
||||
await page.route(BASE_URL + remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async route => {
|
||||
await route.fulfill()
|
||||
})
|
||||
await page.route(BASE_URL + remoteBackendPaths.POST_LOG_EVENT_PATH, async route => {
|
||||
await route.fulfill()
|
||||
})
|
||||
|
||||
// === Other endpoints ===
|
||||
|
||||
|
@ -53,6 +53,7 @@ import LoggerProvider from '#/providers/LoggerProvider'
|
||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import ModalProvider, * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
|
||||
import RemoteBackendProvider from '#/providers/RemoteBackendProvider'
|
||||
import SessionProvider from '#/providers/SessionProvider'
|
||||
import SupportsLocalBackendProvider from '#/providers/SupportsLocalBackendProvider'
|
||||
|
||||
@ -86,6 +87,7 @@ import * as object from '#/utilities/object'
|
||||
|
||||
import * as authServiceModule from '#/authentication/service'
|
||||
|
||||
import type * as types from '../../types/types'
|
||||
import * as reactQueryDevtools from './ReactQueryDevtools'
|
||||
|
||||
// ============================
|
||||
@ -146,7 +148,7 @@ export interface AppProps {
|
||||
readonly onAuthenticated: (accessToken: string | null) => void
|
||||
readonly projectManagerUrl: string | null
|
||||
readonly ydocUrl: string | null
|
||||
readonly appRunner: AppRunner
|
||||
readonly appRunner: types.EditorRunner | null
|
||||
}
|
||||
|
||||
/** Component called by the parent module, returning the root React component for this
|
||||
@ -454,6 +456,7 @@ function AppRouter(props: AppRouterProps) {
|
||||
</SupportsLocalBackendProvider>
|
||||
)
|
||||
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
|
||||
result = <RemoteBackendProvider>{result}</RemoteBackendProvider>
|
||||
result = (
|
||||
<AuthProvider
|
||||
shouldStartInOfflineMode={isAuthenticationDisabled}
|
||||
|
9
app/ide-desktop/lib/dashboard/src/TestAppRunner.tsx
Normal file
9
app/ide-desktop/lib/dashboard/src/TestAppRunner.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
/** @file Placeholder component for GUI used during e2e tests. */
|
||||
|
||||
import type * as types from '../../types/types'
|
||||
|
||||
/** Placeholder component for GUI used during e2e tests. */
|
||||
export function TestAppRunner(props: types.EditorProps) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return props.hidden ? <></> : <div data-testid="gui-editor-root">Vue app loads here.</div>
|
||||
}
|
@ -37,7 +37,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
|
||||
const { value: valueRaw, setValue: setValueRaw } = props
|
||||
// The functionality for inputting `enso-secret`s SHOULD be injected using a plugin,
|
||||
// but it is more convenient to avoid having plugin infrastructure.
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const [value, setValue] = React.useState(valueRaw)
|
||||
const [autocompleteText, setAutocompleteText] = React.useState(() =>
|
||||
|
@ -25,7 +25,7 @@ export interface AssetInfoBarProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export default function AssetInfoBar(props: AssetInfoBarProps) {
|
||||
const { invisible = false, isAssetPanelEnabled, setIsAssetPanelEnabled } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
return (
|
||||
|
@ -99,7 +99,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId } = state
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
|
@ -37,7 +37,7 @@ export default function DatalinkNameColumn(props: DatalinkNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { assetEvents, dispatchAssetListEvent, setIsAssetPanelTemporarilyVisible } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
if (item.type !== backendModule.AssetType.datalink) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -43,7 +43,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
const { selectedKeys, assetEvents, dispatchAssetListEvent, nodeMap } = state
|
||||
const { doToggleDirectoryExpansion } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
if (item.type !== backendModule.AssetType.directory) {
|
||||
|
@ -38,7 +38,7 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState, isEditable } = props
|
||||
const { nodeMap, assetEvents, dispatchAssetListEvent } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
if (item.type !== backendModule.AssetType.file) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -48,7 +48,7 @@ export interface PermissionProps {
|
||||
export default function Permission(props: PermissionProps) {
|
||||
const { asset, self, isOnlyOwner, doDelete } = props
|
||||
const { permission: initialPermission, setPermission: outerSetPermission } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [permission, setPermission] = React.useState(initialPermission)
|
||||
|
@ -83,7 +83,7 @@ export interface ProjectIconProps {
|
||||
export default function ProjectIcon(props: ProjectIconProps) {
|
||||
const { keyProp: key, item, setItem, assetEvents, doOpenManually } = props
|
||||
const { doCloseEditor, doOpenEditor } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { session } = sessionProvider.useSession()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
@ -47,7 +47,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
const { selectedKeys, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
|
||||
const { nodeMap, doOpenManually, doOpenEditor, doCloseEditor } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
|
@ -42,7 +42,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
const { assetEvents, dispatchAssetListEvent } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
if (item.type !== backendModule.AssetType.secret) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -40,7 +40,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
const asset = item.item
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const plusButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
|
@ -2,6 +2,7 @@
|
||||
import '#/tailwind.css'
|
||||
|
||||
import * as main from '#/index'
|
||||
import * as testAppRunner from '#/TestAppRunner'
|
||||
|
||||
// ===================
|
||||
// === Entry point ===
|
||||
@ -25,14 +26,5 @@ main.run({
|
||||
/** The cloud frontend is not capable of running a Project Manager. */
|
||||
projectManagerUrl: null,
|
||||
ydocUrl: null,
|
||||
// This cannot be `appRunner: window.enso` as `window.enso` is set to a new value
|
||||
// every time a new project is opened.
|
||||
appRunner: {
|
||||
stopApp: () => {
|
||||
window.enso?.stopApp()
|
||||
},
|
||||
runApp: async (config, accessToken) => {
|
||||
await window.enso?.runApp(config, accessToken)
|
||||
},
|
||||
},
|
||||
appRunner: testAppRunner.TestAppRunner,
|
||||
})
|
||||
|
@ -8,8 +8,8 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as remoteBackendProvider from '#/providers/RemoteBackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
@ -31,9 +31,7 @@ import UpsertSecretModal from '#/modals/UpsertSecretModal'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as localBackend from '#/services/LocalBackend'
|
||||
import RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import HttpClient from '#/utilities/HttpClient'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
|
||||
@ -66,10 +64,10 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
const { category, hasPasteData, labels, dispatchAssetEvent, dispatchAssetListEvent } = state
|
||||
const { doCreateLabel } = state
|
||||
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { user, accessToken } = authProvider.useNonPartialUserSession()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const remoteBackend = remoteBackendProvider.useRemoteBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const asset = item.item
|
||||
@ -202,12 +200,10 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
action="uploadToCloud"
|
||||
doAction={async () => {
|
||||
unsetModal()
|
||||
if (accessToken == null) {
|
||||
if (remoteBackend == null) {
|
||||
toastAndLog('offlineUploadFilesError')
|
||||
} else {
|
||||
try {
|
||||
const client = new HttpClient([['Authorization', `Bearer ${accessToken}`]])
|
||||
const remoteBackend = new RemoteBackend(client, logger, getText)
|
||||
const projectResponse = await fetch(
|
||||
`./api/project-manager/projects/${localBackend.extractTypeAndId(asset.id).id}/enso-project`
|
||||
)
|
||||
|
@ -58,7 +58,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const { dispatchAssetEvent } = props
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [item, setItemInner] = React.useState(itemRaw)
|
||||
|
@ -28,7 +28,7 @@ export interface AssetVersionsProps {
|
||||
/** A list of previous versions of an asset. */
|
||||
export default function AssetVersions(props: AssetVersionsProps) {
|
||||
const { item } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const isCloud = backend.type === backendService.BackendType.remote
|
||||
|
@ -386,7 +386,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const { targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
|
||||
|
||||
const { user, accessToken } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { getText } = textProvider.useText()
|
||||
|
@ -59,7 +59,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
const { category, pasteData, selectedKeys, clearSelectedKeys, nodeMapRef, event } = props
|
||||
const { dispatchAssetEvent, dispatchAssetListEvent, rootDirectoryId, hidden = false } = props
|
||||
const { doCopy, doCut, doPaste } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
|
@ -26,7 +26,7 @@ export interface BackendSwitcherProps {
|
||||
/** Switcher for choosing the project management backend. */
|
||||
export default function BackendSwitcher(props: BackendSwitcherProps) {
|
||||
const { setBackendType } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
|
||||
|
@ -106,7 +106,7 @@ export default function Drive(props: DriveProps) {
|
||||
const navigate = navigateHooks.useNavigate()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { getText } = textProvider.useText()
|
||||
const [canDownload, setCanDownload] = React.useState(false)
|
||||
|
@ -53,7 +53,7 @@ export interface DriveBarProps {
|
||||
export default function DriveBar(props: DriveBarProps) {
|
||||
const { category, canDownload, doEmptyTrash, doCreateProject, doCreateDirectory } = props
|
||||
const { doCreateSecret, doCreateDatalink, doUploadFiles, dispatchAssetEvent } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
|
@ -1,14 +1,16 @@
|
||||
/** @file The container that launches the IDE. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as load from 'enso-common/src/load'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as gtagHooks from '#/hooks/gtagHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as remoteBackendProvider from '#/providers/RemoteBackendProvider'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
import type * as types from '../../../types/types'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -16,13 +18,6 @@ import * as backendModule from '#/services/Backend'
|
||||
|
||||
/** The horizontal offset of the editor's top bar from the left edge of the window. */
|
||||
const TOP_BAR_X_OFFSET_PX = 96
|
||||
/** The `id` attribute of the element into which the IDE will be rendered. */
|
||||
const IDE_ELEMENT_ID = 'app'
|
||||
const IDE_CDN_BASE_URL = 'https://cdn.enso.org/ide'
|
||||
const JS_EXTENSION: Readonly<Record<backendModule.BackendType, string>> = {
|
||||
[backendModule.BackendType.remote]: '.js.gz',
|
||||
[backendModule.BackendType.local]: '.js',
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Component ===
|
||||
@ -31,27 +26,29 @@ const JS_EXTENSION: Readonly<Record<backendModule.BackendType, string>> = {
|
||||
/** Props for an {@link Editor}. */
|
||||
export interface EditorProps {
|
||||
readonly hidden: boolean
|
||||
readonly supportsLocalBackend: boolean
|
||||
readonly ydocUrl: string | null
|
||||
readonly projectStartupInfo: backendModule.ProjectStartupInfo | null
|
||||
readonly appRunner: AppRunner
|
||||
readonly appRunner: types.EditorRunner | null
|
||||
}
|
||||
|
||||
/** The container that launches the IDE. */
|
||||
export default function Editor(props: EditorProps) {
|
||||
const { hidden, supportsLocalBackend, ydocUrl, projectStartupInfo, appRunner } = props
|
||||
const { hidden, ydocUrl, projectStartupInfo, appRunner: AppRunner } = props
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const gtagEvent = gtagHooks.useGtagEvent()
|
||||
const gtagEventRef = React.useRef(gtagEvent)
|
||||
gtagEventRef.current = gtagEvent
|
||||
const [initialized, setInitialized] = React.useState(supportsLocalBackend)
|
||||
const remoteBackend = remoteBackendProvider.useRemoteBackend()
|
||||
|
||||
React.useEffect(() => {
|
||||
const ideElement = document.getElementById(IDE_ELEMENT_ID)
|
||||
if (ideElement != null) {
|
||||
ideElement.style.display = hidden ? 'none' : ''
|
||||
}
|
||||
}, [hidden])
|
||||
const logEvent = React.useCallback(
|
||||
(message: string, projectId?: string | null, metadata?: object | null) => {
|
||||
if (remoteBackend) {
|
||||
void remoteBackend.logEvent(message, projectId, metadata)
|
||||
}
|
||||
},
|
||||
[remoteBackend]
|
||||
)
|
||||
|
||||
gtagEventRef.current = gtagEvent
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hidden) {
|
||||
@ -61,112 +58,49 @@ export default function Editor(props: EditorProps) {
|
||||
}
|
||||
}, [projectStartupInfo, hidden])
|
||||
|
||||
let hasEffectRun = false
|
||||
|
||||
React.useEffect(() => {
|
||||
// This is a hack to work around the IDE WASM not playing nicely with React Strict Mode.
|
||||
// This is unavoidable as the WASM must fully set up to be able to properly drop its assets,
|
||||
// but React re-executes this side-effect faster tha the WASM can do so.
|
||||
if (hasEffectRun) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
hasEffectRun = true
|
||||
if (projectStartupInfo != null) {
|
||||
const { project, backendType, accessToken } = projectStartupInfo
|
||||
void (async () => {
|
||||
const jsonAddress = project.jsonAddress
|
||||
const binaryAddress = project.binaryAddress
|
||||
const ydocAddress = ydocUrl ?? ''
|
||||
if (jsonAddress == null) {
|
||||
toastAndLog('noJSONEndpointError')
|
||||
} else if (binaryAddress == null) {
|
||||
toastAndLog('noBinaryEndpointError')
|
||||
} else {
|
||||
let assetsRoot: string
|
||||
switch (backendType) {
|
||||
case backendModule.BackendType.remote: {
|
||||
if (project.ideVersion == null) {
|
||||
toastAndLog('noIdeVersionError')
|
||||
// This is too deeply nested to easily return from
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return
|
||||
}
|
||||
assetsRoot = `${IDE_CDN_BASE_URL}/${project.ideVersion.value}/`
|
||||
break
|
||||
}
|
||||
case backendModule.BackendType.local: {
|
||||
assetsRoot = ''
|
||||
break
|
||||
}
|
||||
}
|
||||
const runNewProject = async () => {
|
||||
const engineConfig = {
|
||||
rpcUrl: jsonAddress,
|
||||
dataUrl: binaryAddress,
|
||||
ydocUrl: ydocAddress,
|
||||
}
|
||||
const originalUrl = window.location.href
|
||||
if (backendType === backendModule.BackendType.remote) {
|
||||
// The URL query contains commandline options when running in the desktop,
|
||||
// which will break the entrypoint for opening a fresh IDE instance.
|
||||
history.replaceState(null, '', new URL('.', originalUrl))
|
||||
}
|
||||
try {
|
||||
await appRunner.runApp(
|
||||
{
|
||||
loader: {
|
||||
assetsUrl: `${assetsRoot}dynamic-assets`,
|
||||
wasmUrl: `${assetsRoot}pkg-opt.wasm`,
|
||||
jsUrl: `${assetsRoot}pkg${JS_EXTENSION[backendType]}`,
|
||||
},
|
||||
engine: engineConfig,
|
||||
startup: {
|
||||
project: project.packageName,
|
||||
displayedProjectName: project.name,
|
||||
},
|
||||
window: {
|
||||
topBarOffset: `${TOP_BAR_X_OFFSET_PX}`,
|
||||
},
|
||||
},
|
||||
accessToken,
|
||||
{
|
||||
projectId: project.projectId,
|
||||
ignoreParamsRegex: new RegExp(`^${appUtils.SEARCH_PARAMS_PREFIX}(.+)$`),
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
toastAndLog('openEditorError', error)
|
||||
}
|
||||
if (backendType === backendModule.BackendType.remote) {
|
||||
// Restore original URL so that initialization works correctly on refresh.
|
||||
history.replaceState(null, '', originalUrl)
|
||||
}
|
||||
}
|
||||
if (supportsLocalBackend) {
|
||||
await runNewProject()
|
||||
} else {
|
||||
if (!initialized) {
|
||||
await Promise.all([
|
||||
load.loadStyle(`${assetsRoot}style.css`),
|
||||
load
|
||||
.loadScript(`${assetsRoot}entrypoint.js.gz`)
|
||||
.catch(() => load.loadScript(`${assetsRoot}index.js.gz`)),
|
||||
])
|
||||
setInitialized(true)
|
||||
}
|
||||
await runNewProject()
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
appRunner.stopApp()
|
||||
}
|
||||
const appProps: types.EditorProps | null = React.useMemo(() => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (projectStartupInfo == null) return null
|
||||
const { project } = projectStartupInfo
|
||||
const projectId = projectStartupInfo.projectAsset.id
|
||||
const jsonAddress = project.jsonAddress
|
||||
const binaryAddress = project.binaryAddress
|
||||
const ydocAddress = ydocUrl ?? ''
|
||||
if (jsonAddress == null) {
|
||||
toastAndLog('noJSONEndpointError')
|
||||
return null
|
||||
} else if (binaryAddress == null) {
|
||||
toastAndLog('noBinaryEndpointError')
|
||||
return null
|
||||
} else {
|
||||
return
|
||||
return {
|
||||
config: {
|
||||
engine: {
|
||||
rpcUrl: jsonAddress,
|
||||
dataUrl: binaryAddress,
|
||||
ydocUrl: ydocAddress,
|
||||
},
|
||||
startup: {
|
||||
project: project.packageName,
|
||||
displayedProjectName: project.name,
|
||||
},
|
||||
window: {
|
||||
topBarOffset: `${TOP_BAR_X_OFFSET_PX}`,
|
||||
},
|
||||
},
|
||||
projectId,
|
||||
hidden,
|
||||
ignoreParamsRegex: new RegExp(`^${appUtils.SEARCH_PARAMS_PREFIX}(.+)$`),
|
||||
logEvent,
|
||||
}
|
||||
}
|
||||
}, [projectStartupInfo, toastAndLog, /* should never change */ appRunner])
|
||||
}, [projectStartupInfo, toastAndLog, hidden, logEvent, ydocUrl])
|
||||
|
||||
return <></>
|
||||
if (projectStartupInfo == null || AppRunner == null || appProps == null) {
|
||||
return <></>
|
||||
} else {
|
||||
// Currently the GUI component needs to be fully rerendered whenever the project is changed. Once
|
||||
// this is no longer necessary, the `key` could be removed.
|
||||
return <AppRunner key={appProps.projectId} {...appProps} />
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
||||
const { hidden = false, hasPasteData, directoryKey, directoryId, rootDirectoryId } = props
|
||||
const { dispatchAssetListEvent } = props
|
||||
const { doPaste } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const filesInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
@ -43,7 +43,7 @@ export default function Settings() {
|
||||
)
|
||||
|
||||
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const root = portal.useStrictPortalContext()
|
||||
const [isUserInOrganization, setIsUserInOrganization] = React.useState(true)
|
||||
|
@ -60,7 +60,7 @@ const EVENT_TYPE_NAME: Record<backendModule.EventType, string> = {
|
||||
|
||||
/** Settings tab for viewing and editing organization members. */
|
||||
export default function ActivityLogSettingsTab() {
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const [startDate, setStartDate] = React.useState<Date | null>(null)
|
||||
const [endDate, setEndDate] = React.useState<Date | null>(null)
|
||||
|
@ -20,7 +20,7 @@ import ConfirmDeleteUserModal from '#/modals/ConfirmDeleteUserModal'
|
||||
export default function DeleteUserAccountSettingsSection() {
|
||||
const { signOut } = authProvider.useAuth()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
return (
|
||||
|
@ -24,7 +24,7 @@ const LIST_USERS_STALE_TIME = 60_000
|
||||
/** Settings tab for viewing and editing organization members. */
|
||||
export default function MembersSettingsTab() {
|
||||
const { getText } = textProvider.useText()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
|
||||
const [{ data: members }, { data: invitations }] = reactQuery.useSuspenseQueries({
|
||||
queries: [
|
||||
|
@ -34,7 +34,7 @@ export interface MembersTableProps {
|
||||
export default function MembersTable(props: MembersTableProps) {
|
||||
const { populateWithSelf = false, draggable = false, allowDelete = false } = props
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [selectedKeys, setSelectedKeys] = React.useState<aria.Selection>(new Set())
|
||||
|
@ -30,7 +30,7 @@ export default function OrganizationProfilePictureSettingsSection(
|
||||
) {
|
||||
const { organization, setOrganization } = props
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const doUploadOrganizationPicture = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
@ -30,7 +30,7 @@ export interface OrganizationSettingsSectionProps {
|
||||
export default function OrganizationSettingsSection(props: OrganizationSettingsSectionProps) {
|
||||
const { organization, setOrganization } = props
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const nameRef = React.useRef<HTMLInputElement | null>(null)
|
||||
const emailRef = React.useRef<HTMLInputElement | null>(null)
|
||||
|
@ -21,7 +21,7 @@ import SettingsSection from '#/components/styled/settings/SettingsSection'
|
||||
export default function ProfilePictureSettingsSection() {
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { setUser } = authProvider.useAuth()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
|
@ -21,7 +21,7 @@ import * as object from '#/utilities/object'
|
||||
export default function UserAccountSettingsSection() {
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { setUser } = authProvider.useAuth()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
|
@ -33,7 +33,7 @@ import * as object from '#/utilities/object'
|
||||
|
||||
/** Settings tab for viewing and editing organization members. */
|
||||
export default function UserGroupsSettingsTab() {
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
|
@ -48,7 +48,7 @@ export default function UserBar(props: UserBarProps) {
|
||||
const { projectAsset, setProjectAsset, doRemoveSelf, onSignOut } = props
|
||||
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal, updateModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const self =
|
||||
user != null
|
||||
|
@ -41,7 +41,7 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
|
||||
|
||||
const [inputValue, setInputValue] = React.useState('')
|
||||
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const inputRef = React.useRef<HTMLDivElement>(null)
|
||||
const formRef = React.useRef<HTMLFormElement>(null)
|
||||
|
||||
|
@ -45,7 +45,7 @@ export default function ManageLabelsModal<
|
||||
Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
|
||||
>(props: ManageLabelsModalProps<Asset>) {
|
||||
const { item, setItem, allLabels, doCreateLabel, eventTarget } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
|
@ -59,7 +59,7 @@ export default function ManagePermissionsModal<
|
||||
>(props: ManagePermissionsModalProps<Asset>) {
|
||||
const { item, setItem, self, doRemoveSelf, eventTarget } = props
|
||||
const { user: user } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { getText } = textProvider.useText()
|
||||
|
@ -34,7 +34,7 @@ export interface NewUserGroupModalProps {
|
||||
export default function NewUserGroupModal(props: NewUserGroupModalProps) {
|
||||
const { userGroups: userGroupsRaw, onSubmit: onSubmitRaw, onSuccess, onFailure } = props
|
||||
const { event: positionEvent } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
|
@ -27,7 +27,7 @@ const PLANS_TO_SPECIFY_ORG_NAME = [backendModule.Plan.team, backendModule.Plan.e
|
||||
export function SetOrganizationNameModal() {
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { session } = authProvider.useAuth()
|
||||
|
||||
const userId = session && 'user' in session && session.user?.userId ? session.user.userId : null
|
||||
|
@ -22,7 +22,7 @@ import SvgMask from '#/components/SvgMask'
|
||||
export default function RestoreAccount() {
|
||||
const { getText } = textProvider.useText()
|
||||
const { signOut, restoreUser } = authProvider.useAuth()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
|
||||
const signOutMutation = reactQuery.useMutation({
|
||||
mutationFn: signOut,
|
||||
|
@ -24,7 +24,7 @@ import * as eventModule from '#/utilities/event'
|
||||
export default function SetUsername() {
|
||||
const { setUsername: authSetUsername } = authProvider.useAuth()
|
||||
const { email } = authProvider.usePartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const [username, setUsername] = React.useState('')
|
||||
|
@ -13,6 +13,7 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as remoteBackendProvider from '#/providers/RemoteBackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
@ -48,6 +49,8 @@ import LocalStorage from '#/utilities/LocalStorage'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
|
||||
import type * as types from '../../../../types/types'
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
// ============================
|
||||
@ -114,7 +117,7 @@ LocalStorage.registerKey('projectStartupInfo', {
|
||||
export interface DashboardProps {
|
||||
/** Whether the application may have the local backend running. */
|
||||
readonly supportsLocalBackend: boolean
|
||||
readonly appRunner: AppRunner
|
||||
readonly appRunner: types.EditorRunner | null
|
||||
readonly initialProjectName: string | null
|
||||
readonly projectManagerUrl: string | null
|
||||
readonly ydocUrl: string | null
|
||||
@ -127,8 +130,8 @@ export default function Dashboard(props: DashboardProps) {
|
||||
const { ydocUrl, projectManagerUrl, projectManagerRootDirectory } = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const session = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setBackend } = backendProvider.useSetBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
const { setBackend } = backendProvider.useStrictSetBackend()
|
||||
const { modalRef } = modalProvider.useModalRef()
|
||||
const { updateModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
@ -240,7 +243,12 @@ export default function Dashboard(props: DashboardProps) {
|
||||
abortController
|
||||
)
|
||||
if (!abortController.signal.aborted) {
|
||||
setProjectStartupInfo(object.merge(savedProjectStartupInfo, { project }))
|
||||
setProjectStartupInfo(
|
||||
object.merge(savedProjectStartupInfo, {
|
||||
project,
|
||||
accessToken: session.accessToken,
|
||||
})
|
||||
)
|
||||
if (page === pageSwitcher.Page.editor) {
|
||||
setPage(page)
|
||||
}
|
||||
@ -363,6 +371,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
}
|
||||
}, [inputBindings])
|
||||
|
||||
const remoteBackend = remoteBackendProvider.useStrictRemoteBackend()
|
||||
const setBackendType = React.useCallback(
|
||||
(newBackendType: backendModule.BackendType) => {
|
||||
if (newBackendType !== backend.type) {
|
||||
@ -374,10 +383,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
break
|
||||
}
|
||||
case backendModule.BackendType.remote: {
|
||||
const client = new HttpClient([
|
||||
['Authorization', `Bearer ${session.accessToken ?? ''}`],
|
||||
])
|
||||
setBackend(new RemoteBackend(client, logger, getText))
|
||||
setBackend(remoteBackend)
|
||||
break
|
||||
}
|
||||
}
|
||||
@ -385,9 +391,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
},
|
||||
[
|
||||
backend.type,
|
||||
session.accessToken,
|
||||
logger,
|
||||
getText,
|
||||
remoteBackend,
|
||||
/* should never change */ projectManagerUrl,
|
||||
/* should never change */ projectManagerRootDirectory,
|
||||
/* should never change */ setBackend,
|
||||
@ -532,7 +536,6 @@ export default function Dashboard(props: DashboardProps) {
|
||||
/>
|
||||
<Editor
|
||||
hidden={page !== pageSwitcher.Page.editor}
|
||||
supportsLocalBackend={supportsLocalBackend}
|
||||
ydocUrl={ydocUrl}
|
||||
projectStartupInfo={projectStartupInfo}
|
||||
appRunner={appRunner}
|
||||
|
@ -50,7 +50,7 @@ export function Subscribe() {
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const [searchParams] = router.useSearchParams()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { backend } = backendProvider.useStrictBackend()
|
||||
|
||||
const plan = searchParams.get('plan')
|
||||
|
||||
|
@ -172,7 +172,7 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { cognito } = authService ?? {}
|
||||
const { session, deinitializeSession, onSessionError } = sessionProvider.useSession()
|
||||
const { setBackendWithoutSavingType } = backendProvider.useSetBackend()
|
||||
const { setBackendWithoutSavingType } = backendProvider.useStrictSetBackend()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { getText } = textProvider.useText()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
@ -251,9 +251,35 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
platform: detect.platform(),
|
||||
architecture: detect.architecture(),
|
||||
})
|
||||
|
||||
return gtagHooks.gtagOpenCloseCallback(gtagEventRef, 'open_app', 'close_app')
|
||||
}, [])
|
||||
|
||||
// This is a duplication of `RemoteBackendProvider`, but we cannot use it here, as it would
|
||||
// introduce a cyclic dependency between two providers (`BackendProvider` uses `AuthProvider`).
|
||||
// FIXME: Refactor `remoteBackend` dependant things out of the `AuthProvider`.
|
||||
const remoteBackend = React.useMemo(() => {
|
||||
if (session) {
|
||||
const client = new HttpClient([['Authorization', `Bearer ${session.accessToken}`]])
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return new RemoteBackend(client, logger, getText)
|
||||
}
|
||||
}, [session, getText, logger])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (remoteBackend) {
|
||||
void remoteBackend.logEvent('open_app')
|
||||
const logCloseEvent = () => void remoteBackend.logEvent('close_app')
|
||||
window.addEventListener('beforeunload', logCloseEvent)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', logCloseEvent)
|
||||
logCloseEvent()
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [remoteBackend])
|
||||
|
||||
// This is identical to `hooks.useOnlineCheck`, however it is inline here to avoid any possible
|
||||
// circular dependency.
|
||||
React.useEffect(() => {
|
||||
@ -290,25 +316,24 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
if (!navigator.onLine || forceOfflineMode) {
|
||||
goOfflineInternal()
|
||||
setForceOfflineMode(false)
|
||||
} else if (session == null) {
|
||||
} else if (session == null || remoteBackend == null) {
|
||||
setInitialized(true)
|
||||
if (!initialized) {
|
||||
sentry.setUser(null)
|
||||
setUserSession(null)
|
||||
}
|
||||
} else {
|
||||
const client = new HttpClient([['Authorization', `Bearer ${session.accessToken}`]])
|
||||
const backend = new RemoteBackend(client, logger, getText)
|
||||
// The backend MUST be the remote backend before login is finished.
|
||||
// This is because the "set username" flow requires the remote backend.
|
||||
if (!initialized || userSession == null || userSession.type === UserSessionType.offline) {
|
||||
setBackendWithoutSavingType(backend)
|
||||
setBackendWithoutSavingType(remoteBackend)
|
||||
}
|
||||
gtagEvent('cloud_open')
|
||||
void remoteBackend.logEvent('cloud_open')
|
||||
let user: backendModule.User | null
|
||||
while (true) {
|
||||
try {
|
||||
user = await backend.usersMe()
|
||||
user = await remoteBackend.usersMe()
|
||||
break
|
||||
} catch (error) {
|
||||
// The value may have changed after the `await`.
|
||||
@ -683,6 +708,7 @@ export default function AuthProvider(props: AuthProviderProps) {
|
||||
signOut,
|
||||
session: userSession,
|
||||
setUser,
|
||||
remoteBackend,
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -2,6 +2,8 @@
|
||||
* provider via the shared React context. */
|
||||
import * as React from 'react'
|
||||
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
@ -37,9 +39,7 @@ export interface BackendContextType {
|
||||
readonly setBackendWithoutSavingType: (backend: Backend) => void
|
||||
}
|
||||
|
||||
// @ts-expect-error The default value will never be exposed
|
||||
// as `backend` will always be accessed using `useBackend`.
|
||||
const BackendContext = React.createContext<BackendContextType>(null)
|
||||
const BackendContext = React.createContext<BackendContextType | null>(null)
|
||||
|
||||
/** Props for a {@link BackendProvider}. */
|
||||
export interface BackendProviderProps extends Readonly<React.PropsWithChildren> {
|
||||
@ -70,14 +70,21 @@ export default function BackendProvider(props: BackendProviderProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Provide all exposed methods of backend in context. */
|
||||
function useStrictBackendContext() {
|
||||
const ctx = React.useContext(BackendContext)
|
||||
invariant(ctx != null, 'Backend not provided.')
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Exposes a property to get the current backend. */
|
||||
export function useBackend() {
|
||||
const { backend } = React.useContext(BackendContext)
|
||||
export function useStrictBackend() {
|
||||
const { backend } = useStrictBackendContext()
|
||||
return { backend }
|
||||
}
|
||||
|
||||
/** Exposes a property to set the current backend. */
|
||||
export function useSetBackend() {
|
||||
const { setBackend, setBackendWithoutSavingType } = React.useContext(BackendContext)
|
||||
export function useStrictSetBackend() {
|
||||
const { setBackend, setBackendWithoutSavingType } = useStrictBackendContext()
|
||||
return { setBackend, setBackendWithoutSavingType }
|
||||
}
|
||||
|
@ -0,0 +1,47 @@
|
||||
/** @file The React provider for a `RemoteBackend` instance that is always configured to use active
|
||||
* session token. */
|
||||
import * as React from 'react'
|
||||
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import HttpClient from '#/utilities/HttpClient'
|
||||
|
||||
const RemoteBackendContext = React.createContext<RemoteBackend | null>(null)
|
||||
|
||||
/** A React Provider that lets components get the current remote backend with current user session. */
|
||||
export default function RemoteBackendProvider(props: React.PropsWithChildren) {
|
||||
const { session } = authProvider.useAuth()
|
||||
const { getText } = textProvider.useText()
|
||||
const logger = loggerProvider.useLogger()
|
||||
|
||||
const backend = React.useMemo(() => {
|
||||
if (session?.accessToken == null) {
|
||||
return null
|
||||
} else {
|
||||
const client = new HttpClient([['Authorization', `Bearer ${session.accessToken}`]])
|
||||
return new RemoteBackend(client, logger, getText)
|
||||
}
|
||||
}, [session?.accessToken, logger, getText])
|
||||
|
||||
return (
|
||||
<RemoteBackendContext.Provider value={backend}>{props.children}</RemoteBackendContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Exposes a property to get the a remote backend using current session data. */
|
||||
export function useRemoteBackend() {
|
||||
return React.useContext(RemoteBackendContext)
|
||||
}
|
||||
|
||||
/** Exposes a property to get the a remote backend using current session data. */
|
||||
export function useStrictRemoteBackend() {
|
||||
const backend = useRemoteBackend()
|
||||
invariant(backend != null, 'Remote backend not provided.')
|
||||
return backend
|
||||
}
|
@ -135,6 +135,11 @@ interface DefaultVersionInfo {
|
||||
readonly lastUpdatedEpochMs: number
|
||||
}
|
||||
|
||||
/** Options for {@link RemoteBackend.post} private method. */
|
||||
interface RemoteBackendPostOptions {
|
||||
readonly keepalive?: boolean
|
||||
}
|
||||
|
||||
/** Class for sending requests to the Cloud backend API endpoints. */
|
||||
export default class RemoteBackend extends Backend {
|
||||
readonly type = backend.BackendType.remote
|
||||
@ -1031,6 +1036,29 @@ export default class RemoteBackend extends Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Log an event that will be visible in the organization audit log. */
|
||||
async logEvent(message: string, projectId?: string | null, metadata?: object | null) {
|
||||
const path = remoteBackendPaths.POST_LOG_EVENT_PATH
|
||||
const response = await this.post(
|
||||
path,
|
||||
{
|
||||
message,
|
||||
projectId,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
...(metadata ?? {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
keepalive: true,
|
||||
}
|
||||
)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return this.throw(response, 'logEventBackendError', message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Get the default version given the type of version (IDE or backend). */
|
||||
protected async getDefaultVersion(versionType: backend.VersionType) {
|
||||
const cached = this.defaultVersions[versionType]
|
||||
@ -1055,8 +1083,8 @@ export default class RemoteBackend extends Backend {
|
||||
}
|
||||
|
||||
/** Send a JSON HTTP POST request to the given path. */
|
||||
private post<T = void>(path: string, payload: object) {
|
||||
return this.client.post<T>(`${process.env.ENSO_CLOUD_API_URL}/${path}`, payload)
|
||||
private post<T = void>(path: string, payload: object, options?: RemoteBackendPostOptions) {
|
||||
return this.client.post<T>(`${process.env.ENSO_CLOUD_API_URL}/${path}`, payload, options)
|
||||
}
|
||||
|
||||
/** Send a binary HTTP POST request to the given path. */
|
||||
|
@ -69,6 +69,8 @@ export const CREATE_CHECKOUT_SESSION_PATH = 'payments/checkout-sessions'
|
||||
export const GET_CHECKOUT_SESSION_PATH = 'payments/checkout-sessions'
|
||||
/** Relative HTTP path to the "get log events" endpoint of the Cloud backend API. */
|
||||
export const GET_LOG_EVENTS_PATH = 'log_events'
|
||||
/** Relative HTTP path to the "post log event" endpoint of the Cloud backend API. */
|
||||
export const POST_LOG_EVENT_PATH = 'logs'
|
||||
/** Relative HTTP path to the "change user groups" endpoint of the Cloud backend API. */
|
||||
export function changeUserGroupPath(userId: backend.UserId) {
|
||||
return `users/${userId}/usergroups`
|
||||
|
@ -129,6 +129,7 @@
|
||||
"createCheckoutSessionBackendError": "Could not create checkout session for plan '$0'.",
|
||||
"getCheckoutSessionBackendError": "Could not get checkout session for session ID '$0'.",
|
||||
"getLogEventsBackendError": "Could not get audit log events",
|
||||
"logEventBackendError": "Could not log an event '$0'.",
|
||||
"getDefaultVersionBackendError": "No default $0 version found.",
|
||||
"duplicateUserGroupError": "This user group already exists.",
|
||||
|
||||
|
@ -104,6 +104,7 @@ interface PlaceholderOverrides {
|
||||
readonly createCheckoutSessionBackendError: [string]
|
||||
readonly getCheckoutSessionBackendError: [string]
|
||||
readonly getDefaultVersionBackendError: [string]
|
||||
readonly logEventBackendError: [string]
|
||||
|
||||
readonly subscribeSuccessSubtitle: [string]
|
||||
}
|
||||
|
@ -30,6 +30,20 @@ export interface ResponseWithTypedJson<U> extends Response {
|
||||
readonly json: () => Promise<U>
|
||||
}
|
||||
|
||||
/** Options for {@link HttpClient.post} method. */
|
||||
export interface HttpClientPostOptions {
|
||||
readonly keepalive?: boolean
|
||||
}
|
||||
|
||||
/** Options for {@link HttpClient.request} private method. */
|
||||
export interface HttpClientRequestOptions {
|
||||
readonly method: HttpMethod
|
||||
readonly url: string
|
||||
readonly payload?: BodyInit | null
|
||||
readonly mimetype?: string
|
||||
readonly keepalive?: boolean
|
||||
}
|
||||
|
||||
/** An HTTP client that can be used to create and send HTTP requests asynchronously. */
|
||||
export default class HttpClient {
|
||||
/** Create a new HTTP client with the specified headers to be sent on every request. */
|
||||
@ -43,50 +57,75 @@ export default class HttpClient {
|
||||
|
||||
/** Send an HTTP GET request to the specified URL. */
|
||||
get<T = void>(url: string) {
|
||||
return this.request<T>(HttpMethod.get, url)
|
||||
return this.request<T>({ method: HttpMethod.get, url })
|
||||
}
|
||||
|
||||
/** Send a JSON HTTP POST request to the specified URL. */
|
||||
post<T = void>(url: string, payload: object) {
|
||||
return this.request<T>(HttpMethod.post, url, JSON.stringify(payload), 'application/json')
|
||||
post<T = void>(url: string, payload: object, options?: HttpClientPostOptions) {
|
||||
return this.request<T>({
|
||||
method: HttpMethod.post,
|
||||
url,
|
||||
payload: JSON.stringify(payload),
|
||||
mimetype: 'application/json',
|
||||
keepalive: options?.keepalive ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
/** Send a base64-encoded binary HTTP POST request to the specified URL. */
|
||||
async postBinary<T = void>(url: string, payload: Blob) {
|
||||
return await this.request<T>(HttpMethod.post, url, payload, 'application/octet-stream')
|
||||
return await this.request<T>({
|
||||
method: HttpMethod.post,
|
||||
url,
|
||||
payload,
|
||||
mimetype: 'application/octet-stream',
|
||||
})
|
||||
}
|
||||
|
||||
/** Send a JSON HTTP PATCH request to the specified URL. */
|
||||
patch<T = void>(url: string, payload: object) {
|
||||
return this.request<T>(HttpMethod.patch, url, JSON.stringify(payload), 'application/json')
|
||||
return this.request<T>({
|
||||
method: HttpMethod.patch,
|
||||
url,
|
||||
payload: JSON.stringify(payload),
|
||||
mimetype: 'application/json',
|
||||
})
|
||||
}
|
||||
|
||||
/** Send a JSON HTTP PUT request to the specified URL. */
|
||||
put<T = void>(url: string, payload: object) {
|
||||
return this.request<T>(HttpMethod.put, url, JSON.stringify(payload), 'application/json')
|
||||
return this.request<T>({
|
||||
method: HttpMethod.put,
|
||||
url,
|
||||
payload: JSON.stringify(payload),
|
||||
mimetype: 'application/json',
|
||||
})
|
||||
}
|
||||
|
||||
/** Send a base64-encoded binary HTTP POST request to the specified URL. */
|
||||
async putBinary<T = void>(url: string, payload: Blob) {
|
||||
return await this.request<T>(HttpMethod.put, url, payload, 'application/octet-stream')
|
||||
return await this.request<T>({
|
||||
method: HttpMethod.put,
|
||||
url,
|
||||
payload,
|
||||
mimetype: 'application/octet-stream',
|
||||
})
|
||||
}
|
||||
|
||||
/** Send an HTTP DELETE request to the specified URL. */
|
||||
delete<T = void>(url: string, payload?: Record<string, unknown>) {
|
||||
return this.request<T>(HttpMethod.delete, url, JSON.stringify(payload))
|
||||
return this.request<T>({
|
||||
method: HttpMethod.delete,
|
||||
url,
|
||||
payload: payload ? JSON.stringify(payload) : null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Execute an HTTP request to the specified URL, with the given HTTP method.
|
||||
* @throws {Error} if the HTTP request fails. */
|
||||
private async request<T = void>(
|
||||
method: HttpMethod,
|
||||
url: string,
|
||||
payload?: BodyInit,
|
||||
mimetype?: string
|
||||
) {
|
||||
private async request<T = void>(options: HttpClientRequestOptions) {
|
||||
const headers = new Headers(this.defaultHeaders)
|
||||
if (payload != null) {
|
||||
const contentType = mimetype ?? 'application/json'
|
||||
if (options.payload != null) {
|
||||
const contentType = options.mimetype ?? 'application/json'
|
||||
headers.set('Content-Type', contentType)
|
||||
}
|
||||
|
||||
@ -94,10 +133,11 @@ export default class HttpClient {
|
||||
// This is an UNSAFE type assertion, however this is a HTTP client
|
||||
// and should only be used to query APIs with known response types.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const response = (await fetch(url, {
|
||||
method,
|
||||
const response = (await fetch(options.url, {
|
||||
method: options.method,
|
||||
headers,
|
||||
...(payload != null ? { body: payload } : {}),
|
||||
keepalive: options.keepalive ?? false,
|
||||
...(options.payload != null ? { body: options.payload } : {}),
|
||||
})) as ResponseWithTypedJson<T>
|
||||
document.dispatchEvent(new Event(FETCH_SUCCESS_EVENT_NAME))
|
||||
return response
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user