2024-02-02 12:22:18 +03:00
|
|
|
import * as json from 'lib0/json'
|
2023-09-22 06:43:25 +03:00
|
|
|
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-02-20 02:57:42 +03:00
|
|
|
import * as Ast from '../shared/ast'
|
|
|
|
import { astCount } from '../shared/ast'
|
2024-02-02 12:22:18 +03:00
|
|
|
import { EnsoFileParts, combineFileParts, splitFileContents } from '../shared/ensoFile'
|
2023-10-02 15:01:03 +03:00
|
|
|
import { LanguageServer, computeTextChecksum } from '../shared/languageServer'
|
2024-04-19 16:39:45 +03:00
|
|
|
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'
|
2023-10-02 15:01:03 +03:00
|
|
|
import {
|
|
|
|
DistributedProject,
|
2024-02-02 12:22:18 +03:00
|
|
|
ExternalId,
|
2023-10-02 15:01:03 +03:00
|
|
|
IdMap,
|
|
|
|
ModuleDoc,
|
2024-02-02 12:22:18 +03:00
|
|
|
visMetadataEquals,
|
2023-10-07 23:57:47 +03:00
|
|
|
type Uuid,
|
2023-10-02 15:01:03 +03:00
|
|
|
} from '../shared/yjsModel'
|
2024-02-02 12:22:18 +03:00
|
|
|
import {
|
|
|
|
applyDiffAsTextEdits,
|
|
|
|
applyDocumentUpdates,
|
|
|
|
prettyPrintDiff,
|
|
|
|
translateVisualizationFromFile,
|
|
|
|
} from './edits'
|
2023-10-02 15:01:03 +03:00
|
|
|
import * as fileFormat from './fileFormat'
|
2024-02-02 12:22:18 +03:00
|
|
|
import { deserializeIdMap, serializeIdMap } 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
|
|
|
export class LanguageServerSession {
|
2023-09-22 06:43:25 +03:00
|
|
|
clientId: Uuid
|
|
|
|
indexDoc: WSSharedDoc
|
|
|
|
docs: Map<string, WSSharedDoc>
|
|
|
|
retainCount: number
|
|
|
|
url: string
|
|
|
|
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>
|
2024-04-04 16:49:54 +03:00
|
|
|
clientScope: AbortScope
|
2023-09-22 06:43:25 +03:00
|
|
|
|
|
|
|
constructor(url: string) {
|
2024-04-04 16:49:54 +03:00
|
|
|
this.clientScope = new AbortScope()
|
2023-09-22 06:43:25 +03:00
|
|
|
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
|
|
|
}
|
|
|
|
})
|
2024-04-19 16:39:45 +03:00
|
|
|
this.ls = new LanguageServer(this.clientId, new ReconnectingWebSocketTransport(this.url))
|
|
|
|
this.clientScope.onAbort(() => this.ls.release())
|
|
|
|
this.setupClient()
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
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() {
|
2024-04-19 16:39:45 +03:00
|
|
|
this.ls.reconnect()
|
|
|
|
return exponentialBackoff(() => this.readInitialState())
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
private setupClient() {
|
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)
|
|
|
|
}
|
2024-04-19 16:39:45 +03:00
|
|
|
const result = await this.handleFileEvent(event)
|
|
|
|
if (!result.ok) this.restartClient()
|
|
|
|
})
|
|
|
|
this.ls.on('text/fileModifiedOnDisk', async (event) => {
|
2023-12-19 08:41:14 +03:00
|
|
|
const path = event.path.segments.join('/')
|
2024-04-19 16:39:45 +03:00
|
|
|
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('/')
|
2023-12-19 08:41:14 +03:00
|
|
|
switch (event.kind) {
|
|
|
|
case 'Added': {
|
|
|
|
if (isSourceFile(event.path)) {
|
|
|
|
const fileInfo = await this.ls.fileInfo(event.path)
|
2024-04-19 16:39:45 +03:00
|
|
|
if (!fileInfo.ok) return fileInfo
|
|
|
|
if (fileInfo.value.attributes.kind.type == 'File') {
|
|
|
|
return await exponentialBackoff(
|
2023-12-19 08:41:14 +03:00
|
|
|
() => 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': {
|
2024-04-19 16:39:45 +03:00
|
|
|
return await exponentialBackoff(
|
|
|
|
() => this.tryGetExistingModuleModel(event.path)?.reload() ?? Promise.resolve(Ok()),
|
2023-12-19 08:41:14 +03:00
|
|
|
printingCallbacks(`reloaded file '${path}'`, `reload file '${path}'`),
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
2024-04-19 16:39:45 +03:00
|
|
|
return Ok()
|
|
|
|
},
|
|
|
|
)
|
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')
|
|
|
|
}
|
|
|
|
|
2024-04-19 16:39:45 +03:00
|
|
|
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()
|
|
|
|
},
|
|
|
|
)
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
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)
|
2024-04-19 16:39:45 +03:00
|
|
|
if (!srcModules.ok) return srcModules
|
|
|
|
return Ok(
|
|
|
|
srcModules.value.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()
|
2024-04-04 16:49:54 +03:00
|
|
|
this.clientScope.dispose('LangueServerSession disposed.')
|
2023-12-19 08:41:14 +03:00
|
|
|
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
|
2024-02-02 12:22:18 +03:00
|
|
|
syncedCode: string | null = null
|
|
|
|
syncedIdMap: string | null = null
|
|
|
|
syncedMetaJson: string | null = null
|
2023-10-02 15:01:03 +03:00
|
|
|
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. */
|
2024-04-19 16:39:45 +03:00
|
|
|
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> {
|
2023-12-19 08:41:14 +03:00
|
|
|
this.setState(state)
|
2024-04-19 16:39:45 +03:00
|
|
|
const result = await callback()
|
|
|
|
if (result && !result.ok) return result
|
2023-12-19 08:41:14 +03:00
|
|
|
this.setState(LsSyncState.Synchronized)
|
2024-04-19 16:39:45 +03:00
|
|
|
if (result) return result
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
|
|
|
|
2024-04-19 16:39:45 +03:00
|
|
|
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()
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
2024-04-19 16:39:45 +03:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
)
|
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
|
|
|
|
|
2024-02-20 02:57:42 +03:00
|
|
|
const syncModule = new Ast.MutableModule(this.doc.ydoc)
|
2024-02-02 12:22:18 +03:00
|
|
|
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)
|
|
|
|
}
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
|
2024-02-02 12:22:18 +03:00
|
|
|
private sendLsUpdate(
|
|
|
|
synced: EnsoFileParts,
|
|
|
|
newCode: string | undefined,
|
|
|
|
newIdMap: IdMap | undefined,
|
|
|
|
newMetadata: fileFormat.IdeMetadata['node'] | undefined,
|
2023-10-02 15:01:03 +03:00
|
|
|
) {
|
|
|
|
if (this.syncedContent == null || this.syncedVersion == null) return
|
2024-02-02 12:22:18 +03:00
|
|
|
|
|
|
|
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))
|
|
|
|
}
|
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)
|
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
|
|
|
|
2024-02-02 12:22:18 +03:00
|
|
|
const execute = newCode != null || newIdMap != 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
|
2024-02-02 12:22:18 +03:00
|
|
|
if (newMetadata) this.syncedMeta.ide.node = newMetadata
|
|
|
|
if (newCode) this.syncedCode = newCode
|
|
|
|
if (newIdMapJson) this.syncedIdMap = newIdMapJson
|
|
|
|
if (newMetadataJson) this.syncedMetaJson = newMetadataJson
|
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
|
2024-02-02 12:22:18 +03:00
|
|
|
this.syncedCode = null
|
|
|
|
this.syncedIdMap = null
|
|
|
|
this.syncedMetaJson = null
|
2023-10-02 15:01:03 +03:00
|
|
|
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) {
|
2024-02-02 12:22:18 +03:00
|
|
|
const contentsReceived = splitFileContents(content)
|
|
|
|
let unsyncedIdMap: IdMap | undefined
|
2023-10-02 15:01:03 +03:00
|
|
|
this.doc.ydoc.transact(() => {
|
2024-02-02 12:22:18 +03:00
|
|
|
const { code, idMapJson, metadataJson } = contentsReceived
|
2023-10-02 15:01:03 +03:00
|
|
|
const metadata = fileFormat.tryParseMetadataOrFallback(metadataJson)
|
2024-02-02 12:22:18 +03:00
|
|
|
const nodeMeta = Object.entries(metadata.ide.node)
|
|
|
|
|
|
|
|
let parsedSpans
|
2024-02-20 02:57:42 +03:00
|
|
|
const syncModule = new Ast.MutableModule(this.doc.ydoc)
|
2024-02-02 12:22:18 +03:00
|
|
|
if (code !== this.syncedCode) {
|
2024-02-20 02:57:42 +03:00
|
|
|
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
|
|
|
|
}
|
2024-02-02 12:22:18 +03:00
|
|
|
}
|
|
|
|
const astRoot = syncModule.root()
|
|
|
|
if (!astRoot) return
|
|
|
|
if ((code !== this.syncedCode || idMapJson !== this.syncedIdMap) && idMapJson) {
|
|
|
|
const idMap = deserializeIdMap(idMapJson)
|
2024-02-20 02:57:42 +03:00
|
|
|
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) {
|
2024-02-02 12:22:18 +03:00
|
|
|
if (code !== this.syncedCode) {
|
2024-02-20 02:57:42 +03:00
|
|
|
unsyncedIdMap = Ast.spanMapToIdMap(spans)
|
2024-02-02 12:22:18 +03:00
|
|
|
} else {
|
|
|
|
console.warn(
|
2024-02-20 02:57:42 +03:00
|
|
|
`The LS sent an IdMap-only edit that is missing ${idsNotSetByMap} of our expected ASTs.`,
|
2024-02-02 12:22:18 +03:00
|
|
|
)
|
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
}
|
2024-02-02 12:22:18 +03:00
|
|
|
if (
|
|
|
|
(code !== this.syncedCode ||
|
|
|
|
idMapJson !== this.syncedIdMap ||
|
|
|
|
metadataJson !== this.syncedMetaJson) &&
|
|
|
|
nodeMeta.length !== 0
|
|
|
|
) {
|
2024-02-20 02:57:42 +03:00
|
|
|
const externalIdToAst = new Map<ExternalId, Ast.Ast>()
|
2024-02-02 12:22:18 +03:00
|
|
|
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)
|
2024-04-02 14:27:13 +03:00
|
|
|
const oldColorOverride = metadata.get('colorOverride')
|
|
|
|
const newColorOverride = meta.colorOverride
|
|
|
|
if (oldColorOverride !== newColorOverride) metadata.set('colorOverride', newColorOverride)
|
2024-02-02 12:22:18 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.syncedCode = code
|
|
|
|
this.syncedIdMap = unsyncedIdMap ? null : idMapJson
|
2023-10-02 15:01:03 +03:00
|
|
|
this.syncedContent = content
|
|
|
|
this.syncedVersion = version
|
|
|
|
this.syncedMeta = metadata
|
2024-02-02 12:22:18 +03:00
|
|
|
this.syncedMetaJson = metadataJson
|
2023-10-02 15:01:03 +03:00
|
|
|
}, 'file')
|
2024-02-02 12:22:18 +03:00
|
|
|
if (unsyncedIdMap) this.sendLsUpdate(contentsReceived, undefined, unsyncedIdMap, undefined)
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
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: {
|
2024-04-19 16:39:45 +03:00
|
|
|
assertNever(this.state)
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
}
|
2023-09-22 06:43:25 +03:00
|
|
|
}
|
|
|
|
|
2024-04-19 16:39:45 +03:00
|
|
|
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()
|
2023-12-19 08:41:14 +03:00
|
|
|
})
|
2024-04-19 16:39:45 +03:00
|
|
|
}
|
|
|
|
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(
|
2023-12-19 08:41:14 +03:00
|
|
|
async () => {
|
|
|
|
const result = await this.ls.openTextFile(this.path)
|
2024-04-19 16:39:45 +03:00
|
|
|
if (!result.ok) return result
|
|
|
|
if (!result.value.writeCapability) {
|
|
|
|
return Err(
|
|
|
|
`Could not acquire write capability for module '${this.path.segments.join(
|
|
|
|
'/',
|
|
|
|
)}'`,
|
|
|
|
)
|
2023-12-19 08:41:14 +03:00
|
|
|
}
|
|
|
|
return result
|
|
|
|
},
|
|
|
|
printingCallbacks(
|
|
|
|
`opened file '${path}' for writing`,
|
|
|
|
`open file '${path}' for writing`,
|
|
|
|
),
|
2024-04-19 16:39:45 +03:00
|
|
|
)
|
|
|
|
})
|
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
)
|
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()
|
2024-04-19 16:39:45 +03:00
|
|
|
const closing = await this.ls.closeTextFile(this.path)
|
|
|
|
if (!closing.ok) {
|
|
|
|
closing.error.log(`Closing text file ${this.path}`)
|
|
|
|
}
|
2023-10-02 15:01:03 +03:00
|
|
|
}
|
|
|
|
}
|