mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 09:51:30 +03:00
b7a8909818
- Improved performance by batching simulatenous node edits, including metadata updates when dragging many selected nodes together. - Updated Vue to new version, allowing us to use `defineModel`. - Fixed #9161 - Unified all handling of auto-blur by making `useAutoBlur` cheap to register - all logic goes through a single window event handler. - Combined all `ResizeObserver`s into one. - Fixed the behaviour of repeated toast messages. Now only the latest compilation status is visible at any given time, and the errors disappear once compilation passes. - Actually fixed broken interaction of node and visualization widths. There no longer is a style feedback loop and the visible node backdrop width no longer jumps or randomly fails to update.
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
/**
|
|
* @file A module responsible for translating file edits between the Yjs document updates and the
|
|
* Language server protocol structures.
|
|
*/
|
|
|
|
import diff from 'fast-diff'
|
|
import type { ModuleUpdate } from '../shared/ast'
|
|
import { MutableModule, print, spanMapToIdMap } from '../shared/ast'
|
|
import { EnsoFileParts } from '../shared/ensoFile'
|
|
import { TextEdit } from '../shared/languageServerTypes'
|
|
import { assert } from '../shared/util/assert'
|
|
import { IdMap, ModuleDoc, type VisualizationMetadata } from '../shared/yjsModel'
|
|
import * as fileFormat from './fileFormat'
|
|
|
|
/**
|
|
* The simulated metadata of this size takes c.a. 1 second on my machine. It should be quite
|
|
* bearable, even on slower machines.
|
|
*
|
|
* Full benchmark results (from edits.bench.ts):
|
|
* name hz min max mean p75 p99 p995 p999 rme samples
|
|
* · Diffing 10000 8.7370 108.66 132.93 114.46 111.73 132.93 132.93 132.93 ±11.28% 5
|
|
* · Diffing 15000 4.0483 239.82 257.99 247.02 257.99 257.99 257.99 257.99 ±9.71% 3
|
|
* · Diffing 20000 2.1577 462.40 464.52 463.46 464.52 464.52 464.52 464.52 ±2.90% 2
|
|
* · Diffing 25000 1.3744 727.61 727.61 727.61 727.61 727.61 727.61 727.61 ±0.00% 1
|
|
* · Diffing 30000 0.9850 1,015.25 1,015.25 1,015.25 1,015.25 1,015.25 1,015.25 1,015.25 ±0.00% 1
|
|
* · Diffing 35000 0.6934 1,442.27 1,442.27 1,442.27 1,442.27 1,442.27 1,442.27 1,442.27 ±0.00% 1
|
|
* · Diffing 40000 0.5141 1,945.24 1,945.24 1,945.24 1,945.24 1,945.24 1,945.24 1,945.24 ±0.00% 1
|
|
* · Diffing 50000 0.3315 3,016.59 3,016.59 3,016.59 3,016.59 3,016.59 3,016.59 3,016.59 ±0.00% 1
|
|
* · Diffing 60000 0.2270 4,405.46 4,405.46 4,405.46 4,405.46 4,405.46 4,405.46 4,405.46 ±0.00% 1
|
|
* · Diffing 70000 0.1602 6,240.52 6,240.52 6,240.52 6,240.52 6,240.52 6,240.52 6,240.52 ±0.00% 1
|
|
* · Diffing 80000 0.1233 8,110.54 8,110.54 8,110.54 8,110.54 8,110.54 8,110.54 8,110.54 ±0.00% 1
|
|
* · Diffing 90000 0.0954 10,481.47 10,481.47 10,481.47 10,481.47 10,481.47 10,481.47 10,481.47 ±0.00% 1
|
|
* · Diffing 100000 0.0788 12,683.46 12,683.46 12,683.46 12,683.46 12,683.46 12,683.46 12,683.46 ±0.00% 1
|
|
* · Diffing 250000 0.0107 93,253.97 93,253.97 93,253.97 93,253.97 93,253.97 93,253.97 93,253.97 ±0.00% 1
|
|
*/
|
|
const MAX_SIZE_FOR_NORMAL_DIFF = 30000
|
|
|
|
interface AppliedUpdates {
|
|
newCode: string | undefined
|
|
newIdMap: IdMap | undefined
|
|
newMetadata: fileFormat.IdeMetadata['node'] | undefined
|
|
}
|
|
|
|
export function applyDocumentUpdates(
|
|
doc: ModuleDoc,
|
|
synced: EnsoFileParts,
|
|
update: ModuleUpdate,
|
|
): AppliedUpdates {
|
|
const codeChanged = update.nodesUpdated.size !== 0
|
|
let idsChanged = false
|
|
let metadataChanged = false
|
|
for (const { changes } of update.metadataUpdated) {
|
|
for (const [key] of changes) {
|
|
if (key === 'externalId') {
|
|
idsChanged = true
|
|
} else {
|
|
metadataChanged = true
|
|
}
|
|
}
|
|
if (idsChanged && metadataChanged) break
|
|
}
|
|
|
|
let newIdMap = undefined
|
|
let newCode = undefined
|
|
let newMetadata = undefined
|
|
|
|
const syncModule = new MutableModule(doc.ydoc)
|
|
const root = syncModule.root()
|
|
assert(root != null)
|
|
if (codeChanged || idsChanged || synced.idMapJson == null) {
|
|
const { code, info } = print(root)
|
|
if (codeChanged) newCode = code
|
|
newIdMap = spanMapToIdMap(info)
|
|
}
|
|
if (codeChanged || idsChanged || metadataChanged) {
|
|
// Update the metadata object.
|
|
// Depth-first key order keeps diffs small.
|
|
newMetadata = {} satisfies fileFormat.IdeMetadata['node']
|
|
root.visitRecursiveAst((ast) => {
|
|
let pos = ast.nodeMetadata.get('position')
|
|
const vis = ast.nodeMetadata.get('visualization')
|
|
if (vis && !pos) pos = { x: 0, y: 0 }
|
|
if (pos) {
|
|
newMetadata![ast.externalId] = {
|
|
position: { vector: [Math.round(pos.x), Math.round(-pos.y)] },
|
|
visualization: vis && translateVisualizationToFile(vis),
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
return { newCode, newIdMap, newMetadata }
|
|
}
|
|
|
|
function translateVisualizationToFile(
|
|
vis: VisualizationMetadata,
|
|
): fileFormat.VisualizationMetadata | undefined {
|
|
let project = undefined
|
|
switch (vis.identifier?.module.kind) {
|
|
case 'Builtin':
|
|
project = { project: 'Builtin' } as const
|
|
break
|
|
case 'CurrentProject':
|
|
project = { project: 'CurrentProject' } as const
|
|
break
|
|
case 'Library':
|
|
project = { project: 'Library', contents: vis.identifier.module.name } as const
|
|
break
|
|
}
|
|
return {
|
|
show: vis.visible,
|
|
fullscreen: vis.fullscreen,
|
|
width: vis.width ?? undefined,
|
|
...(project == null || vis.identifier == null ?
|
|
{}
|
|
: {
|
|
project: project,
|
|
name: vis.identifier.name,
|
|
}),
|
|
}
|
|
}
|
|
|
|
export function translateVisualizationFromFile(
|
|
vis: fileFormat.VisualizationMetadata,
|
|
): VisualizationMetadata | undefined {
|
|
let module
|
|
switch (vis.project?.project) {
|
|
case 'Builtin':
|
|
module = { kind: 'Builtin' } as const
|
|
break
|
|
case 'CurrentProject':
|
|
module = { kind: 'CurrentProject' } as const
|
|
break
|
|
case 'Library':
|
|
module = { kind: 'Library', name: vis.project.contents } as const
|
|
break
|
|
default:
|
|
module = null
|
|
}
|
|
return {
|
|
identifier: module && vis.name ? { name: vis.name, module } : null,
|
|
visible: vis.show,
|
|
fullscreen: vis.fullscreen ?? false,
|
|
width: vis.width ?? null,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A simplified diff algorithm.
|
|
*
|
|
* The `fast-diff` package uses Myers' https://neil.fraser.name/writing/diff/myers.pdf with some
|
|
* optimizations to generate minimal diff. Unfortunately, event this algorithm is still to slow
|
|
* for our metadata. Therefore we need to use faster algorithm which will not produce theoretically
|
|
* minimal diff.
|
|
*
|
|
* This is quick implementation making diff which just replaces entire string except common prefix
|
|
* and suffix.
|
|
*/
|
|
export function stupidFastDiff(oldString: string, newString: string): diff.Diff[] {
|
|
const minLength = Math.min(oldString.length, newString.length)
|
|
let commonPrefixLen, commonSuffixLen
|
|
for (commonPrefixLen = 0; commonPrefixLen < minLength; ++commonPrefixLen)
|
|
if (oldString[commonPrefixLen] !== newString[commonPrefixLen]) break
|
|
if (oldString.length === newString.length && oldString.length === commonPrefixLen)
|
|
return [[0, oldString]]
|
|
for (commonSuffixLen = 0; commonSuffixLen < minLength - commonPrefixLen; ++commonSuffixLen)
|
|
if (oldString.at(-1 - commonSuffixLen) !== newString.at(-1 - commonSuffixLen)) break
|
|
const commonPrefix = oldString.substring(0, commonPrefixLen)
|
|
const removed = oldString.substring(commonPrefixLen, oldString.length - commonSuffixLen)
|
|
const added = newString.substring(commonPrefixLen, newString.length - commonSuffixLen)
|
|
const commonSuffix = oldString.substring(oldString.length - commonSuffixLen, oldString.length)
|
|
return (commonPrefix ? ([[0, commonPrefix]] as diff.Diff[]) : [])
|
|
.concat(removed ? [[-1, removed]] : [])
|
|
.concat(added ? [[1, added]] : [])
|
|
.concat(commonSuffix ? [[0, commonSuffix]] : [])
|
|
}
|
|
|
|
export function applyDiffAsTextEdits(
|
|
lineOffset: number,
|
|
oldString: string,
|
|
newString: string,
|
|
): TextEdit[] {
|
|
const changes =
|
|
oldString.length + newString.length > MAX_SIZE_FOR_NORMAL_DIFF ?
|
|
stupidFastDiff(oldString, newString)
|
|
: diff(oldString, newString)
|
|
let newIndex = 0
|
|
let lineNum = lineOffset
|
|
let lineStartIdx = 0
|
|
const edits = []
|
|
for (const [op, text] of changes) {
|
|
if (op === 1) {
|
|
const pos = {
|
|
character: newIndex - lineStartIdx,
|
|
line: lineNum,
|
|
}
|
|
edits.push({ range: { start: pos, end: pos }, text })
|
|
const numLineBreaks = (text.match(/\n/g) ?? []).length
|
|
if (numLineBreaks > 0) {
|
|
lineNum += numLineBreaks
|
|
lineStartIdx = newIndex + text.lastIndexOf('\n') + 1
|
|
}
|
|
newIndex += text.length
|
|
} else if (op === -1) {
|
|
const start = {
|
|
character: newIndex - lineStartIdx,
|
|
line: lineNum,
|
|
}
|
|
const numLineBreaks = (text.match(/\n/g) ?? []).length
|
|
const character =
|
|
numLineBreaks > 0 ?
|
|
text.length - (text.lastIndexOf('\n') + 1)
|
|
: newIndex - lineStartIdx + text.length
|
|
const end = {
|
|
character,
|
|
line: lineNum + numLineBreaks,
|
|
}
|
|
edits.push({ range: { start, end }, text: '' })
|
|
} else if (op === 0) {
|
|
const numLineBreaks = (text.match(/\n/g) ?? []).length
|
|
lineNum += numLineBreaks
|
|
if (numLineBreaks > 0) {
|
|
lineStartIdx = newIndex + text.lastIndexOf('\n') + 1
|
|
}
|
|
newIndex += text.length
|
|
}
|
|
}
|
|
return edits
|
|
}
|
|
|
|
export function prettyPrintDiff(from: string, to: string): string {
|
|
const colReset = '\x1b[0m'
|
|
const colRed = '\x1b[31m'
|
|
const colGreen = '\x1b[32m'
|
|
|
|
const diffs =
|
|
from.length + to.length > MAX_SIZE_FOR_NORMAL_DIFF ? stupidFastDiff(from, to) : diff(from, to)
|
|
if (diffs.length === 1 && diffs[0]![0] === 0) return 'No changes'
|
|
let content = ''
|
|
for (let i = 0; i < diffs.length; i++) {
|
|
const [op, text] = diffs[i]!
|
|
if (op === 1) {
|
|
content += colGreen + text
|
|
} else if (op === -1) {
|
|
content += colRed + text
|
|
} else if (op === 0) {
|
|
content += colReset
|
|
const numNewlines = (text.match(/\n/g) ?? []).length
|
|
if (numNewlines < 2) {
|
|
content += text
|
|
} else {
|
|
const firstNewline = text.indexOf('\n')
|
|
const lastNewline = text.lastIndexOf('\n')
|
|
const firstLine = text.slice(0, firstNewline + 1)
|
|
const lastLine = text.slice(lastNewline + 1)
|
|
const isFirst = i === 0
|
|
const isLast = i === diffs.length - 1
|
|
if (!isFirst) content += firstLine
|
|
if (!isFirst && !isLast) content += '...\n'
|
|
if (!isLast) content += lastLine
|
|
}
|
|
}
|
|
}
|
|
content += colReset
|
|
return content
|
|
}
|
|
|
|
if (import.meta.vitest) {
|
|
const { test, expect } = import.meta.vitest
|
|
|
|
test.each`
|
|
oldStr | newStr | expected
|
|
${''} | ${'foo'} | ${[[1, 'foo']]}
|
|
${'foo'} | ${''} | ${[[-1, 'foo']]}
|
|
${'foo'} | ${'foo'} | ${[[0, 'foo']]}
|
|
${'foo'} | ${'bar'} | ${[[-1, 'foo'], [1, 'bar']]}
|
|
${'ababx'} | ${'acacx'} | ${[[0, 'a'], [-1, 'bab'], [1, 'cac'], [0, 'x']]}
|
|
${'ax'} | ${'acacx'} | ${[[0, 'a'], [1, 'cac'], [0, 'x']]}
|
|
${'ababx'} | ${'ax'} | ${[[0, 'a'], [-1, 'bab'], [0, 'x']]}
|
|
${'ababx'} | ${'abacax'} | ${[[0, 'aba'], [-1, 'b'], [1, 'ca'], [0, 'x']]}
|
|
${'axxxa'} | ${'a'} | ${[[0, 'a'], [-1, 'xxxa']]}
|
|
`('Stupid diff of $oldStr and $newStr', ({ oldStr, newStr, expected }) => {
|
|
expect(stupidFastDiff(oldStr, newStr)).toEqual(expected)
|
|
})
|
|
}
|