e0a5f57793
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
558 lines
16 KiB
TypeScript
558 lines
16 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
function installRafMocks() {
|
|
vi.stubGlobal('requestAnimationFrame', (cb: any) => {
|
|
return setTimeout(() => cb(Date.now()), 0) as unknown as number
|
|
})
|
|
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
|
clearTimeout(id as unknown as ReturnType<typeof setTimeout>)
|
|
})
|
|
}
|
|
|
|
async function loadDiffEditorManager() {
|
|
vi.resetModules()
|
|
|
|
vi.doMock('../src/monaco-shim', () => {
|
|
class Range {
|
|
constructor(
|
|
public startLineNumber: number,
|
|
public startColumn: number,
|
|
public endLineNumber: number,
|
|
public endColumn: number,
|
|
) {}
|
|
}
|
|
|
|
function createModel(initialValue: string, initialLanguage: string) {
|
|
let value = initialValue
|
|
let languageId = initialLanguage
|
|
let alternativeVersionId = 1
|
|
const contentSizeListeners = new Set<() => void>()
|
|
const contentChangeListeners = new Set<() => void>()
|
|
|
|
function lines() {
|
|
return value.split('\n')
|
|
}
|
|
|
|
function getOffsetAt(lineNumber: number, column: number) {
|
|
const parts = lines()
|
|
let offset = 0
|
|
for (let i = 0; i < lineNumber - 1; i++)
|
|
offset += (parts[i] ?? '').length + 1
|
|
return offset + column - 1
|
|
}
|
|
|
|
function emitChange() {
|
|
alternativeVersionId += 1
|
|
contentChangeListeners.forEach(listener => listener())
|
|
contentSizeListeners.forEach(listener => listener())
|
|
}
|
|
|
|
return {
|
|
getValue() {
|
|
return value
|
|
},
|
|
setValue(next: string) {
|
|
value = next
|
|
emitChange()
|
|
},
|
|
getLineCount() {
|
|
return lines().length
|
|
},
|
|
getLineMaxColumn(lineNumber: number) {
|
|
return (lines()[lineNumber - 1] ?? '').length + 1
|
|
},
|
|
getPositionAt(offset: number) {
|
|
const consumed = value.slice(0, offset).split('\n')
|
|
return {
|
|
lineNumber: consumed.length,
|
|
column: consumed[consumed.length - 1].length + 1,
|
|
}
|
|
},
|
|
getLanguageId() {
|
|
return languageId
|
|
},
|
|
getAlternativeVersionId() {
|
|
return alternativeVersionId
|
|
},
|
|
setLanguageId(next: string) {
|
|
languageId = next
|
|
},
|
|
applyEdits(edits: Array<{ range: Range, text: string }>) {
|
|
for (const edit of edits) {
|
|
const start = getOffsetAt(
|
|
edit.range.startLineNumber,
|
|
edit.range.startColumn,
|
|
)
|
|
const end = getOffsetAt(
|
|
edit.range.endLineNumber,
|
|
edit.range.endColumn,
|
|
)
|
|
value = value.slice(0, start) + edit.text + value.slice(end)
|
|
}
|
|
emitChange()
|
|
},
|
|
onDidContentSizeChange(listener: () => void) {
|
|
contentSizeListeners.add(listener)
|
|
return {
|
|
dispose() {
|
|
contentSizeListeners.delete(listener)
|
|
},
|
|
}
|
|
},
|
|
onDidChangeContent(listener: () => void) {
|
|
contentChangeListeners.add(listener)
|
|
return {
|
|
dispose() {
|
|
contentChangeListeners.delete(listener)
|
|
},
|
|
}
|
|
},
|
|
dispose() {},
|
|
}
|
|
}
|
|
|
|
function createCodeEditor(initialModel: any) {
|
|
let model = initialModel
|
|
return {
|
|
getModel() {
|
|
return model
|
|
},
|
|
setModel(next: any) {
|
|
model = next
|
|
},
|
|
getOption(option: string) {
|
|
if (option === editor.EditorOption.lineHeight)
|
|
return 20
|
|
if (option === editor.EditorOption.readOnly)
|
|
return true
|
|
return undefined
|
|
},
|
|
getLayoutInfo() {
|
|
return { height: 200 }
|
|
},
|
|
getScrollTop() {
|
|
return 0
|
|
},
|
|
getScrollHeight() {
|
|
return model.getLineCount() * 20
|
|
},
|
|
setScrollTop() {},
|
|
onDidContentSizeChange(listener: () => void) {
|
|
return model.onDidContentSizeChange(listener)
|
|
},
|
|
onDidChangeModelContent(listener: () => void) {
|
|
return model.onDidChangeContent(listener)
|
|
},
|
|
onDidScrollChange() {
|
|
return { dispose() {} }
|
|
},
|
|
revealLine() {},
|
|
revealLineInCenter() {},
|
|
revealLineInCenterIfOutsideViewport() {},
|
|
getDomNode() {
|
|
return {
|
|
addEventListener() {},
|
|
removeEventListener() {},
|
|
} as any
|
|
},
|
|
deltaDecorations() {
|
|
return []
|
|
},
|
|
updateOptions() {},
|
|
layout() {},
|
|
dispose() {},
|
|
}
|
|
}
|
|
|
|
const editor = {
|
|
EditorOption: {
|
|
lineHeight: 'lineHeight',
|
|
readOnly: 'readOnly',
|
|
},
|
|
ScrollType: {
|
|
Immediate: 0,
|
|
Smooth: 1,
|
|
},
|
|
createModel: vi.fn((value: string, language: string) =>
|
|
createModel(value, language)),
|
|
createDiffEditor: vi.fn(() => {
|
|
const originalEditor = createCodeEditor(createModel('', 'plaintext'))
|
|
const modifiedEditor = createCodeEditor(createModel('', 'plaintext'))
|
|
const updateDiffListeners = new Set<() => void>()
|
|
|
|
return {
|
|
__emitUpdateDiff() {
|
|
updateDiffListeners.forEach(listener => listener())
|
|
},
|
|
setModel(pair: { original: any, modified: any }) {
|
|
originalEditor.setModel(pair.original)
|
|
modifiedEditor.setModel(pair.modified)
|
|
updateDiffListeners.forEach(listener => listener())
|
|
},
|
|
getOriginalEditor() {
|
|
return originalEditor
|
|
},
|
|
getModifiedEditor() {
|
|
return modifiedEditor
|
|
},
|
|
onDidUpdateDiff(listener: () => void) {
|
|
updateDiffListeners.add(listener)
|
|
return {
|
|
dispose() {
|
|
updateDiffListeners.delete(listener)
|
|
},
|
|
}
|
|
},
|
|
getLineChanges() {
|
|
return []
|
|
},
|
|
updateOptions() {},
|
|
getContainerDomNode() {
|
|
return null as any
|
|
},
|
|
dispose() {},
|
|
}
|
|
}),
|
|
setTheme: vi.fn(),
|
|
setModelLanguage: vi.fn((model: any, language: string) => {
|
|
model.setLanguageId(language)
|
|
}),
|
|
}
|
|
|
|
return {
|
|
default: { editor, Range, ScrollType: editor.ScrollType },
|
|
editor,
|
|
Range,
|
|
ScrollType: editor.ScrollType,
|
|
}
|
|
})
|
|
|
|
return await import('../src/core/DiffEditorManager')
|
|
}
|
|
|
|
async function createManager(options: Record<string, unknown>) {
|
|
const { DiffEditorManager } = await loadDiffEditorManager()
|
|
const manager = new DiffEditorManager(
|
|
{
|
|
readOnly: true,
|
|
hideUnchangedRegions: false,
|
|
diffHunkActionsOnHover: false,
|
|
...options,
|
|
} as any,
|
|
400,
|
|
'400px',
|
|
true,
|
|
true,
|
|
0,
|
|
0,
|
|
true,
|
|
)
|
|
|
|
;(manager as any).disposeDiffPresentationTracking = () => {}
|
|
;(manager as any).setupDiffUnchangedRegionEnhancements = () => {}
|
|
;(manager as any).setupDiffHunkInteractions = () => {}
|
|
;(manager as any).applyDiffRootAppearanceClass = () => {}
|
|
;(manager as any).scheduleSyncDiffPresentationDecorations = () => {}
|
|
|
|
const container = {
|
|
style: {} as Record<string, string>,
|
|
classList: {
|
|
add() {},
|
|
remove() {},
|
|
toggle() {},
|
|
contains() { return false },
|
|
},
|
|
innerHTML: '',
|
|
querySelector() {
|
|
return null
|
|
},
|
|
querySelectorAll() {
|
|
return []
|
|
},
|
|
getBoundingClientRect() {
|
|
return { width: 1200, height: 200 }
|
|
},
|
|
clientWidth: 1200,
|
|
} as any
|
|
|
|
await manager.createDiffEditor(
|
|
container,
|
|
'line 1\nline 2\n',
|
|
'line 1\nline 2\n',
|
|
'typescript',
|
|
'vs-dark',
|
|
)
|
|
|
|
return manager as any
|
|
}
|
|
|
|
function waitForAsyncWork() {
|
|
return new Promise(resolve => setTimeout(resolve, 10))
|
|
}
|
|
|
|
describe('DiffEditorManager inline streaming updates', () => {
|
|
beforeEach(() => {
|
|
installRafMocks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('defers inline diff tail appends so both sides flush together', async () => {
|
|
const manager = await createManager({ renderSideBySide: false })
|
|
const appendOriginalSpy = vi.spyOn(manager, 'appendOriginal')
|
|
const appendModifiedSpy = vi.spyOn(manager, 'appendModified')
|
|
|
|
manager.updateDiff(
|
|
'line 1\nline 2\nold value\n',
|
|
'line 1\nline 2\nnew value\n',
|
|
'typescript',
|
|
)
|
|
|
|
expect(appendOriginalSpy).not.toHaveBeenCalled()
|
|
expect(appendModifiedSpy).not.toHaveBeenCalled()
|
|
|
|
await waitForAsyncWork()
|
|
|
|
const { original, modified } = manager.getDiffModels()
|
|
expect(original.getValue()).toBe('line 1\nline 2\nold value\n')
|
|
expect(modified.getValue()).toBe('line 1\nline 2\nnew value\n')
|
|
manager.cleanup()
|
|
})
|
|
|
|
it('keeps side-by-side diff tail append optimization', async () => {
|
|
const manager = await createManager({ renderSideBySide: true, useInlineViewWhenSpaceIsLimited: false })
|
|
const appendOriginalSpy = vi.spyOn(manager, 'appendOriginal')
|
|
const appendModifiedSpy = vi.spyOn(manager, 'appendModified')
|
|
|
|
manager.updateDiff(
|
|
'line 1\nline 2\nold value\n',
|
|
'line 1\nline 2\nnew value\n',
|
|
'typescript',
|
|
)
|
|
|
|
expect(appendOriginalSpy).toHaveBeenCalledOnce()
|
|
expect(appendModifiedSpy).toHaveBeenCalledOnce()
|
|
await waitForAsyncWork()
|
|
manager.cleanup()
|
|
})
|
|
|
|
it('keeps side-by-side diff streaming appends byte-for-byte aligned', async () => {
|
|
const manager = await createManager({ renderSideBySide: true, useInlineViewWhenSpaceIsLimited: false })
|
|
const initial = 'line 1\nline 2\n'
|
|
const originalTail = `{
|
|
".c": "C",
|
|
".cpp": "C++"
|
|
}`
|
|
const modifiedTail = `{
|
|
".c": "C",
|
|
".cpp": "C++",
|
|
".cc": "C++"
|
|
}`
|
|
|
|
for (let i = 1; i <= modifiedTail.length; i++) {
|
|
const expectedOriginal = initial + originalTail.slice(0, Math.min(i, originalTail.length))
|
|
const expectedModified = initial + modifiedTail.slice(0, i)
|
|
manager.updateDiff(
|
|
expectedOriginal,
|
|
expectedModified,
|
|
'json',
|
|
)
|
|
await waitForAsyncWork()
|
|
const { original, modified } = manager.getDiffModels()
|
|
expect(expectedOriginal.startsWith(original.getValue())).toBe(true)
|
|
expect(expectedModified.startsWith(modified.getValue())).toBe(true)
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 80))
|
|
|
|
const { original, modified } = manager.getDiffModels()
|
|
expect(original.getValue()).toBe(initial + originalTail)
|
|
expect(modified.getValue()).toBe(initial + modifiedTail)
|
|
manager.cleanup()
|
|
})
|
|
|
|
it('flushes pending replacement updates before refreshing diff presentation', async () => {
|
|
const manager = await createManager({ renderSideBySide: false })
|
|
;(manager as any).lastContainer.style.removeProperty = vi.fn()
|
|
|
|
manager.updateDiff(
|
|
'line 1\nold value\n',
|
|
'line 1\nnew value\n',
|
|
'typescript',
|
|
)
|
|
|
|
;(manager as any).refreshDiffPresentation()
|
|
|
|
const { original, modified } = manager.getDiffModels()
|
|
expect(original.getValue()).toBe('line 1\nold value\n')
|
|
expect(modified.getValue()).toBe('line 1\nnew value\n')
|
|
manager.cleanup()
|
|
})
|
|
|
|
it('flushes buffered tail appends before refreshing diff presentation', async () => {
|
|
const manager = await createManager({ renderSideBySide: true, useInlineViewWhenSpaceIsLimited: false })
|
|
;(manager as any).lastContainer.style.removeProperty = vi.fn()
|
|
|
|
manager.updateDiff(
|
|
'line 1\nline 2\nold value\n',
|
|
'line 1\nline 2\nnew value\n',
|
|
'typescript',
|
|
)
|
|
|
|
;(manager as any).refreshDiffPresentation()
|
|
|
|
const { original, modified } = manager.getDiffModels()
|
|
expect(original.getValue()).toBe('line 1\nline 2\nold value\n')
|
|
expect(modified.getValue()).toBe('line 1\nline 2\nnew value\n')
|
|
manager.cleanup()
|
|
})
|
|
|
|
it('eagerly grows the diff container before content overflows the inline diff viewport', async () => {
|
|
const manager = await createManager({ renderSideBySide: false })
|
|
const scheduleLayoutSpy = vi.spyOn(
|
|
manager,
|
|
'scheduleSyncDiffEditorLayoutToContainer' as any,
|
|
)
|
|
|
|
;(manager as any).lastContainer = {
|
|
style: { height: '91px' },
|
|
classList: {
|
|
add() {},
|
|
remove() {},
|
|
contains() { return false },
|
|
toggle() {},
|
|
},
|
|
getBoundingClientRect() {
|
|
return { width: 1200, height: 91 }
|
|
},
|
|
clientHeight: 91,
|
|
querySelectorAll() {
|
|
return []
|
|
},
|
|
}
|
|
vi.spyOn(manager, 'computedHeight' as any).mockReturnValue(109)
|
|
|
|
;(manager as any).eagerlyGrowDiffContainerHeight()
|
|
|
|
expect((manager as any).lastContainer.style.height).toBe('109px')
|
|
expect(scheduleLayoutSpy).toHaveBeenCalledOnce()
|
|
manager.cleanup()
|
|
})
|
|
|
|
it('does not force a large min-height for inline diff containers', async () => {
|
|
const manager = await createManager({ renderSideBySide: false })
|
|
|
|
expect((manager as any).lastContainer.style.minHeight || '').toBe('')
|
|
|
|
manager.cleanup()
|
|
})
|
|
|
|
it('does not clear fallback inline delete zones directly from onDidUpdateDiff', async () => {
|
|
const manager = await createManager({ renderSideBySide: false })
|
|
const clearSpy = vi.spyOn(manager, 'clearFallbackInlineDeletedZones' as any)
|
|
|
|
;(manager as any).diffEditorView.__emitUpdateDiff()
|
|
|
|
expect(clearSpy).not.toHaveBeenCalled()
|
|
manager.cleanup()
|
|
})
|
|
|
|
it('keeps inline fallback decorations until native delete nodes are present', async () => {
|
|
const manager = await createManager({ renderSideBySide: false })
|
|
const keepFallbackSpy = vi.spyOn(manager, 'syncFallbackInlineDeletedZones' as any)
|
|
const clearFallbackSpy = vi.spyOn(
|
|
manager,
|
|
'clearFallbackDiffDecorations' as any,
|
|
)
|
|
vi.spyOn(manager, 'hasFreshNativeDiffResult' as any).mockReturnValue(true)
|
|
vi.spyOn(manager, 'getEffectiveLineChanges' as any).mockReturnValue([
|
|
{
|
|
originalStartLineNumber: 2,
|
|
originalEndLineNumber: 2,
|
|
modifiedStartLineNumber: 2,
|
|
modifiedEndLineNumber: 2,
|
|
charChanges: [],
|
|
},
|
|
])
|
|
;(manager as any).diffEditorView.getOriginalEditor().getLayoutInfo = () => ({
|
|
width: 0,
|
|
height: 200,
|
|
})
|
|
|
|
;(manager as any).syncDiffPresentationDecorations()
|
|
|
|
expect(keepFallbackSpy).toHaveBeenCalledOnce()
|
|
expect(clearFallbackSpy).not.toHaveBeenCalled()
|
|
manager.cleanup()
|
|
})
|
|
|
|
it('does not recreate identical inline fallback delete zones', async () => {
|
|
const manager = await createManager({ renderSideBySide: false })
|
|
const originalDocument = (globalThis as any).document
|
|
;(globalThis as any).document = {
|
|
createElement() {
|
|
return {
|
|
className: '',
|
|
style: {} as Record<string, string>,
|
|
children: [] as any[],
|
|
append(...nodes: any[]) {
|
|
this.children.push(...nodes)
|
|
},
|
|
setAttribute() {},
|
|
textContent: '',
|
|
}
|
|
},
|
|
}
|
|
try {
|
|
const addedZones: string[] = []
|
|
const removedZones: string[] = []
|
|
const modifiedEditor = (manager as any).diffEditorView.getModifiedEditor()
|
|
|
|
modifiedEditor.changeViewZones = (callback: (accessor: {
|
|
addZone: (zone: any) => string
|
|
removeZone: (id: string) => void
|
|
}) => void) => {
|
|
callback({
|
|
addZone() {
|
|
const id = `zone-${addedZones.length + 1}`
|
|
addedZones.push(id)
|
|
return id
|
|
},
|
|
removeZone(id: string) {
|
|
removedZones.push(id)
|
|
},
|
|
})
|
|
}
|
|
modifiedEditor.layout = vi.fn()
|
|
modifiedEditor.render = vi.fn()
|
|
|
|
vi.spyOn(manager, 'hasFreshNativeDiffResult' as any).mockReturnValue(false)
|
|
vi.spyOn(manager, 'getEffectiveLineChanges' as any).mockReturnValue([
|
|
{
|
|
originalStartLineNumber: 2,
|
|
originalEndLineNumber: 2,
|
|
modifiedStartLineNumber: 2,
|
|
modifiedEndLineNumber: 2,
|
|
charChanges: [],
|
|
},
|
|
])
|
|
;(manager as any).diffEditorView.getOriginalEditor().getLayoutInfo = () => ({
|
|
width: 0,
|
|
height: 200,
|
|
})
|
|
|
|
;(manager as any).syncDiffPresentationDecorations()
|
|
;(manager as any).syncDiffPresentationDecorations()
|
|
|
|
expect(addedZones).toHaveLength(1)
|
|
expect(removedZones).toHaveLength(0)
|
|
manager.cleanup()
|
|
}
|
|
finally {
|
|
;(globalThis as any).document = originalDocument
|
|
}
|
|
})
|
|
})
|