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:
Paweł Grabarz 2024-05-27 19:32:42 +02:00 committed by GitHub
parent 2c060a2a92
commit 8edf49343f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
103 changed files with 1713 additions and 1774 deletions

View File

@ -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
View File

@ -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
}

View File

@ -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>

View File

@ -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)
})
}

View File

@ -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",

View File

@ -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')

View File

@ -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>

View File

@ -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 }

View File

@ -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)

View File

@ -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
View 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
}

View File

@ -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>

View File

@ -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)),
)

View File

@ -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>]

View File

@ -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 ?? '',

View File

@ -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%);
}
}

View File

@ -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()

View File

@ -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>()

View File

@ -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()

View File

@ -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;
}

View File

@ -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>

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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() ?? '',

View File

@ -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')) {

View File

@ -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

View File

@ -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(() => {

View File

@ -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
}

View File

@ -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,
)

View File

@ -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,

View 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

View File

@ -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()
}

View File

@ -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> }

View File

@ -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 })
})

View File

@ -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 }
})

View File

@ -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 {

View File

@ -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. */

View File

@ -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: {

View File

@ -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)
},
}
}

View File

@ -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. */

View File

@ -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 ===

View File

@ -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}

View 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>
}

View File

@ -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(() =>

View File

@ -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 (

View File

@ -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()

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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,
})

View File

@ -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`
)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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} />
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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 (

View File

@ -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: [

View File

@ -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())

View File

@ -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>) => {

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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,

View File

@ -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('')

View File

@ -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}

View File

@ -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')

View File

@ -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 (

View File

@ -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 }
}

View File

@ -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
}

View File

@ -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. */

View File

@ -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`

View File

@ -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.",

View File

@ -104,6 +104,7 @@ interface PlaceholderOverrides {
readonly createCheckoutSessionBackendError: [string]
readonly getCheckoutSessionBackendError: [string]
readonly getDefaultVersionBackendError: [string]
readonly logEventBackendError: [string]
readonly subscribeSuccessSubtitle: [string]
}

View File

@ -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