mirror of
https://github.com/enso-org/enso.git
synced 2024-12-25 22:53:02 +03:00
de406c69fa
Fixes #8520 If the websocket is closed not by us, we automatically try to reconnect with it, and initialize the protocol again. **Restoring state (execution contexts, attached visualizations) is not part of this PR**. It's a part of making IDE work after hibernation (or LS crash). # Important Notes It required somewhat heavy refactoring: 1. I decided to use an existing implementation of reconnecting websocket. Replaced (later discovered by me) our implementation. 2. The LanguageServer class now handles both reconnecting and re-initializing - that make usage of it simpler (no more `Promise<LanguageServer>` - each method will just wait for (re)connection and initialization. 3. The stuff in `net` src's module was partially moved to shared's counterpart (with tests). Merged `exponentialBackoff` implementations, which also brought me to 4. Rewriting LS client, so it returns Result instead of throwing, what is closer our desired state, and allows us using exponentialBackoff method without any wrappers.
732 lines
25 KiB
TypeScript
732 lines
25 KiB
TypeScript
import * as json from 'lib0/json'
|
|
import * as map from 'lib0/map'
|
|
import { ObservableV2 } from 'lib0/observable'
|
|
import * as random from 'lib0/random'
|
|
import * as Y from 'yjs'
|
|
import * as Ast from '../shared/ast'
|
|
import { astCount } from '../shared/ast'
|
|
import { EnsoFileParts, combineFileParts, splitFileContents } from '../shared/ensoFile'
|
|
import { LanguageServer, computeTextChecksum } from '../shared/languageServer'
|
|
import {
|
|
Checksum,
|
|
FileEdit,
|
|
FileEventKind,
|
|
Path,
|
|
TextEdit,
|
|
response,
|
|
} from '../shared/languageServerTypes'
|
|
import { assertNever } from '../shared/util/assert'
|
|
import { Err, Ok, Result, withContext } from '../shared/util/data/result'
|
|
import { AbortScope, exponentialBackoff, printingCallbacks } from '../shared/util/net'
|
|
import ReconnectingWebSocketTransport from '../shared/util/net/ReconnectingWSTransport'
|
|
import {
|
|
DistributedProject,
|
|
ExternalId,
|
|
IdMap,
|
|
ModuleDoc,
|
|
visMetadataEquals,
|
|
type Uuid,
|
|
} from '../shared/yjsModel'
|
|
import {
|
|
applyDiffAsTextEdits,
|
|
applyDocumentUpdates,
|
|
prettyPrintDiff,
|
|
translateVisualizationFromFile,
|
|
} from './edits'
|
|
import * as fileFormat from './fileFormat'
|
|
import { deserializeIdMap, serializeIdMap } from './serialization'
|
|
import { WSSharedDoc } from './ydoc'
|
|
|
|
const SOURCE_DIR = 'src'
|
|
const EXTENSION = '.enso'
|
|
|
|
const DEBUG_LOG_SYNC = false
|
|
|
|
export class LanguageServerSession {
|
|
clientId: Uuid
|
|
indexDoc: WSSharedDoc
|
|
docs: Map<string, WSSharedDoc>
|
|
retainCount: number
|
|
url: string
|
|
ls: LanguageServer
|
|
connection: response.InitProtocolConnection | undefined
|
|
model: DistributedProject
|
|
projectRootId: Uuid | null
|
|
authoritativeModules: Map<string, ModulePersistence>
|
|
clientScope: AbortScope
|
|
|
|
constructor(url: string) {
|
|
this.clientScope = new AbortScope()
|
|
this.clientId = random.uuidv4() as Uuid
|
|
this.docs = new Map()
|
|
this.retainCount = 0
|
|
this.url = url
|
|
console.log('new session with', url)
|
|
this.indexDoc = new WSSharedDoc()
|
|
this.docs.set('index', this.indexDoc)
|
|
this.model = new DistributedProject(this.indexDoc.doc)
|
|
this.projectRootId = null
|
|
this.authoritativeModules = new Map()
|
|
|
|
this.indexDoc.doc.on('subdocs', (subdocs: { loaded: Set<Y.Doc> }) => {
|
|
for (const doc of subdocs.loaded) {
|
|
const name = this.model.findModuleByDocId(doc.guid)
|
|
if (!name) continue
|
|
const persistence = this.authoritativeModules.get(name)
|
|
if (!persistence) continue
|
|
}
|
|
})
|
|
this.ls = new LanguageServer(this.clientId, new ReconnectingWebSocketTransport(this.url))
|
|
this.clientScope.onAbort(() => this.ls.release())
|
|
this.setupClient()
|
|
}
|
|
|
|
static sessions = new Map<string, LanguageServerSession>()
|
|
static get(url: string): LanguageServerSession {
|
|
const session = map.setIfUndefined(
|
|
LanguageServerSession.sessions,
|
|
url,
|
|
() => new LanguageServerSession(url),
|
|
)
|
|
session.retain()
|
|
return session
|
|
}
|
|
|
|
private restartClient() {
|
|
this.ls.reconnect()
|
|
return exponentialBackoff(() => this.readInitialState())
|
|
}
|
|
|
|
private setupClient() {
|
|
this.ls.on('file/event', async (event) => {
|
|
if (DEBUG_LOG_SYNC) {
|
|
console.log('file/event', event)
|
|
}
|
|
const result = await this.handleFileEvent(event)
|
|
if (!result.ok) this.restartClient()
|
|
})
|
|
this.ls.on('text/fileModifiedOnDisk', async (event) => {
|
|
const path = event.path.segments.join('/')
|
|
const result = await exponentialBackoff(
|
|
async () => this.tryGetExistingModuleModel(event.path)?.reload() ?? Ok(),
|
|
printingCallbacks(`reloaded file '${path}'`, `reload file '${path}'`),
|
|
)
|
|
if (!result.ok) this.restartClient()
|
|
})
|
|
exponentialBackoff(
|
|
() => this.readInitialState(),
|
|
printingCallbacks('read initial state', 'read initial state'),
|
|
).then((result) => {
|
|
if (!result.ok) {
|
|
result.error.log('Could not read initial state')
|
|
exponentialBackoff(
|
|
async () => this.restartClient(),
|
|
printingCallbacks('restarted RPC client', 'restart RPC client'),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
private handleFileEvent(event: { path: Path; kind: FileEventKind }): Promise<Result<void>> {
|
|
return withContext(
|
|
() => 'Handling file/event',
|
|
async () => {
|
|
const path = event.path.segments.join('/')
|
|
switch (event.kind) {
|
|
case 'Added': {
|
|
if (isSourceFile(event.path)) {
|
|
const fileInfo = await this.ls.fileInfo(event.path)
|
|
if (!fileInfo.ok) return fileInfo
|
|
if (fileInfo.value.attributes.kind.type == 'File') {
|
|
return await exponentialBackoff(
|
|
() => this.getModuleModel(event.path).open(),
|
|
printingCallbacks(`opened new file '${path}'`, `open new file '${path}'`),
|
|
)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
case 'Modified': {
|
|
return await exponentialBackoff(
|
|
() => this.tryGetExistingModuleModel(event.path)?.reload() ?? Promise.resolve(Ok()),
|
|
printingCallbacks(`reloaded file '${path}'`, `reload file '${path}'`),
|
|
)
|
|
}
|
|
}
|
|
return Ok()
|
|
},
|
|
)
|
|
}
|
|
|
|
private assertProjectRoot(): asserts this is { projectRootId: Uuid } {
|
|
if (this.projectRootId == null) throw new Error('Missing project root')
|
|
}
|
|
|
|
private async readInitialState(): Promise<Result<void>> {
|
|
return await withContext(
|
|
() => 'When reading initial state',
|
|
async () => {
|
|
let moduleOpenPromises: Promise<Result<void>>[] = []
|
|
const projectRoot = (await this.ls.contentRoots).find((root) => root.type === 'Project')
|
|
if (!projectRoot) return Err('Missing project root')
|
|
this.projectRootId = projectRoot.id
|
|
const aquireResult = await this.ls.acquireReceivesTreeUpdates({
|
|
rootId: this.projectRootId,
|
|
segments: [],
|
|
})
|
|
if (!aquireResult.ok) return aquireResult
|
|
const files = await this.scanSourceFiles()
|
|
if (!files.ok) return files
|
|
moduleOpenPromises = this.indexDoc.doc.transact(
|
|
() =>
|
|
files.value.map((file) =>
|
|
this.getModuleModel(pushPathSegment(file.path, file.name)).open(),
|
|
),
|
|
this,
|
|
)
|
|
const results = await Promise.all(moduleOpenPromises)
|
|
return results.find((res) => !res.ok) ?? Ok()
|
|
},
|
|
)
|
|
}
|
|
|
|
async scanSourceFiles() {
|
|
this.assertProjectRoot()
|
|
const sourceDir: Path = { rootId: this.projectRootId, segments: [SOURCE_DIR] }
|
|
const srcModules = await this.ls.listFiles(sourceDir)
|
|
if (!srcModules.ok) return srcModules
|
|
return Ok(
|
|
srcModules.value.paths.filter(
|
|
(file) => file.type === 'File' && file.name.endsWith(EXTENSION),
|
|
),
|
|
)
|
|
}
|
|
|
|
tryGetExistingModuleModel(path: Path): ModulePersistence | undefined {
|
|
const name = pathToModuleName(path)
|
|
return this.authoritativeModules.get(name)
|
|
}
|
|
|
|
getModuleModel(path: Path): ModulePersistence {
|
|
const name = pathToModuleName(path)
|
|
return map.setIfUndefined(this.authoritativeModules, name, () => {
|
|
const wsDoc = new WSSharedDoc()
|
|
this.docs.set(wsDoc.doc.guid, wsDoc)
|
|
this.model.createUnloadedModule(name, wsDoc.doc)
|
|
const mod = new ModulePersistence(this.ls, path, wsDoc.doc)
|
|
mod.once('removed', () => {
|
|
const index = this.model.findModuleByDocId(wsDoc.doc.guid)
|
|
this.docs.delete(wsDoc.doc.guid)
|
|
this.authoritativeModules.delete(name)
|
|
if (index != null) this.model.deleteModule(index)
|
|
})
|
|
return mod
|
|
})
|
|
}
|
|
|
|
retain() {
|
|
this.retainCount += 1
|
|
}
|
|
|
|
async release(): Promise<void> {
|
|
this.retainCount -= 1
|
|
if (this.retainCount !== 0) return
|
|
const modules = this.authoritativeModules.values()
|
|
const moduleDisposePromises = Array.from(modules, (mod) => mod.dispose())
|
|
this.authoritativeModules.clear()
|
|
this.model.doc.destroy()
|
|
this.clientScope.dispose('LangueServerSession disposed.')
|
|
LanguageServerSession.sessions.delete(this.url)
|
|
await Promise.all(moduleDisposePromises)
|
|
}
|
|
|
|
getYDoc(guid: string): WSSharedDoc | undefined {
|
|
return this.docs.get(guid)
|
|
}
|
|
}
|
|
|
|
function isSourceFile(path: Path): boolean {
|
|
return (
|
|
path.segments[0] === SOURCE_DIR && path.segments[path.segments.length - 1].endsWith(EXTENSION)
|
|
)
|
|
}
|
|
|
|
function pathToModuleName(path: Path): string {
|
|
if (path.segments[0] === SOURCE_DIR) return path.segments.slice(1).join('/')
|
|
else return '//' + path.segments.join('/')
|
|
}
|
|
|
|
function pushPathSegment(path: Path, segment: string): Path {
|
|
return { rootId: path.rootId, segments: [...path.segments, segment] }
|
|
}
|
|
|
|
enum LsSyncState {
|
|
Closed,
|
|
Opening,
|
|
Synchronized,
|
|
WritingFile,
|
|
WriteError,
|
|
Reloading,
|
|
Closing,
|
|
Disposed,
|
|
}
|
|
|
|
enum LsAction {
|
|
Open,
|
|
Close,
|
|
Reload,
|
|
}
|
|
|
|
class ModulePersistence extends ObservableV2<{ removed: () => void }> {
|
|
ls: LanguageServer
|
|
path: Path
|
|
doc: ModuleDoc = new ModuleDoc(new Y.Doc())
|
|
readonly state: LsSyncState = LsSyncState.Closed
|
|
readonly lastAction = Promise.resolve()
|
|
updateToApply: Uint8Array | null = null
|
|
syncedCode: string | null = null
|
|
syncedIdMap: string | null = null
|
|
syncedMetaJson: string | null = null
|
|
syncedContent: string | null = null
|
|
syncedVersion: Checksum | null = null
|
|
syncedMeta: fileFormat.Metadata = fileFormat.tryParseMetadataOrFallback(null)
|
|
queuedAction: LsAction | null = null
|
|
cleanup = () => {}
|
|
|
|
constructor(ls: LanguageServer, path: Path, sharedDoc: Y.Doc) {
|
|
super()
|
|
this.ls = ls
|
|
this.path = path
|
|
|
|
const onRemoteUpdate = this.queueRemoteUpdate.bind(this)
|
|
const onLocalUpdate = (update: Uint8Array, origin: unknown) => {
|
|
if (origin === 'file') Y.applyUpdate(sharedDoc, update, this)
|
|
}
|
|
const onFileModified = this.handleFileModified.bind(this)
|
|
const onFileRemoved = this.handleFileRemoved.bind(this)
|
|
this.doc.ydoc.on('update', onLocalUpdate)
|
|
sharedDoc.on('update', onRemoteUpdate)
|
|
this.ls.on('text/fileModifiedOnDisk', onFileModified)
|
|
this.ls.on('file/rootRemoved', onFileRemoved)
|
|
this.cleanup = () => {
|
|
this.doc.ydoc.off('update', onLocalUpdate)
|
|
sharedDoc.off('update', onRemoteUpdate)
|
|
this.ls.off('text/fileModifiedOnDisk', onFileModified)
|
|
this.ls.off('file/rootRemoved', onFileRemoved)
|
|
}
|
|
}
|
|
|
|
private inState(...states: LsSyncState[]): boolean {
|
|
return states.includes(this.state)
|
|
}
|
|
|
|
private setState(state: LsSyncState) {
|
|
if (this.state !== LsSyncState.Disposed) {
|
|
if (DEBUG_LOG_SYNC) {
|
|
console.debug('State change:', LsSyncState[this.state], '->', LsSyncState[state])
|
|
}
|
|
// This is SAFE. `this.state` is only `readonly` to ensure that this is the only place
|
|
// where it is mutated.
|
|
// @ts-expect-error
|
|
this.state = state
|
|
if (state === LsSyncState.Synchronized) this.trySyncRemoveUpdates()
|
|
} else {
|
|
throw new Error('LsSync disposed')
|
|
}
|
|
}
|
|
|
|
private setLastAction<T>(lastAction: Promise<T>) {
|
|
// This is SAFE. `this.lastAction` is only `readonly` to ensure that this is the only place
|
|
// where it is mutated.
|
|
// @ts-expect-error
|
|
this.lastAction = lastAction.then(
|
|
() => {},
|
|
() => {},
|
|
)
|
|
return lastAction
|
|
}
|
|
|
|
/** Set the current state to the given state while the callback is running.
|
|
* Set the current state back to {@link LsSyncState.Synchronized} when the callback finishes. */
|
|
private async withState(state: LsSyncState, callback: () => void | Promise<void>): Promise<void>
|
|
private async withState(
|
|
state: LsSyncState,
|
|
callback: () => Result<void> | Promise<Result<void>>,
|
|
): Promise<Result<void>>
|
|
private async withState(
|
|
state: LsSyncState,
|
|
callback: () => void | Promise<void> | Result<void> | Promise<Result<void>>,
|
|
): Promise<Result<void> | void> {
|
|
this.setState(state)
|
|
const result = await callback()
|
|
if (result && !result.ok) return result
|
|
this.setState(LsSyncState.Synchronized)
|
|
if (result) return result
|
|
}
|
|
|
|
async open(): Promise<Result<void>> {
|
|
return await withContext(
|
|
() => `When opening module ${this.path}`,
|
|
async () => {
|
|
this.queuedAction = LsAction.Open
|
|
switch (this.state) {
|
|
case LsSyncState.Disposed:
|
|
case LsSyncState.WritingFile:
|
|
case LsSyncState.Synchronized:
|
|
case LsSyncState.WriteError:
|
|
case LsSyncState.Reloading: {
|
|
return Ok()
|
|
}
|
|
case LsSyncState.Closing: {
|
|
await this.lastAction
|
|
if (this.queuedAction === LsAction.Open) return await this.open()
|
|
return Ok()
|
|
}
|
|
case LsSyncState.Opening: {
|
|
await this.lastAction
|
|
return Ok()
|
|
}
|
|
case LsSyncState.Closed: {
|
|
await this.withState(LsSyncState.Opening, async () => {
|
|
const promise = this.ls.openTextFile(this.path)
|
|
this.setLastAction(
|
|
promise.then((res) => !res.ok && this.setState(LsSyncState.Closed)),
|
|
)
|
|
const result = await promise
|
|
if (!result.ok) return result
|
|
if (!result.value.writeCapability) {
|
|
return Err(
|
|
`Could not acquire write capability for module '${this.path.segments.join('/')}'`,
|
|
)
|
|
}
|
|
this.syncFileContents(result.value.content, result.value.currentVersion)
|
|
return Ok()
|
|
})
|
|
return Ok()
|
|
}
|
|
default: {
|
|
assertNever(this.state)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
handleFileRemoved() {
|
|
if (this.inState(LsSyncState.Closed)) return
|
|
this.close()
|
|
}
|
|
|
|
handleFileModified() {
|
|
if (this.inState(LsSyncState.Closed)) return
|
|
}
|
|
|
|
queueRemoteUpdate(update: Uint8Array, origin: unknown) {
|
|
if (origin === this) return
|
|
if (this.updateToApply != null) {
|
|
this.updateToApply = Y.mergeUpdates([this.updateToApply, update])
|
|
} else {
|
|
this.updateToApply = update
|
|
}
|
|
this.trySyncRemoveUpdates()
|
|
}
|
|
|
|
trySyncRemoveUpdates() {
|
|
if (this.updateToApply == null) return
|
|
// apply updates to the ls-representation doc if we are already in sync with the LS.
|
|
if (!this.inState(LsSyncState.Synchronized)) return
|
|
const update = this.updateToApply
|
|
this.updateToApply = null
|
|
|
|
const syncModule = new Ast.MutableModule(this.doc.ydoc)
|
|
const moduleUpdate = syncModule.applyUpdate(update, 'remote')
|
|
if (moduleUpdate && this.syncedContent) {
|
|
const synced = splitFileContents(this.syncedContent)
|
|
const { newCode, newIdMap, newMetadata } = applyDocumentUpdates(
|
|
this.doc,
|
|
synced,
|
|
moduleUpdate,
|
|
)
|
|
this.sendLsUpdate(synced, newCode, newIdMap, newMetadata)
|
|
}
|
|
}
|
|
|
|
private sendLsUpdate(
|
|
synced: EnsoFileParts,
|
|
newCode: string | undefined,
|
|
newIdMap: IdMap | undefined,
|
|
newMetadata: fileFormat.IdeMetadata['node'] | undefined,
|
|
) {
|
|
if (this.syncedContent == null || this.syncedVersion == null) return
|
|
|
|
const code = newCode ?? synced.code
|
|
const newMetadataJson =
|
|
newMetadata &&
|
|
json.stringify({ ...this.syncedMeta, ide: { ...this.syncedMeta.ide, node: newMetadata } })
|
|
const newIdMapJson = newIdMap && serializeIdMap(newIdMap)
|
|
const newContent = combineFileParts({
|
|
code,
|
|
idMapJson: newIdMapJson ?? synced.idMapJson ?? '[]',
|
|
metadataJson: newMetadataJson ?? synced.metadataJson ?? '{}',
|
|
})
|
|
|
|
const edits: TextEdit[] = []
|
|
if (newCode) edits.push(...applyDiffAsTextEdits(0, synced.code, newCode))
|
|
if (newIdMap || newMetadata) {
|
|
const oldMetaContent = this.syncedContent.slice(synced.code.length)
|
|
const metaContent = newContent.slice(code.length)
|
|
const metaStartLine = (code.match(/\n/g) ?? []).length
|
|
edits.push(...applyDiffAsTextEdits(metaStartLine, oldMetaContent, metaContent))
|
|
}
|
|
|
|
const newVersion = computeTextChecksum(newContent)
|
|
|
|
if (DEBUG_LOG_SYNC) {
|
|
console.debug(' === changes === ')
|
|
console.debug('number of edits:', edits.length)
|
|
if (edits.length > 0) {
|
|
console.debug('version:', this.syncedVersion, '->', newVersion)
|
|
console.debug('Content diff:')
|
|
console.debug(prettyPrintDiff(this.syncedContent, newContent))
|
|
}
|
|
console.debug(' =============== ')
|
|
}
|
|
|
|
this.setState(LsSyncState.WritingFile)
|
|
|
|
const execute = newCode != null || newIdMap != null
|
|
const edit: FileEdit = { path: this.path, edits, oldVersion: this.syncedVersion, newVersion }
|
|
const apply = this.ls.applyEdit(edit, execute)
|
|
const promise = apply.then(
|
|
() => {
|
|
this.syncedContent = newContent
|
|
this.syncedVersion = newVersion
|
|
if (newMetadata) this.syncedMeta.ide.node = newMetadata
|
|
if (newCode) this.syncedCode = newCode
|
|
if (newIdMapJson) this.syncedIdMap = newIdMapJson
|
|
if (newMetadataJson) this.syncedMetaJson = newMetadataJson
|
|
this.setState(LsSyncState.Synchronized)
|
|
},
|
|
(error) => {
|
|
console.error('Could not apply edit:', error)
|
|
// Try to recover by reloading the file.
|
|
// Drop the attempted updates, since applying them have failed.
|
|
this.setState(LsSyncState.WriteError)
|
|
this.syncedContent = null
|
|
this.syncedVersion = null
|
|
this.syncedCode = null
|
|
this.syncedIdMap = null
|
|
this.syncedMetaJson = null
|
|
return this.reload()
|
|
},
|
|
)
|
|
this.setLastAction(promise)
|
|
return promise
|
|
}
|
|
|
|
private syncFileContents(content: string, version: Checksum) {
|
|
const contentsReceived = splitFileContents(content)
|
|
let unsyncedIdMap: IdMap | undefined
|
|
this.doc.ydoc.transact(() => {
|
|
const { code, idMapJson, metadataJson } = contentsReceived
|
|
const metadata = fileFormat.tryParseMetadataOrFallback(metadataJson)
|
|
const nodeMeta = Object.entries(metadata.ide.node)
|
|
|
|
let parsedSpans
|
|
const syncModule = new Ast.MutableModule(this.doc.ydoc)
|
|
if (code !== this.syncedCode) {
|
|
const syncRoot = syncModule.root()
|
|
if (syncRoot) {
|
|
const edit = syncModule.edit()
|
|
edit.getVersion(syncRoot).syncToCode(code)
|
|
const editedRoot = edit.root()
|
|
if (editedRoot instanceof Ast.BodyBlock) Ast.repair(editedRoot, edit)
|
|
syncModule.applyEdit(edit)
|
|
} else {
|
|
const { root, spans } = Ast.parseBlockWithSpans(code, syncModule)
|
|
syncModule.syncRoot(root)
|
|
parsedSpans = spans
|
|
}
|
|
}
|
|
const astRoot = syncModule.root()
|
|
if (!astRoot) return
|
|
if ((code !== this.syncedCode || idMapJson !== this.syncedIdMap) && idMapJson) {
|
|
const idMap = deserializeIdMap(idMapJson)
|
|
const spans = parsedSpans ?? Ast.print(astRoot).info
|
|
const idsAssigned = Ast.setExternalIds(syncModule, spans, idMap)
|
|
const numberOfAsts = astCount(astRoot)
|
|
const idsNotSetByMap = numberOfAsts - idsAssigned
|
|
if (idsNotSetByMap > 0) {
|
|
if (code !== this.syncedCode) {
|
|
unsyncedIdMap = Ast.spanMapToIdMap(spans)
|
|
} else {
|
|
console.warn(
|
|
`The LS sent an IdMap-only edit that is missing ${idsNotSetByMap} of our expected ASTs.`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if (
|
|
(code !== this.syncedCode ||
|
|
idMapJson !== this.syncedIdMap ||
|
|
metadataJson !== this.syncedMetaJson) &&
|
|
nodeMeta.length !== 0
|
|
) {
|
|
const externalIdToAst = new Map<ExternalId, Ast.Ast>()
|
|
astRoot.visitRecursiveAst((ast) => {
|
|
if (!externalIdToAst.has(ast.externalId)) externalIdToAst.set(ast.externalId, ast)
|
|
})
|
|
const missing = new Set<string>()
|
|
for (const [id, meta] of nodeMeta) {
|
|
if (typeof id !== 'string') continue
|
|
const ast = externalIdToAst.get(id as ExternalId)
|
|
if (!ast) {
|
|
missing.add(id)
|
|
continue
|
|
}
|
|
const metadata = syncModule.getVersion(ast).mutableNodeMetadata()
|
|
const oldPos = metadata.get('position')
|
|
const newPos = { x: meta.position.vector[0], y: -meta.position.vector[1] }
|
|
if (oldPos?.x !== newPos.x || oldPos?.y !== newPos.y) metadata.set('position', newPos)
|
|
const oldVis = metadata.get('visualization')
|
|
const newVis = meta.visualization && translateVisualizationFromFile(meta.visualization)
|
|
if (!visMetadataEquals(newVis, oldVis)) metadata.set('visualization', newVis)
|
|
const oldColorOverride = metadata.get('colorOverride')
|
|
const newColorOverride = meta.colorOverride
|
|
if (oldColorOverride !== newColorOverride) metadata.set('colorOverride', newColorOverride)
|
|
}
|
|
}
|
|
|
|
this.syncedCode = code
|
|
this.syncedIdMap = unsyncedIdMap ? null : idMapJson
|
|
this.syncedContent = content
|
|
this.syncedVersion = version
|
|
this.syncedMeta = metadata
|
|
this.syncedMetaJson = metadataJson
|
|
}, 'file')
|
|
if (unsyncedIdMap) this.sendLsUpdate(contentsReceived, undefined, unsyncedIdMap, undefined)
|
|
}
|
|
|
|
async close() {
|
|
this.queuedAction = LsAction.Close
|
|
switch (this.state) {
|
|
case LsSyncState.Disposed:
|
|
case LsSyncState.Closed: {
|
|
return
|
|
}
|
|
case LsSyncState.Closing: {
|
|
await this.lastAction
|
|
return
|
|
}
|
|
case LsSyncState.Opening:
|
|
case LsSyncState.WritingFile:
|
|
case LsSyncState.Reloading: {
|
|
await this.lastAction
|
|
if (this.queuedAction === LsAction.Close) {
|
|
await this.close()
|
|
}
|
|
return
|
|
}
|
|
case LsSyncState.WriteError:
|
|
case LsSyncState.Synchronized: {
|
|
this.setState(LsSyncState.Closing)
|
|
const promise = this.ls.closeTextFile(this.path)
|
|
const state = this.state
|
|
this.setLastAction(promise.catch(() => this.setState(state)))
|
|
await promise
|
|
this.setState(LsSyncState.Closed)
|
|
return
|
|
}
|
|
default: {
|
|
assertNever(this.state)
|
|
}
|
|
}
|
|
}
|
|
|
|
async reload(): Promise<Result<void>> {
|
|
return await withContext(
|
|
() => `When reloading module ${this.path}`,
|
|
async () => {
|
|
this.queuedAction = LsAction.Reload
|
|
switch (this.state) {
|
|
case LsSyncState.Opening:
|
|
case LsSyncState.Disposed:
|
|
case LsSyncState.Closed:
|
|
case LsSyncState.Closing: {
|
|
return Ok()
|
|
}
|
|
case LsSyncState.Reloading: {
|
|
await this.lastAction
|
|
return Ok()
|
|
}
|
|
case LsSyncState.WritingFile: {
|
|
await this.lastAction
|
|
if (this.queuedAction === LsAction.Reload) return await this.reload()
|
|
return Ok()
|
|
}
|
|
case LsSyncState.Synchronized: {
|
|
return this.withState(LsSyncState.Reloading, async () => {
|
|
const promise = Promise.all([
|
|
this.ls.readFile(this.path),
|
|
this.ls.fileChecksum(this.path),
|
|
])
|
|
this.setLastAction(promise)
|
|
const [contents, checksum] = await promise
|
|
if (!contents.ok) return contents
|
|
if (!checksum.ok) return checksum
|
|
this.syncFileContents(contents.value.contents, checksum.value.checksum)
|
|
return Ok()
|
|
})
|
|
}
|
|
case LsSyncState.WriteError: {
|
|
return this.withState(LsSyncState.Reloading, async () => {
|
|
const path = this.path.segments.join('/')
|
|
const reloading = this.ls.closeTextFile(this.path).then(async (closing) => {
|
|
if (!closing.ok) closing.error.log('Could not close file after write error:')
|
|
return exponentialBackoff(
|
|
async () => {
|
|
const result = await this.ls.openTextFile(this.path)
|
|
if (!result.ok) return result
|
|
if (!result.value.writeCapability) {
|
|
return Err(
|
|
`Could not acquire write capability for module '${this.path.segments.join(
|
|
'/',
|
|
)}'`,
|
|
)
|
|
}
|
|
return result
|
|
},
|
|
printingCallbacks(
|
|
`opened file '${path}' for writing`,
|
|
`open file '${path}' for writing`,
|
|
),
|
|
)
|
|
})
|
|
|
|
this.setLastAction(reloading)
|
|
const result = await reloading
|
|
if (!result.ok) return result
|
|
this.syncFileContents(result.value.content, result.value.currentVersion)
|
|
return Ok()
|
|
})
|
|
}
|
|
default: {
|
|
assertNever(this.state)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
async dispose(): Promise<void> {
|
|
this.cleanup()
|
|
const alreadyClosed = this.inState(LsSyncState.Closing, LsSyncState.Closed)
|
|
this.setState(LsSyncState.Disposed)
|
|
if (alreadyClosed) return Promise.resolve()
|
|
const closing = await this.ls.closeTextFile(this.path)
|
|
if (!closing.ok) {
|
|
closing.error.log(`Closing text file ${this.path}`)
|
|
}
|
|
}
|
|
}
|