1
0
forked from boos/BoosAPI

feat(desktop): embed BoosAPI Desktop client as a subproject

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.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,233 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('../src/monaco-shim', () => {
class Range {
constructor(
public lineNumber: number,
public column: number,
public endLineNumber: number,
public endColumn: number,
) {}
}
const editor = {
EditorOption: { lineHeight: 16, readOnly: 0 },
ScrollType: { Smooth: 1 },
setModelLanguage: vi.fn(),
}
return {
default: { editor, Range, languages: { getLanguages: () => [] } },
editor,
Range,
languages: { getLanguages: () => [] },
}
})
import { DiffEditorManager } from '../src/core/DiffEditorManager'
import { EditorManager } from '../src/core/EditorManager'
const EDITOR_RAF_KINDS = [
'maybe-scroll',
'reveal',
'maybe-resume',
'content-size-change',
'sync-last-known',
'immediate-reveal',
'update',
'append',
] as const
const DIFF_RAF_KINDS = [
'sync-diff-presentation',
'capture-diff-unchanged-state',
'restore-diff-unchanged-state',
'patch-diff-unchanged-regions',
'maybe-scroll-diff',
'revealDiff',
'immediate-reveal-diff',
'maybe-resume-diff',
'content-size-change-diff',
'sync-last-known-modified',
'diff',
'appendDiff',
] as const
function createEditorManager() {
return new EditorManager(
{ readOnly: true } as any,
600,
'600px',
true,
true,
32,
2,
75,
)
}
function createDiffEditorManager() {
return new DiffEditorManager(
{ readOnly: true } as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
)
}
function stubRafScheduler(instance: any) {
const cancel = vi.fn()
instance.rafScheduler = { schedule: vi.fn(), cancel }
return cancel
}
describe('EditorManager cleanup semantics', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('cleanup cancels every scheduled RAF kind and pending timers', () => {
const manager = createEditorManager()
const cancel = stubRafScheduler(manager)
const revealDebounceHandle = 101
const revealIdleHandle = 102
const suppressionHandle = 103
const throttleHandle = 104
manager['revealDebounceId'] = revealDebounceHandle
manager['revealIdleTimerId'] = revealIdleHandle
manager['scrollWatcherSuppressionTimer'] = suppressionHandle
manager['updateThrottleTimer'] = throttleHandle
manager['appendBuffer'].push('x')
manager['appendBufferScheduled'] = true
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
manager.cleanup()
for (const kind of EDITOR_RAF_KINDS)
expect(cancel).toHaveBeenCalledWith(kind)
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
expect(clearSpy).toHaveBeenCalledWith(throttleHandle)
expect(manager['revealDebounceId']).toBeNull()
expect(manager['revealIdleTimerId']).toBeNull()
expect(manager['scrollWatcherSuppressionTimer']).toBeNull()
expect(manager['updateThrottleTimer']).toBeNull()
expect(manager['appendBufferScheduled']).toBe(false)
expect(manager['appendBuffer']).toHaveLength(0)
})
it('safeClean mirrors cleanup for RAFs, timers, and append state', () => {
const manager = createEditorManager()
const cancel = stubRafScheduler(manager)
const revealDebounceHandle = 201
const revealIdleHandle = 202
const suppressionHandle = 203
const throttleHandle = 204
manager['revealDebounceId'] = revealDebounceHandle
manager['revealIdleTimerId'] = revealIdleHandle
manager['scrollWatcherSuppressionTimer'] = suppressionHandle
manager['updateThrottleTimer'] = throttleHandle
manager['appendBuffer'].push('x')
manager['appendBufferScheduled'] = true
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
manager.safeClean()
for (const kind of EDITOR_RAF_KINDS)
expect(cancel).toHaveBeenCalledWith(kind)
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
expect(clearSpy).toHaveBeenCalledWith(throttleHandle)
expect(manager['revealDebounceId']).toBeNull()
expect(manager['revealIdleTimerId']).toBeNull()
expect(manager['scrollWatcherSuppressionTimer']).toBeNull()
expect(manager['updateThrottleTimer']).toBeNull()
expect(manager['appendBufferScheduled']).toBe(false)
expect(manager['appendBuffer']).toHaveLength(0)
})
})
describe('DiffEditorManager cleanup semantics', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('cleanup clears diff RAF queues, timers, and append buffers', () => {
const manager = createDiffEditorManager()
const cancel = stubRafScheduler(manager)
const revealDebounceHandle = 301
const revealIdleHandle = 302
const suppressionHandle = 303
const appendThrottleHandle = 304
const hideHandle = 305
manager['revealDebounceIdDiff'] = revealDebounceHandle
manager['revealIdleTimerIdDiff'] = revealIdleHandle
manager['diffScrollWatcherSuppressionTimer'] = suppressionHandle
manager['appendFlushThrottleTimerDiff'] = appendThrottleHandle
manager['diffHunkHideTimer'] = hideHandle
manager['appendBufferOriginalDiff'].push('left')
manager['appendBufferModifiedDiff'].push('right')
manager['appendBufferDiffScheduled'] = true
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
manager.cleanup()
for (const kind of DIFF_RAF_KINDS)
expect(cancel).toHaveBeenCalledWith(kind)
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
expect(clearSpy).toHaveBeenCalledWith(appendThrottleHandle)
expect(clearSpy).toHaveBeenCalledWith(hideHandle)
expect(manager['revealDebounceIdDiff']).toBeNull()
expect(manager['revealIdleTimerIdDiff']).toBeNull()
expect(manager['diffScrollWatcherSuppressionTimer']).toBeNull()
expect(manager['appendFlushThrottleTimerDiff']).toBeNull()
expect(manager['diffHunkHideTimer']).toBeNull()
expect(manager['appendBufferDiffScheduled']).toBe(false)
expect(manager['appendBufferOriginalDiff']).toHaveLength(0)
expect(manager['appendBufferModifiedDiff']).toHaveLength(0)
})
it('safeClean also cancels diff RAFs, timers, and append buffers', () => {
const manager = createDiffEditorManager()
const cancel = stubRafScheduler(manager)
const revealDebounceHandle = 401
const revealIdleHandle = 402
const suppressionHandle = 403
const appendThrottleHandle = 404
const hideHandle = 405
manager['revealDebounceIdDiff'] = revealDebounceHandle
manager['revealIdleTimerIdDiff'] = revealIdleHandle
manager['diffScrollWatcherSuppressionTimer'] = suppressionHandle
manager['appendFlushThrottleTimerDiff'] = appendThrottleHandle
manager['diffHunkHideTimer'] = hideHandle
manager['appendBufferOriginalDiff'].push('left')
manager['appendBufferModifiedDiff'].push('right')
manager['appendBufferDiffScheduled'] = true
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
manager.safeClean()
for (const kind of DIFF_RAF_KINDS)
expect(cancel).toHaveBeenCalledWith(kind)
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
expect(clearSpy).toHaveBeenCalledWith(appendThrottleHandle)
expect(clearSpy).toHaveBeenCalledWith(hideHandle)
expect(manager['revealDebounceIdDiff']).toBeNull()
expect(manager['revealIdleTimerIdDiff']).toBeNull()
expect(manager['diffScrollWatcherSuppressionTimer']).toBeNull()
expect(manager['appendFlushThrottleTimerDiff']).toBeNull()
expect(manager['diffHunkHideTimer']).toBeNull()
expect(manager['appendBufferDiffScheduled']).toBe(false)
expect(manager['appendBufferOriginalDiff']).toHaveLength(0)
expect(manager['appendBufferModifiedDiff']).toHaveLength(0)
})
})
@@ -0,0 +1,316 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function deferred<T = unknown>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
async function loadUseMonacoHarness(options: Record<string, any> = {}) {
vi.resetModules()
const registerCalls: Array<{
themes: any
languages: any
deferred: ReturnType<typeof deferred>
}> = []
const editorManagers: any[] = []
const diffManagers: any[] = []
vi.doMock('../src/utils/registerMonacoThemes', () => {
return {
registerMonacoThemes: vi.fn((themes: any, languages: any) => {
const call = {
themes,
languages,
deferred: deferred(),
}
registerCalls.push(call)
return call.deferred.promise
}),
clearHighlighterCache: vi.fn(),
getOrCreateHighlighter: vi.fn(),
}
})
vi.doMock('../src/core/EditorManager', () => {
return {
EditorManager: class {
cleanup = vi.fn()
safeClean = vi.fn()
appendCode = vi.fn((appendText: string) => {
this.code += appendText
})
updateCode = vi.fn((code: string) => {
this.code = code
})
setLanguage = vi.fn()
code = ''
throttleMs = 50
editorView = {
dispose: vi.fn(),
getModel: vi.fn(() => ({
getValue: () => this.code,
getLanguageId: () => 'javascript',
})),
}
createEditor = vi.fn(async (
_container: HTMLElement,
code: string,
_language: string,
_theme: string,
) => {
this.code = code
return this.editorView
})
constructor(...args: any[]) {
this.throttleMs = args[8] ?? 50
editorManagers.push(this)
}
getEditorView() {
return this.editorView
}
getCode() {
return this.code
}
setUpdateThrottleMs(ms: number) {
this.throttleMs = ms
}
getUpdateThrottleMs() {
return this.throttleMs
}
},
}
})
vi.doMock('../src/core/DiffEditorManager', () => {
return {
DiffEditorManager: class {
cleanup = vi.fn()
safeClean = vi.fn()
notifyThemeChange = vi.fn()
updateDiff = vi.fn()
updateOriginal = vi.fn()
updateModified = vi.fn()
appendOriginal = vi.fn()
appendModified = vi.fn()
refreshDiffPresentation = vi.fn()
setLanguage = vi.fn()
diffEditorView = { dispose: vi.fn() }
models = { original: null, modified: null }
createDiffEditor = vi.fn(async () => this.diffEditorView)
constructor() {
diffManagers.push(this)
}
getDiffEditorView() {
return this.diffEditorView
}
getDiffModels() {
return this.models
}
async setDiffModels(models: any) {
this.models = models
}
},
}
})
vi.doMock('../src/monaco-shim', () => {
const setTheme = vi.fn()
const setModelLanguage = vi.fn()
const editor = {
setTheme,
setModelLanguage,
EditorOption: { lineHeight: 16 },
ScrollType: { Smooth: 1 },
}
const languages = {
getLanguages: () => [{ id: 'javascript' }],
register: vi.fn(),
}
const Range = class {
constructor(
public startLineNumber: number,
public startColumn: number,
public endLineNumber: number,
public endColumn: number,
) {}
}
return {
default: { editor, languages, Range, ScrollType: { Smooth: 1 } },
editor,
languages,
Range,
ScrollType: { Smooth: 1 },
}
})
const base = await import('../src/index.base')
const monaco = await import('../src/monaco-shim')
const api = base.useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript'],
...options,
})
return {
api,
monaco,
registerCalls,
editorManagers,
diffManagers,
}
}
describe('useMonaco create lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
it('replays the latest queued update after createEditor commits', async () => {
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness()
const container = {} as HTMLElement
const createPromise = api.createEditor(container, 'initial', 'javascript')
expect(registerCalls).toHaveLength(1)
api.updateCode('queued', 'javascript')
registerCalls[0].deferred.resolve(null)
await createPromise
expect(editorManagers).toHaveLength(1)
expect(editorManagers[0].updateCode).toHaveBeenCalledWith('queued', 'javascript')
})
it('rejects superseded createEditor requests and disposes request-local resources', async () => {
const firstDispose = vi.fn()
const secondDispose = vi.fn()
let beforeCreateCount = 0
const { api, registerCalls } = await loadUseMonacoHarness({
onBeforeCreate: () => {
beforeCreateCount += 1
return [{ dispose: beforeCreateCount === 1 ? firstDispose : secondDispose }]
},
})
const firstPromise = api.createEditor({} as HTMLElement, 'first', 'javascript')
expect(registerCalls).toHaveLength(1)
const secondPromise = api.createEditor({} as HTMLElement, 'second', 'javascript')
expect(registerCalls).toHaveLength(2)
expect(firstDispose).toHaveBeenCalledTimes(1)
expect(secondDispose).not.toHaveBeenCalled()
registerCalls[1].deferred.resolve(null)
await secondPromise
registerCalls[0].deferred.resolve(null)
await expect(firstPromise).rejects.toMatchObject({
message: 'Editor creation was superseded',
code: 'STREAM_MONACO_CREATE_SUPERSEDED',
})
expect(secondDispose).not.toHaveBeenCalled()
})
it('cleanupEditor cancels an in-flight create and disposes pending resources', async () => {
const dispose = vi.fn()
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness({
onBeforeCreate: () => [{ dispose }],
})
const createPromise = api.createEditor({} as HTMLElement, 'code', 'javascript')
expect(registerCalls).toHaveLength(1)
api.cleanupEditor()
expect(dispose).toHaveBeenCalledTimes(1)
expect(editorManagers).toHaveLength(0)
registerCalls[0].deferred.resolve(null)
await expect(createPromise).rejects.toMatchObject({
message: 'Editor creation was superseded',
code: 'STREAM_MONACO_CREATE_SUPERSEDED',
})
})
it('stabilizes createEditor on the latest requested theme during registration', async () => {
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness()
const container = {} as HTMLElement
const createPromise = api.createEditor(container, 'code', 'javascript')
expect(registerCalls).toHaveLength(1)
const setThemePromise = api.setTheme('vitesse-light')
expect(registerCalls).toHaveLength(2)
registerCalls[0].deferred.resolve(null)
for (let i = 0; i < 5 && registerCalls.length < 3; i++) {
await Promise.resolve()
}
expect(registerCalls).toHaveLength(3)
registerCalls[1].deferred.resolve(null)
registerCalls[2].deferred.resolve(null)
await Promise.all([createPromise, setThemePromise])
expect(editorManagers).toHaveLength(1)
expect(editorManagers[0].createEditor).toHaveBeenCalledWith(
container,
'code',
'javascript',
'vitesse-light',
)
})
it('safeClean does not cancel an in-flight create and cleanupEditor disposes committed resources', async () => {
const dispose = vi.fn()
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness({
onBeforeCreate: () => [{ dispose }],
})
const createPromise = api.createEditor({} as HTMLElement, 'code', 'javascript')
expect(registerCalls).toHaveLength(1)
api.safeClean()
expect(dispose).not.toHaveBeenCalled()
registerCalls[0].deferred.resolve(null)
await createPromise
expect(editorManagers).toHaveLength(1)
expect(editorManagers[0].safeClean).not.toHaveBeenCalled()
expect(dispose).not.toHaveBeenCalled()
api.safeClean()
expect(editorManagers[0].safeClean).toHaveBeenCalledTimes(1)
expect(dispose).not.toHaveBeenCalled()
api.cleanupEditor()
expect(editorManagers[0].cleanup).toHaveBeenCalledTimes(1)
expect(dispose).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,326 @@
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
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() {
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
},
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
let scrollTop = 0
const scrollListeners = new Set<(e: any) => void>()
const domNode = {
addEventListener() {},
removeEventListener() {},
}
const api: any = {
revealCalls: [] as Array<{ line: number }>,
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: 40 }
},
getScrollTop() {
return scrollTop
},
getScrollHeight() {
return model.getLineCount() * 20
},
setScrollTop(next: number) {
scrollTop = next
scrollListeners.forEach(listener => listener({ scrollTop }))
},
onDidContentSizeChange(listener: () => void) {
return model.onDidContentSizeChange(listener)
},
onDidChangeModelContent(listener: () => void) {
return model.onDidChangeContent(listener)
},
onDidScrollChange(listener: (e: any) => void) {
scrollListeners.add(listener)
return {
dispose() {
scrollListeners.delete(listener)
},
}
},
revealLine(line: number) {
api.revealCalls.push({ line })
scrollTop = Math.max(0, api.getScrollHeight() - api.getLayoutInfo().height)
},
revealLineInCenter(line: number) {
api.revealLine(line)
},
revealLineInCenterIfOutsideViewport(line: number) {
api.revealLine(line)
},
getDomNode() {
return domNode as any
},
dispose() {},
}
return api
}
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 {
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)
},
}
},
updateOptions() {},
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() {
const { DiffEditorManager } = await loadDiffEditorManager()
const manager = new DiffEditorManager(
{
readOnly: true,
hideUnchangedRegions: false,
diffHunkActionsOnHover: false,
} as any,
40,
'40px',
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: {},
innerHTML: '',
} as any
await manager.createDiffEditor(
container,
'a\nb\nc',
'a\nb\nc',
'javascript',
'test-theme',
)
await vi.runAllTimersAsync()
return {
manager: manager as any,
modifiedEditor: (manager as any).diffEditorView.getModifiedEditor() as any,
}
}
describe('DiffEditorManager auto-scroll pause', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'))
installRafMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('keeps diff auto-scroll paused after the user scrolls up', async () => {
const { manager, modifiedEditor } = await createManager()
expect(modifiedEditor.revealCalls).toHaveLength(1)
modifiedEditor.setScrollTop(0)
expect(manager.shouldAutoScrollDiff).toBe(false)
manager.appendModified('\nd')
await vi.runAllTimersAsync()
expect(manager.shouldAutoScrollDiff).toBe(false)
const revealCountAfterPause = modifiedEditor.revealCalls.length
manager.appendModified('\ne')
await vi.runAllTimersAsync()
expect(manager.shouldAutoScrollDiff).toBe(false)
expect(modifiedEditor.revealCalls).toHaveLength(revealCountAfterPause)
})
it('does not let delayed diff reveals resume auto-scroll after pause', async () => {
const { manager } = await createManager()
manager.shouldAutoScrollDiff = false
manager.revealTicketDiff = 1
manager.performRevealDiffTicketed(4, 1)
await vi.runAllTimersAsync()
expect(manager.shouldAutoScrollDiff).toBe(false)
manager.shouldAutoScrollDiff = false
manager.revealTicketDiff = 2
manager.performImmediateRevealDiff(4, 2)
expect(manager.shouldAutoScrollDiff).toBe(false)
})
})
@@ -0,0 +1,557 @@
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
}
})
})
@@ -0,0 +1,762 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../src/monaco-shim', () => {
class Range {
constructor(
public startLineNumber: number,
public startColumn: number,
public endLineNumber: number,
public endColumn: number,
) {}
}
return {
default: { editor: { EditorOption: { lineHeight: 'lineHeight' } }, Range },
editor: { EditorOption: { lineHeight: 'lineHeight' } },
Range,
}
})
import { DiffEditorManager } from '../src/core/DiffEditorManager'
function createClassList() {
const classes = new Set<string>()
return {
classList: {
toggle(name: string, force?: boolean) {
if (force)
classes.add(name)
else classes.delete(name)
},
remove(...names: string[]) {
names.forEach(name => classes.delete(name))
},
contains(name: string) {
return classes.has(name)
},
},
}
}
function createToggleClassList(initial: string[] = []) {
const classes = new Set(initial)
return {
classList: {
add(...names: string[]) {
names.forEach(name => classes.add(name))
},
remove(...names: string[]) {
names.forEach(name => classes.delete(name))
},
toggle(name: string, force?: boolean) {
if (force === undefined) {
if (classes.has(name)) {
classes.delete(name)
return false
}
classes.add(name)
return true
}
if (force)
classes.add(name)
else classes.delete(name)
return force
},
contains(name: string) {
return classes.has(name)
},
},
}
}
function createPresentationHarness(
preserveNativeDiffDecorationsOnStaleAppend: boolean,
) {
const manager = new DiffEditorManager(
{ readOnly: true } as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
)
const { classList } = createClassList()
const originalDeltaDecorations = vi.fn((_: string[], next: unknown[]) =>
next.map((_, index) => `original-${index}`))
const modifiedDeltaDecorations = vi.fn((_: string[], next: unknown[]) =>
next.map((_, index) => `modified-${index}`))
;(manager as any).lastContainer = { classList }
;(manager as any).isDiffInlineMode = () => false
;(manager as any).diffComputedVersions = { original: 1, modified: 1 }
;(manager as any).preserveNativeDiffDecorationsOnStaleAppend
= preserveNativeDiffDecorationsOnStaleAppend
;(manager as any).originalModel = {
getAlternativeVersionId: () => 2,
getValue: () => 'const value = 1\nreturn value',
}
;(manager as any).modifiedModel = {
getAlternativeVersionId: () => 2,
getValue: () => 'const value = 2\nreturn value\nconsole.log(value)',
}
;(manager as any).diffEditorView = {
getLineChanges: () => [
{
originalStartLineNumber: 1,
originalEndLineNumber: 1,
modifiedStartLineNumber: 1,
modifiedEndLineNumber: 1,
charChanges: [],
},
],
getOriginalEditor: () => ({
deltaDecorations: originalDeltaDecorations,
}),
getModifiedEditor: () => ({
deltaDecorations: modifiedDeltaDecorations,
}),
}
return {
manager,
classList,
originalDeltaDecorations,
modifiedDeltaDecorations,
}
}
describe('DiffEditorManager diff presentation', () => {
it('forces glyph margin when unchanged-region folding is enabled', () => {
const manager = new DiffEditorManager(
{ readOnly: true, glyphMargin: false } as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
)
const options = (manager as any).resolveDiffPresentationEditorOptions({
enabled: true,
})
const disabledOptions = (manager as any).resolveDiffPresentationEditorOptions({
enabled: false,
})
expect(options.glyphMargin).toBe(true)
expect(disabledOptions.glyphMargin).toBe(false)
})
it('hides the native fold glyph that overlaps the folded inline summary row', () => {
const manager = new DiffEditorManager(
{ readOnly: true } as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
)
const modifiedCenter = {
...createToggleClassList(),
getBoundingClientRect: () => ({
top: 120,
height: 32,
width: 220,
}),
}
const overlappingGlyph = {
...createToggleClassList(),
getBoundingClientRect: () => ({
top: 127,
height: 18,
}),
}
const distantGlyph = {
...createToggleClassList(),
getBoundingClientRect: () => ({
top: 180,
height: 18,
}),
}
;(manager as any).lastContainer = {
querySelectorAll(selector: string) {
if (selector === '.diff-hidden-lines .center')
return [modifiedCenter]
if (selector === '.editor.modified .diff-hidden-lines .center')
return [modifiedCenter]
if (selector === '.diff-hidden-lines .top, .diff-hidden-lines .bottom')
return []
if (selector === '.fold-unchanged')
return [overlappingGlyph, distantGlyph]
return []
},
}
;(manager as any).applyDiffRootAppearanceClass = vi.fn()
;(manager as any).syncDiffUnchangedViewZoneHeights = vi.fn(() => false)
;(manager as any).patchDiffUnchangedCenter = vi.fn()
;(manager as any).clearDiffUnchangedBridgeSources = vi.fn()
;(manager as any).renderMergedDiffUnchangedBridge = vi.fn(() => null)
;(manager as any).pruneDiffUnchangedBridgeEntries = vi.fn()
;(manager as any).syncDiffUnchangedOverlayScrollBaseline = vi.fn()
;(manager as any).patchDiffUnchangedFoldGlyph = vi.fn()
;(manager as any).scanAndPatchDiffUnchangedRegions()
expect(
overlappingGlyph.classList.contains('stream-monaco-fold-unchanged-hidden'),
).toBe(true)
expect(
distantGlyph.classList.contains('stream-monaco-fold-unchanged-hidden'),
).toBe(false)
})
it('does not overwrite persisted unchanged state while folding is disabled', () => {
const manager = new DiffEditorManager(
{ readOnly: true } as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
)
;(manager as any).diffPersistedUnchangedModelState = { collapsed: true }
;(manager as any).diffHideUnchangedRegionsResolved = { enabled: false }
;(manager as any).diffEditorView = {
saveViewState: () => ({
modelState: { expanded: true },
}),
}
;(manager as any).capturePersistedDiffUnchangedState()
expect((manager as any).diffPersistedUnchangedModelState).toEqual({
collapsed: true,
})
})
it('restores the previous unchanged state instead of collapsing all context', () => {
const manager = new DiffEditorManager(
{ readOnly: true } as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
)
const restoreViewState = vi.fn()
;(manager as any).diffPreviousUnchangedModelState = {
hiddenAreas: [{ startLineNumber: 7, endLineNumber: 25 }],
}
;(manager as any).diffEditorView = {
saveViewState: () => ({
original: { scrollTop: 10 },
modified: { scrollTop: 10 },
modelState: { hiddenAreas: [] },
}),
restoreViewState,
}
;(manager as any).applyPendingDiffScrollRestore = vi.fn()
const restored = (manager as any).restorePreviousDiffUnchangedState()
expect(restored).toBe(true)
expect(restoreViewState).toHaveBeenCalledWith({
original: { scrollTop: 10 },
modified: { scrollTop: 10 },
modelState: { hiddenAreas: [{ startLineNumber: 7, endLineNumber: 25 }] },
})
expect((manager as any).diffPersistedUnchangedModelState).toEqual({
hiddenAreas: [{ startLineNumber: 7, endLineNumber: 25 }],
})
})
it('recomputes unchanged-region folding without restoring the stale model state', () => {
const manager = new DiffEditorManager(
{ readOnly: true } as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
)
const setDiffModels = vi.fn()
;(manager as any).originalModel = { getValue: () => 'a' }
;(manager as any).modifiedModel = { getValue: () => 'b' }
;(manager as any).diffEditorView = {
updateOptions: vi.fn(),
}
;(manager as any).diffHideUnchangedRegionsDeferred = false
;(manager as any).diffHideUnchangedRegionsResolved = { enabled: false }
;(manager as any).resolveDiffHideUnchangedRegionsOption = () => ({ enabled: true })
;(manager as any).resolveDiffPresentationEditorOptions = () => ({})
;(manager as any).withLockedDiffScrollPosition = (fn: () => void) => fn()
;(manager as any).diffHeightManager = { update: vi.fn() }
;(manager as any).applyDiffRootAppearanceClass = vi.fn()
;(manager as any).schedulePatchDiffUnchangedRegionsAfterInteraction = vi.fn()
;(manager as any).repositionDiffHunkNodes = vi.fn()
;(manager as any).setDiffModels = setDiffModels
;(manager as any).refreshDiffPresentation()
expect(setDiffModels).toHaveBeenCalledWith(
{
original: (manager as any).originalModel,
modified: (manager as any).modifiedModel,
},
{
preserveViewState: true,
preserveModelState: false,
},
)
})
it('preserves scroll state without restoring the saved model state when requested', async () => {
const manager = new DiffEditorManager(
{ readOnly: true } as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
)
const restoreViewState = vi.fn()
const setModel = vi.fn()
const waitForDiff = vi.fn(async () => {})
const preparedViewModel = {
waitForDiff,
dispose: vi.fn(),
}
const originalModel = {
getValue: () => 'const a = 1',
getLineCount: () => 1,
getLanguageId: () => 'ts',
}
const modifiedModel = {
getValue: () => 'const b = 2',
getLineCount: () => 1,
getLanguageId: () => 'ts',
}
;(manager as any).originalModel = originalModel
;(manager as any).modifiedModel = modifiedModel
;(manager as any).originalModelOwned = false
;(manager as any).modifiedModelOwned = false
;(manager as any).captureDiffScrollPosition = () => null
;(manager as any).captureModifiedViewportAnchor = () => null
;(manager as any).queuePendingDiffScrollRestore = vi.fn()
;(manager as any).withLockedDiffScrollPosition = (fn: () => void) => fn()
;(manager as any).flushOriginalAppendBufferSync = vi.fn()
;(manager as any).flushModifiedAppendBufferSync = vi.fn()
;(manager as any).computedHeight = () => 0
;(manager as any).getEffectiveLineChanges = () => []
;(manager as any).hideDiffHunkActions = vi.fn()
;(manager as any).clearDiffUnchangedBridgeOverlay = vi.fn()
;(manager as any).syncDiffUnchangedViewZoneHeights = vi.fn()
;(manager as any).refreshDiffPresentation = vi.fn()
;(manager as any).scheduleSyncDiffPresentationDecorations = vi.fn()
;(manager as any).applyPendingDiffScrollRestore = vi.fn()
;(manager as any).scheduleRestoreModifiedViewportAnchor = vi.fn()
;(manager as any).disposePreviousDiffModel = vi.fn()
;(manager as any).diffEditorView = {
createViewModel: vi.fn(() => preparedViewModel),
setModel,
saveViewState: vi.fn(() => ({
original: { scrollTop: 10 },
modified: { scrollTop: 10 },
modelState: { hiddenAreas: [{ startLineNumber: 4, endLineNumber: 20 }] },
})),
restoreViewState,
getModifiedEditor: () => ({
getScrollHeight: () => 0,
getOption: () => 18,
}),
}
await (manager as any).setDiffModels(
{
original: originalModel,
modified: modifiedModel,
},
{
preserveViewState: true,
preserveModelState: false,
},
)
expect(waitForDiff).toHaveBeenCalled()
expect(setModel).toHaveBeenCalled()
expect(restoreViewState).not.toHaveBeenCalled()
})
it('keeps native diff blocks visible while stale during tail appends', () => {
const {
manager,
classList,
originalDeltaDecorations,
modifiedDeltaDecorations,
} = createPresentationHarness(true)
;(manager as any).syncDiffPresentationDecorations()
expect(classList.contains('stream-monaco-diff-native-stale')).toBe(false)
expect(originalDeltaDecorations).toHaveBeenCalledOnce()
expect(modifiedDeltaDecorations).toHaveBeenCalledOnce()
expect(originalDeltaDecorations.mock.calls[0][1]).not.toHaveLength(0)
expect(modifiedDeltaDecorations.mock.calls[0][1]).not.toHaveLength(0)
})
it('keeps native diff blocks visible when only the DOM still has stale native markers', () => {
const { manager, classList } = createPresentationHarness(true)
;(manager as any).diffEditorView.getLineChanges = () => null
;(manager as any).lastContainer.querySelector = vi.fn(() => ({}))
;(manager as any).syncDiffPresentationDecorations()
expect(classList.contains('stream-monaco-diff-native-stale')).toBe(false)
})
it('still hides native diff blocks for non-append stale updates', () => {
const { manager, classList } = createPresentationHarness(false)
;(manager as any).syncDiffPresentationDecorations()
expect(classList.contains('stream-monaco-diff-native-stale')).toBe(true)
})
it('adds fallback inline delete zones while native inline diff is stale', () => {
const createdNodes: Array<{
className: string
style: Record<string, string>
children: any[]
append: (...nodes: any[]) => void
setAttribute: () => void
textContent: string
}> = []
const originalDocument = (globalThis as any).document
;(globalThis as any).document = {
createElement() {
const node = {
className: '',
style: {} as Record<string, string>,
children: [] as any[],
append(...next: any[]) {
this.children.push(...next)
},
setAttribute() {},
textContent: '',
}
createdNodes.push(node)
return node
},
}
try {
const { manager, classList } = createPresentationHarness(true)
const addedZones: any[] = []
const removedZoneIds: string[] = []
const applyFontInfo = vi.fn()
;(manager as any).isDiffInlineMode = () => true
;(manager as any).clearFallbackInlineDeletedZones = DiffEditorManager.prototype[
'clearFallbackInlineDeletedZones'
]
;(manager as any).originalModel = {
getAlternativeVersionId: () => 2,
getValue: () => 'const value = 1\nreturn value',
getLineContent: (line: number) =>
['const value = 1', 'return value'][line - 1] ?? '',
}
;(manager as any).modifiedModel = {
getAlternativeVersionId: () => 2,
getValue: () => 'const value = 2\nreturn value',
getLineCount: () => 2,
}
;(manager as any).diffEditorView = {
getLineChanges: () => null,
getOriginalEditor: () => ({
deltaDecorations: vi.fn((_: string[], next: unknown[]) =>
next.map((_, index) => `original-${index}`)),
}),
getModifiedEditor: () => ({
deltaDecorations: vi.fn((_: string[], next: unknown[]) =>
next.map((_, index) => `modified-${index}`)),
getModel: () => (manager as any).modifiedModel,
getOption: () => 20,
applyFontInfo,
changeViewZones(callback: (accessor: {
addZone: (zone: any) => string
removeZone: (id: string) => void
}) => void) {
callback({
addZone(zone) {
addedZones.push(zone)
return `zone-${addedZones.length}`
},
removeZone(id) {
removedZoneIds.push(id)
},
})
},
}),
}
;(manager as any).syncDiffPresentationDecorations()
expect(classList.contains('stream-monaco-diff-native-stale')).toBe(true)
expect(addedZones).toHaveLength(1)
expect(addedZones[0].afterLineNumber).toBe(0)
expect(addedZones[0].heightInLines).toBe(1)
expect(addedZones[0].domNode.className).toBe(
'stream-monaco-fallback-inline-delete-zone',
)
expect(addedZones[0].domNode.children).toHaveLength(1)
expect(addedZones[0].domNode.children[0].textContent).toBe(
'const value = 1',
)
expect(applyFontInfo).toHaveBeenCalledWith(addedZones[0].domNode)
expect(applyFontInfo).toHaveBeenCalledWith(addedZones[0].marginDomNode)
expect(removedZoneIds).toHaveLength(0)
expect(createdNodes.length).toBeGreaterThan(0)
}
finally {
;(globalThis as any).document = originalDocument
}
})
it('clears fallback inline delete zones as soon as native inline delete zones appear', () => {
const originalDocument = (globalThis as any).document
const originalHTMLElement = (globalThis as any).HTMLElement
class FakeElement {
constructor(public className: string) {}
matches(selector: string) {
return selector.split(',').some((entry) => {
const parts = entry.trim().split('.').filter(Boolean)
return parts.every(part => this.className.split(/\s+/).includes(part))
})
}
querySelector() {
return null
}
}
;(globalThis as any).HTMLElement = FakeElement
;(globalThis as any).document = {
createElement() {
return {
className: '',
style: {} as Record<string, string>,
children: [] as any[],
append() {},
setAttribute() {},
textContent: '',
}
},
}
try {
const { manager, classList } = createPresentationHarness(true)
const addedZones: any[] = []
;(manager as any).isDiffInlineMode = () => true
;(manager as any).inlineDiffStreamingPresentationActive = true
;(manager as any).hasFreshNativeDiffResult = () => false
;(manager as any).clearFallbackInlineDeletedZones = DiffEditorManager.prototype[
'clearFallbackInlineDeletedZones'
]
;(manager as any).lastContainer = {
classList,
querySelectorAll(selector: string) {
if (selector.includes('.editor.modified .view-zones [monaco-view-zone]'))
return [new FakeElement('view-lines line-delete monaco-mouse-cursor-text')]
if (selector.includes('.editor.modified .margin-view-zones [monaco-view-zone]'))
return [new FakeElement('inline-deleted-margin-view-zone')]
return []
},
querySelector: vi.fn(() => null),
}
;(manager as any).originalModel = {
getAlternativeVersionId: () => 2,
getValue: () => 'const value = 1\nreturn value',
getLineContent: (line: number) =>
['const value = 1', 'return value'][line - 1] ?? '',
}
;(manager as any).modifiedModel = {
getAlternativeVersionId: () => 2,
getValue: () => 'const value = 2\nreturn value',
getLineCount: () => 2,
}
;(manager as any).diffEditorView = {
getLineChanges: () => null,
getOriginalEditor: () => ({
deltaDecorations: vi.fn((_: string[], next: unknown[]) =>
next.map((_, index) => `original-${index}`)),
}),
getModifiedEditor: () => ({
deltaDecorations: vi.fn((_: string[], next: unknown[]) =>
next.map((_, index) => `modified-${index}`)),
getModel: () => (manager as any).modifiedModel,
getOption: () => 20,
changeViewZones(callback: (accessor: {
addZone: (zone: any) => string
removeZone: (id: string) => void
}) => void) {
callback({
addZone(zone) {
addedZones.push(zone)
return `zone-${addedZones.length}`
},
removeZone() {},
})
},
}),
}
;(manager as any).syncDiffPresentationDecorations()
expect(classList.contains('stream-monaco-diff-inline-native-ready')).toBe(true)
expect(addedZones).toHaveLength(0)
}
finally {
;(globalThis as any).document = originalDocument
;(globalThis as any).HTMLElement = originalHTMLElement
}
})
it('reuses native inline delete wrappers instead of adding another fallback zone', () => {
class FakeElement {
className = ''
style: Record<string, string> = {}
children: any[] = []
attributes = new Map<string, string>()
constructor(
private readonly selectorMap: Record<string, unknown> = {},
) {}
querySelector(selector: string) {
return this.selectorMap[selector] ?? null
}
append(node: any) {
this.children.push(node)
node.parentElement = this
}
removeChild(node: any) {
this.children = this.children.filter(child => child !== node)
}
setAttribute(name: string, value: string) {
this.attributes.set(name, value)
}
getAttribute(name: string) {
return this.attributes.get(name) ?? null
}
}
const originalDocument = (globalThis as any).document
const originalHTMLElement = (globalThis as any).HTMLElement
;(globalThis as any).HTMLElement = FakeElement
;(globalThis as any).document = {
createElement() {
return new FakeElement()
},
}
try {
const { manager } = createPresentationHarness(true)
const applyFontInfo = vi.fn()
const nativeDeleteMarker = new FakeElement()
nativeDeleteMarker.className = 'view-lines line-delete monaco-mouse-cursor-text'
const nativeMarginMarker = new FakeElement()
nativeMarginMarker.className = 'inline-deleted-margin-view-zone'
const viewWrapper = new FakeElement({
'.view-lines.line-delete': nativeDeleteMarker,
})
viewWrapper.style.top = '54px'
viewWrapper.setAttribute('monaco-view-zone', 'native-1')
const marginWrapper = new FakeElement({
'.inline-deleted-margin-view-zone': nativeMarginMarker,
})
marginWrapper.style.top = '54px'
marginWrapper.setAttribute('monaco-view-zone', 'native-1')
;(manager as any).lastContainer = {
querySelectorAll(selector: string) {
if (selector.includes('.editor.modified .view-zones [monaco-view-zone]'))
return [viewWrapper]
if (selector.includes('.editor.modified .margin-view-zones [monaco-view-zone]'))
return [marginWrapper]
return []
},
}
;(manager as any).diffEditorView = {
getModifiedEditor: () => ({
getModel: () => ({
getLineCount: () => 4,
}),
getOption: () => 18,
applyFontInfo,
changeViewZones: vi.fn(),
}),
}
;(manager as any).originalModel = {
getLineContent: () => '"version": "0.0.49",',
}
;(manager as any).isDiffInlineMode = () => true
;(manager as any).syncFallbackInlineDeletedZones([
{
originalStartLineNumber: 1,
originalEndLineNumber: 1,
modifiedStartLineNumber: 4,
modifiedEndLineNumber: 4,
charChanges: [],
},
])
expect(viewWrapper.children).toHaveLength(1)
expect(viewWrapper.children[0].className).toBe(
'stream-monaco-fallback-inline-delete-zone',
)
expect(marginWrapper.children).toHaveLength(1)
expect(marginWrapper.children[0].className).toBe(
'stream-monaco-fallback-inline-delete-margin',
)
}
finally {
;(globalThis as any).document = originalDocument
;(globalThis as any).HTMLElement = originalHTMLElement
}
})
})
@@ -0,0 +1,176 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../src/monaco-shim', () => {
class Range {
constructor(
public startLineNumber: number,
public startColumn: number,
public endLineNumber: number,
public endColumn: number,
) {}
}
return {
default: {
Range,
editor: {
EditorOption: {
lineHeight: 'lineHeight',
},
},
},
Range,
editor: {
EditorOption: {
lineHeight: 'lineHeight',
},
},
}
})
import {
applyDefaultDiffHunkAction,
findLineChangeByHoverLine,
hasModifiedLines,
hasOriginalLines,
inferInlineDiffHunkHoverSide,
positionDiffHunkNode,
} from '../src/core/diffHunk'
function createModel(lines: string[]) {
const value = () => lines.join('\n')
return {
getLineCount() {
return lines.length
},
getLineMaxColumn(line: number) {
return (lines[line - 1] ?? '').length + 1
},
getValueInRange(range: any) {
const start = range.startLineNumber - 1
const end = range.endLineNumber - 1
const selected = lines.slice(start, Math.max(start, end))
if (range.endColumn !== 1 && end < lines.length)
selected.push(lines[end] ?? '')
return `${selected.join('\n')}${selected.length > 0 ? '\n' : ''}`
},
applyEdits: vi.fn(),
}
}
describe('diffHunk helpers', () => {
it('detects whether a hunk has original or modified lines', () => {
const insertion = {
originalStartLineNumber: 0,
originalEndLineNumber: 0,
modifiedStartLineNumber: 3,
modifiedEndLineNumber: 4,
} as any
expect(hasOriginalLines(insertion)).toBe(false)
expect(hasModifiedLines(insertion)).toBe(true)
})
it('finds the nearest hovered line change within tolerance', () => {
const changes = [
{
originalStartLineNumber: 2,
originalEndLineNumber: 3,
modifiedStartLineNumber: 2,
modifiedEndLineNumber: 3,
},
{
originalStartLineNumber: 10,
originalEndLineNumber: 10,
modifiedStartLineNumber: 12,
modifiedEndLineNumber: 12,
},
] as any
expect(findLineChangeByHoverLine(changes, 'modified', 3)).toBe(changes[0])
expect(findLineChangeByHoverLine(changes, 'modified', 14)).toBe(changes[1])
expect(findLineChangeByHoverLine(changes, 'modified', 20)).toBeNull()
})
it('infers inline hover side from DOM hint or hover line fallback', () => {
const change = {
originalStartLineNumber: 4,
originalEndLineNumber: 5,
modifiedStartLineNumber: 6,
modifiedEndLineNumber: 7,
} as any
const deletedTarget = {
closest(selector: string) {
return selector.includes('line-delete') ? {} : null
},
} as HTMLElement
const insertedTarget = {
closest(selector: string) {
return selector.includes('line-insert') ? {} : null
},
} as HTMLElement
expect(inferInlineDiffHunkHoverSide(change, 5, deletedTarget)).toBe('upper')
expect(inferInlineDiffHunkHoverSide(change, 7, insertedTarget)).toBe('lower')
expect(inferInlineDiffHunkHoverSide(change, 5, null)).toBe('upper')
expect(inferInlineDiffHunkHoverSide(change, 6, null)).toBe('lower')
})
it('applies the default lower-stage action by copying modified lines into original', () => {
const originalModel = createModel(['a', 'b'])
const modifiedModel = createModel(['a', 'b', 'c'])
const lineChange = {
originalStartLineNumber: 0,
originalEndLineNumber: 0,
modifiedStartLineNumber: 3,
modifiedEndLineNumber: 3,
} as any
applyDefaultDiffHunkAction({
action: 'stage',
side: 'lower',
lineChange,
originalModel: originalModel as any,
modifiedModel: modifiedModel as any,
})
expect(originalModel.applyEdits).toHaveBeenCalledTimes(1)
expect(originalModel.applyEdits.mock.calls[0][0][0].text).toBe('c\n')
})
it('positions a hunk node within the editor host', () => {
const node = {
offsetWidth: 120,
offsetHeight: 24,
style: {
transform: '',
display: '',
},
} as unknown as HTMLDivElement
const editor = {
getContainerDomNode() {
return {
offsetLeft: 10,
clientWidth: 400,
offsetTop: 20,
clientHeight: 200,
}
},
getTopForLineNumber(line: number) {
return line * 20
},
getScrollTop() {
return 20
},
getOption() {
return 20
},
} as any
positionDiffHunkNode(node, editor, 5)
expect(node.style.display).toBe('flex')
expect(node.style.transform).toContain('translate(')
})
})
@@ -0,0 +1,203 @@
import { describe, expect, it } from 'vitest'
import {
buildDiffHunkMetadataLabel,
formatDiffUnchangedCountLabel,
measureDiffUnchangedSurroundingLines,
resolveDiffMetadataLabel,
resolveDiffUnchangedMergeRole,
resolveDiffUnchangedRevealLayout,
resolveDiffUnchangedSummaryLabel,
} from '../src/core/diffUnchanged'
function createRect(top: number, bottom: number, left = 0, width = 20) {
return {
top,
bottom,
left,
width,
height: bottom - top,
right: left + width,
}
}
function createLineNode(lineNumber: number, top: number) {
return {
textContent: String(lineNumber),
getBoundingClientRect() {
return createRect(top, top + 10)
},
}
}
describe('diffUnchanged helpers', () => {
it('formats unchanged count labels', () => {
expect(formatDiffUnchangedCountLabel('12 hidden lines')).toBe('12 unmodified lines')
expect(formatDiffUnchangedCountLabel('1 hidden line')).toBe('1 unmodified line')
})
it('measures surrounding visible lines around a hidden region', () => {
const editorRoot = {
querySelectorAll() {
return [
createLineNode(2, 10),
createLineNode(3, 20),
createLineNode(9, 80),
createLineNode(10, 90),
]
},
}
const primaryNode = {
closest(selector: string) {
return selector.includes('.editor.modified') ? editorRoot : null
},
getBoundingClientRect() {
return createRect(40, 60)
},
}
expect(measureDiffUnchangedSurroundingLines(primaryNode as any)).toEqual({
previousVisibleLine: 3,
nextVisibleLine: 9,
})
})
it('builds and resolves metadata labels from line changes', () => {
const primaryNode = {
closest() {
return {
querySelectorAll() {
return [createLineNode(6, 90)]
},
}
},
getBoundingClientRect() {
return createRect(40, 60)
},
}
const lineChange = {
originalStartLineNumber: 4,
originalEndLineNumber: 5,
modifiedStartLineNumber: 6,
modifiedEndLineNumber: 8,
}
expect(
buildDiffHunkMetadataLabel(lineChange as any, {
contextLineCount: 2,
originalTotalLines: 20,
modifiedTotalLines: 24,
}),
).toEqual({
originalStart: 2,
modifiedStart: 4,
label: '@@ -2,6 +4,7 @@',
})
expect(
resolveDiffMetadataLabel({
lineChanges: [lineChange as any],
pairIndex: 0,
primaryNode: primaryNode as any,
contextLineCount: 2,
originalTotalLines: 20,
modifiedTotalLines: 24,
}),
).toBe('@@ -2,6 +4,7 @@')
expect(
resolveDiffUnchangedSummaryLabel({
countText: '3 unmodified lines',
unchangedRegionStyle: 'metadata',
lineChanges: [lineChange as any],
pairIndex: 0,
primaryNode: primaryNode as any,
contextLineCount: 2,
originalTotalLines: 20,
modifiedTotalLines: 24,
}),
).toBe('@@ -2,6 +4,7 @@')
expect(
resolveDiffUnchangedSummaryLabel({
countText: '3 unmodified lines',
unchangedRegionStyle: 'line-info',
lineChanges: [lineChange as any],
pairIndex: 0,
primaryNode: primaryNode as any,
contextLineCount: 2,
originalTotalLines: 20,
modifiedTotalLines: 24,
}),
).toBe('3 unmodified lines')
})
it('resolves reveal handle layout at file boundaries', () => {
const primaryNode = {
closest() {
return {
querySelectorAll() {
return [createLineNode(1, 90)]
},
}
},
getBoundingClientRect() {
return createRect(40, 60)
},
}
expect(
resolveDiffUnchangedRevealLayout({
primaryNode: primaryNode as any,
countText: '3 hidden lines',
pairIndex: 0,
pairCount: 2,
modelLineCount: 10,
}),
).toEqual({
showTopHandle: false,
showBottomHandle: true,
})
})
it('resolves unchanged merge role from editor host centers', () => {
class HTMLElementMock {}
const previousHTMLElement = globalThis.HTMLElement
globalThis.HTMLElement = HTMLElementMock as any
const node = Object.assign(new HTMLElementMock(), {
getBoundingClientRect() {
return createRect(0, 20, 40, 20)
},
})
const diffRoot = Object.assign(new HTMLElementMock(), {
getBoundingClientRect() {
return createRect(0, 20, 0, 200)
},
})
const originalHost = Object.assign(new HTMLElementMock(), {
getBoundingClientRect() {
return createRect(0, 20, 20, 40)
},
})
const modifiedHost = Object.assign(new HTMLElementMock(), {
getBoundingClientRect() {
return createRect(0, 20, 120, 40)
},
})
try {
expect(
resolveDiffUnchangedMergeRole({
node: node as any,
diffRoot: diffRoot as any,
originalHost: originalHost as any,
modifiedHost: modifiedHost as any,
}),
).toBe('secondary')
}
finally {
globalThis.HTMLElement = previousHTMLElement
}
})
})
@@ -0,0 +1,229 @@
import { describe, expect, it } from 'vitest'
import {
activateDiffUnchangedExpandAction,
bindDiffUnchangedRevealButtonAction,
collectDiffUnchangedViewZoneIds,
findDiffUnchangedActivationAction,
findDiffUnchangedExpandAction,
resolveDiffUnchangedWheelScrollTarget,
resolveDiffUnchangedViewZoneHeight,
shouldHandleDiffUnchangedCenterClick,
shouldHandleDiffUnchangedWheel,
shouldIgnoreDiffUnchangedCenterClickTarget,
} from '../src/core/diffUnchangedDom'
describe('diffUnchangedDom helpers', () => {
it('resolves unchanged view-zone heights from style', () => {
expect(resolveDiffUnchangedViewZoneHeight('simple')).toBe(28)
expect(resolveDiffUnchangedViewZoneHeight('line-info')).toBe(32)
expect(resolveDiffUnchangedViewZoneHeight('line-info-basic')).toBe(32)
expect(resolveDiffUnchangedViewZoneHeight('metadata')).toBe(32)
})
it('collects visible diff unchanged view-zone ids that align with widgets', () => {
const editorRoot = {
querySelectorAll(selector: string) {
if (selector === '.diff-hidden-lines-widget') {
return [
{ style: { top: '20' } },
{ style: { top: '-100001' } },
]
}
if (
selector
=== '.view-zones > div[monaco-view-zone][monaco-visible-view-zone="true"]'
) {
return [
{
style: { top: '120', height: '32' },
getAttribute(name: string) {
return name === 'monaco-view-zone' ? 'zone-a' : null
},
},
{
style: { top: '120', height: '0' },
getAttribute(name: string) {
return name === 'monaco-view-zone' ? 'zone-b' : null
},
},
{
style: { top: '160', height: '32' },
getAttribute(name: string) {
return name === 'monaco-view-zone' ? 'zone-c' : null
},
},
]
}
return []
},
}
expect(
collectDiffUnchangedViewZoneIds(editorRoot as any, 100),
).toEqual(['zone-a'])
})
it('finds expand and activation actions from unchanged widgets', () => {
const expandAction = { kind: 'expand' }
const fallbackAction = { kind: 'fallback' }
const centerNode = {
querySelector(selector: string) {
return selector === 'a' ? expandAction : null
},
}
const primaryNode = {
querySelector(selector: string) {
return selector === 'a, button' ? fallbackAction : null
},
}
expect(findDiffUnchangedExpandAction(centerNode as any)).toBe(expandAction)
expect(
findDiffUnchangedActivationAction(null, primaryNode as any),
).toBe(fallbackAction)
})
it('ignores center clicks that originate from links or breadcrumbs', () => {
class HTMLElementMock {
constructor(private readonly result: unknown) {}
closest() {
return this.result
}
}
const previousHTMLElement = globalThis.HTMLElement
globalThis.HTMLElement = HTMLElementMock as any
try {
expect(
shouldIgnoreDiffUnchangedCenterClickTarget(
new HTMLElementMock({}) as any,
),
).toBe(true)
expect(
shouldIgnoreDiffUnchangedCenterClickTarget(
new HTMLElementMock(null) as any,
),
).toBe(false)
expect(shouldIgnoreDiffUnchangedCenterClickTarget(null)).toBe(false)
}
finally {
globalThis.HTMLElement = previousHTMLElement
}
})
it('handles only primary center clicks that are not already on actions', () => {
class HTMLElementMock {
constructor(private readonly result: unknown) {}
closest() {
return this.result
}
}
const previousHTMLElement = globalThis.HTMLElement
globalThis.HTMLElement = HTMLElementMock as any
try {
expect(
shouldHandleDiffUnchangedCenterClick({
button: 0,
target: new HTMLElementMock(null) as any,
} as any),
).toBe(true)
expect(
shouldHandleDiffUnchangedCenterClick({
button: 1,
target: new HTMLElementMock(null) as any,
} as any),
).toBe(false)
expect(
shouldHandleDiffUnchangedCenterClick({
button: 0,
target: new HTMLElementMock({}) as any,
} as any),
).toBe(false)
}
finally {
globalThis.HTMLElement = previousHTMLElement
}
})
it('resolves unchanged wheel handling and scroll targets', () => {
expect(shouldHandleDiffUnchangedWheel({ deltaX: 0, deltaY: 0.4 } as any)).toBe(false)
expect(shouldHandleDiffUnchangedWheel({ deltaX: 0, deltaY: 2 } as any)).toBe(true)
expect(
resolveDiffUnchangedWheelScrollTarget(10, 20, {
deltaX: 5,
deltaY: 7,
} as any),
).toEqual({
targetScrollTop: 17,
targetScrollLeft: 25,
syncHorizontal: true,
})
})
it('binds reveal button activation only when a handle exists', () => {
const events: string[] = []
const button = {
onclick: null as null | ((event: any) => void),
}
const handle = { id: 'top' }
bindDiffUnchangedRevealButtonAction(
button as any,
handle as any,
(nextHandle) => {
events.push((nextHandle as any).id)
},
)
button.onclick?.({
preventDefault() {
events.push('prevent')
},
stopPropagation() {
events.push('stop')
},
})
expect(events).toEqual(['prevent', 'stop', 'top'])
bindDiffUnchangedRevealButtonAction(button as any, null, () => {
events.push('unexpected')
})
expect(button.onclick).toBeNull()
})
it('activates the expand action from a center root when present', () => {
const events: string[] = []
const root = {
querySelector(selector: string) {
if (selector !== 'a')
return null
return {
click() {
events.push('click')
},
}
},
}
expect(
activateDiffUnchangedExpandAction(root as any, () => {
events.push('before')
}),
).toBe(true)
expect(events).toEqual(['before', 'click'])
expect(
activateDiffUnchangedExpandAction({ querySelector: () => null } as any),
).toBe(false)
})
})
@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('../src/monaco-shim', () => {
const editor = {
EditorOption: {
lineHeight: 'lineHeight',
},
}
return {
default: { editor },
editor,
}
})
import {
computeDiffHeight,
computeDiffRawHeight,
hasVerticalScrollbar,
isUserNearBottom,
revealEditorLine,
} from '../src/core/diffViewport'
function createCodeEditor(lineCount: number, scrollHeight: number, lineHeight = 20) {
return {
getModel() {
return {
getLineCount() {
return lineCount
},
}
},
getOption() {
return lineHeight
},
getScrollHeight() {
return scrollHeight
},
revealLine: vi.fn(),
revealLineInCenter: vi.fn(),
revealLineInCenterIfOutsideViewport: vi.fn(),
}
}
describe('diffViewport helpers', () => {
it('computeDiffRawHeight prefers rendered scroll height and clamps to max', () => {
const originalEditor = createCodeEditor(3, 140)
const modifiedEditor = createCodeEditor(5, 220)
const diffEditorView = {
getOriginalEditor() {
return originalEditor as any
},
getModifiedEditor() {
return modifiedEditor as any
},
} as any
expect(computeDiffRawHeight({ diffEditorView, maxHeightValue: 180 })).toBe(180)
expect(computeDiffRawHeight({ diffEditorView, maxHeightValue: 260 })).toBe(220)
})
it('computeDiffHeight preserves inline streaming floor', () => {
expect(
computeDiffHeight({
rawHeight: 120,
isInlineMode: true,
inlineDiffStreamingPresentationActive: true,
inlineDiffStreamingHeightFloor: 180,
}),
).toEqual({
height: 180,
nextInlineDiffStreamingHeightFloor: 180,
})
})
it('viewport helpers detect scroll state consistently', () => {
expect(
hasVerticalScrollbar({
computedHeight: 100,
lineHeight: 20,
scrollHeight: 130,
}),
).toBe(true)
expect(
isUserNearBottom(
{
computedHeight: 100,
lineHeight: 20,
scrollHeight: 200,
scrollTop: 120,
viewportHeight: 40,
},
{
autoScrollThresholdLines: 2,
autoScrollThresholdPx: 8,
},
),
).toBe(true)
})
it('revealEditorLine dispatches to the requested reveal strategy', () => {
const editor = createCodeEditor(5, 120)
revealEditorLine(editor as any, 4, 'centerIfOutside', 1)
revealEditorLine(editor as any, 5, 'center')
revealEditorLine(editor as any, 6, 'bottom')
expect(editor.revealLineInCenterIfOutsideViewport).toHaveBeenCalledWith(4, 1)
expect(editor.revealLineInCenter).toHaveBeenCalledWith(5)
expect(editor.revealLine).toHaveBeenCalledWith(6)
})
})
@@ -0,0 +1,477 @@
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 loadUseMonaco() {
vi.resetModules()
vi.doMock('../src/utils/registerMonacoThemes', () => {
return {
clearHighlighterCache: () => {},
getOrCreateHighlighter: async () => null,
registerMonacoThemes: async () => null,
}
})
vi.doMock('../src/monaco-shim', () => {
let lastCreatedModel: any = null
let lastCreatedEditor: any = null
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 getValueCallCount = 0
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() {
contentChangeListeners.forEach(listener => listener())
contentSizeListeners.forEach(listener => listener())
}
return {
getValue() {
getValueCallCount += 1
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
},
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)
},
}
},
__getGetValueCallCount() {
return getValueCallCount
},
}
}
const editor = {
EditorOption: {
lineHeight: 'lineHeight',
readOnly: 'readOnly',
},
ScrollType: {
Smooth: 'smooth',
},
create: vi.fn((_: any, options: any) => {
const model = createModel(options.value ?? '', options.language ?? 'plaintext')
lastCreatedModel = model
const scrollListeners = new Set<(e: any) => void>()
let scrollTop = 0
const domNode = {
addEventListener() {},
removeEventListener() {},
}
const editorApi = {
getModel() {
return model
},
getValue() {
return model.getValue()
},
getOption(option: string) {
if (option === editor.EditorOption.lineHeight)
return 20
if (option === editor.EditorOption.readOnly)
return options.readOnly ?? true
return undefined
},
getLayoutInfo() {
return { height: 240 }
},
getScrollTop() {
return scrollTop
},
setScrollTop: vi.fn((next: number) => {
scrollTop = next
}),
getScrollHeight() {
return model.getLineCount() * 20
},
getContentHeight() {
return model.getLineCount() * 20
},
onDidContentSizeChange(listener: () => void) {
return model.onDidContentSizeChange(listener)
},
onDidChangeModelContent(listener: () => void) {
return model.onDidChangeContent(listener)
},
onDidScrollChange(listener: (e: any) => void) {
scrollListeners.add(listener)
return {
dispose() {
scrollListeners.delete(listener)
},
}
},
executeEdits(_: string, edits: Array<{ range: Range, text: string }>) {
model.applyEdits(edits)
},
revealLine: vi.fn(),
revealLineInCenter: vi.fn(),
revealLineInCenterIfOutsideViewport: vi.fn(),
dispose() {},
getDomNode() {
return domNode as any
},
__setScrollTop(next: number) {
scrollTop = next
},
}
lastCreatedEditor = editorApi
return editorApi
}),
setTheme: vi.fn(),
setModelLanguage: vi.fn((model: any, language: string) => {
model.setLanguageId(language)
}),
}
const languages = {
getLanguages: () => [],
register: vi.fn(),
}
return {
default: { editor, languages, Range },
editor,
languages,
Range,
ScrollType: editor.ScrollType,
__getLastModel() {
return lastCreatedModel
},
__getLastEditor() {
return lastCreatedEditor
},
}
})
const base = await import('../src/index.base')
const monacoModule: any = await import('../src/monaco-shim')
return {
...base,
__getLastModel: monacoModule.__getLastModel,
__getLastEditor: monacoModule.__getLastEditor,
}
}
describe('EditorManager update throttling', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-01T10:00:00.000Z'))
installRafMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('respects updateThrottleMs after createEditor uses EditorManager', async () => {
const { useMonaco } = await loadUseMonaco()
const monaco = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript'],
readOnly: true,
updateThrottleMs: 50,
})
const container = { style: {}, innerHTML: '' } as any
await monaco.createEditor(container, '', 'javascript')
await vi.runAllTimersAsync()
monaco.updateCode('a', 'javascript')
await vi.runAllTimersAsync()
expect(monaco.getCode()).toBe('a')
monaco.updateCode('ab', 'javascript')
await vi.advanceTimersByTimeAsync(20)
expect(monaco.getCode()).toBe('a')
await vi.advanceTimersByTimeAsync(40)
expect(monaco.getCode()).toBe('ab')
})
it('propagates runtime throttle changes into the active EditorManager', async () => {
const { useMonaco } = await loadUseMonaco()
const monaco = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript'],
readOnly: true,
updateThrottleMs: 100,
})
const container = { style: {}, innerHTML: '' } as any
await monaco.createEditor(container, '', 'javascript')
await vi.runAllTimersAsync()
monaco.updateCode('a', 'javascript')
await vi.runAllTimersAsync()
expect(monaco.getCode()).toBe('a')
monaco.updateCode('ab', 'javascript')
await vi.advanceTimersByTimeAsync(20)
expect(monaco.getCode()).toBe('a')
monaco.setUpdateThrottleMs(0)
expect(monaco.getUpdateThrottleMs()).toBe(0)
await vi.runAllTimersAsync()
expect(monaco.getCode()).toBe('ab')
})
it('avoids rereading the whole model during programmatic streaming appends', async () => {
const { useMonaco, __getLastModel } = await loadUseMonaco()
const monaco = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript'],
readOnly: true,
updateThrottleMs: 0,
})
const container = { style: {}, innerHTML: '' } as any
await monaco.createEditor(container, '', 'javascript')
await vi.runAllTimersAsync()
const model = __getLastModel()
const beforeFirstUpdate = model.__getGetValueCallCount()
monaco.updateCode('a', 'javascript')
await vi.runAllTimersAsync()
expect(model.__getGetValueCallCount() - beforeFirstUpdate).toBe(0)
const beforeSecondUpdate = model.__getGetValueCallCount()
monaco.updateCode('ab', 'javascript')
await vi.runAllTimersAsync()
expect(model.__getGetValueCallCount() - beforeSecondUpdate).toBe(0)
expect(monaco.getCode()).toBe('ab')
})
it('keeps frame-by-frame streaming appends byte-for-byte aligned', async () => {
const { useMonaco } = await loadUseMonaco()
const monaco = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['json'],
readOnly: true,
updateThrottleMs: 0,
})
const code = `{
".c": "C",
".cpp": "C++",
".cc": "C++"
}`
const container = { style: {}, innerHTML: '' } as any
await monaco.createEditor(container, '', 'json')
await vi.runAllTimersAsync()
for (let i = 1; i <= code.length; i++) {
monaco.updateCode(code.slice(0, i), 'json')
await vi.runAllTimersAsync()
}
expect(monaco.getCode()).toBe(code)
})
it('does not reveal while streamed content is still shorter than the editor height', async () => {
const { useMonaco, __getLastEditor } = await loadUseMonaco()
const monaco = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['json'],
readOnly: true,
updateThrottleMs: 0,
})
const code = `{
".c": "C",
".cpp": "C++",
".cc": "C++"
}`
const container = { style: {}, innerHTML: '' } as any
await monaco.createEditor(container, '', 'json')
await vi.runAllTimersAsync()
const editor = __getLastEditor()
for (const line of code.split('\n')) {
const next = monaco.getCode()
? `${monaco.getCode()}\n${line}`
: line
monaco.updateCode(next, 'json')
await vi.runAllTimersAsync()
}
expect(editor.revealLine).not.toHaveBeenCalled()
expect(editor.revealLineInCenter).not.toHaveBeenCalled()
expect(editor.revealLineInCenterIfOutsideViewport).not.toHaveBeenCalled()
})
it('uses Monaco content height without forcing a minimum host height', async () => {
const { useMonaco } = await loadUseMonaco()
const monaco = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['json'],
readOnly: true,
updateThrottleMs: 0,
})
const container = { style: {}, innerHTML: '' } as any
await monaco.createEditor(container, '', 'json')
await vi.runAllTimersAsync()
expect(container.style.minHeight).toBeUndefined()
expect(container.style.height).toBe('20px')
monaco.updateCode('{\n "a": 1\n}', 'json')
await vi.runAllTimersAsync()
expect(container.style.height).toBe('60px')
})
it('keeps scroll pinned to top before streamed content reaches max height', async () => {
const { useMonaco, __getLastEditor } = await loadUseMonaco()
const monaco = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['json'],
readOnly: true,
updateThrottleMs: 0,
})
const container = { style: {}, innerHTML: '' } as any
await monaco.createEditor(container, '', 'json')
await vi.runAllTimersAsync()
const editor = __getLastEditor()
editor.__setScrollTop(8)
monaco.updateCode('{\n "a": 1\n}', 'json')
await vi.runAllTimersAsync()
expect(container.style.height).toBe('60px')
expect(container.style.overflow).toBe('hidden')
expect(editor.setScrollTop).toHaveBeenCalledWith(0)
})
it('does not fight an external host height within the height hysteresis', async () => {
const { useMonaco } = await loadUseMonaco()
const monaco = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['json'],
readOnly: true,
updateThrottleMs: 0,
})
const container = {
style: {},
innerHTML: '',
getBoundingClientRect: () => ({ height: 41 }),
} as any
await monaco.createEditor(container, '', 'json')
await vi.runAllTimersAsync()
monaco.updateCode('{\n}', 'json')
container.style.height = '41px'
await vi.runAllTimersAsync()
expect(container.style.height).toBe('41px')
})
it('still syncs externally edited content before the next streamed update', async () => {
const { useMonaco, __getLastModel } = await loadUseMonaco()
const monaco = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript'],
readOnly: false,
updateThrottleMs: 0,
})
const container = { style: {}, innerHTML: '' } as any
await monaco.createEditor(container, '', 'javascript')
await vi.runAllTimersAsync()
const model = __getLastModel()
model.setValue('hello')
await vi.runAllTimersAsync()
monaco.updateCode('hello world', 'javascript')
await vi.runAllTimersAsync()
expect(monaco.getCode()).toBe('hello world')
})
})
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockWrapper } from './mockWrapper'
/**
* 测试 getCode API 功能
*
* 这个测试验证了在 updateCode 后调用 getCode 能够获取最新的代码内容
*/
describe('getCode API', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(Date.now())
})
afterEach(() => {
vi.useRealTimers()
})
it('should return current code from model', async () => {
const w = createMockWrapper({ updateThrottleMs: 0 })
// 初始代码为空
expect(w.model.getValue()).toBe('')
// 更新代码
w.updateCode('console.log("hello")')
await vi.runAllTimersAsync()
// 验证可以获取最新代码
expect(w.model.getValue()).toBe('console.log("hello")')
})
it('should return updated code after multiple updates', async () => {
const w = createMockWrapper({ updateThrottleMs: 0 })
// 第一次更新
w.updateCode('const a = 1')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('const a = 1')
// 第二次更新
w.updateCode('const a = 2')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('const a = 2')
// 第三次更新
w.updateCode('const a = 3')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('const a = 3')
})
it('should return appended code correctly', async () => {
const w = createMockWrapper({ updateThrottleMs: 0 })
// 初始代码
w.updateCode('const a = 1')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('const a = 1')
// 追加代码(流式场景)
w.updateCode('const a = 1\n')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('const a = 1\n')
w.updateCode('const a = 1\nconst b = 2')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('const a = 1\nconst b = 2')
})
it('should handle streaming updates and return latest code', async () => {
const w = createMockWrapper({ updateThrottleMs: 0 })
// 模拟流式更新场景
w.updateCode('c')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('c')
w.updateCode('co')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('co')
w.updateCode('con')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('con')
w.updateCode('cons')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('cons')
w.updateCode('const')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('const')
})
it('should return latest code with rapid updates', async () => {
const w = createMockWrapper({ updateThrottleMs: 0 })
// 快速连续更新
w.updateCode('a')
w.updateCode('ab')
w.updateCode('abc')
w.updateCode('abcd')
await vi.runAllTimersAsync()
// 应该得到最后一次更新的值
expect(w.model.getValue()).toBe('abcd')
})
it('should return code with throttling enabled', async () => {
const w = createMockWrapper({ updateThrottleMs: 50 })
w.updateCode('initial')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('initial')
// 快速更新
w.updateCode('updated')
// 在节流期内,代码还未更新
expect(w.model.getValue()).toBe('initial')
// 等待节流时间过去
vi.advanceTimersByTime(60)
await vi.runAllTimersAsync()
// 现在应该更新了
expect(w.model.getValue()).toBe('updated')
})
it('should return empty string for empty updates', async () => {
const w = createMockWrapper({ updateThrottleMs: 0 })
w.updateCode('')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('')
w.updateCode('some code')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('some code')
// 清空代码
w.updateCode('')
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('')
})
it('should handle complex code with special characters', async () => {
const w = createMockWrapper({ updateThrottleMs: 0 })
const complexCode = `function test() {
const str = "Hello, World!";
const num = 42;
return \`\${str} \${num}\`;
}`
w.updateCode(complexCode)
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe(complexCode)
})
it('should return code after incremental append updates', async () => {
const w = createMockWrapper({ updateThrottleMs: 0 })
// 模拟用户输入或流式生成的场景
let code = ''
const increments = ['const ', 'x ', '= ', '10', ';']
for (const increment of increments) {
code += increment
w.updateCode(code)
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe(code)
}
expect(w.model.getValue()).toBe('const x = 10;')
})
})
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { createHeightManager } from '../src/utils/height'
// polyfill requestAnimationFrame for test environment
if (typeof (globalThis as any).requestAnimationFrame === 'undefined') {
;(globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 0)
;(globalThis as any).cancelAnimationFrame = (id: number) => clearTimeout(id)
}
describe('createHeightManager', () => {
it('applies computed height and suppresses during application', async () => {
// create a fake container
const container = { style: { height: '' } } as unknown as HTMLElement
let computeCalls = 0
const cm = () => {
computeCalls++
return 100 + computeCalls
}
const m = createHeightManager(container, cm)
// first update should set height
m.update()
// wait a tick for RAF via requestAnimationFrame
await new Promise(res => setTimeout(res, 0))
expect(container.style.height).toMatch(/px$/)
// second update with same computed value should not change
m.update()
await new Promise(res => setTimeout(res, 0))
expect(container.style.height).not.toBe('')
m.dispose()
})
})
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest'
describe('should', () => {
it('exported', () => {
expect(1).toEqual(1)
})
})
@@ -0,0 +1,179 @@
import { describe, expect, it, vi } from 'vitest'
import { DiffEditorManager } from '../src/core/DiffEditorManager'
import { EditorManager } from '../src/core/EditorManager'
vi.mock('../src/monaco-shim', () => {
const editor = {
EditorOption: { lineHeight: 16, readOnly: 0 },
ScrollType: { Smooth: 1 },
setModelLanguage: vi.fn(),
}
class Range {
constructor(
public startLineNumber: number,
public startColumn: number,
public endLineNumber: number,
public endColumn: number,
) {}
}
return {
default: { editor, Range, languages: { getLanguages: () => [] } },
editor,
Range,
languages: { getLanguages: () => [] },
}
})
function createStubModel(initial = '') {
let value = initial
const setValue = vi.fn((next: string) => {
value = next
})
const applyEdits = vi.fn()
return {
getValue() {
return value
},
setValue,
applyEdits,
getLineCount() {
return Math.max(1, value.split('\n').length)
},
getLanguageId() {
return 'plaintext'
},
getLineMaxColumn(line: number) {
const lines = value.split('\n')
const index = Math.max(0, Math.min(line - 1, lines.length - 1))
return (lines[index]?.length ?? 0) + 1
},
getPositionAt(offset: number) {
return {
lineNumber: 1,
column: Math.max(1, offset + 1),
}
},
}
}
function createStubCodeEditor(model: ReturnType<typeof createStubModel>) {
return {
getModel() {
return model
},
getOption() {
return false
},
executeEdits: vi.fn(),
getScrollHeight() {
return 0
},
getScrollTop() {
return 0
},
getLayoutInfo() {
return { height: 0 }
},
}
}
describe('minimal edit option overrides', () => {
it('EditorManager respects minimalEditMaxChars from options', () => {
const model = createStubModel('abc')
const manager = new EditorManager(
{ readOnly: false, minimalEditMaxChars: 1 } as any,
600,
'600px',
true,
true,
32,
2,
75,
0,
)
;(manager as any).editorView = createStubCodeEditor(model)
;(manager as any).applyMinimalEdit('abc', 'abXYc')
expect(model.setValue).toHaveBeenCalledWith('abXYc')
expect((manager as any).editorView.executeEdits).not.toHaveBeenCalled()
})
it('EditorManager respects minimalEditMaxChangeRatio from options', () => {
const model = createStubModel('abcd')
const manager = new EditorManager(
{
readOnly: false,
minimalEditMaxChars: 1000,
minimalEditMaxChangeRatio: 0.1,
} as any,
600,
'600px',
true,
true,
32,
2,
75,
0,
)
;(manager as any).editorView = createStubCodeEditor(model)
;(manager as any).applyMinimalEdit('abcd', 'a1234567')
expect(model.setValue).toHaveBeenCalledWith('a1234567')
expect((manager as any).editorView.executeEdits).not.toHaveBeenCalled()
})
it('DiffEditorManager respects minimalEditMaxChars from options', () => {
const model = createStubModel('abc')
const manager = new DiffEditorManager(
{ readOnly: true, minimalEditMaxChars: 1 } as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
0,
)
;(manager as any).modifiedModel = model
;(manager as any).applyMinimalEditToModel(model, 'abc', 'abXYc')
expect(model.setValue).toHaveBeenCalledWith('abXYc')
expect(model.applyEdits).not.toHaveBeenCalled()
})
it('DiffEditorManager respects minimalEditMaxChangeRatio from options', () => {
const model = createStubModel('abcd')
const manager = new DiffEditorManager(
{
readOnly: true,
minimalEditMaxChars: 1000,
minimalEditMaxChangeRatio: 0.1,
} as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
0,
)
;(manager as any).modifiedModel = model
;(manager as any).applyMinimalEditToModel(model, 'abcd', 'a1234567')
expect(model.setValue).toHaveBeenCalledWith('a1234567')
expect(model.applyEdits).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { computeMinimalEdit } from '../src/minimalEdit'
describe('computeMinimalEdit', () => {
it('no-op when equal', () => {
expect(computeMinimalEdit('abc', 'abc')).toBeNull()
})
it('append only', () => {
const res = computeMinimalEdit('abc', 'abcdef')!
expect(res.start).toBe(3)
expect(res.replaceText).toBe('def')
})
it('prepend only', () => {
const res = computeMinimalEdit('world', 'hello world')!
expect(res.start).toBe(0)
expect(res.replaceText).toBe('hello ')
})
it('middle change', () => {
const res = computeMinimalEdit('aXc', 'aYc')!
expect(res.start).toBe(1)
expect(res.replaceText).toBe('Y')
})
it('replace all (prev empty)', () => {
const res = computeMinimalEdit('', 'foo')!
expect(res.start).toBe(0)
expect(res.replaceText).toBe('foo')
})
it('replace all (next empty)', () => {
const res = computeMinimalEdit('bar', '')!
expect(res.start).toBe(0)
expect(res.replaceText).toBe('')
})
it('emoji (UTF-16) consistency', () => {
// 😀 is surrogate pair (U+1F600)
const prev = 'hi😀'
const next = 'hi😀 there'
const res = computeMinimalEdit(prev, next)!
// start should be at prev.length (4 UTF-16 code units: h i surrogate pair)
expect(res.start).toBe(prev.length)
expect(res.replaceText).toBe(' there')
})
})
@@ -0,0 +1,100 @@
export function createMockWrapper(opts: { updateThrottleMs?: number } = {}) {
const appendBuffer: string[] = []
let appendBufferScheduled = false
let appendImmediate: ReturnType<typeof setImmediate> | null = null
let pendingUpdate: { code: string } | null = null
let lastKnownCode = ''
let throttleMs = opts.updateThrottleMs ?? 50
let lastFlushTime = 0
let throttleTimer: ReturnType<typeof setTimeout> | null = null
const model = {
value: '',
getValue() { return this.value },
setValue(v: string) { this.value = v },
getLineCount() { return this.value.split('\n').length },
getLanguageId() { return 'javascript' },
}
function clearBufferedAppends() {
appendBuffer.length = 0
appendBufferScheduled = false
if (appendImmediate != null) {
clearImmediate(appendImmediate)
appendImmediate = null
}
}
function scheduleAppendFlush() {
appendBufferScheduled = true
appendImmediate = setImmediate(() => {
appendImmediate = null
const text = appendBuffer.join('')
appendBuffer.length = 0
appendBufferScheduled = false
model.value += text
})
}
function flushPendingUpdate() {
lastFlushTime = Date.now()
if (!pendingUpdate)
return
const { code } = pendingUpdate
pendingUpdate = null
let prev: string
if (appendBuffer.length > 0) {
clearBufferedAppends()
prev = model.getValue()
lastKnownCode = prev
}
else {
prev = lastKnownCode || model.getValue()
if (!lastKnownCode)
lastKnownCode = prev
}
if (prev === code)
return
if (code.startsWith(prev)) {
const suffix = code.slice(prev.length)
if (suffix) {
appendBuffer.push(suffix)
// schedule append flush on next tick
if (!appendBufferScheduled) {
scheduleAppendFlush()
}
}
lastKnownCode = code
return
}
model.setValue(code)
lastKnownCode = code
}
function updateCode(code: string) {
pendingUpdate = { code }
// emulate raf via setImmediate
setImmediate(() => {
if (!throttleMs) { flushPendingUpdate(); return }
const now = Date.now()
const since = now - lastFlushTime
if (since >= throttleMs) { flushPendingUpdate(); return }
if (throttleTimer != null)
return
const wait = throttleMs - since
throttleTimer = setTimeout(() => { throttleTimer = null; setImmediate(flushPendingUpdate) }, wait)
})
}
function setThrottleMs(ms: number) {
throttleMs = ms
}
function getThrottleMs() {
return throttleMs
}
return { updateCode, model, setThrottleMs, getThrottleMs }
}
export type MockWrapper = ReturnType<typeof createMockWrapper>
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { createRafScheduler } from '../src/utils/raf'
describe('createRafScheduler', () => {
it('schedules and cancels using global time source', async () => {
const calls: Array<{ cb: FrameRequestCallback, id: number }> = []
let nextId = 1
const origRaf = (globalThis as any).requestAnimationFrame
const origCancel = (globalThis as any).cancelAnimationFrame
;(globalThis as any).requestAnimationFrame = (cb: FrameRequestCallback) => {
const id = nextId++
calls.push({ cb, id })
return id
}
;(globalThis as any).cancelAnimationFrame = (id: number) => {
const idx = calls.findIndex(c => c.id === id)
if (idx !== -1)
calls.splice(idx, 1)
}
try {
const s = createRafScheduler()
let fired = false
s.schedule('update', () => {
fired = true
})
// find scheduled call and invoke
expect(calls.length).toBe(1)
const call = calls[0]
// invoking should call cb
call.cb(123)
// simulate that the time source would have removed the entry when executed
calls.splice(0, 1)
expect(fired).toBe(true)
// schedule and then cancel
s.schedule('diff', () => {
throw new Error('should not run')
})
expect(calls.length).toBe(1)
s.cancel('diff')
// cancelled
expect(calls.length).toBe(0)
}
finally {
;(globalThis as any).requestAnimationFrame = origRaf
;(globalThis as any).cancelAnimationFrame = origCancel
}
})
})
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from 'vitest'
import { registerMonacoThemes } from '../src/utils/registerMonacoThemes'
// Mock the module under test to avoid resolving heavy deps (monaco/shiki) during test bundling
vi.mock('../src/utils/registerMonacoThemes', () => {
return {
registerMonacoThemes: async () => ({
setTheme: async () => undefined,
codeToHtml: (code: string) => `<pre>${code}</pre>`,
}),
// setHighlighterTheme removed; expose a mock highlighter instead
}
})
describe('registerMonacoThemes (API)', () => {
it('exports functions and they are callable', async () => {
const themes = ['vitesse-dark']
const langs = ['javascript']
const highlighter = await registerMonacoThemes(themes as any, langs)
expect(highlighter).toBeTruthy()
expect(typeof highlighter.codeToHtml).toBe('function')
// previously tested setHighlighterTheme; removed in refactor — ensure highlighter is usable
expect(typeof (highlighter as any).setTheme === 'function' || typeof (highlighter as any).codeToHtml === 'function').toBeTruthy()
})
})
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from 'vitest'
describe('registerMonacoThemes', () => {
it('re-registers when themes array is mutated in place', async () => {
vi.resetModules()
const loadTheme = vi.fn(async () => undefined)
const createHighlighter = vi.fn(async () => ({ loadTheme }))
vi.doMock('shiki', () => ({ createHighlighter }))
vi.doMock('@shikijs/monaco', () => ({ shikiToMonaco: vi.fn() }))
vi.doMock('../src/monaco-shim', () => {
const editor = { defineTheme: vi.fn(), setTheme: vi.fn(), create: vi.fn() }
const languages = { getLanguages: () => [], register: vi.fn(), setTokensProvider: vi.fn() }
return { default: { editor, languages }, editor, languages, Range: class {} }
})
const { registerMonacoThemes } = await import('../src/utils/registerMonacoThemes')
const themes: any[] = ['vitesse-dark', 'vitesse-light']
const langs = ['javascript']
await registerMonacoThemes(themes, langs)
themes.push('andromeeda')
await registerMonacoThemes(themes, langs)
// Shared Monaco highlighter is created once; additional themes are loaded incrementally.
expect(createHighlighter).toHaveBeenCalledTimes(1)
expect(loadTheme).toHaveBeenCalledWith('andromeeda')
})
it('fully resets shared monaco highlighter state when clearing the cache', async () => {
vi.resetModules()
const createHighlighter = vi.fn(async () => ({ loadTheme: vi.fn(async () => undefined) }))
vi.doMock('shiki', () => ({ createHighlighter }))
vi.doMock('@shikijs/monaco', () => ({ shikiToMonaco: vi.fn() }))
vi.doMock('../src/monaco-shim', () => {
const editor = { defineTheme: vi.fn(), setTheme: vi.fn(), create: vi.fn() }
const languages = { getLanguages: () => [], register: vi.fn(), setTokensProvider: vi.fn() }
return { default: { editor, languages }, editor, languages, Range: class {} }
})
const {
clearHighlighterCache,
registerMonacoThemes,
} = await import('../src/utils/registerMonacoThemes')
await registerMonacoThemes(['vitesse-dark', 'vitesse-light'], ['javascript'])
clearHighlighterCache()
await registerMonacoThemes(['vitesse-dark', 'vitesse-light'], ['javascript'])
expect(createHighlighter).toHaveBeenCalledTimes(2)
})
})
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { createScrollWatcherForEditor } from '../src/utils/scroll'
describe('createScrollWatcherForEditor', () => {
it('calls onPause when delta < 0 and onMaybeResume otherwise', () => {
let paused = false
let resumed = false
let last = 50
const editor = (() => {
let handler: ((e: any) => void) | null = null
return {
getScrollTop() { return last },
onDidScrollChange(cb: (e: any) => void) {
handler = cb
return {
dispose() {
handler = null
},
}
},
// helper to simulate
__emit(e: any) { handler?.(e) },
}
})()
const disp = createScrollWatcherForEditor(editor as any, {
onPause: () => { paused = true },
onMaybeResume: () => { resumed = true },
getLast: () => last,
setLast: (v: number) => { last = v },
})
// simulate up scroll (delta < 0)
editor.__emit({ scrollTop: 40 })
expect(paused).toBe(true)
// simulate down scroll
editor.__emit({ scrollTop: 100 })
expect(resumed).toBe(true)
disp?.dispose?.()
})
})
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Mock the local monaco shim so tests don't try to resolve the real
// 'monaco-editor' package entry via vite.
vi.mock('../src/monaco-shim', () => {
const setTheme = vi.fn()
const setModelLanguage = vi.fn()
const editor = {
setTheme,
setModelLanguage,
IStandaloneCodeEditor: class {},
EditorOption: { lineHeight: 16 },
}
const languages = {
getLanguages: () => [],
register: () => {},
}
const Range = class {}
return { default: { editor, languages, Range }, editor, languages, Range }
})
describe('setTheme behavior', () => {
beforeEach(() => {
// src/index.ts keeps theme state at module scope; isolate each test.
vi.resetModules()
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should not call monaco.editor.setTheme when theme is already applied', async () => {
const monaco = await import('../src/monaco-shim')
const mod = await import('../src/index')
const { setTheme } = mod.useMonaco({ themes: ['vitesse-dark', 'vitesse-light'] })
// apply once
await setTheme('vitesse-dark')
// applying same theme again without force should be no-op
await setTheme('vitesse-dark')
expect(monaco.editor.setTheme).toHaveBeenCalled()
// should only have been called once by the first application
expect((monaco.editor.setTheme as any).mock.calls.length).toBe(1)
})
it('should call monaco.editor.setTheme when force=true even if same theme', async () => {
const monaco = await import('../src/monaco-shim')
const mod = await import('../src/index')
const { setTheme } = mod.useMonaco({ themes: ['vitesse-dark', 'vitesse-light'] })
await setTheme('vitesse-dark')
// force reapplication
await setTheme('vitesse-dark', true)
expect((monaco.editor.setTheme as any).mock.calls.length).toBe(2)
})
it('should not no-op when another instance changed the global theme', async () => {
const monaco = await import('../src/monaco-shim')
const mod = await import('../src/index')
const a = mod.useMonaco({ themes: ['vitesse-dark', 'vitesse-light'] })
const b = mod.useMonaco({ themes: ['vitesse-dark', 'vitesse-light'] })
await a.setTheme('vitesse-dark')
await b.setTheme('vitesse-light')
await a.setTheme('vitesse-dark')
expect((monaco.editor.setTheme as any).mock.calls.length).toBe(3)
})
it('auto-loads missing shiki theme when highlighter is cached', async () => {
vi.resetModules()
// Mock shiki/monaco registration layer: always returns the same highlighter
// instance (simulating singleton caching), and requires explicit loadTheme
// before setTheme will succeed.
const loadedThemes = new Set<string>(['vitesse-dark', 'vitesse-light'])
const highlighter = {
loadTheme: vi.fn(async (t: string) => {
loadedThemes.add(t)
}),
setTheme: vi.fn(async (t: string) => {
if (!loadedThemes.has(t)) {
const err: any = new Error(`Theme ${t} not found, you may need to load it first`)
err.name = 'ShikiError'
throw err
}
}),
codeToHtml: vi.fn(() => ''),
}
vi.doMock('../src/utils/registerMonacoThemes', () => {
return {
clearHighlighterCache: () => {},
getOrCreateHighlighter: async () => highlighter,
registerMonacoThemes: async () => highlighter,
setThemeRegisterPromise: (p: any) => p,
}
})
// re-mock monaco shim for this resetModules scope
vi.doMock('../src/monaco-shim', () => {
const setTheme = vi.fn()
const setModelLanguage = vi.fn()
const editor = {
setTheme,
setModelLanguage,
IStandaloneCodeEditor: class {},
EditorOption: { lineHeight: 16 },
}
const languages = {
getLanguages: () => [],
register: () => {},
}
const Range = class {}
return { default: { editor, languages, Range }, editor, languages, Range }
})
const monaco = await import('../src/monaco-shim')
const mod = await import('../src/index')
const { setTheme } = mod.useMonaco({ themes: ['vitesse-dark', 'vitesse-light'] })
await setTheme('andromeeda')
expect(highlighter.loadTheme).toHaveBeenCalledWith('andromeeda')
expect(monaco.editor.setTheme).toHaveBeenCalledWith('andromeeda')
})
})
@@ -0,0 +1,180 @@
import { describe, expect, it, vi } from 'vitest'
import { EditorManager } from '../src/core/EditorManager'
import { createMockWrapper } from './mockWrapper'
vi.mock('../src/monaco-shim', () => {
const editor = {
EditorOption: { lineHeight: 16, readOnly: 0 },
ScrollType: { Smooth: 1 },
setModelLanguage: vi.fn(),
}
class Range {
constructor(
public lineNumber: number,
public column: number,
public endLineNumber: number,
public endColumn: number,
) {}
}
return {
default: { editor, Range, languages: { getLanguages: () => [] } },
editor,
Range,
languages: { getLanguages: () => [] },
}
})
function createManualScheduler() {
const callbacks = new Map<string, FrameRequestCallback>()
return {
schedule: vi.fn((kind: string, cb: FrameRequestCallback) => {
callbacks.set(kind, cb)
}),
cancel: vi.fn((kind: string) => {
callbacks.delete(kind)
}),
run(kind: string) {
const cb = callbacks.get(kind)
if (!cb)
return
callbacks.delete(kind)
cb(0 as unknown as DOMHighResTimeStamp)
},
has(kind: string) {
return callbacks.has(kind)
},
}
}
function createStubModel(initial = '') {
let value = initial
return {
getValue() {
return value
},
setValue(v: string) {
value = v
},
getLineCount() {
return Math.max(1, value.split('\n').length)
},
getLineMaxColumn(line: number) {
const lines = value.split('\n')
const idx = Math.max(0, Math.min(line - 1, lines.length - 1))
return (lines[idx]?.length ?? 0) + 1
},
getLanguageId() {
return 'plaintext'
},
applyEdits(edits: Array<{ text: string }>) {
for (const edit of edits)
value = value + edit.text
},
getPositionAt(offset: number) {
return {
lineNumber: 1,
column: Math.max(1, offset + 1),
}
},
dispose() {},
}
}
function createStubCodeEditor(model: ReturnType<typeof createStubModel>) {
return {
getModel() {
return model
},
getOption() {
return false
},
executeEdits: vi.fn((_source: string, edits: Array<{ text: string }>) => {
for (const edit of edits)
model.setValue(model.getValue() + edit.text)
}),
getScrollHeight: () => 0,
getScrollTop: () => 0,
getLayoutInfo: () => ({ height: 0 }),
}
}
function createEditorManagerHarness() {
const manager = new EditorManager(
{ readOnly: false } as any,
600,
'600px',
true,
true,
32,
2,
75,
0,
)
const scheduler = createManualScheduler()
;(manager as any).rafScheduler = scheduler
const model = createStubModel('')
const editorView = createStubCodeEditor(model)
;(manager as any).editorView = editorView
;(manager as any).lastKnownCode = ''
return { manager, scheduler, model }
}
describe('buffered append races', () => {
it('editor manager drops stale append buffers before an authoritative overwrite', () => {
const { manager, scheduler, model } = createEditorManagerHarness()
const overwrite = 'x'.repeat(16)
manager.updateCode('a', 'plaintext')
scheduler.run('update')
expect(scheduler.has('append')).toBe(true)
manager.updateCode(overwrite, 'plaintext')
scheduler.run('update')
scheduler.run('append')
expect(model.getValue()).toBe(overwrite)
})
it('mock wrapper drops a buffered append before a later authoritative overwrite', () => {
const immediates: Array<{ handle: number, fn: () => void }> = []
let handleSeed = 1
const overwrite = 'x'.repeat(16)
const originalSetImmediate = globalThis.setImmediate
const originalClearImmediate = globalThis.clearImmediate
globalThis.setImmediate = ((fn: (...args: any[]) => void) => {
const handle = handleSeed++
immediates.push({ handle, fn: () => fn() })
return handle as unknown as ReturnType<typeof setImmediate>
}) as typeof setImmediate
globalThis.clearImmediate = ((handle: ReturnType<typeof setImmediate>) => {
const idx = immediates.findIndex(entry => entry.handle === (handle as unknown as number))
if (idx !== -1)
immediates.splice(idx, 1)
}) as typeof clearImmediate
const runNextImmediate = () => {
const entry = immediates.shift()
if (entry)
entry.fn()
}
try {
const wrapper = createMockWrapper({ updateThrottleMs: 0 })
wrapper.updateCode('a')
wrapper.updateCode(overwrite)
runNextImmediate()
runNextImmediate()
while (immediates.length > 0)
runNextImmediate()
expect(wrapper.model.getValue()).toBe(overwrite)
}
finally {
globalThis.setImmediate = originalSetImmediate
globalThis.clearImmediate = originalClearImmediate
}
})
})
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockWrapper } from './mockWrapper'
describe('updateCode throttling', () => {
beforeEach(() => {
vi.useFakeTimers()
// initialize fake system time to a real timestamp so Date.now() calculations are meaningful
vi.setSystemTime(Date.now())
})
afterEach(() => {
vi.useRealTimers()
})
it('respects updateThrottleMs and batches updates', async () => {
const w = createMockWrapper({ updateThrottleMs: 50 })
// send multiple updates quickly; first flush should take the latest pending value
w.updateCode('a')
w.updateCode('ab')
w.updateCode('abc')
// run scheduled timers (setImmediate) -> this will flush once immediately
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('abc')
// now send another update immediately; it should be throttled
w.updateCode('abcd')
// advance less than throttle -> should still not flush
vi.advanceTimersByTime(20)
expect(w.model.getValue()).toBe('abc')
// advance past throttle window and process timers -> now it flushes
vi.advanceTimersByTime(60)
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('abcd')
})
it('allows changing throttle at runtime', async () => {
const w = createMockWrapper({ updateThrottleMs: 100 })
expect(w.getThrottleMs()).toBe(100)
w.setThrottleMs(0)
expect(w.getThrottleMs()).toBe(0)
w.updateCode('x')
// immediate flush when throttle==0
await vi.runAllTimersAsync()
expect(w.model.getValue()).toBe('x')
})
})