2023-09-22 06:43:25 +03:00
|
|
|
import { Client, RequestManager, WebSocketTransport } from '@open-rpc/client-js'
|
|
|
|
import * as map from 'lib0/map'
|
2023-09-27 18:33:41 +03:00
|
|
|
import { ObservableV2 } from 'lib0/observable'
|
2023-09-22 06:43:25 +03:00
|
|
|
import * as random from 'lib0/random'
|
|
|
|
import * as Y from 'yjs'
|
2024-01-18 16:13:31 +03:00
|
|
|
import { splitFileContents } from '../shared/ensoFile'
|
2023-10-02 15:01:03 +03:00
|
|
|
import { LanguageServer, computeTextChecksum } from '../shared/languageServer'
|
2023-12-19 08:41:14 +03:00
|
|
|
import { Checksum, FileEdit, Path, response } from '../shared/languageServerTypes'
|
|
|
|
import { exponentialBackoff, printingCallbacks } from '../shared/retry'
|
2023-10-02 15:01:03 +03:00
|
|
|
import {
|
|
|
|
DistributedProject,
|
|
|
|
IdMap,
|
|
|
|
ModuleDoc,
|
2023-10-07 23:57:47 +03:00
|
|
|
type NodeMetadata,
|
|
|
|
type Uuid,
|
2023-10-02 15:01:03 +03:00
|
|
|
} from '../shared/yjsModel'
|
2024-01-18 16:13:31 +03:00
|
|
|
import { applyDocumentUpdates, prettyPrintDiff, translateVisualizationFromFile } from './edits'
|
2023-10-02 15:01:03 +03:00
|
|
|
import * as fileFormat from './fileFormat'
|
2024-01-02 18:18:11 +03:00
|
|
|
import { deserializeIdMap } from './serialization'
|
2023-09-22 06:43:25 +03:00
|
|
|
import { WSSharedDoc } from './ydoc'
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
const SOURCE_DIR = 'src'
|
|
|
|
const EXTENSION = '.enso'
|
2023-09-22 06:43:25 +03:00
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
const DEBUG_LOG_SYNC = false
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
function createOpenRPCClient(url: string) {
|
|
|
|
const transport = new WebSocketTransport(url)
|
|
|
|
const requestManager = new RequestManager([transport])
|
|
|
|
transport.connection.on('error', (error) =>
|
|
|
|
console.error('Language Server transport error:', error),
|
|
|
|
)
|
|
|
|
return new Client(requestManager)
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
export class LanguageServerSession {
|
2023-09-22 06:43:25 +03:00
|
|
|
clientId: Uuid
|
|
|
|
indexDoc: WSSharedDoc
|
|
|
|
docs: Map<string, WSSharedDoc>
|
|
|
|
retainCount: number
|
|
|
|
url: string
|
|
|
|
client: Client
|
|
|
|
ls: LanguageServer
|
2023-12-19 08:41:14 +03:00
|
|
|
connection: response.InitProtocolConnection | undefined
|
2023-09-22 06:43:25 +03:00
|
|
|
model: DistributedProject
|
|
|
|
projectRootId: Uuid | null
|
|
|
|
authoritativeModules: Map<string, ModulePersistence>
|
|
|
|
|
|
|
|
constructor(url: string) {
|
|
|
|
this.clientId = random.uuidv4() as Uuid
|
|
|
|
this.docs = new Map()
|
|
|
|
this.retainCount = 0
|
|
|
|
this.url = url
|
2023-10-02 15:01:03 +03:00
|
|
|
console.log('new session with', url)
|
2023-09-22 06:43:25 +03:00
|
|
|
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)
|
2023-12-19 08:41:14 +03:00
|
|
|
if (!name) continue
|
2023-09-22 06:43:25 +03:00
|
|
|
const persistence = this.authoritativeModules.get(name)
|
2023-12-19 08:41:14 +03:00
|
|
|
if (!persistence) continue
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
})
|
2023-12-19 08:41:14 +03:00
|
|
|
const { client, ls } = this.setupClient()
|
|
|
|
this.client = client
|
|
|
|
this.ls = ls
|
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
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.client.close()
|
|
|
|
this.ls.destroy()
|
|
|
|
this.connection = undefined
|
|
|
|
this.setupClient()
|
|
|
|
}
|
|
|
|
|
|
|
|
private setupClient() {
|
|
|
|
this.client = createOpenRPCClient(this.url)
|
|
|
|
this.ls = new LanguageServer(this.client)
|
2023-11-01 15:22:49 +03:00
|
|
|
this.ls.on('file/event', async (event) => {
|
2023-10-02 15:01:03 +03:00
|
|
|
if (DEBUG_LOG_SYNC) {
|
|
|
|
console.log('file/event', event)
|
|
|
|
}
|
2023-12-19 08:41:14 +03:00
|
|
|
const path = event.path.segments.join('/')
|
|
|
|
try {
|
|
|
|
switch (event.kind) {
|
|
|
|
case 'Added': {
|
|
|
|
if (isSourceFile(event.path)) {
|
|
|
|
const fileInfo = await this.ls.fileInfo(event.path)
|
|
|
|
if (fileInfo.attributes.kind.type == 'File') {
|
|
|
|
await exponentialBackoff(
|
|
|
|
() => this.getModuleModel(event.path).open(),
|
|
|
|
printingCallbacks(`opened new file '${path}'`, `open new file '${path}'`),
|
|
|
|
)
|
|
|
|
}
|
2023-11-01 15:22:49 +03:00
|
|
|
}
|
2023-12-19 08:41:14 +03:00
|
|
|
break
|
2023-11-01 15:22:49 +03:00
|
|
|
}
|
2023-12-19 08:41:14 +03:00
|
|
|
case 'Modified': {
|
|
|
|
await exponentialBackoff(
|
|
|
|
async () => this.tryGetExistingModuleModel(event.path)?.reload(),
|
|
|
|
printingCallbacks(`reloaded file '${path}'`, `reload file '${path}'`),
|
|
|
|
)
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch {
|
|
|
|
this.restartClient()
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
})
|
2023-10-02 15:01:03 +03:00
|
|
|
this.ls.on('text/fileModifiedOnDisk', async (event) => {
|
2023-12-19 08:41:14 +03:00
|
|
|
const path = event.path.segments.join('/')
|
|
|
|
try {
|
|
|
|
await exponentialBackoff(
|
|
|
|
async () => this.tryGetExistingModuleModel(event.path)?.reload(),
|
|
|
|
printingCallbacks(`reloaded file '${path}'`, `reload file '${path}'`),
|
|
|
|
)
|
|
|
|
} catch {
|
|
|
|
this.restartClient()
|
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
})
|
2023-12-19 08:41:14 +03:00
|
|
|
exponentialBackoff(
|
|
|
|
() => this.readInitialState(),
|
|
|
|
printingCallbacks('read initial state', 'read initial state'),
|
|
|
|
).catch((error) => {
|
|
|
|
console.error('Could not read initial state.')
|
|
|
|
console.error(error)
|
|
|
|
exponentialBackoff(
|
|
|
|
async () => this.restartClient(),
|
|
|
|
printingCallbacks('restarted RPC client', 'restart RPC client'),
|
|
|
|
)
|
|
|
|
})
|
|
|
|
return { client: this.client, ls: this.ls }
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
private assertProjectRoot(): asserts this is { projectRootId: Uuid } {
|
|
|
|
if (this.projectRootId == null) throw new Error('Missing project root')
|
|
|
|
}
|
|
|
|
|
|
|
|
private async readInitialState() {
|
2023-12-19 08:41:14 +03:00
|
|
|
let moduleOpenPromises: Promise<void>[] = []
|
2023-09-22 06:43:25 +03:00
|
|
|
try {
|
2023-12-19 08:41:14 +03:00
|
|
|
const connection = this.connection ?? (await this.ls.initProtocolConnection(this.clientId))
|
|
|
|
this.connection = connection
|
|
|
|
const projectRoot = connection.contentRoots.find((root) => root.type === 'Project')
|
|
|
|
if (!projectRoot) throw new Error('Missing project root')
|
2023-09-22 06:43:25 +03:00
|
|
|
this.projectRootId = projectRoot.id
|
|
|
|
await this.ls.acquireReceivesTreeUpdates({ rootId: this.projectRootId, segments: [] })
|
2023-12-19 08:41:14 +03:00
|
|
|
const files = await this.scanSourceFiles()
|
|
|
|
moduleOpenPromises = this.indexDoc.doc.transact(
|
|
|
|
() =>
|
|
|
|
files.map((file) => this.getModuleModel(pushPathSegment(file.path, file.name)).open()),
|
|
|
|
this,
|
2023-09-22 06:43:25 +03:00
|
|
|
)
|
2023-12-19 08:41:14 +03:00
|
|
|
await Promise.all(moduleOpenPromises)
|
2023-09-22 06:43:25 +03:00
|
|
|
} catch (error) {
|
2023-12-19 08:41:14 +03:00
|
|
|
console.error('LS initialization failed.')
|
|
|
|
throw error
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
console.log('LS connection initialized.')
|
|
|
|
}
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
async scanSourceFiles() {
|
2023-09-22 06:43:25 +03:00
|
|
|
this.assertProjectRoot()
|
2023-12-19 08:41:14 +03:00
|
|
|
const sourceDir: Path = { rootId: this.projectRootId, segments: [SOURCE_DIR] }
|
|
|
|
const srcModules = await this.ls.listFiles(sourceDir)
|
|
|
|
return srcModules.paths.filter((file) => file.type === 'File' && file.name.endsWith(EXTENSION))
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
tryGetExistingModuleModel(path: Path): ModulePersistence | undefined {
|
2023-10-02 15:01:03 +03:00
|
|
|
const name = pathToModuleName(path)
|
2023-12-19 08:41:14 +03:00
|
|
|
return this.authoritativeModules.get(name)
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
|
2023-09-22 06:43:25 +03:00
|
|
|
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)
|
2023-10-02 15:01:03 +03:00
|
|
|
this.model.createUnloadedModule(name, wsDoc.doc)
|
|
|
|
const mod = new ModulePersistence(this.ls, path, wsDoc.doc)
|
2023-09-22 06:43:25 +03:00
|
|
|
mod.once('removed', () => {
|
|
|
|
const index = this.model.findModuleByDocId(wsDoc.doc.guid)
|
2023-10-02 15:01:03 +03:00
|
|
|
this.docs.delete(wsDoc.doc.guid)
|
2023-09-22 06:43:25 +03:00
|
|
|
this.authoritativeModules.delete(name)
|
2023-12-19 08:41:14 +03:00
|
|
|
if (index != null) this.model.deleteModule(index)
|
2023-09-22 06:43:25 +03:00
|
|
|
})
|
|
|
|
return mod
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
retain() {
|
2023-12-19 08:41:14 +03:00
|
|
|
this.retainCount += 1
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
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.ls.dispose()
|
|
|
|
LanguageServerSession.sessions.delete(this.url)
|
|
|
|
await Promise.all(moduleDisposePromises)
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
getYDoc(guid: string): WSSharedDoc | undefined {
|
|
|
|
return this.docs.get(guid)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
function isSourceFile(path: Path): boolean {
|
|
|
|
return (
|
|
|
|
path.segments[0] === SOURCE_DIR && path.segments[path.segments.length - 1].endsWith(EXTENSION)
|
|
|
|
)
|
2023-11-01 15:22:49 +03:00
|
|
|
}
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
function pathToModuleName(path: Path): string {
|
|
|
|
if (path.segments[0] === SOURCE_DIR) return path.segments.slice(1).join('/')
|
|
|
|
else return '//' + path.segments.join('/')
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
function pushPathSegment(path: Path, segment: string): Path {
|
|
|
|
return { rootId: path.rootId, segments: [...path.segments, segment] }
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
enum LsSyncState {
|
|
|
|
Closed,
|
|
|
|
Opening,
|
|
|
|
Synchronized,
|
|
|
|
WritingFile,
|
|
|
|
WriteError,
|
|
|
|
Reloading,
|
|
|
|
Closing,
|
|
|
|
Disposed,
|
|
|
|
}
|
|
|
|
|
|
|
|
enum LsAction {
|
|
|
|
Open,
|
|
|
|
Close,
|
|
|
|
Reload,
|
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
|
2023-09-27 18:33:41 +03:00
|
|
|
class ModulePersistence extends ObservableV2<{ removed: () => void }> {
|
2023-09-22 06:43:25 +03:00
|
|
|
ls: LanguageServer
|
|
|
|
path: Path
|
2023-10-02 15:01:03 +03:00
|
|
|
doc: ModuleDoc = new ModuleDoc(new Y.Doc())
|
2023-12-19 08:41:14 +03:00
|
|
|
readonly state: LsSyncState = LsSyncState.Closed
|
|
|
|
readonly lastAction = Promise.resolve()
|
2023-10-02 15:01:03 +03:00
|
|
|
updateToApply: Uint8Array | null = null
|
|
|
|
syncedContent: string | null = null
|
|
|
|
syncedVersion: Checksum | null = null
|
|
|
|
syncedMeta: fileFormat.Metadata = fileFormat.tryParseMetadataOrFallback(null)
|
|
|
|
queuedAction: LsAction | null = null
|
|
|
|
cleanup = () => {}
|
2023-12-19 08:41:14 +03:00
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
constructor(ls: LanguageServer, path: Path, sharedDoc: Y.Doc) {
|
2023-09-22 06:43:25 +03:00
|
|
|
super()
|
|
|
|
this.ls = ls
|
|
|
|
this.path = path
|
2023-10-02 15:01:03 +03:00
|
|
|
|
|
|
|
const onRemoteUpdate = this.queueRemoteUpdate.bind(this)
|
|
|
|
const onLocalUpdate = (update: Uint8Array, origin: unknown) => {
|
2023-12-19 08:41:14 +03:00
|
|
|
if (origin === 'file') Y.applyUpdate(sharedDoc, update, this)
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
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)
|
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
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>) {
|
|
|
|
this.setState(state)
|
|
|
|
await callback()
|
|
|
|
this.setState(LsSyncState.Synchronized)
|
|
|
|
}
|
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
async open() {
|
|
|
|
this.queuedAction = LsAction.Open
|
|
|
|
switch (this.state) {
|
|
|
|
case LsSyncState.Disposed:
|
|
|
|
case LsSyncState.WritingFile:
|
|
|
|
case LsSyncState.Synchronized:
|
|
|
|
case LsSyncState.WriteError:
|
2023-12-19 08:41:14 +03:00
|
|
|
case LsSyncState.Reloading: {
|
2023-10-02 15:01:03 +03:00
|
|
|
return
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
|
|
|
case LsSyncState.Closing: {
|
2023-10-02 15:01:03 +03:00
|
|
|
await this.lastAction
|
2023-12-19 08:41:14 +03:00
|
|
|
if (this.queuedAction === LsAction.Open) await this.open()
|
2023-10-02 15:01:03 +03:00
|
|
|
return
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
|
|
|
case LsSyncState.Opening: {
|
2023-10-02 15:01:03 +03:00
|
|
|
await this.lastAction
|
|
|
|
return
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
|
|
|
case LsSyncState.Closed: {
|
|
|
|
await this.withState(LsSyncState.Opening, async () => {
|
|
|
|
const promise = this.ls.openTextFile(this.path)
|
|
|
|
this.setLastAction(promise.catch(() => this.setState(LsSyncState.Closed)))
|
|
|
|
const result = await promise
|
|
|
|
if (!result.writeCapability) {
|
|
|
|
console.error('Could not acquire write capability for module:', this.path)
|
|
|
|
throw new Error(
|
|
|
|
`Could not acquire write capability for module '${this.path.segments.join('/')}'`,
|
|
|
|
)
|
|
|
|
}
|
2023-10-02 15:01:03 +03:00
|
|
|
this.syncFileContents(result.content, result.currentVersion)
|
2023-12-19 08:41:14 +03:00
|
|
|
})
|
2023-10-02 15:01:03 +03:00
|
|
|
return
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
2023-10-02 15:01:03 +03:00
|
|
|
default: {
|
2023-12-19 08:41:14 +03:00
|
|
|
this.state satisfies never
|
|
|
|
return
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
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
|
|
|
|
|
2023-12-19 20:58:11 +03:00
|
|
|
let dataKeys: Y.YMapEvent<any>['keys'] | null = null
|
2023-10-02 15:01:03 +03:00
|
|
|
let metadataKeys: Y.YMapEvent<NodeMetadata>['keys'] | null = null
|
2023-12-19 20:58:11 +03:00
|
|
|
const observeData = (event: Y.YMapEvent<any>) => (dataKeys = event.keys)
|
2023-10-02 15:01:03 +03:00
|
|
|
const observeMetadata = (event: Y.YMapEvent<NodeMetadata>) => (metadataKeys = event.keys)
|
|
|
|
|
2023-12-19 20:58:11 +03:00
|
|
|
this.doc.data.observe(observeData)
|
2023-10-02 15:01:03 +03:00
|
|
|
this.doc.metadata.observe(observeMetadata)
|
|
|
|
Y.applyUpdate(this.doc.ydoc, update, 'remote')
|
2023-12-19 20:58:11 +03:00
|
|
|
this.doc.data.unobserve(observeData)
|
2023-10-02 15:01:03 +03:00
|
|
|
this.doc.metadata.unobserve(observeMetadata)
|
2023-12-19 20:58:11 +03:00
|
|
|
this.writeSyncedEvents(dataKeys, metadataKeys)
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
private writeSyncedEvents(
|
2023-12-19 20:58:11 +03:00
|
|
|
dataKeys: Y.YMapEvent<any>['keys'] | null,
|
2023-10-02 15:01:03 +03:00
|
|
|
metadataKeys: Y.YMapEvent<NodeMetadata>['keys'] | null,
|
|
|
|
) {
|
|
|
|
if (this.syncedContent == null || this.syncedVersion == null) return
|
2023-12-19 20:58:11 +03:00
|
|
|
if (!dataKeys && !metadataKeys) return
|
2023-10-02 15:01:03 +03:00
|
|
|
|
2023-10-04 13:53:54 +03:00
|
|
|
const { edits, newContent, newMetadata } = applyDocumentUpdates(
|
|
|
|
this.doc,
|
|
|
|
this.syncedMeta,
|
|
|
|
this.syncedContent,
|
2023-12-19 20:58:11 +03:00
|
|
|
dataKeys,
|
2023-10-04 13:53:54 +03:00
|
|
|
metadataKeys,
|
|
|
|
)
|
2023-10-03 21:07:20 +03:00
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
const newVersion = computeTextChecksum(newContent)
|
|
|
|
|
|
|
|
if (DEBUG_LOG_SYNC) {
|
2023-12-19 08:41:14 +03:00
|
|
|
console.debug(' === changes === ')
|
|
|
|
console.debug('number of edits:', edits.length)
|
|
|
|
console.debug('metadata:', metadataKeys)
|
2023-12-19 20:58:11 +03:00
|
|
|
console.debug('data:', dataKeys)
|
2023-10-04 13:53:54 +03:00
|
|
|
if (edits.length > 0) {
|
2023-12-19 08:41:14 +03:00
|
|
|
console.debug('version:', this.syncedVersion, '->', newVersion)
|
|
|
|
console.debug('Content diff:')
|
|
|
|
console.debug(prettyPrintDiff(this.syncedContent, newContent))
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
2023-12-19 08:41:14 +03:00
|
|
|
console.debug(' =============== ')
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
this.setState(LsSyncState.WritingFile)
|
2023-10-07 23:57:47 +03:00
|
|
|
|
2023-12-19 20:58:11 +03:00
|
|
|
const execute = dataKeys != null
|
2023-12-19 08:41:14 +03:00
|
|
|
const edit: FileEdit = { path: this.path, edits, oldVersion: this.syncedVersion, newVersion }
|
|
|
|
const apply = this.ls.applyEdit(edit, execute)
|
|
|
|
const promise = apply.then(
|
2023-10-02 15:01:03 +03:00
|
|
|
() => {
|
|
|
|
this.syncedContent = newContent
|
|
|
|
this.syncedVersion = newVersion
|
2023-10-04 13:53:54 +03:00
|
|
|
this.syncedMeta = newMetadata
|
2023-12-19 08:41:14 +03:00
|
|
|
this.setState(LsSyncState.Synchronized)
|
2023-10-02 15:01:03 +03:00
|
|
|
},
|
2023-12-19 08:41:14 +03:00
|
|
|
(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)
|
2023-10-02 15:01:03 +03:00
|
|
|
this.syncedContent = null
|
|
|
|
this.syncedVersion = null
|
|
|
|
return this.reload()
|
|
|
|
},
|
2023-12-19 08:41:14 +03:00
|
|
|
)
|
|
|
|
this.setLastAction(promise)
|
|
|
|
return promise
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
private syncFileContents(content: string, version: Checksum) {
|
|
|
|
this.doc.ydoc.transact(() => {
|
2024-01-18 16:13:31 +03:00
|
|
|
const { code, idMapJson, metadataJson } = splitFileContents(content)
|
2023-10-02 15:01:03 +03:00
|
|
|
const metadata = fileFormat.tryParseMetadataOrFallback(metadataJson)
|
|
|
|
const nodeMeta = metadata.ide.node
|
|
|
|
|
2024-01-02 18:18:11 +03:00
|
|
|
const idMap = idMapJson ? deserializeIdMap(idMapJson) : new IdMap()
|
2023-12-19 20:58:11 +03:00
|
|
|
this.doc.setIdMap(idMap)
|
2023-10-02 15:01:03 +03:00
|
|
|
|
|
|
|
const keysToDelete = new Set(this.doc.metadata.keys())
|
|
|
|
for (const [id, meta] of Object.entries(nodeMeta)) {
|
|
|
|
if (typeof id !== 'string') continue
|
|
|
|
const formattedMeta: NodeMetadata = {
|
2023-10-07 23:57:47 +03:00
|
|
|
x: meta.position.vector[0],
|
|
|
|
y: meta.position.vector[1],
|
|
|
|
vis: (meta.visualization && translateVisualizationFromFile(meta.visualization)) ?? null,
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
2023-10-02 15:01:03 +03:00
|
|
|
keysToDelete.delete(id)
|
|
|
|
this.doc.metadata.set(id, formattedMeta)
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
2023-12-19 08:41:14 +03:00
|
|
|
for (const id of keysToDelete) this.doc.metadata.delete(id)
|
2023-10-02 15:01:03 +03:00
|
|
|
this.syncedContent = content
|
|
|
|
this.syncedVersion = version
|
|
|
|
this.syncedMeta = metadata
|
2023-12-19 20:58:11 +03:00
|
|
|
|
|
|
|
this.doc.setCode(code)
|
2023-10-02 15:01:03 +03:00
|
|
|
}, 'file')
|
|
|
|
}
|
|
|
|
|
|
|
|
async close() {
|
|
|
|
this.queuedAction = LsAction.Close
|
|
|
|
switch (this.state) {
|
|
|
|
case LsSyncState.Disposed:
|
2023-12-19 08:41:14 +03:00
|
|
|
case LsSyncState.Closed: {
|
2023-10-02 15:01:03 +03:00
|
|
|
return
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
|
|
|
case LsSyncState.Closing: {
|
2023-10-02 15:01:03 +03:00
|
|
|
await this.lastAction
|
|
|
|
return
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
2023-10-02 15:01:03 +03:00
|
|
|
case LsSyncState.Opening:
|
|
|
|
case LsSyncState.WritingFile:
|
2023-12-19 08:41:14 +03:00
|
|
|
case LsSyncState.Reloading: {
|
2023-10-02 15:01:03 +03:00
|
|
|
await this.lastAction
|
|
|
|
if (this.queuedAction === LsAction.Close) {
|
|
|
|
await this.close()
|
|
|
|
}
|
|
|
|
return
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
2023-10-02 15:01:03 +03:00
|
|
|
case LsSyncState.WriteError:
|
|
|
|
case LsSyncState.Synchronized: {
|
2023-12-19 08:41:14 +03:00
|
|
|
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)
|
2023-10-02 15:01:03 +03:00
|
|
|
return
|
|
|
|
}
|
|
|
|
default: {
|
2023-12-19 08:41:14 +03:00
|
|
|
this.state satisfies never
|
|
|
|
return
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
2023-10-02 15:01:03 +03:00
|
|
|
async reload() {
|
|
|
|
this.queuedAction = LsAction.Reload
|
|
|
|
switch (this.state) {
|
|
|
|
case LsSyncState.Opening:
|
|
|
|
case LsSyncState.Disposed:
|
|
|
|
case LsSyncState.Closed:
|
2023-12-19 08:41:14 +03:00
|
|
|
case LsSyncState.Closing: {
|
2023-10-02 15:01:03 +03:00
|
|
|
return
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
|
|
|
case LsSyncState.Reloading: {
|
2023-10-02 15:01:03 +03:00
|
|
|
await this.lastAction
|
|
|
|
return
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
|
|
|
case LsSyncState.WritingFile: {
|
2023-10-02 15:01:03 +03:00
|
|
|
await this.lastAction
|
2023-12-19 08:41:14 +03:00
|
|
|
if (this.queuedAction === LsAction.Reload) await this.reload()
|
2023-10-02 15:01:03 +03:00
|
|
|
return
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
|
|
|
case LsSyncState.Synchronized: {
|
|
|
|
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
|
|
|
|
this.syncFileContents(contents.contents, checksum.checksum)
|
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
2023-10-02 15:01:03 +03:00
|
|
|
case LsSyncState.WriteError: {
|
2023-12-19 08:41:14 +03:00
|
|
|
this.withState(LsSyncState.Reloading, async () => {
|
|
|
|
const path = this.path.segments.join('/')
|
|
|
|
const reloading = this.ls
|
|
|
|
.closeTextFile(this.path)
|
|
|
|
.catch((error) => {
|
|
|
|
console.error('Could not close file after write error:')
|
|
|
|
console.error(error)
|
|
|
|
})
|
|
|
|
.then(
|
|
|
|
() =>
|
|
|
|
exponentialBackoff(
|
|
|
|
async () => {
|
|
|
|
const result = await this.ls.openTextFile(this.path)
|
|
|
|
if (!result.writeCapability) {
|
|
|
|
const message = `Could not acquire write capability for module '${this.path.segments.join(
|
|
|
|
'/',
|
|
|
|
)}'`
|
|
|
|
console.error(message)
|
|
|
|
throw new Error(message)
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
},
|
|
|
|
printingCallbacks(
|
|
|
|
`opened file '${path}' for writing`,
|
|
|
|
`open file '${path}' for writing`,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
(error) => {
|
|
|
|
console.error('Could not reopen file after write error:')
|
|
|
|
console.error(error)
|
|
|
|
// This error is unrecoverable.
|
|
|
|
throw error
|
|
|
|
},
|
|
|
|
)
|
|
|
|
this.setLastAction(reloading)
|
|
|
|
const result = await reloading
|
|
|
|
this.syncFileContents(result.content, result.currentVersion)
|
2023-10-02 15:01:03 +03:00
|
|
|
})
|
|
|
|
return
|
|
|
|
}
|
|
|
|
default: {
|
2023-12-19 08:41:14 +03:00
|
|
|
this.state satisfies never
|
|
|
|
return
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-12-19 08:41:14 +03:00
|
|
|
async dispose(): Promise<void> {
|
2023-10-02 15:01:03 +03:00
|
|
|
this.cleanup()
|
|
|
|
const alreadyClosed = this.inState(LsSyncState.Closing, LsSyncState.Closed)
|
2023-12-19 08:41:14 +03:00
|
|
|
this.setState(LsSyncState.Disposed)
|
|
|
|
if (alreadyClosed) return Promise.resolve()
|
|
|
|
return this.ls.closeTextFile(this.path)
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
}
|