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,180 @@
/**
* @module detect
* (Language detector)
*/
/**
* Supported language identifiers
*/
export type CodeLanguage
= | 'bash'
| 'html'
| 'http'
| 'js'
| 'ts'
| 'py'
| 'sql'
| 'pl'
| 'lua'
| 'make'
| 'uri'
| 'css'
| 'diff'
| 'md'
| 'docker'
| 'xml'
| 'c'
| 'rs'
| 'go'
| 'java'
| 'asm'
| 'json'
| 'yaml'
| 'toml'
| 'mermaid'
| 'plain'
/**
* Language detection feature with pattern and score
*/
export type LanguageFeature = [RegExp, number]
/**
* Language definition with identifier and detection features
*/
export type LanguageDefinition = [CodeLanguage, ...LanguageFeature[]]
/**
* Language detection definitions
*/
export const languages: LanguageDefinition[] = [
[
'bash',
[/#!(\/usr)?\/bin\/bash/g, 500],
[/\b(if|elif|then|fi|echo)\b|\$/g, 10],
],
['html', [/<\/?[a-z-][^\n>]*>/g, 10], [/^\s+<!DOCTYPE\s+html/g, 500]],
['http', [/^(GET|HEAD|POST|PUT|DELETE|PATCH|HTTP)\b/g, 500]],
[
'js',
[
/\b(console|await|async|function|export|import|this|class|for|let|const|map|join|require)\b/g,
10,
],
],
[
'ts',
[
/\b(console|await|async|function|export|import|this|class|for|let|const|map|join|require|implements|interface|namespace)\b/g,
10,
],
],
['py', [/\b(def|print|class|and|or|lambda)\b/g, 10]],
['sql', [/\b(SELECT|INSERT|FROM)\b/g, 50]],
['pl', [/#!(\/usr)?\/bin\/perl/g, 500], [/\b(use|print)\b|\$/g, 10]],
['lua', [/#!(\/usr)?\/bin\/lua/g, 500]],
[
'make',
[/\b(ifneq|endif|if|elif|then|fi|echo|.PHONY|^[a-z]+ ?:$)\b|\$/gm, 10],
],
['uri', [/https?:|mailto:|tel:|ftp:/g, 30]],
['css', [/^(@import|@page|@media|(\.|#)[a-z]+)/gm, 20]],
['diff', [/^[+><-]/gm, 10], [/^@@[-+,0-9 ]+@@/gm, 25]],
['md', [/^(>|\t\*|\t\d+.|#{1,6} |-\s+|\*\s+)/gm, 25], [/\[.*\](.*)/g, 10]],
['docker', [/^(FROM|ENTRYPOINT|RUN)/gm, 500]],
['xml', [/<\/?[a-z-][^\n>]*>/g, 10], [/^<\?xml/g, 500]],
['c', [/#include\b|\bprintf\s+\(/g, 100]],
['rs', [/^\s+(use|fn|mut|match)\b/gm, 100]],
['go', [/\b(func|fmt|package)\b/g, 100]],
['java', [/^import\s+java/gm, 500]],
['asm', [/^(section|global main|extern|\t(call|mov|ret))/gm, 100]],
['css', [/^(@import|@page|@media|(\.|#)[a-z]+)/gm, 20]],
['json', [/\b(true|false|null|\{\})\b|"[^"]+":/g, 10]],
['yaml', [/^(\s+)?[a-z][a-z0-9]*:/gim, 10]],
['toml', [/^\s*\[.*\]\s*$/gm, 100], [/^\s*[\w-]+ *= */gm, 20]],
[
'mermaid',
[
/^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|mindmap)/gm,
500,
],
[/\b(-->|--o|--x|=>|\[\]|[{}])\b/g, 10],
],
]
/**
* Try to find the language the given code belongs to
*
* @param {string} code The code to analyze
* @param {LanguageDefinition[]} [additionalLanguages] Additional language definitions to supplement the built-in ones
* @returns {CodeLanguage} The detected language of the code
*/
export function detectLanguage(
code: string,
additionalLanguages?: LanguageDefinition[],
): CodeLanguage {
const allLanguages = additionalLanguages
? [...languages, ...additionalLanguages]
: languages
return (
allLanguages
.map(
([lang, ...features]) =>
[
lang,
features.reduce(
(acc: number, [match, score]: LanguageFeature) =>
acc + [...code.matchAll(match)].length * score,
0,
),
] as [CodeLanguage, number],
)
.filter(([, score]) => score > 20)
.sort((a, b) => b[1] - a[1])[0]?.[0] || 'plain'
)
}
export function processedLanguage(language: string) {
// Legacy (Webpack 4) fallback: Monaco doesn't ship a built-in `vue` tokenizer.
// When Shiki integration is unreliable, mapping to `html` preserves at least
// a reasonable highlight for .vue-like blocks.
try {
if ((globalThis as any)?.__streamMonacoLegacy__ && /^vue$/i.test(language))
return 'html'
}
catch {}
// eslint-disable-next-line regexp/no-dupe-disjunctions
if (/^(?:shellscript|bash|sh|shell|zsh)/i.test(language))
return 'shellscript'
if (/^(?:powershell|ps1?)/i.test(language))
return 'powershell'
return language.split(':')[0]
}
/**
* 使用示例:
*
* // 基本用法
* const language1 = detectLanguage('console.log("hello")') // 'js'
*
* // 使用自定义语言检测规则
* const customLanguages: LanguageDefinition[] = [
* ['vue', [/<template>/g, 100], [/<script>/g, 50], [/<style>/g, 50]],
* ['kotlin', [/\b(fun|class|val|var)\b/g, 20]]
* ]
*
* const language2 = detectLanguage(`
* <template>
* <div>Hello Vue</div>
* </template>
* `, customLanguages) // 'vue'
*
* const language3 = detectLanguage(`
* fun main() {
* val name = "Kotlin"
* println("Hello $name")
* }
* `, customLanguages) // 'kotlin'
*/
@@ -0,0 +1,70 @@
import type { MonacoTheme } from './type'
export const defaultLanguages = [
'jsx',
'tsx',
'vue',
'csharp',
'python',
'java',
'c',
'cpp',
'rust',
'go',
'shell',
'shellscript',
'powershell',
'sql',
'json',
'html',
'javascript',
'typescript',
'css',
'markdown',
'xml',
'yaml',
'toml',
'dockerfile',
'kotlin',
'objective-c',
'objective-cpp',
'php',
'ruby',
'scala',
'svelte',
'swift',
'erlang',
'angular-html',
'angular-ts',
'dart',
'lua',
'mermaid',
'cmake',
'nginx',
]
export const defaultThemes: MonacoTheme[] = ['vitesse-dark', 'vitesse-light']
export const defaultScrollbar = {
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
handleMouseWheel: true,
/**
* 是否始终消费鼠标滚轮事件,默认为 false
* 如果为 true,则鼠标滚轮事件不会传递给其他元素
*/
alwaysConsumeMouseWheel: false,
}
export const padding = 16
// default debounce for reveal (ms) used when revealDebounceMs is not provided
export const defaultRevealDebounceMs = 75
// Default idle batching for reveal: when set, many small appends will delay
// the final "scroll to bottom" until this idle period has passed. A higher
// value reduces layout churn during rapid streaming. Default: 200ms.
export const defaultRevealBatchOnIdleMs = 200
// When strings exceed this length, avoid expensive character-by-character
// minimal edit computation and instead perform a full model.setValue(). This
// avoids O(n) CPU on extremely large documents during frequent updates.
export const minimalEditMaxChars = 200_000
// If the absolute length difference between prev and next exceeds this ratio
// of the larger string, prefer full setValue to avoid long scans.
export const minimalEditMaxChangeRatio = 0.5
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,937 @@
import type { MonacoLanguage, MonacoOptions } from '../type'
import { processedLanguage } from '../code.detect'
import { defaultRevealBatchOnIdleMs, defaultRevealDebounceMs, defaultScrollbar, minimalEditMaxChangeRatio, minimalEditMaxChars, padding } from '../constant'
import { computeMinimalEdit } from '../minimalEdit'
import * as monaco from '../monaco-shim'
import { createHeightManager } from '../utils/height'
import { error, log } from '../utils/logger'
import { createRafScheduler } from '../utils/raf'
import { createScrollWatcherForEditor } from '../utils/scroll'
export class EditorManager {
private editorView: monaco.editor.IStandaloneCodeEditor | null = null
private lastContainer: HTMLElement | null = null
private lastKnownCode: string | null = null
private pendingUpdate: { code: string, lang: string } | null = null
private _hasScrollBar = false
private updateThrottleMs = 50
private minimalEditMaxCharsValue = minimalEditMaxChars
private minimalEditMaxChangeRatioValue = minimalEditMaxChangeRatio
private lastUpdateFlushTime = 0
private updateThrottleTimer: number | null = null
private shouldAutoScroll = true
private scrollWatcher: any = null
private scrollWatcherSuppressionTimer: number | null = null
private lastScrollTop = 0
private cachedScrollHeight: number | null = null
private cachedLineHeight: number | null = null
private cachedComputedHeight: number | null = null
private cachedLineCount: number | null = null
private lastKnownCodeDirty = false
private programmaticContentChangeDepth = 0
private debug = false
// read a small set of viewport/layout metrics once to avoid repeated DOM reads
private measureViewport() {
if (!this.editorView)
return null
const li = this.editorView.getLayoutInfo?.() ?? null
const lineHeight = this.cachedLineHeight ?? this.editorView.getOption(
monaco.editor.EditorOption.lineHeight,
)
const scrollTop = this.editorView.getScrollTop?.() ?? this.lastScrollTop ?? 0
const scrollHeight = this.editorView.getScrollHeight?.() ?? this.cachedScrollHeight ?? (li?.height ?? 0)
const computedHeight = this.cachedComputedHeight ?? this.computedHeight(this.editorView)
// update caches
this.cachedLineHeight = lineHeight
this.cachedScrollHeight = scrollHeight
this.cachedComputedHeight = computedHeight
this.lastScrollTop = scrollTop
return { li, lineHeight, scrollTop, scrollHeight, computedHeight }
}
private appendBuffer: string[] = []
private appendBufferScheduled = false
private rafScheduler = createRafScheduler()
private editorHeightManager: ReturnType<typeof createHeightManager> | null = null
// debounce id for reveal to coalesce rapid calls (ms)
private revealDebounceId: number | null = null
private readonly revealDebounceMs = defaultRevealDebounceMs
// idle timer for final batch reveal
private revealIdleTimerId: number | null = null
private revealTicket = 0
private revealStrategyOption?: 'bottom' | 'centerIfOutside' | 'center'
private revealBatchOnIdleMsOption?: number
private readonly scrollWatcherSuppressionMs = 500
constructor(
private options: MonacoOptions,
private maxHeightValue: number,
private maxHeightCSS: string,
private autoScrollOnUpdate: boolean,
private autoScrollInitial: boolean,
private autoScrollThresholdPx: number,
private autoScrollThresholdLines: number,
private revealDebounceMsOption?: number,
private updateThrottleMsOption?: number,
) {
this.updateThrottleMs
= this.updateThrottleMsOption
?? (this.options as any).updateThrottleMs
?? 50
this.minimalEditMaxCharsValue
= (this.options as any).minimalEditMaxChars
?? minimalEditMaxChars
this.minimalEditMaxChangeRatioValue
= (this.options as any).minimalEditMaxChangeRatio
?? minimalEditMaxChangeRatio
}
private cancelRafs() {
this.rafScheduler.cancel('update')
this.rafScheduler.cancel('sync-last-known')
this.rafScheduler.cancel('content-size-change')
this.rafScheduler.cancel('maybe-scroll')
this.rafScheduler.cancel('reveal')
this.rafScheduler.cancel('immediate-reveal')
this.rafScheduler.cancel('maybe-resume')
this.rafScheduler.cancel('append')
}
private clearRevealTimers() {
if (this.revealDebounceId != null) {
clearTimeout(this.revealDebounceId)
this.revealDebounceId = null
}
if (this.revealIdleTimerId != null) {
clearTimeout(this.revealIdleTimerId)
this.revealIdleTimerId = null
}
}
private resetAppendState() {
this.appendBufferScheduled = false
this.appendBuffer.length = 0
}
private clearAsyncWork() {
this.cancelRafs()
this.pendingUpdate = null
this.lastKnownCodeDirty = false
this.resetAppendState()
this.clearRevealTimers()
if (this.scrollWatcherSuppressionTimer != null) {
clearTimeout(this.scrollWatcherSuppressionTimer)
this.scrollWatcherSuppressionTimer = null
}
if (this.updateThrottleTimer != null) {
clearTimeout(this.updateThrottleTimer)
this.updateThrottleTimer = null
}
}
// initialize debug flag: prefer explicit runtime flag, then options, fallback to false
private initDebugFlag() {
// Browser global override: `window.__STREAM_MONACO_DEBUG__` (true/false)
if (typeof window !== 'undefined' && (window as any).__STREAM_MONACO_DEBUG__ !== undefined) {
this.debug = Boolean((window as any).__STREAM_MONACO_DEBUG__)
return
}
// Option-level override: `options.debug`
if (this.options && (this.options as any).debug !== undefined) {
this.debug = Boolean((this.options as any).debug)
return
}
this.debug = false
}
// instance-level debug logger helper to avoid repetitive guards
private dlog(...args: any[]) {
if (!this.debug)
return
log('EditorManager', ...args)
}
private runAsProgrammaticContentChange<T>(fn: () => T): T {
this.programmaticContentChangeDepth += 1
try {
return fn()
}
finally {
this.programmaticContentChangeDepth -= 1
}
}
private hasVerticalScrollbar(): boolean {
if (!this.editorView)
return false
if (this._hasScrollBar)
return true
const m = this.measureViewport()
if (!m)
return false
return this._hasScrollBar = (m.scrollHeight > m.computedHeight + padding / 2)
}
private userIsNearBottom(): boolean {
if (!this.editorView)
return true
const m = this.measureViewport()
if (!m || !m.li)
return true
const lineThreshold = (this.autoScrollThresholdLines ?? 0) * m.lineHeight
const threshold = Math.max(lineThreshold || 0, this.autoScrollThresholdPx || 0)
const distance = m.scrollHeight - (m.scrollTop + m.li.height)
return distance <= threshold
}
private computedHeight(editorView: monaco.editor.IStandaloneCodeEditor) {
const contentHeight = editorView.getContentHeight?.()
if (
typeof contentHeight === 'number'
&& Number.isFinite(contentHeight)
&& contentHeight > 0
) {
const height = Math.min(contentHeight, this.maxHeightValue)
log('EditorManager.computedHeight', { contentHeight, computed: height, maxHeightValue: this.maxHeightValue })
return height
}
const lineCount = this.cachedLineCount ?? editorView.getModel()?.getLineCount() ?? 1
const lineHeight = editorView.getOption(monaco.editor.EditorOption.lineHeight)
const height = Math.min(lineCount * lineHeight + padding, this.maxHeightValue)
// log is defensive itself; no need for an extra try/catch here
log('EditorManager.computedHeight', { lineCount, lineHeight, computed: height, maxHeightValue: this.maxHeightValue })
return height
}
private maybeScrollToBottom(targetLine?: number) {
// Defer measurement and reveal work to the raf scheduler so we avoid forcing sync layout
// during hot update paths. This coalesces multiple calls into one frame.
this.rafScheduler.schedule('maybe-scroll', () => {
const hasVS = this.hasVerticalScrollbar()
this.dlog('maybeScrollToBottom called', { autoScrollOnUpdate: this.autoScrollOnUpdate, shouldAutoScroll: this.shouldAutoScroll, hasVerticalScrollbar: hasVS, targetLine })
if (!(this.autoScrollOnUpdate && this.shouldAutoScroll && this.hasVerticalScrollbar())) {
this.dlog('maybeScrollToBottom skipped (auto-scroll conditions not met)')
return
}
const model = this.editorView!.getModel()
const line = targetLine ?? model?.getLineCount() ?? 1
// if revealBatchOnIdleMs is provided, use idle-batching: delay final reveal until idle
const batchMs = this.revealBatchOnIdleMsOption ?? this.options.revealBatchOnIdleMs ?? defaultRevealBatchOnIdleMs
if (typeof batchMs === 'number' && batchMs > 0) {
if (this.revealIdleTimerId != null) {
clearTimeout(this.revealIdleTimerId)
}
const ticket = ++this.revealTicket
this.dlog('scheduled idle reveal ticket=', ticket, 'line=', line, 'batchMs=', batchMs)
this.revealIdleTimerId = (setTimeout(() => {
this.revealIdleTimerId = null
this.dlog('idle reveal timer firing, ticket=', ticket, 'line=', line)
this.performReveal(line, ticket)
}, batchMs) as unknown) as number
return
}
// otherwise use debounce behavior
if (this.revealDebounceId != null) {
clearTimeout(this.revealDebounceId)
this.revealDebounceId = null
}
const ms = (typeof this.revealDebounceMs === 'number' && this.revealDebounceMs > 0)
? this.revealDebounceMs
: (typeof this.revealDebounceMsOption === 'number' && this.revealDebounceMsOption > 0)
? this.revealDebounceMsOption
: this.revealDebounceMs
this.revealDebounceId = (setTimeout(() => {
this.revealDebounceId = null
const ticket = ++this.revealTicket
this.dlog('scheduled debounce reveal ticket=', ticket, 'line=', line, 'ms=', ms)
this.performReveal(line, ticket)
}, ms) as unknown) as number
})
}
private performReveal(line: number, ticket: number) {
this.rafScheduler.schedule('reveal', () => {
if (ticket !== this.revealTicket) {
this.dlog('performReveal skipped, stale ticket', ticket, 'current', this.revealTicket)
return
}
this.dlog('performReveal executing, ticket=', ticket, 'line=', line)
const strategy = this.revealStrategyOption ?? this.options.revealStrategy ?? 'centerIfOutside'
this.dlog('performReveal strategy=', strategy)
const ScrollType: any = (monaco as any).ScrollType || (monaco as any).editor?.ScrollType
const smooth = (ScrollType && typeof ScrollType.Smooth !== 'undefined') ? ScrollType.Smooth : undefined
try {
if (strategy === 'bottom') {
if (typeof smooth !== 'undefined')
this.editorView!.revealLine(line, smooth)
else this.editorView!.revealLine(line)
}
else if (strategy === 'center') {
if (typeof smooth !== 'undefined')
this.editorView!.revealLineInCenter(line, smooth)
else this.editorView!.revealLineInCenter(line)
}
else {
if (typeof smooth !== 'undefined')
this.editorView!.revealLineInCenterIfOutsideViewport(line, smooth)
else this.editorView!.revealLineInCenterIfOutsideViewport(line)
}
}
catch {
// fallback to simple revealLine
try {
this.editorView!.revealLine(line)
}
catch { }
}
})
}
// Try to reveal immediately (synchronous) to keep the scroll in-step with
// edits when we know an update just appended or changed line count.
// This is a fast-path to reduce the perceived lag before the debounced
// reveal kicks in. It still leaves the debounced/idle reveal as a fallback
// for coalescing frequent updates.
private performImmediateReveal(line: number, ticket: number) {
this.dlog('performImmediateReveal line=', line, 'ticket=', ticket)
try {
if (!this.editorView)
return
if (ticket !== this.revealTicket) {
this.dlog('performImmediateReveal skipped, stale ticket', ticket, 'current', this.revealTicket)
return
}
// Prefer an immediate (non-animated) reveal here to avoid visual
// jitter caused by smooth scroll animations. Some Monaco builds expose
// a ScrollType with Immediate; otherwise calling revealLine without
// a Smooth type typically performs an instant jump.
const ScrollType: any = (monaco as any).ScrollType || (monaco as any).editor?.ScrollType
const immediate = (ScrollType && typeof ScrollType.Immediate !== 'undefined') ? ScrollType.Immediate : undefined
if (typeof immediate !== 'undefined') {
this.editorView.revealLine(line, immediate)
}
else {
this.editorView.revealLine(line)
}
}
catch { }
// Update cached viewport metrics to keep internal state consistent
try {
this.measureViewport()
}
catch { }
}
// Force an immediate (synchronous) reveal of a line, bypassing the
// ticketing/debounce mechanism. Used when we explicitly applied the
// container to the max height and need the scroll to follow immediately.
private forceReveal(line: number) {
try {
if (!this.editorView)
return
const ScrollType: any = (monaco as any).ScrollType || (monaco as any).editor?.ScrollType
const immediate = (ScrollType && typeof ScrollType.Immediate !== 'undefined') ? ScrollType.Immediate : undefined
if (typeof immediate !== 'undefined')
this.editorView.revealLine(line, immediate)
else this.editorView.revealLine(line)
}
catch { }
try {
this.measureViewport()
}
catch { }
// After forcing a reveal, ensure auto-scroll state is on and the
// lastScrollTop is synchronized so the scrollWatcher doesn't treat
// this programmatic scroll as user input.
try {
this.shouldAutoScroll = true
this.lastScrollTop = this.editorView?.getScrollTop?.() ?? this.lastScrollTop
}
catch { }
}
private isOverflowAuto() {
try {
return !!this.lastContainer && this.lastContainer.style.overflow === 'auto'
}
catch { return false }
}
private shouldPerformImmediateReveal() {
return this.autoScrollOnUpdate && this.shouldAutoScroll && this.hasVerticalScrollbar() && this.isOverflowAuto()
}
private syncNonOverflowingLayout() {
if (!this.editorView || !this.lastContainer)
return null
const computed = this.computedHeight(this.editorView)
if (computed >= this.maxHeightValue - 1) {
this.lastContainer.style.height = `${this.maxHeightValue}px`
return computed
}
const currentHeight
= Number.parseFloat(this.lastContainer.style.height || '')
|| this.lastContainer.getBoundingClientRect?.().height
|| 0
if (currentHeight <= 0 || Math.abs(computed - currentHeight) > 12)
this.lastContainer.style.height = `${computed}px`
this.lastContainer.style.overflow = 'hidden'
this._hasScrollBar = false
try {
this.editorView.layout?.()
}
catch {}
try {
if ((this.editorView.getScrollTop?.() ?? 0) !== 0)
this.editorView.setScrollTop?.(0)
this.lastScrollTop = 0
}
catch {}
return computed
}
async createEditor(
container: HTMLElement,
code: string,
language: string,
currentTheme: string,
) {
this.cleanup()
this.lastContainer = container
this.initDebugFlag()
this.dlog('createEditor container, maxHeight', this.maxHeightValue)
// Start with hidden overflow so we don't show a scrollbar before the
// editor reaches its configured max height. We'll toggle to `auto`
// when the computed height reaches `maxHeightValue`.
container.style.overflow = 'hidden'
container.style.maxHeight = this.maxHeightCSS
this.editorView = monaco.editor.create(container, {
value: code,
language: processedLanguage(language) || language,
theme: currentTheme,
scrollBeyondLastLine: false,
minimap: { enabled: false },
automaticLayout: true,
readOnly: this.options.readOnly ?? true,
contextmenu: false,
scrollbar: {
...defaultScrollbar,
...(this.options.scrollbar || {}),
},
...this.options,
})
monaco.editor.setTheme(currentTheme)
this.lastKnownCode = this.editorView.getValue()
if (this.editorHeightManager) {
try {
this.editorHeightManager.dispose()
}
catch { }
this.editorHeightManager = null
}
// clear any pending reveal debounce
if (this.revealDebounceId != null) {
clearTimeout(this.revealDebounceId)
this.revealDebounceId = null
}
if (this.revealIdleTimerId != null) {
clearTimeout(this.revealIdleTimerId)
}
this.revealIdleTimerId = null
// Compute and apply the editor's content height. Consumers such as
// CodeBlockNode may also sync this same host height from Monaco, so avoid
// enforcing a separate minimum that would fight the host.
this.editorHeightManager = createHeightManager(container, () => {
const computed = this.computedHeight(this.editorView!)
return Math.min(computed, this.maxHeightValue)
})
this.editorHeightManager.update()
// If the initial computed height already reaches (or is very near) the
// configured max height, apply it immediately so the UI shows the
// scrolled state without waiting for the debounced height manager.
const initialComputed = this.computedHeight(this.editorView)
if (initialComputed >= this.maxHeightValue - 1) {
container.style.height = `${this.maxHeightValue}px`
container.style.overflow = 'auto'
this.dlog('applied immediate maxHeight on createEditor', this.maxHeightValue)
}
this.cachedScrollHeight = this.editorView.getScrollHeight?.() ?? null
this.cachedLineHeight = this.editorView.getOption?.(monaco.editor.EditorOption.lineHeight) ?? null
this.cachedComputedHeight = this.computedHeight(this.editorView)
this.cachedLineCount = this.editorView.getModel()?.getLineCount() ?? null
this.editorView.onDidContentSizeChange?.(() => {
this._hasScrollBar = false
// Defer expensive measurements to RAF to avoid forcing sync layout on
// the content-size-change event which can fire frequently.
this.rafScheduler.schedule('content-size-change', () => {
try {
this.dlog('content-size-change frame')
this.cachedLineCount = this.editorView?.getModel()?.getLineCount() ?? this.cachedLineCount
this.cachedComputedHeight = null
const m = this.measureViewport()
this.dlog('content-size-change measure', m)
if (this.editorHeightManager?.isSuppressed()) {
this.dlog('content-size-change skipped height update (suppressed)')
return
}
this.dlog('content-size-change calling heightManager.update')
this.editorHeightManager?.update()
// Toggle overflow based on whether we've effectively reached max height.
const computed = this.computedHeight(this.editorView!)
if (this.lastContainer) {
const prevOverflow = this.lastContainer.style.overflow
const newOverflow = computed >= this.maxHeightValue - 1 ? 'auto' : 'hidden'
if (prevOverflow !== newOverflow) {
this.lastContainer.style.overflow = newOverflow
// If we just switched to visible overflow and auto-scroll is on,
// schedule a scroll to bottom so the user sees the latest content.
if (newOverflow === 'auto' && this.shouldAutoScroll) {
this.maybeScrollToBottom()
}
}
}
}
catch (err) { error('EditorManager', 'content-size-change error', err) }
})
})
// Avoid calling getValue() synchronously on every content change; instead
// mark dirty and sync once per RAF frame to reduce CPU and style recalcs.
this.editorView.onDidChangeModelContent(() => {
if (this.programmaticContentChangeDepth > 0)
return
this.lastKnownCodeDirty = true
this.rafScheduler.schedule('sync-last-known', () => this.syncLastKnownCode())
})
this.shouldAutoScroll = !!this.autoScrollInitial
if (this.scrollWatcher) {
this.scrollWatcher.dispose()
this.scrollWatcher = null
}
this.scrollWatcher = createScrollWatcherForEditor(this.editorView, {
onPause: () => { this.shouldAutoScroll = false },
onMaybeResume: () => {
// defer the expensive userIsNearBottom check to the raf scheduler
this.rafScheduler.schedule('maybe-resume', () => {
this.shouldAutoScroll = this.userIsNearBottom()
})
},
getLast: () => this.lastScrollTop,
setLast: (v: number) => { this.lastScrollTop = v },
})
this.maybeScrollToBottom()
return this.editorView
}
private syncLastKnownCode() {
if (!this.editorView || !this.lastKnownCodeDirty)
return
const model = this.editorView.getModel()
if (model) {
this.lastKnownCode = model.getValue()
this.cachedLineCount = model.getLineCount() ?? this.cachedLineCount
this.cachedComputedHeight = null
}
this.lastKnownCodeDirty = false
}
private suppressScrollWatcher(ms: number) {
if (!this.scrollWatcher || typeof this.scrollWatcher.setSuppressed !== 'function')
return
this.dlog('suppressScrollWatcher', ms)
// clear existing timer
if (this.scrollWatcherSuppressionTimer != null) {
clearTimeout(this.scrollWatcherSuppressionTimer)
this.scrollWatcherSuppressionTimer = null
}
this.scrollWatcher.setSuppressed(true)
this.scrollWatcherSuppressionTimer = (setTimeout(() => {
if (this.scrollWatcher && typeof this.scrollWatcher.setSuppressed === 'function') {
this.scrollWatcher.setSuppressed(false)
this.dlog('suppressScrollWatcher cleared')
}
this.scrollWatcherSuppressionTimer = null
}, ms) as unknown) as number
}
// Schedule an immediate reveal after layout has settled (two RAFs).
// This reduces races where a reveal runs before the editor/container
// has applied its new size, which can cause reveal to be a no-op or
// be applied in the wrong order relative to other reveals.
private scheduleImmediateRevealAfterLayout(line: number) {
const ticket = ++this.revealTicket
this.dlog('scheduleImmediateRevealAfterLayout ticket=', ticket, 'line=', line)
this.rafScheduler.schedule('immediate-reveal', async () => {
try {
// If we have a height manager, compute the expected applied height
const target = this.editorView && this.editorHeightManager
? Math.min(this.computedHeight(this.editorView), this.maxHeightValue)
: -1
if (target !== -1 && this.editorHeightManager) {
// Wait until the height manager reports the target as applied
await this.waitForHeightApplied(target, 500)
}
else {
// fallback: wait two frames to let layout settle
await new Promise<void>(r => requestAnimationFrame(() => requestAnimationFrame(() => r())))
}
this.dlog('running delayed immediate reveal', 'ticket=', ticket, 'line=', line)
this.performImmediateReveal(line, ticket)
}
catch (err) { error('EditorManager', 'scheduleImmediateRevealAfterLayout error', err) }
})
}
private waitForHeightApplied(target: number, timeoutMs = 500) {
return new Promise<void>((resolve) => {
const start = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()
const check = () => {
try {
const last = this.editorHeightManager?.getLastApplied?.() ?? -1
if (last !== -1 && Math.abs(last - target) <= 12) {
this.dlog('waitForHeightApplied satisfied', last, 'target=', target)
resolve()
return
}
const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()
if (now - start > timeoutMs) {
log('EditorManager', 'waitForHeightApplied timeout', last, 'target=', target)
resolve()
return
}
}
catch { }
requestAnimationFrame(check)
}
check()
})
}
updateCode(newCode: string, codeLanguage: string) {
this.pendingUpdate = { code: newCode, lang: codeLanguage }
this.rafScheduler.schedule('update', () => {
if (!this.updateThrottleMs) {
this.flushPendingUpdate()
return
}
const now = Date.now()
const since = now - this.lastUpdateFlushTime
if (since >= this.updateThrottleMs) {
this.flushPendingUpdate()
return
}
if (this.updateThrottleTimer != null)
return
const wait = this.updateThrottleMs - since
this.updateThrottleTimer = (setTimeout(() => {
this.updateThrottleTimer = null
this.rafScheduler.schedule('update', () => this.flushPendingUpdate())
}, wait) as unknown) as number
})
}
private flushPendingUpdate() {
if (!this.pendingUpdate || !this.editorView)
return
const model = this.editorView.getModel()
if (!model)
return
const { code: newCode, lang: codeLanguage } = this.pendingUpdate
this.pendingUpdate = null
this.lastUpdateFlushTime = Date.now()
const processedCodeLanguage = processedLanguage(codeLanguage)
const languageId = model.getLanguageId()
if (languageId !== processedCodeLanguage) {
if (processedCodeLanguage)
monaco.editor.setModelLanguage(model, processedCodeLanguage)
const prevLineCount = model.getLineCount()
this.runAsProgrammaticContentChange(() => {
model.setValue(newCode)
})
this.lastKnownCode = newCode
const newLineCount = model.getLineCount()
this.cachedLineCount = newLineCount
this.cachedComputedHeight = null
if (newLineCount !== prevLineCount) {
// Temporarily suppress the scroll watcher while we run the
// immediate reveal to avoid the watcher interpreting the layout
// driven scroll as user input and toggling auto-scroll state.
const shouldImmediate = this.shouldPerformImmediateReveal()
if (shouldImmediate)
this.suppressScrollWatcher(this.scrollWatcherSuppressionMs)
const computed = this.syncNonOverflowingLayout()
if (computed != null && computed >= this.maxHeightValue - 1)
this.forceReveal(newLineCount)
}
return
}
let prevCode: string
if (this.appendBuffer.length > 0) {
this.resetAppendState()
this.rafScheduler.cancel('append')
try {
prevCode = model.getValue()
this.lastKnownCode = prevCode
}
catch {
prevCode = this.lastKnownCode ?? ''
}
}
else if (this.lastKnownCode != null) {
prevCode = this.lastKnownCode
}
else {
prevCode = this.editorView.getValue()
this.lastKnownCode = prevCode
}
if (prevCode === newCode)
return
if (newCode.startsWith(prevCode) && prevCode.length < newCode.length) {
const suffix = newCode.slice(prevCode.length)
if (suffix)
this.appendCode(suffix, codeLanguage)
return
}
const prevLineCount = model.getLineCount()
this.applyMinimalEdit(prevCode, newCode)
this.lastKnownCode = newCode
const newLineCount = model.getLineCount()
this.cachedLineCount = newLineCount
this.cachedComputedHeight = null
if (newLineCount !== prevLineCount) {
const shouldImmediate = this.shouldPerformImmediateReveal()
if (shouldImmediate)
this.suppressScrollWatcher(this.scrollWatcherSuppressionMs)
this.syncNonOverflowingLayout()
if (shouldImmediate) {
this.scheduleImmediateRevealAfterLayout(newLineCount)
}
else {
this.maybeScrollToBottom(newLineCount)
}
}
}
appendCode(appendText: string, codeLanguage?: string) {
if (!this.editorView)
return
const model = this.editorView.getModel()
if (!model)
return
const processedCodeLanguage = codeLanguage ? processedLanguage(codeLanguage) : model.getLanguageId()
if (processedCodeLanguage && model.getLanguageId() !== processedCodeLanguage)
monaco.editor.setModelLanguage(model, processedCodeLanguage)
// Buffer-only; actual edit is applied in flushAppendBuffer once per frame
if (appendText) {
this.appendBuffer.push(appendText)
if (!this.appendBufferScheduled) {
this.appendBufferScheduled = true
this.rafScheduler.schedule('append', () => this.flushAppendBuffer())
}
}
}
private applyMinimalEdit(prev: string, next: string) {
if (!this.editorView)
return
const model = this.editorView.getModel()
if (!model)
return
// Avoid expensive minimal edit calculation for extremely large documents
// or when the size delta is very large; fallback to full setValue.
const maxChars = this.minimalEditMaxCharsValue
const ratio = this.minimalEditMaxChangeRatioValue
const maxLen = Math.max(prev.length, next.length)
const changeRatio = maxLen > 0 ? Math.abs(next.length - prev.length) / maxLen : 0
if (prev.length + next.length > maxChars || changeRatio > ratio) {
const prevLineCount = model.getLineCount()
this.runAsProgrammaticContentChange(() => {
model.setValue(next)
})
this.lastKnownCode = next
const newLineCount = model.getLineCount()
this.cachedLineCount = newLineCount
if (newLineCount !== prevLineCount) {
this.syncNonOverflowingLayout()
this.maybeScrollToBottom(newLineCount)
}
return
}
const res = computeMinimalEdit(prev, next)
if (!res)
return
const { start, endPrevIncl, replaceText } = res
const rangeStart = model.getPositionAt(start)
const rangeEnd = model.getPositionAt(endPrevIncl + 1)
const range = new monaco.Range(
rangeStart.lineNumber,
rangeStart.column,
rangeEnd.lineNumber,
rangeEnd.column,
)
const isReadOnly = this.editorView.getOption(monaco.editor.EditorOption.readOnly)
const edit = [{ range, text: replaceText, forceMoveMarkers: true }]
this.runAsProgrammaticContentChange(() => {
if (isReadOnly)
model.applyEdits(edit)
else this.editorView!.executeEdits('minimal-replace', edit)
})
}
private flushAppendBuffer() {
if (!this.editorView)
return
if (this.appendBuffer.length === 0)
return
this.appendBufferScheduled = false
const model = this.editorView.getModel()
if (!model) {
this.appendBuffer.length = 0
return
}
const text = this.appendBuffer.join('')
this.appendBuffer.length = 0
const lastLine = model.getLineCount()
const lastColumn = model.getLineMaxColumn(lastLine)
const range = new monaco.Range(lastLine, lastColumn, lastLine, lastColumn)
const isReadOnly = this.editorView.getOption(monaco.editor.EditorOption.readOnly)
this.runAsProgrammaticContentChange(() => {
if (isReadOnly) {
model.applyEdits([{ range, text, forceMoveMarkers: true }])
}
else {
this.editorView!.executeEdits('append', [{ range, text, forceMoveMarkers: true }])
}
})
if (this.lastKnownCode != null)
this.lastKnownCode = this.lastKnownCode + text
else
this.lastKnownCode = model.getValue()
const newLineCount = model.getLineCount()
if (lastLine !== newLineCount) {
this.cachedLineCount = newLineCount
this.cachedComputedHeight = null
const shouldImmediate = this.shouldPerformImmediateReveal()
if (shouldImmediate)
this.suppressScrollWatcher(this.scrollWatcherSuppressionMs)
this.syncNonOverflowingLayout()
if (shouldImmediate) {
try {
this.forceReveal(newLineCount)
}
catch { }
}
else {
this.maybeScrollToBottom(newLineCount)
}
}
}
setLanguage(language: MonacoLanguage, languages: MonacoLanguage[]) {
if (languages.includes(language)) {
if (this.editorView) {
const model = this.editorView.getModel()
if (model && model.getLanguageId() !== language)
monaco.editor.setModelLanguage(model, language)
}
}
else {
console.warn(`Language "${language}" is not registered. Available languages: ${languages.join(', ')}`)
}
}
getEditorView() {
return this.editorView
}
getCode() {
return this.editorView?.getModel()?.getValue() ?? null
}
setUpdateThrottleMs(ms: number) {
this.updateThrottleMs = ms
if (!this.updateThrottleMs && this.updateThrottleTimer != null) {
clearTimeout(this.updateThrottleTimer)
this.updateThrottleTimer = null
this.rafScheduler.schedule('update', () => this.flushPendingUpdate())
}
}
getUpdateThrottleMs() {
return this.updateThrottleMs
}
cleanup() {
this.clearAsyncWork()
if (this.editorView) {
this.editorView.dispose()
this.editorView = null
}
this.lastKnownCode = null
if (this.lastContainer) {
this.lastContainer.style.minHeight = ''
this.lastContainer.innerHTML = ''
this.lastContainer = null
}
if (this.scrollWatcher) {
this.scrollWatcher.dispose()
this.scrollWatcher = null
}
if (this.editorHeightManager) {
this.editorHeightManager.dispose()
this.editorHeightManager = null
}
}
safeClean() {
this.clearAsyncWork()
// Cancel/cleanup watchers and timers
if (this.scrollWatcher) {
try {
this.scrollWatcher.dispose()
}
catch { }
this.scrollWatcher = null
}
this._hasScrollBar = false
this.shouldAutoScroll = !!this.autoScrollInitial
this.lastScrollTop = 0
if (this.editorHeightManager) {
this.editorHeightManager.dispose()
this.editorHeightManager = null
}
}
}
@@ -0,0 +1,357 @@
import type * as monaco from '../monaco-shim'
type DiffAppearance = 'light' | 'dark'
type DiffAppearanceOption = DiffAppearance | 'auto' | undefined
interface DiffUnchangedLineInfoRailMetrics {
leftInset: number
width: number | null
}
interface ApplyDiffRootAppearanceClassOptions {
container: HTMLElement | null
diffEditorView: monaco.editor.IStandaloneDiffEditor | null
diffAppearance: DiffAppearanceOption
themeName: string | null | undefined
currentSignature: string | null
lineStyle: 'background' | 'bar'
unchangedRegionStyle: 'line-info' | 'line-info-basic' | 'metadata' | 'simple'
isInlineMode: boolean
lineStyleClasses: readonly string[]
unchangedRegionStyleClasses: readonly string[]
layoutModeClasses: readonly string[]
appearanceClasses: readonly string[]
}
function parseCssColorRgb(color: string): [number, number, number] | null {
const normalized = color.trim().toLowerCase()
const rgbMatch = normalized.match(
/^rgba?\(\s*([+\-.\d]+)\s*,\s*([+\-.\d]+)\s*,\s*([+\-.\d]+)/,
)
if (rgbMatch) {
return [
Number.parseFloat(rgbMatch[1]),
Number.parseFloat(rgbMatch[2]),
Number.parseFloat(rgbMatch[3]),
]
}
const hexMatch = normalized.match(/^#([\da-f]{3,8})$/i)
if (!hexMatch)
return null
const hex = hexMatch[1]
if (hex.length === 3 || hex.length === 4) {
return [
Number.parseInt(`${hex[0]}${hex[0]}`, 16),
Number.parseInt(`${hex[1]}${hex[1]}`, 16),
Number.parseInt(`${hex[2]}${hex[2]}`, 16),
]
}
if (hex.length === 6 || hex.length === 8) {
return [
Number.parseInt(hex.slice(0, 2), 16),
Number.parseInt(hex.slice(2, 4), 16),
Number.parseInt(hex.slice(4, 6), 16),
]
}
return null
}
function resolveCssColorLuminance(color: string): number | null {
const rgb = parseCssColorRgb(color)
if (!rgb)
return null
const channel = (value: number) => {
const normalized = Math.max(0, Math.min(255, value)) / 255
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4
}
const [r, g, b] = rgb
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}
function looksLikeDarkThemeName(themeName: string | null | undefined) {
if (!themeName)
return false
const normalized = themeName.toLowerCase()
return (
[
'dark',
'night',
'moon',
'black',
'dracula',
'mocha',
'frappe',
'macchiato',
'palenight',
'ocean',
'poimandres',
'monokai',
'laserwave',
'tokyo',
'slack-dark',
'rose-pine',
'github-dark',
'material-theme',
'one-dark',
'catppuccin-mocha',
'catppuccin-frappe',
'catppuccin-macchiato',
].some(token => normalized.includes(token))
&& !normalized.includes('light')
&& !normalized.includes('latte')
&& !normalized.includes('dawn')
&& !normalized.includes('lotus')
)
}
function looksLikeLightThemeName(themeName: string | null | undefined) {
if (!themeName)
return false
const normalized = themeName.toLowerCase()
return [
'light',
'day',
'dawn',
'latte',
'solarized-light',
'github-light',
'rose-pine-dawn',
'catppuccin-latte',
'one-light',
'vitesse-light',
'snazzy-light',
'material-lighter',
'material-theme-lighter',
'lotus',
].some(token => normalized.includes(token))
}
function resolveDiffAppearance({
container,
diffAppearance,
diffEditorView,
themeName,
}: Pick<
ApplyDiffRootAppearanceClassOptions,
'container' | 'diffAppearance' | 'diffEditorView' | 'themeName'
>): DiffAppearance {
if (diffAppearance === 'light')
return 'light'
if (diffAppearance === 'dark')
return 'dark'
if (looksLikeDarkThemeName(themeName))
return 'dark'
if (looksLikeLightThemeName(themeName))
return 'light'
const appearanceProbeNodes = [
diffEditorView?.getModifiedEditor().getContainerDomNode?.(),
diffEditorView?.getOriginalEditor().getContainerDomNode?.(),
container,
]
for (const node of appearanceProbeNodes) {
if (!(node instanceof HTMLElement))
continue
const style = globalThis.getComputedStyle(node)
const editorSurface = node.querySelector<HTMLElement>(
'.monaco-editor .monaco-editor-background, .monaco-editor .margin, .monaco-editor .lines-content',
)
const candidates = [
style.getPropertyValue('--stream-monaco-editor-bg'),
style.getPropertyValue('--vscode-editor-background'),
editorSurface
? globalThis.getComputedStyle(editorSurface).backgroundColor
: '',
style.backgroundColor,
]
for (const color of candidates) {
const luminance = resolveCssColorLuminance(color)
if (luminance == null)
continue
return luminance <= 0.42 ? 'dark' : 'light'
}
}
return looksLikeDarkThemeName(themeName) ? 'dark' : 'light'
}
function syncDiffRootThemeVariables(
container: HTMLElement,
diffEditorView: monaco.editor.IStandaloneDiffEditor | null,
appearance: DiffAppearance,
) {
const probeNodes = [
diffEditorView?.getModifiedEditor().getContainerDomNode?.(),
diffEditorView?.getOriginalEditor().getContainerDomNode?.(),
container,
]
const containerStyle = globalThis.getComputedStyle(container)
const fixedBackgroundColor = containerStyle
.getPropertyValue('--stream-monaco-fixed-editor-bg')
.trim() || null
let backgroundColor: string | null = null
let foregroundColor: string | null = null
for (const node of probeNodes) {
if (!(node instanceof HTMLElement))
continue
const backgroundProbe = node.querySelector<HTMLElement>(
'.monaco-editor-background, .margin, .lines-content',
) ?? node
const foregroundProbe = node.querySelector<HTMLElement>(
'.view-lines, .monaco-editor, .view-overlays',
) ?? node
const nextBackground = globalThis.getComputedStyle(backgroundProbe)
.backgroundColor
if (
!backgroundColor
&& resolveCssColorLuminance(nextBackground) != null
) {
backgroundColor = nextBackground
}
const nextForeground = globalThis.getComputedStyle(foregroundProbe).color
if (
!foregroundColor
&& resolveCssColorLuminance(nextForeground) != null
) {
foregroundColor = nextForeground
}
if (backgroundColor && foregroundColor)
break
}
const resolvedBackgroundColor = fixedBackgroundColor
|| backgroundColor
|| (appearance === 'dark' ? 'rgb(10 10 11)' : 'rgb(255 255 255)')
if (resolvedBackgroundColor) {
container.style.setProperty(
'--stream-monaco-editor-bg',
resolvedBackgroundColor,
)
}
else {
container.style.removeProperty('--stream-monaco-editor-bg')
}
if (foregroundColor) {
container.style.setProperty('--stream-monaco-editor-fg', foregroundColor)
}
else {
container.style.removeProperty('--stream-monaco-editor-fg')
}
}
export function resolveDiffUnchangedLineInfoRailMetrics(
node: HTMLElement,
): DiffUnchangedLineInfoRailMetrics {
const editorRoot = node.closest<HTMLElement>('.monaco-editor')
if (!editorRoot) {
return {
leftInset: 0,
width: null,
}
}
const editorRect = editorRoot.getBoundingClientRect()
const lineNumberNode = Array.from(
editorRoot.querySelectorAll<HTMLElement>('.line-numbers'),
).find((candidate) => {
const rect = candidate.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
})
if (!lineNumberNode) {
return {
leftInset: 0,
width: null,
}
}
const lineNumberRect = lineNumberNode.getBoundingClientRect()
return {
leftInset: Math.max(0, lineNumberRect.left - editorRect.left),
width: Math.max(0, lineNumberRect.width) || null,
}
}
export function applyDiffRootAppearanceClass({
appearanceClasses,
container,
currentSignature,
diffAppearance,
diffEditorView,
isInlineMode,
layoutModeClasses,
lineStyle,
lineStyleClasses,
themeName,
unchangedRegionStyle,
unchangedRegionStyleClasses,
}: ApplyDiffRootAppearanceClassOptions): string | null {
if (!container)
return currentSignature
const resolvedAppearance = resolveDiffAppearance({
container,
diffAppearance,
diffEditorView,
themeName,
})
syncDiffRootThemeVariables(container, diffEditorView, resolvedAppearance)
const containerClassList = container.classList
const activeLineStyleClass = `stream-monaco-diff-style-${lineStyle}`
const activeUnchangedRegionStyleClass
= `stream-monaco-diff-unchanged-style-${unchangedRegionStyle}`
const activeLayoutModeClass = isInlineMode
? 'stream-monaco-diff-inline'
: 'stream-monaco-diff-side-by-side'
const activeAppearanceClass
= `stream-monaco-diff-appearance-${resolvedAppearance}`
const nextSignature = [
activeLineStyleClass,
activeUnchangedRegionStyleClass,
activeLayoutModeClass,
activeAppearanceClass,
].join('|')
if (
currentSignature === nextSignature
&& containerClassList.contains('stream-monaco-diff-root')
) {
return currentSignature
}
containerClassList.add('stream-monaco-diff-root')
for (const className of lineStyleClasses) {
containerClassList.toggle(className, className === activeLineStyleClass)
}
for (const className of unchangedRegionStyleClasses) {
containerClassList.toggle(
className,
className === activeUnchangedRegionStyleClass,
)
}
for (const className of layoutModeClasses) {
containerClassList.toggle(className, className === activeLayoutModeClass)
}
for (const className of appearanceClasses) {
containerClassList.toggle(className, className === activeAppearanceClass)
}
return nextSignature
}
@@ -0,0 +1,307 @@
import type {
DiffHunkActionContext,
DiffHunkActionKind,
DiffHunkSide,
} from '../type'
import * as monaco from '../monaco-shim'
export type DiffEditorSide = 'original' | 'modified'
export function createDiffHunkActionNode(
side: DiffHunkSide,
onAction: (side: DiffHunkSide, action: DiffHunkActionKind) => void,
): HTMLDivElement {
const node = document.createElement('div')
node.className = 'stream-monaco-diff-hunk-actions'
node.dataset.side = side
const createButton = (action: DiffHunkActionKind, label: string) => {
const button = document.createElement('button')
button.type = 'button'
button.textContent = label
button.dataset.action = action
button.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
onAction(side, action)
})
return button
}
node.append(
createButton('revert', 'Revert'),
createButton('stage', 'Stage'),
)
return node
}
export function hasOriginalLines(change: monaco.editor.ILineChange) {
return (
change.originalStartLineNumber > 0
&& change.originalEndLineNumber >= change.originalStartLineNumber
)
}
export function hasModifiedLines(change: monaco.editor.ILineChange) {
return (
change.modifiedStartLineNumber > 0
&& change.modifiedEndLineNumber >= change.modifiedStartLineNumber
)
}
export function inferInlineDiffHunkHoverSide(
change: monaco.editor.ILineChange,
hoverLine: number,
targetElement: HTMLElement | null,
): DiffHunkSide {
if (
targetElement?.closest(
'.line-delete, .char-delete, .inline-deleted-text, .inline-deleted-margin-view-zone',
)
) {
return 'upper'
}
if (
targetElement?.closest(
'.line-insert, .char-insert, .gutter-insert, .view-line',
)
) {
return 'lower'
}
if (!hasModifiedLines(change))
return 'upper'
if (!hasOriginalLines(change))
return 'lower'
const modifiedAnchor = Math.max(
1,
change.modifiedStartLineNumber || change.modifiedEndLineNumber || 1,
)
return hoverLine < modifiedAnchor ? 'upper' : 'lower'
}
function distanceToLineChange(
side: DiffEditorSide,
change: monaco.editor.ILineChange,
line: number,
) {
const hasRange = side === 'original'
? hasOriginalLines(change)
: hasModifiedLines(change)
const start = side === 'original'
? change.originalStartLineNumber
: change.modifiedStartLineNumber
const end = side === 'original'
? change.originalEndLineNumber
: change.modifiedEndLineNumber
if (hasRange) {
if (line < start)
return start - line
if (line > end)
return line - end
return 0
}
const fallbackAnchor = Math.max(1, start || end || 1)
return Math.abs(line - fallbackAnchor)
}
export function findLineChangeByHoverLine(
lineChanges: monaco.editor.ILineChange[],
side: DiffEditorSide,
line: number,
) {
if (lineChanges.length === 0)
return null
let best: monaco.editor.ILineChange | null = null
let bestDistance = Number.POSITIVE_INFINITY
for (const change of lineChanges) {
const distance = distanceToLineChange(side, change, line)
if (distance < bestDistance) {
bestDistance = distance
best = change
if (distance === 0)
break
}
}
if (bestDistance > 2)
return null
return best
}
export function getFullLineRange(
model: monaco.editor.ITextModel,
startLine: number,
endLine: number,
) {
if (endLine < startLine)
return null
const lineCount = model.getLineCount()
if (lineCount < 1)
return null
const start = Math.max(1, Math.min(startLine, lineCount))
const end = Math.max(start, Math.min(endLine, lineCount))
if (end < lineCount)
return new monaco.Range(start, 1, end + 1, 1)
return new monaco.Range(start, 1, end, model.getLineMaxColumn(end))
}
export function getLinesText(
model: monaco.editor.ITextModel,
startLine: number,
endLine: number,
) {
const range = getFullLineRange(model, startLine, endLine)
if (!range)
return ''
return model.getValueInRange(range)
}
export function getInsertRangeBeforeLine(
model: monaco.editor.ITextModel,
lineNumber: number,
) {
const lineCount = model.getLineCount()
if (lineNumber <= 1)
return new monaco.Range(1, 1, 1, 1)
if (lineNumber <= lineCount)
return new monaco.Range(lineNumber, 1, lineNumber, 1)
const lastLine = lineCount
const lastColumn = model.getLineMaxColumn(lastLine)
return new monaco.Range(lastLine, lastColumn, lastLine, lastColumn)
}
export function getInsertRangeAfterLine(
model: monaco.editor.ITextModel,
lineNumber: number,
) {
const lineCount = model.getLineCount()
if (lineNumber < 1)
return new monaco.Range(1, 1, 1, 1)
if (lineNumber < lineCount)
return new monaco.Range(lineNumber + 1, 1, lineNumber + 1, 1)
const lastLine = lineCount
const lastColumn = model.getLineMaxColumn(lastLine)
return new monaco.Range(lastLine, lastColumn, lastLine, lastColumn)
}
export function applyDefaultDiffHunkAction(context: DiffHunkActionContext) {
const { action, side, lineChange, originalModel, modifiedModel } = context
const hasOriginal = hasOriginalLines(lineChange)
const hasModified = hasModifiedLines(lineChange)
if (action === 'revert' && side === 'upper') {
if (!hasOriginal)
return
const text = getLinesText(
originalModel,
lineChange.originalStartLineNumber,
lineChange.originalEndLineNumber,
)
if (!text)
return
const range = hasModified
? getInsertRangeBeforeLine(
modifiedModel,
lineChange.modifiedStartLineNumber,
)
: getInsertRangeAfterLine(
modifiedModel,
Math.max(
0,
lineChange.modifiedStartLineNumber
|| lineChange.modifiedEndLineNumber,
),
)
modifiedModel.applyEdits([{ range, text, forceMoveMarkers: true }])
return
}
if (action === 'revert' && side === 'lower') {
if (!hasModified)
return
const range = getFullLineRange(
modifiedModel,
lineChange.modifiedStartLineNumber,
lineChange.modifiedEndLineNumber,
)
if (!range)
return
modifiedModel.applyEdits([{ range, text: '', forceMoveMarkers: true }])
return
}
if (action === 'stage' && side === 'upper') {
if (!hasOriginal)
return
const range = getFullLineRange(
originalModel,
lineChange.originalStartLineNumber,
lineChange.originalEndLineNumber,
)
if (!range)
return
originalModel.applyEdits([{ range, text: '', forceMoveMarkers: true }])
return
}
if (action === 'stage' && side === 'lower') {
if (!hasModified)
return
const text = getLinesText(
modifiedModel,
lineChange.modifiedStartLineNumber,
lineChange.modifiedEndLineNumber,
)
if (!text)
return
const anchor = hasOriginal
? lineChange.originalEndLineNumber
: Math.max(0, lineChange.originalStartLineNumber)
const range = getInsertRangeAfterLine(originalModel, anchor)
originalModel.applyEdits([{ range, text, forceMoveMarkers: true }])
}
}
export function setDiffHunkNodeEnabled(
node: HTMLDivElement | null,
enabled: boolean,
) {
if (!node)
return
const buttons = node.querySelectorAll('button')
buttons.forEach((button) => {
;(button as HTMLButtonElement).disabled = !enabled
})
}
export function positionDiffHunkNode(
node: HTMLDivElement,
editor: monaco.editor.IStandaloneCodeEditor,
anchorLine: number,
extraOffsetY = 0,
) {
const host = editor.getContainerDomNode()
const line = Math.max(1, anchorLine)
const rawTop = editor.getTopForLineNumber(line) - (editor.getScrollTop?.() ?? 0)
const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight)
const nodeWidth = node.offsetWidth || 130
const nodeHeight = node.offsetHeight || 30
const left = host.offsetLeft + Math.max(6, host.clientWidth - nodeWidth - 10)
const hostTop = host.offsetTop
const minTop = hostTop + 4
const maxTop = hostTop + Math.max(4, host.clientHeight - nodeHeight - 4)
const top = Math.min(
maxTop,
Math.max(
minTop,
hostTop + rawTop + Math.round(lineHeight * 0.2) + extraOffsetY,
),
)
node.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`
node.style.display = 'flex'
}
@@ -0,0 +1,279 @@
import type * as monaco from '../monaco-shim'
import type { DiffUnchangedRegionStyle } from '../type'
export interface DiffUnchangedSurroundingLines {
previousVisibleLine: number | null
nextVisibleLine: number | null
}
interface BuildDiffHunkMetadataLabelOptions {
contextLineCount: number
originalTotalLines: number
modifiedTotalLines: number
}
interface DiffHunkMetadataLabel {
modifiedStart: number
originalStart: number
label: string
}
interface ResolveDiffMetadataLabelOptions
extends BuildDiffHunkMetadataLabelOptions {
lineChanges: monaco.editor.ILineChange[]
pairIndex: number
primaryNode: HTMLElement
}
interface ResolveDiffUnchangedRevealLayoutOptions {
countText: string
modelLineCount: number | null
pairCount: number
pairIndex: number
primaryNode: HTMLElement
}
interface ResolveDiffUnchangedSummaryLabelOptions
extends ResolveDiffMetadataLabelOptions {
countText: string
unchangedRegionStyle: DiffUnchangedRegionStyle
}
interface ResolveDiffUnchangedMergeRoleOptions {
diffRoot: Element | null
modifiedHost: HTMLElement | null | undefined
node: HTMLElement
originalHost: HTMLElement | null | undefined
}
export function formatDiffUnchangedCountLabel(text: string) {
const match = text.match(/\d+/)
const count = match ? Number.parseInt(match[0], 10) : Number.NaN
if (Number.isFinite(count))
return `${count} unmodified ${count === 1 ? 'line' : 'lines'}`
return text.replace(/hidden/gi, 'unmodified')
}
export function countDiffLines(startLineNumber: number, endLineNumber: number) {
return endLineNumber >= startLineNumber
? endLineNumber - startLineNumber + 1
: 0
}
export function measureDiffUnchangedSurroundingLines(
primaryNode: HTMLElement,
): DiffUnchangedSurroundingLines {
const editorRoot
= primaryNode.closest<HTMLElement>('.editor.modified')
?? primaryNode.closest<HTMLElement>('.monaco-editor')
if (!editorRoot) {
return {
previousVisibleLine: null,
nextVisibleLine: null,
}
}
const widgetRect = primaryNode.getBoundingClientRect()
let previousVisibleLine: number | null = null
let nextVisibleLine: number | null = null
const lineNumberNodes = editorRoot.querySelectorAll<HTMLElement>('.line-numbers')
lineNumberNodes.forEach((node) => {
const lineNumber = Number.parseInt(node.textContent?.trim() || '', 10)
if (!Number.isFinite(lineNumber))
return
const top = node.getBoundingClientRect().top
if (top < widgetRect.top - 1) {
previousVisibleLine = previousVisibleLine == null
? lineNumber
: Math.max(previousVisibleLine, lineNumber)
}
else if (top > widgetRect.bottom + 1) {
nextVisibleLine = nextVisibleLine == null
? lineNumber
: Math.min(nextVisibleLine, lineNumber)
}
})
return {
previousVisibleLine,
nextVisibleLine,
}
}
function formatDiffMetadataRange(startLineNumber: number, lineCount: number) {
return `${startLineNumber},${Math.max(0, lineCount)}`
}
export function buildDiffHunkMetadataLabel(
change: monaco.editor.ILineChange,
options: BuildDiffHunkMetadataLabelOptions,
): DiffHunkMetadataLabel {
const { contextLineCount, modifiedTotalLines, originalTotalLines } = options
const originalChangedCount = countDiffLines(
change.originalStartLineNumber,
change.originalEndLineNumber,
)
const modifiedChangedCount = countDiffLines(
change.modifiedStartLineNumber,
change.modifiedEndLineNumber,
)
const originalAnchor = Math.min(
Math.max(change.originalStartLineNumber, 1),
Math.max(1, originalTotalLines + 1),
)
const modifiedAnchor = Math.min(
Math.max(change.modifiedStartLineNumber, 1),
Math.max(1, modifiedTotalLines + 1),
)
const originalStart = Math.max(1, originalAnchor - contextLineCount)
const modifiedStart = Math.max(1, modifiedAnchor - contextLineCount)
const originalEnd = originalChangedCount > 0
? Math.min(originalTotalLines, change.originalEndLineNumber + contextLineCount)
: Math.min(originalTotalLines, originalAnchor + contextLineCount - 1)
const modifiedEnd = modifiedChangedCount > 0
? Math.min(modifiedTotalLines, change.modifiedEndLineNumber + contextLineCount)
: Math.min(modifiedTotalLines, modifiedAnchor + contextLineCount - 1)
const originalDisplayCount = originalEnd >= originalStart
? originalEnd - originalStart + 1
: 0
const modifiedDisplayCount = modifiedEnd >= modifiedStart
? modifiedEnd - modifiedStart + 1
: 0
return {
modifiedStart,
originalStart,
label: `@@ -${formatDiffMetadataRange(
originalStart,
originalDisplayCount,
)} +${formatDiffMetadataRange(modifiedStart, modifiedDisplayCount)} @@`,
}
}
export function resolveDiffMetadataLabel(
options: ResolveDiffMetadataLabelOptions,
) {
const {
contextLineCount,
lineChanges,
modifiedTotalLines,
originalTotalLines,
pairIndex,
primaryNode,
} = options
if (lineChanges.length === 0)
return null
const metadataEntries = lineChanges.map(change =>
buildDiffHunkMetadataLabel(change, {
contextLineCount,
modifiedTotalLines,
originalTotalLines,
}),
)
const { nextVisibleLine } = measureDiffUnchangedSurroundingLines(primaryNode)
if (nextVisibleLine != null) {
const candidateStarts = [nextVisibleLine, nextVisibleLine - 1].filter(
value => value >= 1,
)
for (const candidateStart of candidateStarts) {
const matching = metadataEntries.find(
entry => entry.modifiedStart === candidateStart,
)
if (matching)
return matching.label
}
}
return metadataEntries[Math.min(pairIndex, metadataEntries.length - 1)]?.label ?? null
}
export function resolveDiffUnchangedSummaryLabel(
options: ResolveDiffUnchangedSummaryLabelOptions,
) {
const { countText, unchangedRegionStyle, ...metadataOptions } = options
if (unchangedRegionStyle !== 'metadata')
return countText
return resolveDiffMetadataLabel(metadataOptions) ?? countText
}
export function resolveDiffUnchangedRevealLayout(
options: ResolveDiffUnchangedRevealLayoutOptions,
) {
const { countText, modelLineCount, pairCount, pairIndex, primaryNode } = options
let showTopHandle = pairCount === 1 || pairIndex > 0
let showBottomHandle = pairCount === 1 || pairIndex < pairCount - 1
const countMatch = countText.match(/\d+/)
const hiddenCount = countMatch
? Number.parseInt(countMatch[0], 10)
: Number.NaN
if (!Number.isFinite(hiddenCount))
return { showTopHandle, showBottomHandle }
const { previousVisibleLine, nextVisibleLine }
= measureDiffUnchangedSurroundingLines(primaryNode)
if (previousVisibleLine == null && nextVisibleLine != null) {
showTopHandle = false
showBottomHandle = true
return { showTopHandle, showBottomHandle }
}
if (nextVisibleLine == null && previousVisibleLine != null) {
showTopHandle = true
showBottomHandle = false
return { showTopHandle, showBottomHandle }
}
if (nextVisibleLine != null && nextVisibleLine - hiddenCount === 1) {
showTopHandle = false
showBottomHandle = true
}
if (
previousVisibleLine != null
&& modelLineCount != null
&& previousVisibleLine + hiddenCount === modelLineCount
) {
showTopHandle = true
showBottomHandle = false
}
return { showTopHandle, showBottomHandle }
}
export function resolveDiffUnchangedMergeRole(
options: ResolveDiffUnchangedMergeRoleOptions,
): 'none' | 'primary' | 'secondary' {
const { diffRoot, modifiedHost, node, originalHost } = options
if (typeof HTMLElement === 'undefined')
return 'none'
if (!(diffRoot instanceof HTMLElement))
return 'none'
const nodeRect = node.getBoundingClientRect()
const nodeCenter = nodeRect.left + nodeRect.width / 2
if (originalHost instanceof HTMLElement && modifiedHost instanceof HTMLElement) {
const originalRect = originalHost.getBoundingClientRect()
const modifiedRect = modifiedHost.getBoundingClientRect()
const originalCenter = originalRect.left + originalRect.width / 2
const modifiedCenter = modifiedRect.left + modifiedRect.width / 2
return Math.abs(nodeCenter - originalCenter)
<= Math.abs(nodeCenter - modifiedCenter)
? 'secondary'
: 'primary'
}
const diffRect = diffRoot.getBoundingClientRect()
return nodeCenter < diffRect.left + diffRect.width / 2
? 'secondary'
: 'primary'
}
@@ -0,0 +1,461 @@
import type { DiffUnchangedRegionStyle } from '../type'
const diffUnchangedSummaryStyleClasses = [
'stream-monaco-unchanged-summary-line-info',
'stream-monaco-unchanged-summary-line-info-basic',
'stream-monaco-unchanged-summary-metadata',
'stream-monaco-unchanged-summary-simple',
] as const
interface DiffUnchangedBridgeRect {
height: number
left: number
right: number
top: number
width: number
}
interface DiffUnchangedBridgeTextStyle {
color: string
fontFamily: string
fontSize: string
lineHeight: string
}
interface SyncDiffUnchangedBridgeNodeOptions {
bridge: HTMLDivElement
bridgeLeftInset: number
bridgeRailWidth: number | null
containerRect: Pick<DOMRect, 'left' | 'top'>
containerScrollLeft: number
containerScrollTop: number
editorBackgroundColor: string
primaryAnchorRect: DiffUnchangedBridgeRect
primaryStyle: DiffUnchangedBridgeTextStyle
secondaryAnchorRect: DiffUnchangedBridgeRect
unchangedRegionStyle: DiffUnchangedRegionStyle
}
export function createDiffUnchangedBridgeScaffold() {
const bridge = document.createElement('div')
bridge.className = 'stream-monaco-diff-unchanged-bridge'
bridge.setAttribute('role', 'group')
syncDiffUnchangedBridgeVisibility(bridge, false)
const summary = document.createElement('button')
summary.type = 'button'
summary.className = 'stream-monaco-unchanged-summary'
const visualMeta = document.createElement('div')
visualMeta.className = 'stream-monaco-unchanged-meta'
summary.append(visualMeta)
const divider = document.createElement('span')
divider.className = 'stream-monaco-unchanged-pane-divider'
divider.setAttribute('aria-hidden', 'true')
bridge.append(summary, divider)
return {
bridge,
summary,
visualMeta,
divider,
}
}
export function createDiffUnchangedBridgeOverlay() {
const overlay = document.createElement('div')
overlay.className = 'stream-monaco-diff-unchanged-overlay'
return overlay
}
export function syncDiffUnchangedBridgeVisibility(
bridge: HTMLDivElement,
visible: boolean,
) {
bridge.hidden = !visible
bridge.toggleAttribute('aria-hidden', !visible)
}
export function clearDiffUnchangedBridgeSourceClasses(container: HTMLElement) {
const bridgedCenters = container.querySelectorAll<HTMLElement>(
'.stream-monaco-unchanged-bridge-source',
)
bridgedCenters.forEach(node =>
node.classList.remove('stream-monaco-unchanged-bridge-source'),
)
}
export function resetDiffUnchangedOverlayTransform(
overlay: HTMLDivElement | null | undefined,
) {
if (!overlay)
return
overlay.style.transform = 'translate3d(0px, 0px, 0px)'
}
export function resolveDiffUnchangedViewZoneHeight(
unchangedRegionStyle: DiffUnchangedRegionStyle,
) {
return unchangedRegionStyle === 'simple' ? 28 : 32
}
export function collectDiffUnchangedViewZoneIds(
editorRoot: HTMLElement,
scrollTop: number,
) {
const widgetTopValues = Array.from(
editorRoot.querySelectorAll<HTMLElement>('.diff-hidden-lines-widget'),
)
.map(node => Number.parseFloat(node.style.top || 'NaN'))
.filter(value => Number.isFinite(value) && value > -100000)
if (widgetTopValues.length === 0)
return []
return Array.from(
editorRoot.querySelectorAll<HTMLElement>(
'.view-zones > div[monaco-view-zone][monaco-visible-view-zone="true"]',
),
)
.filter((node) => {
const zoneTop = Number.parseFloat(node.style.top || 'NaN')
const currentHeight = Number.parseFloat(node.style.height || '0')
return (
Number.isFinite(zoneTop)
&& Number.isFinite(currentHeight)
&& currentHeight > 0
&& widgetTopValues.some(
widgetTop => Math.abs(zoneTop - scrollTop - widgetTop) < 0.5,
)
)
})
.map(node => node.getAttribute('monaco-view-zone'))
.filter((value): value is string => Boolean(value))
}
export function findDiffUnchangedActivationAction(
...roots: Array<ParentNode | null | undefined>
) {
for (const root of roots) {
const action = root?.querySelector<HTMLElement>('a, button') ?? null
if (action)
return action
}
return null
}
export function findDiffUnchangedExpandAction(
root: ParentNode | null | undefined,
) {
return root?.querySelector<HTMLElement>('a') ?? null
}
export function shouldIgnoreDiffUnchangedCenterClickTarget(
target: EventTarget | null,
) {
return target instanceof HTMLElement
&& Boolean(target.closest('a, .breadcrumb-item'))
}
export function shouldHandleDiffUnchangedCenterClick(
event: Pick<MouseEvent, 'button' | 'target'>,
) {
return (
event.button === 0
&& !shouldIgnoreDiffUnchangedCenterClickTarget(event.target)
)
}
export function activateDiffUnchangedExpandAction(
root: ParentNode | null | undefined,
onBeforeActivate?: (action: HTMLElement) => void,
) {
const action = findDiffUnchangedExpandAction(root)
if (!action)
return false
onBeforeActivate?.(action)
action.click()
return true
}
export function syncDiffUnchangedCenterNode(
node: HTMLElement,
mergeRole: 'none' | 'primary' | 'secondary',
) {
node.classList.add('stream-monaco-clickable')
node.title = 'Click to expand all unmodified lines'
node.classList.toggle(
'stream-monaco-unchanged-merged-secondary',
mergeRole === 'secondary',
)
node.classList.toggle(
'stream-monaco-unchanged-merged-primary',
mergeRole === 'primary',
)
}
export function syncDiffUnchangedMetaNode(
metaNode: HTMLElement,
unchangedRegionStyle: DiffUnchangedRegionStyle,
summaryLabel: string,
) {
const lastStyle = metaNode.dataset.style
const lastLabel = metaNode.dataset.label
if (lastStyle === unchangedRegionStyle && lastLabel === summaryLabel)
return
metaNode.dataset.style = unchangedRegionStyle
metaNode.dataset.label = summaryLabel
metaNode.replaceChildren()
metaNode.classList.toggle(
'stream-monaco-unchanged-meta-simple',
unchangedRegionStyle === 'simple',
)
if (unchangedRegionStyle === 'simple') {
const simpleBar = document.createElement('span')
simpleBar.className = 'stream-monaco-unchanged-simple-bar'
simpleBar.setAttribute('aria-hidden', 'true')
metaNode.append(simpleBar)
return
}
const label = document.createElement('span')
label.className = unchangedRegionStyle === 'metadata'
? 'stream-monaco-unchanged-metadata-label'
: 'stream-monaco-unchanged-count'
label.textContent = summaryLabel
metaNode.append(label)
}
export function syncDiffUnchangedSummaryButton(
summary: HTMLButtonElement,
unchangedRegionStyle: DiffUnchangedRegionStyle,
summaryLabel: string,
) {
summary.classList.remove(...diffUnchangedSummaryStyleClasses)
summary.classList.add(
`stream-monaco-unchanged-summary-${unchangedRegionStyle}`,
)
const summaryInteractive
= unchangedRegionStyle === 'line-info'
|| unchangedRegionStyle === 'line-info-basic'
summary.disabled = !summaryInteractive
summary.tabIndex = summaryInteractive ? 0 : -1
if (summaryInteractive) {
summary.removeAttribute('aria-hidden')
summary.setAttribute(
'aria-label',
`${summaryLabel}. Expand all unmodified lines`,
)
summary.title = 'Expand all unmodified lines'
return
}
if (unchangedRegionStyle === 'simple') {
summary.setAttribute('aria-hidden', 'true')
summary.removeAttribute('aria-label')
summary.title = ''
return
}
summary.removeAttribute('aria-hidden')
summary.removeAttribute('aria-label')
summary.title = ''
}
export function syncDiffUnchangedExpandAction(
action: HTMLElement,
hidden: boolean,
) {
action.classList.add('stream-monaco-unchanged-expand')
action.dataset.streamMonacoLabel = 'Expand all'
action.title = 'Expand all unmodified lines'
action.setAttribute('aria-label', 'Expand all unmodified lines')
action.toggleAttribute('aria-hidden', hidden)
action.tabIndex = hidden ? -1 : 0
}
export function createDiffUnchangedRevealButton(direction: 'up' | 'down') {
const button = document.createElement('button')
button.type = 'button'
button.className = 'stream-monaco-unchanged-reveal'
button.innerHTML = `<span class="codicon codicon-chevron-${direction}"></span>`
button.dataset.direction = direction
return button
}
export function syncDiffUnchangedRevealButtonNode(
button: HTMLButtonElement,
handle: HTMLElement | null,
label: string,
) {
button.hidden = !handle
button.disabled = !handle
button.toggleAttribute('aria-hidden', !handle)
button.title = handle ? label : ''
button.setAttribute('aria-label', handle ? label : '')
}
export function bindDiffUnchangedRevealButtonAction(
button: HTMLButtonElement,
handle: HTMLElement | null,
onActivate: (handle: HTMLElement) => void,
) {
button.onclick = handle
? (event) => {
event.preventDefault()
event.stopPropagation()
onActivate(handle)
}
: null
}
export function shouldHandleDiffUnchangedWheel(
event: Pick<WheelEvent, 'deltaX' | 'deltaY'>,
) {
return Math.abs(event.deltaY) >= 0.5 || Math.abs(event.deltaX) >= 0.5
}
export function resolveDiffUnchangedWheelScrollTarget(
scrollTop: number,
scrollLeft: number,
event: Pick<WheelEvent, 'deltaX' | 'deltaY'>,
) {
return {
targetScrollTop: scrollTop + event.deltaY,
targetScrollLeft: scrollLeft + event.deltaX,
syncHorizontal: Math.abs(event.deltaX) >= 0.5,
}
}
export function syncDiffUnchangedBridgeNode(
options: SyncDiffUnchangedBridgeNodeOptions,
) {
const {
bridge,
bridgeLeftInset,
bridgeRailWidth,
containerRect,
containerScrollLeft,
containerScrollTop,
editorBackgroundColor,
primaryAnchorRect,
primaryStyle,
secondaryAnchorRect,
unchangedRegionStyle,
} = options
bridge.className = 'stream-monaco-diff-unchanged-bridge'
bridge.classList.add(
`stream-monaco-diff-unchanged-bridge-${unchangedRegionStyle}`,
)
bridge.style.left = `${
secondaryAnchorRect.left
- containerRect.left
+ containerScrollLeft
+ bridgeLeftInset
}px`
bridge.style.top = `${
primaryAnchorRect.top - containerRect.top + containerScrollTop
}px`
bridge.style.width = `${Math.max(
0,
primaryAnchorRect.right - secondaryAnchorRect.left - bridgeLeftInset,
)}px`
bridge.style.height = `${Math.max(
secondaryAnchorRect.height,
primaryAnchorRect.height,
)}px`
bridge.style.color = primaryStyle.color
bridge.style.fontFamily = primaryStyle.fontFamily
bridge.style.fontSize = primaryStyle.fontSize
bridge.style.lineHeight = primaryStyle.lineHeight
bridge.style.setProperty('--stream-monaco-unchanged-fg', primaryStyle.color)
bridge.style.setProperty(
'--stream-monaco-editor-bg',
editorBackgroundColor,
)
bridge.style.setProperty(
'--stream-monaco-unchanged-split-offset',
`${Math.max(0, secondaryAnchorRect.width - bridgeLeftInset)}px`,
)
if (bridgeRailWidth) {
bridge.style.setProperty(
'--stream-monaco-unchanged-rail-width',
`${bridgeRailWidth}px`,
)
return
}
bridge.style.removeProperty('--stream-monaco-unchanged-rail-width')
}
export function syncDiffUnchangedRailNode(
rail: HTMLDivElement,
showTopHandle: boolean,
showBottomHandle: boolean,
) {
const shouldRenderRail = showTopHandle || showBottomHandle
rail.hidden = !shouldRenderRail
rail.toggleAttribute('aria-hidden', !shouldRenderRail)
rail.classList.toggle(
'stream-monaco-unchanged-rail-top-only',
showTopHandle && !showBottomHandle,
)
rail.classList.toggle(
'stream-monaco-unchanged-rail-bottom-only',
!showTopHandle && showBottomHandle,
)
rail.classList.toggle(
'stream-monaco-unchanged-rail-both',
showTopHandle && showBottomHandle,
)
}
export function dispatchSyntheticPrimaryMouseDown(node: HTMLElement) {
const view = node.ownerDocument.defaultView
if (!view)
return
const rect = node.getBoundingClientRect()
node.dispatchEvent(
new view.MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
button: 0,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
}),
)
}
export function dispatchSyntheticPrimaryMouseTap(node: HTMLElement) {
const view = node.ownerDocument.defaultView
if (!view)
return
const rect = node.getBoundingClientRect()
const clientX = rect.left + rect.width / 2
const clientY = rect.top + rect.height / 2
node.dispatchEvent(
new view.MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
button: 0,
clientX,
clientY,
}),
)
node.dispatchEvent(
new view.MouseEvent('mouseup', {
bubbles: true,
cancelable: true,
button: 0,
clientX,
clientY,
}),
)
}
@@ -0,0 +1,169 @@
import { padding } from '../constant'
import * as monaco from '../monaco-shim'
interface ComputeDiffRawHeightOptions {
diffEditorView: monaco.editor.IStandaloneDiffEditor | null
maxHeightValue: number
}
interface ComputeDiffHeightOptions {
rawHeight: number
isInlineMode: boolean
inlineDiffStreamingPresentationActive: boolean
inlineDiffStreamingHeightFloor: number
}
interface DiffViewportMeasurement {
computedHeight: number
lineHeight: number
scrollHeight: number
scrollTop: number
viewportHeight: number
}
interface NearBottomOptions {
autoScrollThresholdLines: number
autoScrollThresholdPx: number
}
export function computeDiffRawHeight({
diffEditorView,
maxHeightValue,
}: ComputeDiffRawHeightOptions): number {
if (!diffEditorView)
return Math.min(18 + padding, maxHeightValue)
const modifiedEditor = diffEditorView.getModifiedEditor()
const originalEditor = diffEditorView.getOriginalEditor()
const lineHeight = modifiedEditor.getOption(
monaco.editor.EditorOption.lineHeight,
)
const originalLineCount = originalEditor.getModel()?.getLineCount() ?? 1
const modifiedLineCount = modifiedEditor.getModel()?.getLineCount() ?? 1
const lineCount = Math.max(originalLineCount, modifiedLineCount)
const fromLines = lineCount * lineHeight + padding
const scrollHeight = Math.max(
originalEditor.getScrollHeight?.() ?? 0,
modifiedEditor.getScrollHeight?.() ?? 0,
)
return Math.min(Math.max(fromLines, scrollHeight), maxHeightValue)
}
export function computeDiffHeight({
inlineDiffStreamingHeightFloor,
inlineDiffStreamingPresentationActive,
isInlineMode,
rawHeight,
}: ComputeDiffHeightOptions) {
if (
!isInlineMode
|| (
!inlineDiffStreamingPresentationActive
&& inlineDiffStreamingHeightFloor <= 0
)
) {
return {
height: rawHeight,
nextInlineDiffStreamingHeightFloor: inlineDiffStreamingHeightFloor,
}
}
const nextInlineDiffStreamingHeightFloor = Math.max(
rawHeight,
inlineDiffStreamingHeightFloor,
)
return {
height: nextInlineDiffStreamingHeightFloor,
nextInlineDiffStreamingHeightFloor,
}
}
export function readContainerLayoutSize(container: HTMLElement) {
const rect = container.getBoundingClientRect?.()
return {
width: container.clientWidth || rect?.width || 0,
height: container.clientHeight || rect?.height || 0,
}
}
export function hasVerticalScrollbar(measurement: {
computedHeight: number
lineHeight: number
scrollHeight: number
}) {
const epsilon = Math.max(2, Math.round(measurement.lineHeight / 8))
return measurement.scrollHeight
> measurement.computedHeight + Math.max(padding / 2, epsilon)
}
export function isUserNearBottom(
measurement: DiffViewportMeasurement,
options: NearBottomOptions,
) {
const lineThreshold
= options.autoScrollThresholdLines * measurement.lineHeight
const threshold = Math.max(lineThreshold || 0, options.autoScrollThresholdPx)
const distance = measurement.scrollHeight
- (measurement.scrollTop + measurement.viewportHeight)
return distance <= threshold
}
export function revealEditorLine(
editor: monaco.editor.ICodeEditor,
line: number,
strategy: 'bottom' | 'centerIfOutside' | 'center',
scrollType?: unknown,
) {
if (strategy === 'bottom') {
if (typeof scrollType !== 'undefined')
editor.revealLine(line, scrollType as any)
else
editor.revealLine(line)
return
}
if (strategy === 'center') {
if (typeof scrollType !== 'undefined')
editor.revealLineInCenter(line, scrollType as any)
else
editor.revealLineInCenter(line)
return
}
if (typeof scrollType !== 'undefined')
editor.revealLineInCenterIfOutsideViewport(line, scrollType as any)
else
editor.revealLineInCenterIfOutsideViewport(line)
}
export function waitForElementHeightApplied(
element: HTMLElement | null,
target: number,
timeoutMs = 500,
) {
return new Promise<void>((resolve) => {
const start = typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now()
const check = () => {
const applied = element
? Number.parseFloat((element.style.height || '').replace('px', '')) || 0
: -1
if (applied >= target - 1) {
resolve()
return
}
const now = typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now()
if (now - start > timeoutMs) {
resolve()
return
}
requestAnimationFrame(check)
}
check()
})
}
@@ -0,0 +1,169 @@
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
}
function tryDeriveBaseUrlFromVsPath(vsPath: string): string | undefined {
if (!vsPath)
return undefined
// Common: ".../min/vs" or ".../vs"
const trimmed = vsPath.endsWith('/') ? vsPath.slice(0, -1) : vsPath
if (trimmed.endsWith('/vs'))
return normalizeBaseUrl(trimmed.slice(0, -3))
const vsIndex = trimmed.indexOf('/vs/')
if (vsIndex > 0)
return normalizeBaseUrl(trimmed.slice(0, vsIndex))
return undefined
}
function detectMonacoBaseUrlFromScripts(): string | undefined {
if (typeof document === 'undefined')
return undefined
const scripts = Array.from(document.getElementsByTagName('script'))
for (const script of scripts) {
const src = script.getAttribute('src') || ''
if (!src)
continue
const loaderMatch = src.match(/^(.*)\/vs\/loader\.js.*$/)
if (loaderMatch)
return normalizeBaseUrl(loaderMatch[1])
const vsIndex = src.indexOf('/vs/')
if (vsIndex > 0)
return normalizeBaseUrl(src.slice(0, vsIndex))
}
return undefined
}
function detectMonacoBaseUrlFromAmdRequire(): string | undefined {
// Monaco AMD loader exposes a global `require` with config paths.
const amdRequire = (globalThis as any).require
const vsPath = amdRequire?.s?.contexts?._?.config?.paths?.vs
if (typeof vsPath !== 'string' || !vsPath)
return undefined
try {
return tryDeriveBaseUrlFromVsPath(String(new URL(vsPath, location.href)))
}
catch {
return tryDeriveBaseUrlFromVsPath(vsPath)
}
}
function detectMonacoBaseUrlFromExistingWorkerUrl(
getWorkerUrl: (moduleId: string, label: string) => string,
): string | undefined {
let workerUrl: string | undefined
try {
workerUrl = getWorkerUrl('', 'editorWorkerService')
}
catch {
// ignore
}
if (!workerUrl) {
try {
workerUrl = getWorkerUrl('', 'typescript')
}
catch {
// ignore
}
}
if (!workerUrl)
return undefined
try {
const resolved = new URL(workerUrl, location.href)
const vsIndex = resolved.pathname.indexOf('/vs/')
if (vsIndex <= 0)
return undefined
return normalizeBaseUrl(
`${resolved.origin}${resolved.pathname.slice(0, vsIndex)}`,
)
}
catch {
return undefined
}
}
function makeWorkerUrl(baseUrl: string): string | undefined {
if (typeof URL === 'undefined' || typeof Blob === 'undefined')
return undefined
const normalized = normalizeBaseUrl(baseUrl)
const workerMainUrl = `${normalized}vs/base/worker/workerMain.js`
const source = `self.MonacoEnvironment={baseUrl:${JSON.stringify(normalized)}};importScripts(${JSON.stringify(workerMainUrl)});`
try {
return URL.createObjectURL(
new Blob([source], { type: 'text/javascript' }),
)
}
catch {
return undefined
}
}
export function ensureMonacoWorkersLegacy(options?: { baseUrl?: string }): void {
if (typeof window === 'undefined' || typeof document === 'undefined')
return
try {
// eslint-disable-next-line no-restricted-globals
const existing = (self as any).MonacoEnvironment
if (existing?.getWorker)
return
// If the host already provided a getWorkerUrl but it points at a different
// origin (common with CDN setups), Monaco's Worker constructor can fail.
// In that case, we switch to the recommended blob wrapper that uses
// importScripts(workerMain.js).
const existingGetWorkerUrl = existing?.getWorkerUrl as
| undefined
| ((moduleId: string, label: string) => string)
if (typeof existingGetWorkerUrl === 'function') {
const existingBaseUrl = detectMonacoBaseUrlFromExistingWorkerUrl(
existingGetWorkerUrl,
)
if (existingBaseUrl) {
const workerUrl = makeWorkerUrl(existingBaseUrl)
if (workerUrl) {
// eslint-disable-next-line no-restricted-globals
;(self as any).MonacoEnvironment = {
getWorkerUrl() {
return workerUrl
},
}
}
}
return
}
const baseUrl
= options?.baseUrl
?? detectMonacoBaseUrlFromScripts()
?? detectMonacoBaseUrlFromAmdRequire()
if (!baseUrl)
return
const workerUrl = makeWorkerUrl(baseUrl)
if (!workerUrl) {
return
}
;(globalThis as any).MonacoEnvironment = {
getWorkerUrl() {
return workerUrl
},
}
}
catch {
// ignore - best effort
}
}
@@ -0,0 +1,35 @@
import { editorWorkerPath, workerPathByLabel } from './preloadMonacoWorkers.shared'
export function ensureMonacoWorkers(): void {
if (typeof window === 'undefined' || typeof document === 'undefined')
return
// IMPORTANT: install synchronously. Many consumers import and immediately
// create editors without awaiting any async setup.
try {
// eslint-disable-next-line no-restricted-globals
const existing = (self as any).MonacoEnvironment
// Respect consumer configuration (e.g. monaco-editor-webpack-plugin).
if (existing?.getWorker || existing?.getWorkerUrl)
return
const workerUrlByLabel: Record<string, URL> = Object.fromEntries(
Object.entries(workerPathByLabel).map(([label, path]) => [
label,
new URL(path, import.meta.url),
]),
)
const workerUrlEditor = new URL(editorWorkerPath, import.meta.url)
// eslint-disable-next-line no-restricted-globals
;(self as any).MonacoEnvironment = {
getWorker(_: any, label: string) {
const url = workerUrlByLabel[label] ?? workerUrlEditor
return new Worker(url, { type: 'module' })
},
}
}
catch {
// ignore - best effort
}
}
@@ -0,0 +1,850 @@
import type {
DiffModelPair,
DiffModelTransitionOptions,
MonacoLanguage,
MonacoOptions,
MonacoTheme,
UseMonacoReturn,
} from './type'
import { detectLanguage } from './code.detect'
import {
defaultLanguages,
defaultRevealDebounceMs,
defaultThemes,
} from './constant'
import { DiffEditorManager } from './core/DiffEditorManager'
import { EditorManager } from './core/EditorManager'
import * as monaco from './monaco-shim'
import { computed } from './reactivity'
import {
clearHighlighterCache,
getOrCreateHighlighter,
registerMonacoThemes,
} from './utils/registerMonacoThemes'
// Monaco theme is effectively global within a runtime (monaco.editor.setTheme).
// When many code blocks mount/virtualize concurrently, some editors may be
// created after a theme switch and miss the per-instance setTheme call.
// Keep a module-level "last requested theme" so late-created editors can
// initialize with the latest theme instead of falling back to themes[0].
let globalRequestedThemeName: string | null = null
let globalThemeRequestSeq = 0
// Best-effort tracking of what this library last applied via monaco.editor.setTheme.
// This must be module-level because Monaco theme is global, while useMonaco() is per-instance.
let globalAppliedThemeName: string | null = null
/**
* useMonaco 组合式函数
*
* 提供 Monaco 编辑器的创建、销毁、内容/主题/语言更新等能力。
* 支持主题自动切换、语言高亮、代码更新等功能。
*
* @param {MonacoOptions} [monacoOptions] - 编辑器初始化配置,支持 Monaco 原生配置及扩展项
* @param {number | string} [monacoOptions.MAX_HEIGHT] - 编辑器最大高度,可以是数字(像素)或 CSS 字符串(如 '100%', 'calc(100vh - 100px)')
* @param {boolean} [monacoOptions.readOnly] - 是否为只读模式
* @param {MonacoTheme[]} [monacoOptions.themes] - 主题数组,至少包含两个主题:[暗色主题, 亮色主题]
* @param {MonacoLanguage[]} [monacoOptions.languages] - 支持的编程语言数组
* @param {string} [monacoOptions.theme] - 初始主题名称
* @param {boolean} [monacoOptions.isCleanOnBeforeCreate] - 是否在创建前清理之前注册的资源, 默认为 true
* @param {(monaco: typeof import('monaco-editor')) => monaco.IDisposable[]} [monacoOptions.onBeforeCreate] - 编辑器创建前的钩子函数
*
* @returns {{
* createEditor: (container: HTMLElement, code: string, language: string) => Promise<monaco.editor.IStandaloneCodeEditor>,
* createDiffEditor: (
* container: HTMLElement,
* originalCode: string,
* modifiedCode: string,
* language: string,
* ) => Promise<monaco.editor.IStandaloneDiffEditor>,
* cleanupEditor: () => void,
* updateCode: (newCode: string, codeLanguage: string) => void,
* appendCode: (appendText: string, codeLanguage?: string) => void,
* updateDiff: (
* originalCode: string,
* modifiedCode: string,
* codeLanguage?: string,
* ) => void,
* updateOriginal: (newCode: string, codeLanguage?: string) => void,
* updateModified: (newCode: string, codeLanguage?: string) => void,
* appendOriginal: (appendText: string, codeLanguage?: string) => void,
* appendModified: (appendText: string, codeLanguage?: string) => void,
* setDiffModels: (models: DiffModelPair, options?: DiffModelTransitionOptions) => Promise<void>,
* setTheme: (theme: MonacoTheme) => Promise<void>,
* refreshDiffPresentation: () => void,
* setLanguage: (language: MonacoLanguage) => void,
* getCurrentTheme: () => string,
* getEditor: () => typeof monaco.editor,
* getEditorView: () => monaco.editor.IStandaloneCodeEditor | null,
* getDiffEditorView: () => monaco.editor.IStandaloneDiffEditor | null,
* getDiffModels: () => { original: monaco.editor.ITextModel | null, modified: monaco.editor.ITextModel | null },
* getCode: () => string | { original: string, modified: string } | null,
* }} 返回对象包含以下方法和属性:
*
* @property {Function} createEditor - 创建并挂载 Monaco 编辑器到指定容器
* @property {Function} cleanupEditor - 销毁编辑器并清理容器
* @property {Function} updateCode - 更新编辑器内容和语言,必要时滚动到底部
* @property {Function} appendCode - 在编辑器末尾追加文本,必要时滚动到底部
* @property {Function} createDiffEditor - 创建并挂载 Diff 编辑器
* @property {Function} updateDiff - 更新 Diff 编辑器的 original/modified 内容(RAF 合并、增量更新)
* @property {Function} updateOriginal - 仅更新 Diff 的 original 内容(增量更新)
* @property {Function} updateModified - 仅更新 Diff 的 modified 内容(增量更新)
* @property {Function} appendOriginal - 在 Diff 的 original 末尾追加(显式流式场景)
* @property {Function} appendModified - 在 Diff 的 modified 末尾追加(显式流式场景)
* @property {Function} setDiffModels - 切换为一对新的 Diff models;当内容未变化时自动走保留视图状态的无抖动路径
* @property {Function} setTheme - 切换编辑器主题,返回 Promise,在主题应用完成时 resolve
* @property {Function} refreshDiffPresentation - 在不 remount 的情况下,重算 diff chrome / unchanged overlay 的表现层
* @property {Function} setLanguage - 切换编辑器语言
* @property {Function} getCurrentTheme - 获取当前主题名称
* @property {Function} getEditor - 获取 Monaco 的静态 editor 对象(用于静态方法调用)
* @property {Function} getEditorView - 获取当前编辑器实例
* @property {Function} getDiffEditorView - 获取当前 Diff 编辑器实例
* @property {Function} getDiffModels - 获取 Diff 的 original/modified 两个模型
* @property {Function} getCode - 获取当前编辑器或 Diff 编辑器中的代码内容
*
* @throws {Error} 当主题数组不是数组或长度小于2时抛出错误
*
* @example
* ```typescript
* import { useMonaco } from 'stream-monaco'
*
* const { createEditor, updateCode, setTheme } = useMonaco({
* themes: ['vitesse-dark', 'vitesse-light'],
* languages: ['javascript', 'typescript'],
* readOnly: false
* })
*
* // 创建编辑器
* const editor = await createEditor(containerRef.value, 'console.log("hello")', 'javascript')
*
* // 更新代码
* updateCode('console.log("world")', 'javascript')
*
* // 切换主题
* setTheme('vitesse-light')
* ```
*/
function useMonaco(monacoOptions: MonacoOptions = {}): UseMonacoReturn {
// per-instance disposables (avoid cross-instance interference)
const disposals: monaco.IDisposable[] = []
const pendingCreateDisposables = new Map<number, monaco.IDisposable[]>()
let editorView: monaco.editor.IStandaloneCodeEditor | null = null
let editorMgr: EditorManager | null = null
let diffEditorView: monaco.editor.IStandaloneDiffEditor | null = null
let diffMgr: DiffEditorManager | null = null
let originalModel: monaco.editor.ITextModel | null = null
let modifiedModel: monaco.editor.ITextModel | null = null
const themes
= monacoOptions.themes && monacoOptions.themes?.length
? monacoOptions.themes
: defaultThemes
if (!Array.isArray(themes) || themes.length < 2) {
throw new Error(
'Monaco themes must be an array with at least two themes: [darkTheme, lightTheme]',
)
}
const languages = monacoOptions.languages ?? defaultLanguages
const MAX_HEIGHT = monacoOptions.MAX_HEIGHT ?? 500
const autoScrollOnUpdate = monacoOptions.autoScrollOnUpdate ?? true
const autoScrollInitial = monacoOptions.autoScrollInitial ?? true
const autoScrollThresholdPx = monacoOptions.autoScrollThresholdPx ?? 32
const autoScrollThresholdLines = monacoOptions.autoScrollThresholdLines ?? 2
const diffAutoScroll = monacoOptions.diffAutoScroll ?? true
// 处理 MAX_HEIGHT,转换为数值和CSS字符串
const getMaxHeightValue = (): number => {
if (typeof MAX_HEIGHT === 'number') {
return MAX_HEIGHT
}
// 如果是字符串,尝试解析数值部分(用于高度比较)
const match = MAX_HEIGHT.match(/^(\d+(?:\.\d+)?)/)
return match ? Number.parseFloat(match[1]) : 500 // 默认值
}
const getMaxHeightCSS = (): string => {
if (typeof MAX_HEIGHT === 'number') {
return `${MAX_HEIGHT}px`
}
return MAX_HEIGHT
}
const maxHeightValue = getMaxHeightValue()
const maxHeightCSS = getMaxHeightCSS()
let createRequestSeq = 0
let activeCreateRequestId: number | null = null
let activeCreateKind: 'editor' | 'diff' | null = null
let queuedEditorUpdateDuringCreate: {
requestId: number
code: string
lang: string
} | null = null
const currentTheme = computed<string>(
() =>
monacoOptions.theme
?? (typeof themes[0] === 'string' ? themes[0] : (themes[0] as any).name),
)
// Track the latest theme requested via setTheme(). This prevents the
// initialization path (which otherwise falls back to `themes[0]`) from
// overwriting a theme chosen before createEditor/createDiffEditor runs.
let requestedThemeName: string | null
= monacoOptions.theme ?? globalRequestedThemeName ?? currentTheme.value
// Internal helper that applies a theme and invokes MonacoOptions.onThemeChange
// after the theme has been applied. Exposed internally so watchers can call
// the same logic and callers can await exported setTheme for completion.
async function tryLoadAndSetShikiTheme(highlighter: any, themeName: string) {
if (!highlighter || typeof highlighter.setTheme !== 'function')
return
try {
await highlighter.setTheme(themeName)
}
catch (err: any) {
const message = err?.message ? String(err.message) : String(err)
// Shiki throws: "Theme <name> not found, you may need to load it first"
// If the highlighter supports incremental loading, attempt it once.
const lower = message.toLowerCase()
const looksLikeMissingThemeError
= lower.includes('theme') && lower.includes('not found')
if (
typeof highlighter.loadTheme === 'function'
&& looksLikeMissingThemeError
) {
await highlighter.loadTheme(themeName)
await highlighter.setTheme(themeName)
return
}
throw err
}
}
async function setThemeInternal(
theme: MonacoTheme,
force = false,
): Promise<void> {
const themeName = typeof theme === 'string' ? theme : (theme as any).name
globalThemeRequestSeq += 1
const token = globalThemeRequestSeq
// Persist the user's intent so editor initialization won't override it.
requestedThemeName = themeName
globalRequestedThemeName = themeName
// Monaco theme is global. Per-instance globalAppliedThemeName can become stale
// when another useMonaco() instance changes the theme.
if (!force && themeName === globalAppliedThemeName) {
return
}
// Last-write-wins: if another setTheme() call happened after this one
// started, abort before doing any further work.
if (token !== globalThemeRequestSeq) {
return
}
await registerMonacoThemes(themes, languages).catch(() => undefined)
if (token !== globalThemeRequestSeq) {
return
}
const availableNames = themes.map(t =>
typeof t === 'string' ? t : (t as any).name,
)
if (!availableNames.includes(themeName)) {
try {
const extended = availableNames.concat(themeName)
const maybeHighlighter = await registerMonacoThemes(
extended as any,
languages,
)
await tryLoadAndSetShikiTheme(maybeHighlighter, themeName).catch(
() => undefined,
)
}
catch {
console.warn(
`Theme "${themeName}" is not registered and automatic registration failed. Available themes: ${availableNames.join(
', ',
)}`,
)
return
}
}
if (token !== globalThemeRequestSeq) {
return
}
try {
monaco.editor.setTheme(themeName)
globalAppliedThemeName = themeName
monacoOptions.theme = themeName as any
}
catch {
try {
const maybeHighlighter = await registerMonacoThemes(themes, languages)
if (token !== globalThemeRequestSeq) {
return
}
monaco.editor.setTheme(themeName)
globalAppliedThemeName = themeName
monacoOptions.theme = themeName as any
await tryLoadAndSetShikiTheme(maybeHighlighter, themeName).catch(
() => undefined,
)
}
catch (err2) {
console.warn(`Failed to set theme "${themeName}":`, err2)
return
}
}
if (token !== globalThemeRequestSeq) {
return
}
try {
diffMgr?.notifyThemeChange(themeName as any)
}
catch (err) {
console.warn('diff theme sync threw an error:', err)
}
// call user callback if provided; await to allow callers to observe completion
try {
if (typeof monacoOptions.onThemeChange === 'function') {
await monacoOptions.onThemeChange(themeName as any)
}
}
catch (err) {
console.warn('onThemeChange callback threw an error:', err)
}
}
async function ensureThemeRegistered(themeName: string) {
const availableNames = themes.map(t =>
typeof t === 'string' ? t : (t as any).name,
)
const list = availableNames.includes(themeName)
? themes
: (themes.concat(themeName) as any)
await registerMonacoThemes(list as any, languages)
}
function resolveRequestedThemeName() {
return (
requestedThemeName
?? globalRequestedThemeName
?? monacoOptions.theme
?? currentTheme.value
)
}
function commitAppliedTheme(themeName: string) {
requestedThemeName = themeName
globalRequestedThemeName = themeName
globalAppliedThemeName = themeName
monacoOptions.theme = themeName as any
}
async function notifyThemeApplied(themeName: string) {
if (typeof monacoOptions.onThemeChange !== 'function')
return
try {
await monacoOptions.onThemeChange(themeName as any)
}
catch (err) {
console.warn('onThemeChange callback threw an error:', err)
}
}
function disposeDisposables(
items: monaco.IDisposable[] | null | undefined,
) {
if (!items?.length)
return
for (const item of items) {
try {
item.dispose()
}
catch {}
}
}
function takePendingCreateDisposables(requestId: number) {
const items = pendingCreateDisposables.get(requestId) ?? []
pendingCreateDisposables.delete(requestId)
return items
}
function disposeAllPendingCreateDisposables() {
for (const requestId of Array.from(pendingCreateDisposables.keys())) {
disposeDisposables(takePendingCreateDisposables(requestId))
}
}
function cleanupInstances() {
if (editorMgr) {
editorMgr.cleanup()
editorMgr = null
}
else if (editorView) {
try {
editorView.dispose()
}
catch {}
}
if (diffMgr) {
diffMgr.cleanup()
diffMgr = null
}
else {
try {
diffEditorView?.dispose()
}
catch {}
try {
originalModel?.dispose()
}
catch {}
try {
modifiedModel?.dispose()
}
catch {}
}
editorView = null
diffEditorView = null
originalModel = null
modifiedModel = null
}
function createSupersededError() {
const err = new Error('Editor creation was superseded')
;(err as any).name = 'AbortError'
;(err as any).code = 'STREAM_MONACO_CREATE_SUPERSEDED'
return err
}
function isCreateActive(
requestId: number,
kind: 'editor' | 'diff',
) {
return (
activeCreateRequestId === requestId
&& activeCreateKind === kind
)
}
function assertCreateStillActive(
requestId: number,
kind: 'editor' | 'diff',
) {
if (!isCreateActive(requestId, kind)) {
throw createSupersededError()
}
}
function cancelPendingCreates() {
activeCreateRequestId = null
activeCreateKind = null
queuedEditorUpdateDuringCreate = null
disposeAllPendingCreateDisposables()
}
async function resolveCreateThemeName(
requestId: number,
kind: 'editor' | 'diff',
) {
let themeName = resolveRequestedThemeName()
while (true) {
assertCreateStillActive(requestId, kind)
await ensureThemeRegistered(themeName)
assertCreateStillActive(requestId, kind)
const latestThemeName = resolveRequestedThemeName()
if (latestThemeName === themeName)
return themeName
themeName = latestThemeName
}
}
async function createEditor(
container: HTMLElement,
code: string,
language: string,
) {
cancelPendingCreates()
cleanupInstances()
const requestId = ++createRequestSeq
activeCreateRequestId = requestId
activeCreateKind = 'editor'
if (monacoOptions.isCleanOnBeforeCreate ?? true) {
disposeDisposables(disposals.splice(0))
}
const requestDisposables = monacoOptions.onBeforeCreate?.(monaco) ?? []
if (requestDisposables.length) {
pendingCreateDisposables.set(requestId, requestDisposables)
}
let nextEditorMgr: EditorManager | null = null
try {
const initialThemeName = await resolveCreateThemeName(requestId, 'editor')
nextEditorMgr = new EditorManager(
monacoOptions,
maxHeightValue,
maxHeightCSS,
autoScrollOnUpdate,
autoScrollInitial,
autoScrollThresholdPx,
autoScrollThresholdLines,
monacoOptions.revealDebounceMs,
monacoOptions.updateThrottleMs,
)
const nextEditorView = await nextEditorMgr.createEditor(
container,
code,
language,
initialThemeName,
)
assertCreateStillActive(requestId, 'editor')
editorMgr = nextEditorMgr
editorView = nextEditorView
diffMgr = null
diffEditorView = null
originalModel = null
modifiedModel = null
commitAppliedTheme(initialThemeName)
const committedDisposables = takePendingCreateDisposables(requestId)
if (committedDisposables.length) {
disposals.push(...committedDisposables)
}
activeCreateRequestId = null
activeCreateKind = null
const queuedUpdate = queuedEditorUpdateDuringCreate
if (queuedUpdate?.requestId === requestId) {
queuedEditorUpdateDuringCreate = null
editorMgr.updateCode(queuedUpdate.code, queuedUpdate.lang)
}
await notifyThemeApplied(initialThemeName)
return nextEditorView
}
catch (error) {
if (nextEditorMgr) {
try {
nextEditorMgr.cleanup()
}
catch {}
}
disposeDisposables(takePendingCreateDisposables(requestId))
if (activeCreateRequestId === requestId) {
activeCreateRequestId = null
activeCreateKind = null
queuedEditorUpdateDuringCreate = null
}
throw error
}
}
async function createDiffEditor(
container: HTMLElement,
originalCode: string,
modifiedCode: string,
language: string,
) {
cancelPendingCreates()
cleanupInstances()
const requestId = ++createRequestSeq
activeCreateRequestId = requestId
activeCreateKind = 'diff'
if (monacoOptions.isCleanOnBeforeCreate ?? true) {
disposeDisposables(disposals.splice(0))
}
const requestDisposables = monacoOptions.onBeforeCreate?.(monaco) ?? []
if (requestDisposables.length) {
pendingCreateDisposables.set(requestId, requestDisposables)
}
let nextDiffMgr: DiffEditorManager | null = null
try {
const initialThemeName = await resolveCreateThemeName(requestId, 'diff')
nextDiffMgr = new DiffEditorManager(
monacoOptions,
maxHeightValue,
maxHeightCSS,
autoScrollOnUpdate,
autoScrollInitial,
autoScrollThresholdPx,
autoScrollThresholdLines,
diffAutoScroll,
monacoOptions.revealDebounceMs,
monacoOptions.diffUpdateThrottleMs,
)
const nextDiffEditorView = await nextDiffMgr.createDiffEditor(
container,
originalCode,
modifiedCode,
language,
initialThemeName,
)
assertCreateStillActive(requestId, 'diff')
diffMgr = nextDiffMgr
diffEditorView = nextDiffEditorView
editorMgr = null
editorView = null
const models = diffMgr.getDiffModels()
originalModel = models.original
modifiedModel = models.modified
commitAppliedTheme(initialThemeName)
const committedDisposables = takePendingCreateDisposables(requestId)
if (committedDisposables.length) {
disposals.push(...committedDisposables)
}
activeCreateRequestId = null
activeCreateKind = null
await notifyThemeApplied(initialThemeName)
return nextDiffEditorView
}
catch (error) {
if (nextDiffMgr) {
try {
nextDiffMgr.cleanup()
}
catch {}
}
disposeDisposables(takePendingCreateDisposables(requestId))
if (activeCreateRequestId === requestId) {
activeCreateRequestId = null
activeCreateKind = null
}
throw error
}
}
function cleanupEditor() {
cancelPendingCreates()
cleanupInstances()
disposeDisposables(disposals.splice(0))
}
function appendCode(appendText: string, codeLanguage?: string) {
if (editorMgr) {
editorMgr.appendCode(appendText, codeLanguage)
}
}
function updateCode(newCode: string, codeLanguage: string) {
if (editorMgr) {
editorMgr.updateCode(newCode, codeLanguage)
return
}
if (activeCreateRequestId != null && activeCreateKind === 'editor') {
queuedEditorUpdateDuringCreate = {
requestId: activeCreateRequestId,
code: newCode,
lang: codeLanguage,
}
}
}
function setUpdateThrottleMs(ms: number) {
monacoOptions.updateThrottleMs = ms
editorMgr?.setUpdateThrottleMs(ms)
}
function getUpdateThrottleMs() {
return editorMgr?.getUpdateThrottleMs() ?? monacoOptions.updateThrottleMs ?? 50
}
function updateDiff(
originalCode: string,
modifiedCode: string,
codeLanguage?: string,
) {
if (diffMgr)
diffMgr.updateDiff(originalCode, modifiedCode, codeLanguage)
}
function updateOriginal(newCode: string, codeLanguage?: string) {
if (diffMgr)
diffMgr.updateOriginal(newCode, codeLanguage)
}
function updateModified(newCode: string, codeLanguage?: string) {
if (diffMgr)
diffMgr.updateModified(newCode, codeLanguage)
}
function appendOriginal(appendText: string, codeLanguage?: string) {
if (diffMgr)
diffMgr.appendOriginal(appendText, codeLanguage)
}
function appendModified(appendText: string, codeLanguage?: string) {
if (diffMgr)
diffMgr.appendModified(appendText, codeLanguage)
}
async function setDiffModels(
models: DiffModelPair,
options?: DiffModelTransitionOptions,
) {
if (!diffMgr)
return
await diffMgr.setDiffModels(models, options)
const activeModels = diffMgr.getDiffModels()
originalModel = activeModels.original
modifiedModel = activeModels.modified
}
function refreshDiffPresentation() {
if (diffMgr)
diffMgr.refreshDiffPresentation()
}
return {
createEditor,
createDiffEditor,
cleanupEditor,
safeClean() {
if (editorMgr) {
try {
editorMgr.safeClean()
}
catch {}
}
if (diffMgr) {
try {
diffMgr.safeClean()
}
catch {}
}
},
updateCode,
appendCode,
updateDiff,
updateOriginal,
updateModified,
appendOriginal,
appendModified,
setDiffModels,
setTheme: setThemeInternal,
refreshDiffPresentation,
setLanguage(language: MonacoLanguage) {
if (editorMgr) {
editorMgr.setLanguage(language, languages as any)
return
}
if (diffMgr) {
diffMgr.setLanguage(language, languages as any)
return
}
if (languages.includes(language)) {
if (editorView) {
const model = editorView.getModel()
if (model && model.getLanguageId() !== language)
monaco.editor.setModelLanguage(model, language)
}
if (originalModel && originalModel.getLanguageId() !== language)
monaco.editor.setModelLanguage(originalModel, language)
if (modifiedModel && modifiedModel.getLanguageId() !== language)
monaco.editor.setModelLanguage(modifiedModel, language)
}
else {
console.warn(
`Language "${language}" is not registered. Available languages: ${languages.join(
', ',
)}`,
)
}
},
getCurrentTheme() {
return (
globalAppliedThemeName
?? requestedThemeName
?? globalRequestedThemeName
?? currentTheme.value
)
},
getEditor() {
return monaco.editor
},
getEditorView() {
return editorMgr?.getEditorView() ?? editorView
},
getDiffEditorView() {
return diffMgr?.getDiffEditorView() ?? diffEditorView
},
getDiffModels() {
if (diffMgr)
return diffMgr.getDiffModels()
return { original: originalModel, modified: modifiedModel }
},
getMonacoInstance() {
return monaco
},
setUpdateThrottleMs,
getUpdateThrottleMs,
getCode() {
if (editorMgr)
return editorMgr.getCode()
if (editorView) {
try {
return editorView.getModel()?.getValue() ?? null
}
catch {
return null
}
}
const diffModels = diffMgr?.getDiffModels() ?? {
original: originalModel,
modified: modifiedModel,
}
if (diffEditorView || (diffModels.original && diffModels.modified)) {
try {
return {
original: diffModels.original?.getValue() ?? '',
modified: diffModels.modified?.getValue() ?? '',
}
}
catch {
return null
}
}
return null
},
}
}
export {
clearHighlighterCache,
defaultRevealDebounceMs,
detectLanguage,
getOrCreateHighlighter,
registerMonacoThemes,
useMonaco,
}
export * from './type'
@@ -0,0 +1,229 @@
import { conf as cppConf, language as cppLanguage } from 'monaco-editor/esm/vs/basic-languages/cpp/cpp'
// Eagerly register Monarch tokenizers as a robust fallback. The basic-languages
// contributions use lazy dynamic imports which can fail in some legacy bundler
// setups (Webpack 4), resulting in a single default token type (no colors).
import { conf as jsConf, language as jsLanguage } from 'monaco-editor/esm/vs/basic-languages/javascript/javascript'
import { conf as psConf, language as psLanguage } from 'monaco-editor/esm/vs/basic-languages/powershell/powershell'
import { conf as pyConf, language as pyLanguage } from 'monaco-editor/esm/vs/basic-languages/python/python'
import { conf as shConf, language as shLanguage } from 'monaco-editor/esm/vs/basic-languages/shell/shell'
import { conf as tsConf, language as tsLanguage } from 'monaco-editor/esm/vs/basic-languages/typescript/typescript'
import { IOutlineModelService } from 'monaco-editor/esm/vs/editor/contrib/documentSymbols/browser/outlineModel'
import { ISuggestMemoryService } from 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestMemory'
import { processedLanguage } from './code.detect'
import { ensureMonacoWorkersLegacy } from './ensureMonacoWorkers.legacy'
import * as monaco from './monaco-shim'
// Force Monaco's built-in tokenizers to be registered in legacy (Webpack 4)
// builds. Without these contributions, Monaco falls back to a single default
// token type (no colors) when Shiki providers fail to install.
import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution'
import 'monaco-editor/esm/vs/basic-languages/python/python.contribution'
import 'monaco-editor/esm/vs/basic-languages/cpp/cpp.contribution'
import 'monaco-editor/esm/vs/basic-languages/shell/shell.contribution'
import 'monaco-editor/esm/vs/basic-languages/powershell/powershell.contribution'
import 'monaco-editor/esm/vs/language/json/monaco.contribution'
import 'monaco-editor/esm/vs/language/typescript/monaco.contribution'
import 'monaco-editor/esm/vs/language/html/monaco.contribution'
import 'monaco-editor/esm/vs/language/css/monaco.contribution'
export { ensureMonacoWorkersLegacy } from './ensureMonacoWorkers.legacy'
export * from './index.base'
export { preloadMonacoWorkers } from './preloadMonacoWorkers.legacy'
const LEGACY_ONIG_INIT_KEY = '__streamMonacoLegacyOnigurumaInit__'
const LEGACY_ENGINE_KEY = '__streamMonacoLegacyShikiEngine__'
const LEGACY_ERROR_FILTER_KEY = '__streamMonacoLegacyMonacoErrorFilterInstalled__'
const LEGACY_FLAG_KEY = '__streamMonacoLegacy__'
const LEGACY_MONACO_LANGS_INIT_KEY = '__streamMonacoLegacyMonacoLangsInit__'
const LEGACY_TOKENS_PROVIDER_GUARD_KEY = '__streamMonacoLegacyTokensProviderGuardInstalled__'
// Ensure these modules are not tree-shaken in legacy builds. Their module side
// effects register critical services used by editor contributions.
void ISuggestMemoryService
void IOutlineModelService
function ensureLegacyOnigurumaInit() {
if (typeof globalThis === 'undefined')
return
const g = globalThis as any
if (g[LEGACY_ONIG_INIT_KEY])
return
// Kick off best-effort Shiki regex engine init early, but don't block module eval.
// Webpack 4 consumers can otherwise hit a race where Monaco tokenization starts
// before the TextMate regex engine is ready, causing `null.compileAG`.
//
// Strategy:
// Legacy (Webpack 4) environments are particularly prone to Oniguruma WASM
// initialization issues that manifest as `null.compileAG` at runtime.
// To provide deterministic behavior, we default to Shiki's JavaScript regex
// engine for legacy builds. This avoids WASM entirely and restores syntax
// highlighting reliably (with a performance trade-off).
const init = (async () => {
try {
const shiki = await import('shiki')
if (typeof (shiki as any).createJavaScriptRegexEngine === 'function')
return (shiki as any).createJavaScriptRegexEngine()
return null
}
catch {
return null
}
})()
g[LEGACY_ONIG_INIT_KEY] = init.then(() => true, () => false)
g[LEGACY_ENGINE_KEY] = init
}
function ensureLegacyMonacoLanguageContributions() {
if (typeof globalThis === 'undefined')
return
const g = globalThis as any
if (g[LEGACY_MONACO_LANGS_INIT_KEY])
return
const init = Promise.resolve().then(() => {
// Register a minimal set of Monarch tokenizers synchronously to guarantee
// syntax highlighting in legacy runtimes (even when Shiki/TextMate fails).
try {
const langs: any = (monaco as any).languages
if (typeof langs?.setMonarchTokensProvider === 'function') {
langs.setMonarchTokensProvider('javascript', jsLanguage as any)
langs.setLanguageConfiguration?.('javascript', jsConf as any)
langs.setMonarchTokensProvider('typescript', tsLanguage as any)
langs.setLanguageConfiguration?.('typescript', tsConf as any)
langs.setMonarchTokensProvider('python', pyLanguage as any)
langs.setLanguageConfiguration?.('python', pyConf as any)
langs.setMonarchTokensProvider('cpp', cppLanguage as any)
langs.setLanguageConfiguration?.('cpp', cppConf as any)
// basic-languages uses `shell` id
langs.setMonarchTokensProvider('shell', shLanguage as any)
langs.setLanguageConfiguration?.('shell', shConf as any)
langs.setMonarchTokensProvider('powershell', psLanguage as any)
langs.setLanguageConfiguration?.('powershell', psConf as any)
}
}
catch {}
// Re-apply language ids to force re-tokenization for already-created models.
// Also apply legacy language normalization (e.g. vue -> html).
try {
for (const model of monaco.editor.getModels()) {
const current = model.getLanguageId()
const next = processedLanguage(current) || current
if (next !== current)
monaco.editor.setModelLanguage(model, next)
else
monaco.editor.setModelLanguage(model, current)
}
}
catch {}
})
g[LEGACY_MONACO_LANGS_INIT_KEY] = init
}
function ensureLegacyTokensProviderGuard() {
if (typeof globalThis === 'undefined')
return
const g = globalThis as any
if (g[LEGACY_TOKENS_PROVIDER_GUARD_KEY])
return
g[LEGACY_TOKENS_PROVIDER_GUARD_KEY] = true
try {
const langs: any = (monaco as any).languages
const setTokensProvider = typeof langs?.setTokensProvider === 'function'
? langs.setTokensProvider.bind(langs)
: null
if (!setTokensProvider)
return
langs.setTokensProvider = (lang: string, provider: any) => {
// Wrap tokenization so runtime failures won't crash the app.
if (provider && typeof provider.tokenize === 'function') {
const originalTokenize = provider.tokenize.bind(provider)
const getInitialState = typeof provider.getInitialState === 'function'
? provider.getInitialState.bind(provider)
: null
const wrappedProvider = {
...provider,
tokenize(line: string, state: any) {
try {
return originalTokenize(line, state)
}
catch {
return {
endState: state,
tokens: [{ startIndex: 0, scopes: '' }],
}
}
},
}
// If this provider throws immediately (e.g. missing Oniguruma/WASM causing
// `null.compileAG`), don't install it at all so Monaco's built-in
// tokenizers (loaded via contributions) can remain active.
try {
const st = getInitialState ? getInitialState() : null
wrappedProvider.tokenize('const a = 1', st)
}
catch {
return { dispose() {} } as any
}
return setTokensProvider(lang, wrappedProvider)
}
return setTokensProvider(lang, provider)
}
}
catch {}
}
function ensureLegacyMonacoErrorFilter() {
if (typeof globalThis === 'undefined')
return
const g = globalThis as any
if (g[LEGACY_ERROR_FILTER_KEY])
return
g[LEGACY_ERROR_FILTER_KEY] = true
// Best-effort: prevent Monaco from crashing the app on known Shiki/TextMate
// tokenization failures in legacy (Webpack 4) setups.
//
// Monaco funnels internal failures through `vs/base/common/errors`.
// That module is not public API, but this patch is legacy-only and guarded.
;(async () => {
try {
const mod: any = await import('monaco-editor/esm/vs/base/common/errors.js')
const handler = mod?.errorHandler
if (!handler || typeof handler.unexpectedErrorHandler !== 'function')
return
const prev = handler.unexpectedErrorHandler.bind(handler)
handler.unexpectedErrorHandler = (err: any) => {
const msg = err?.message ? String(err.message) : String(err)
if (msg.includes('compileAG') || msg.includes('Cannot read properties of null (reading'))
return
return prev(err)
}
}
catch {}
})()
}
ensureLegacyOnigurumaInit()
;(globalThis as any)[LEGACY_FLAG_KEY] = true
ensureLegacyMonacoLanguageContributions()
ensureLegacyTokensProviderGuard()
ensureLegacyMonacoErrorFilter()
ensureMonacoWorkersLegacy()
+11
View File
@@ -0,0 +1,11 @@
// Best-effort: install worker hook during module evaluation to avoid the
// "Could not create web worker(s)" fallback when consumers don't explicitly
// call `preloadMonacoWorkers()`.
import { ensureMonacoWorkers } from './ensureMonacoWorkers'
export { ensureMonacoWorkers } from './ensureMonacoWorkers'
export * from './index.base'
export { preloadMonacoWorkers } from './preloadMonacoWorkers'
ensureMonacoWorkers()
@@ -0,0 +1,40 @@
export interface MinimalEditResult {
start: number
endPrevIncl: number
endNextIncl: number
replaceText: string
}
/**
* Compute the minimal middle replacement between two UTF-16 strings.
* Returns null when prev === next (no edits required).
*/
export function computeMinimalEdit(
prev: string,
next: string,
): MinimalEditResult | null {
if (prev === next)
return null
let start = 0
const minLen = Math.min(prev.length, next.length)
while (start < minLen && prev.charCodeAt(start) === next.charCodeAt(start))
start++
let endPrev = prev.length - 1
let endNext = next.length - 1
while (
endPrev >= start
&& endNext >= start
&& prev.charCodeAt(endPrev) === next.charCodeAt(endNext)
) {
endPrev--
endNext--
}
return {
start,
endPrevIncl: endPrev,
endNextIncl: endNext,
replaceText: next.slice(start, endNext + 1),
}
}
@@ -0,0 +1,41 @@
declare module 'monaco-editor/esm/vs/basic-languages/cpp/cpp' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/basic-languages/javascript/javascript' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/basic-languages/powershell/powershell' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/basic-languages/python/python' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/basic-languages/shell/shell' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/basic-languages/typescript/typescript' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/editor/contrib/documentSymbols/browser/outlineModel' {
export const IOutlineModelService: any
}
declare module 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestMemory' {
export const ISuggestMemoryService: any
}
declare module 'monaco-editor/esm/vs/base/common/errors.js' {
export const errorHandler: any
}
@@ -0,0 +1,25 @@
// Small shim to centralize the monaco import. Tests can mock this file to
// avoid resolving the real 'monaco-editor' package during vitest runs.
//
// IMPORTANT:
// Use the ESM `editor.api` entrypoint so Monaco's language contributions under
// `monaco-editor/esm/vs/**` register against the same singleton instance.
// In legacy bundlers (Webpack 4), mixing `import 'monaco-editor'` with
// `import 'monaco-editor/esm/vs/...'` can create duplicate module instances,
// causing tokenizers/providers to register on the "other" instance (no colors).
import * as _monaco from 'monaco-editor/esm/vs/editor/editor.api'
// re-export the original module but also provide a `monaco` namespace default
// so existing imports like `import * as monaco from '.../monaco-shim'` keep
// the `monaco.editor` typing available.
const monaco: typeof _monaco = _monaco as any
const monacoAny = monaco as any
for (const name of ['css', 'html', 'json', 'typescript']) {
monacoAny.languages[name] ??= monacoAny[name]
}
export const ScrollType
= Reflect.get(_monaco as object, 'ScrollType') ?? (_monaco as any).editor?.ScrollType
export { monaco as default }
export * from 'monaco-editor/esm/vs/editor/editor.api'
@@ -0,0 +1,53 @@
import { ensureMonacoWorkersLegacy } from './ensureMonacoWorkers.legacy'
import { workerPathByLabel } from './preloadMonacoWorkers.shared'
// Webpack 4 (Vue CLI 4) cannot parse `import.meta.url`. This legacy build
// relies on the host app configuring Monaco workers (e.g. via
// monaco-editor-webpack-plugin), but includes a best-effort auto-config for
// CDN/AMD setups.
export async function preloadMonacoWorkers(): Promise<void> {
if (typeof window === 'undefined' || typeof document === 'undefined')
return
// Best-effort: if the host app didn't configure workers, try to install a
// same-origin blob worker URL based on detected Monaco baseUrl.
ensureMonacoWorkersLegacy()
// eslint-disable-next-line no-restricted-globals
const env = (self as any).MonacoEnvironment as
| undefined
| { getWorkerUrl?: (moduleId: string, label: string) => string }
const getWorkerUrl = env?.getWorkerUrl
if (typeof getWorkerUrl !== 'function')
return
// Monaco usually asks for `editorWorkerService` as the default label when no
// specialized worker exists.
const labelsToWarm = Array.from(
new Set([...Object.keys(workerPathByLabel), 'editorWorkerService']),
)
const urls = labelsToWarm
.map((label) => {
try {
return getWorkerUrl('', label)
}
catch {
return undefined
}
})
.filter(Boolean)
const unique = Array.from(new Set(urls.filter(Boolean).map(String)))
try {
await Promise.all(
unique.map(u =>
fetch(u, { method: 'GET', cache: 'force-cache' }).catch(() => undefined),
),
)
}
catch {
// swallow errors - preloading is best-effort
}
}
@@ -0,0 +1,17 @@
export const editorWorkerPath = 'monaco-editor/esm/vs/editor/editor.worker.js'
export const workerPathByLabel: Record<string, string> = {
json: 'monaco-editor/esm/vs/language/json/json.worker.js',
css: 'monaco-editor/esm/vs/language/css/css.worker.js',
scss: 'monaco-editor/esm/vs/language/css/css.worker.js',
less: 'monaco-editor/esm/vs/language/css/css.worker.js',
html: 'monaco-editor/esm/vs/language/html/html.worker.js',
handlebars: 'monaco-editor/esm/vs/language/html/html.worker.js',
razor: 'monaco-editor/esm/vs/language/html/html.worker.js',
typescript: 'monaco-editor/esm/vs/language/typescript/ts.worker.js',
javascript: 'monaco-editor/esm/vs/language/typescript/ts.worker.js',
}
export const uniqueWorkerPaths: string[] = Array.from(
new Set([editorWorkerPath, ...Object.values(workerPathByLabel)]),
)
@@ -0,0 +1,28 @@
import { ensureMonacoWorkers } from './ensureMonacoWorkers'
// Expose a function so consumers can proactively preload worker loaders
// without relying on module evaluation timing.
import { uniqueWorkerPaths } from './preloadMonacoWorkers.shared'
export async function preloadMonacoWorkers(): Promise<void> {
if (typeof window === 'undefined' || typeof document === 'undefined')
return
const unique = uniqueWorkerPaths.map(p => String(new URL(p, import.meta.url)))
// IMPORTANT: install synchronously (before the first `await`).
ensureMonacoWorkers()
try {
// best-effort fetch to warm caches; do not throw on individual failures
await Promise.all(
unique.map(u =>
fetch(u, { method: 'GET', cache: 'force-cache' }).catch(
() => undefined,
),
),
)
}
catch {
// swallow errors - preloading is best-effort
}
}
@@ -0,0 +1,57 @@
// Thin adapter over alien-signals to provide Vue-like APIs used internally.
import { computed as aComputed, effect as aEffect, signal as aSignal } from 'alien-signals'
export type WatchStopHandle = () => void
export interface Ref<T> { value: T }
export function ref<T>(initial: T): Ref<T> {
const s = aSignal<T>(initial)
return Object.defineProperty({} as Ref<T>, 'value', {
get() { return s() },
set(v: T) { s(v) },
enumerable: true,
configurable: false,
})
}
export function computed<T>(getter: () => T): Ref<T> {
const c = aComputed<T>(() => getter())
return Object.defineProperty({} as Ref<T>, 'value', {
get() { return c() },
set(_: T) { /* readonly */ },
enumerable: true,
configurable: false,
})
}
interface WatchOptions { immediate?: boolean, flush?: 'post' | 'sync' }
export function watch<T>(
source: () => T,
cb: (newVal: T, oldVal: T | undefined) => void,
options: WatchOptions = {},
): WatchStopHandle {
let initialized = false
let oldVal: T | undefined
const stop = aEffect(() => {
const newVal = source()
if (!initialized) {
initialized = true
if (options.immediate) {
if (options.flush === 'post')
queueMicrotask(() => cb(newVal, oldVal))
else
cb(newVal, oldVal)
}
}
else if (!Object.is(newVal, oldVal)) {
if (options.flush === 'post')
queueMicrotask(() => cb(newVal, oldVal))
else
cb(newVal, oldVal)
}
oldVal = newVal
})
return stop
}
+551
View File
@@ -0,0 +1,551 @@
import type * as monaco from 'monaco-editor'
import type {
Highlighter as _ShikiHighlighter,
SpecialTheme,
ThemeInput,
} from 'shiki'
import type * as monacoShim from './monaco-shim'
export type ShikiHighlighter = _ShikiHighlighter | any
export type MonacoModule = typeof monacoShim
export type MonacoEditorInstance = monaco.editor.IStandaloneCodeEditor
export type MonacoDiffEditorInstance = monaco.editor.IStandaloneDiffEditor
export type MonacoTheme
= | 'andromeeda'
| 'aurora-x'
| 'ayu-dark'
| 'catppuccin-frappe'
| 'catppuccin-latte'
| 'catppuccin-macchiato'
| 'catppuccin-mocha'
| 'dark-plus'
| 'dracula'
| 'dracula-soft'
| 'everforest-dark'
| 'everforest-light'
| 'github-dark'
| 'github-dark-default'
| 'github-dark-dimmed'
| 'github-dark-high-contrast'
| 'github-light'
| 'github-light-default'
| 'github-light-high-contrast'
| 'gruvbox-dark-hard'
| 'gruvbox-dark-medium'
| 'gruvbox-dark-soft'
| 'gruvbox-light-hard'
| 'gruvbox-light-medium'
| 'gruvbox-light-soft'
| 'houston'
| 'kanagawa-dragon'
| 'kanagawa-lotus'
| 'kanagawa-wave'
| 'laserwave'
| 'light-plus'
| 'material-theme'
| 'material-theme-darker'
| 'material-theme-lighter'
| 'material-theme-ocean'
| 'material-theme-palenight'
| 'min-dark'
| 'min-light'
| 'monokai'
| 'night-owl'
| 'nord'
| 'one-dark-pro'
| 'one-light'
| 'plastic'
| 'poimandres'
| 'red'
| 'rose-pine'
| 'rose-pine-dawn'
| 'rose-pine-moon'
| 'slack-dark'
| 'slack-ochin'
| 'snazzy-light'
| 'solarized-dark'
| 'solarized-light'
| 'synthwave-84'
| 'tokyo-night'
| 'vesper'
| 'vitesse-black'
| 'vitesse-dark'
| 'vitesse-light'
| ThemeInput
| string
| SpecialTheme
export type MonacoLanguage
= | 'abap'
| 'actionscript-3'
| 'ada'
| 'angular-html'
| 'angular-ts'
| 'apache'
| 'apex'
| 'apl'
| 'applescript'
| 'ara'
| 'asciidoc'
| 'asm'
| 'astro'
| 'awk'
| 'ballerina'
| 'bat'
| 'beancount'
| 'berry'
| 'bibtex'
| 'bicep'
| 'blade'
| 'bsl'
| 'c'
| 'cadence'
| 'cairo'
| 'clarity'
| 'clojure'
| 'cmake'
| 'cobol'
| 'codeowners'
| 'codeql'
| 'coffee'
| 'common-lisp'
| 'coq'
| 'cpp'
| 'crystal'
| 'csharp'
| 'css'
| 'csv'
| 'cue'
| 'cypher'
| 'd'
| 'dart'
| 'dax'
| 'desktop'
| 'diff'
| 'docker'
| 'dotenv'
| 'dream-maker'
| 'edge'
| 'elixir'
| 'elm'
| 'emacs-lisp'
| 'erb'
| 'erlang'
| 'fennel'
| 'fish'
| 'fluent'
| 'fortran-fixed-form'
| 'fortran-free-form'
| 'fsharp'
| 'gdresource'
| 'gdscript'
| 'gdshader'
| 'genie'
| 'gherkin'
| 'git-commit'
| 'git-rebase'
| 'gleam'
| 'glimmer-js'
| 'glimmer-ts'
| 'glsl'
| 'gnuplot'
| 'go'
| 'graphql'
| 'groovy'
| 'hack'
| 'haml'
| 'handlebars'
| 'haskell'
| 'haxe'
| 'hcl'
| 'hjson'
| 'hlsl'
| 'html'
| 'html-derivative'
| 'http'
| 'hxml'
| 'hy'
| 'imba'
| 'ini'
| 'java'
| 'javascript'
| 'jinja'
| 'jison'
| 'json'
| 'json5'
| 'jsonc'
| 'jsonl'
| 'jsonnet'
| 'jssm'
| 'jsx'
| 'julia'
| 'kotlin'
| 'kusto'
| 'latex'
| 'lean'
| 'less'
| 'liquid'
| 'llvm'
| 'log'
| 'logo'
| 'lua'
| 'luau'
| 'make'
| 'markdown'
| 'marko'
| 'matlab'
| 'mdc'
| 'mdx'
| 'mermaid'
| 'mipsasm'
| 'mojo'
| 'move'
| 'narrat'
| 'nextflow'
| 'nginx'
| 'nim'
| 'nix'
| 'nushell'
| 'objective-c'
| 'objective-cpp'
| 'ocaml'
| 'pascal'
| 'perl'
| 'php'
| 'plsql'
| 'po'
| 'polar'
| 'postcss'
| 'powerquery'
| 'powershell'
| 'prisma'
| 'prolog'
| 'proto'
| 'pug'
| 'puppet'
| 'purescript'
| 'python'
| 'qml'
| 'qmldir'
| 'qss'
| 'r'
| 'racket'
| 'raku'
| 'razor'
| 'reg'
| 'regexp'
| 'rel'
| 'riscv'
| 'rst'
| 'ruby'
| 'rust'
| 'sas'
| 'sass'
| 'scala'
| 'scheme'
| 'scss'
| 'sdbl'
| 'shaderlab'
| 'shellscript'
| 'shellsession'
| 'smalltalk'
| 'solidity'
| 'soy'
| 'sparql'
| 'splunk'
| 'sql'
| 'ssh-config'
| 'stata'
| 'stylus'
| 'svelte'
| 'swift'
| 'system-verilog'
| 'systemd'
| 'talonscript'
| 'tasl'
| 'tcl'
| 'templ'
| 'terraform'
| 'tex'
| 'toml'
| 'ts-tags'
| 'tsv'
| 'tsx'
| 'turtle'
| 'twig'
| 'typescript'
| 'typespec'
| 'typst'
| 'v'
| 'vala'
| 'vb'
| 'verilog'
| 'vhdl'
| 'viml'
| 'vue'
| 'vue-html'
| 'vyper'
| 'wasm'
| 'wenyan'
| 'wgsl'
| 'wikitext'
| 'wit'
| 'wolfram'
| 'xml'
| 'xsl'
| 'yaml'
| 'zenscript'
| 'zig'
| string
export type DiffHideUnchangedRegions
= | boolean
| NonNullable<
monaco.editor.IDiffEditorConstructionOptions['hideUnchangedRegions']
>
export type DiffLineStyle = 'background' | 'bar'
export type DiffAppearance = 'auto' | 'light' | 'dark'
export type DiffUnchangedRegionStyle
= | 'line-info'
| 'line-info-basic'
| 'metadata'
| 'simple'
export interface DiffModels {
original: monaco.editor.ITextModel | null
modified: monaco.editor.ITextModel | null
}
export interface DiffModelPair {
original: monaco.editor.ITextModel
modified: monaco.editor.ITextModel
}
export interface DiffModelTransitionOptions {
codeLanguage?: MonacoLanguage
preserveViewState?: boolean
preserveModelState?: boolean
}
export interface DiffCodeValue {
original: string
modified: string
}
export type MonacoCodeValue = string | DiffCodeValue | null
export interface MonacoOptions
extends monaco.editor.IStandaloneEditorConstructionOptions,
monaco.editor.IDiffEditorConstructionOptions {
MAX_HEIGHT?: number | string
readOnly?: boolean
themes?: MonacoTheme[]
languages?: MonacoLanguage[]
theme?: string
isCleanOnBeforeCreate?: boolean
/**
* 控制更新时的自动滚动行为:当为 true 时,如果当前接近底部则在新增内容后自动滚动到底部;
* 当为 false 时,将完全禁用自动滚动。
* 默认 true。
*/
autoScrollOnUpdate?: boolean
/**
* 编辑器创建时是否默认启用自动滚动。用户一旦滚离底部将自动暂停,回到底部附近再恢复。
* 默认 true(保持原有行为)。
*/
autoScrollInitial?: boolean
/**
* 触发“接近底部”的绝对像素阈值。如果设置,将与 autoScrollThresholdLines 共同取最大值。
* 默认 32。
*/
autoScrollThresholdPx?: number
/**
* 触发“接近底部”的相对行数阈值(以当前行高计算)。如果设置,将与 autoScrollThresholdPx 共同取最大值。
* 默认 2 行。
*/
autoScrollThresholdLines?: number
/**
* 是否启用 Diff 编辑器 modified 侧的自动滚动逻辑。
* 当为 false 时,updateDiff/appendModified 等不会触发自动滚动。
* 默认 true(与单编辑器体验保持一致)。
*/
diffAutoScroll?: boolean
/**
* Controls Monaco's diff unchanged-region folding behavior.
* - `true`: use stream-monaco defaults (enabled with compact context lines)
* - `false`: disable unchanged-region folding
* - object: forward to Monaco `hideUnchangedRegions`
*
* Default: `true`
*/
diffHideUnchangedRegions?: DiffHideUnchangedRegions
/**
* Controls how changed lines are visually emphasized in the diff editor.
* - `background`: richer filled blocks for added/removed lines
* - `bar`: subtler fill with stronger leading bars, closer to review UIs
*
* Default: `background`
*/
diffLineStyle?: DiffLineStyle
/**
* Controls the overall chrome appearance of the diff editor shell.
* - `auto`: infer light/dark appearance from the active Monaco theme
* - `light`: force light diff chrome
* - `dark`: force dark diff chrome
*
* Token colors still follow the active Monaco/Shiki theme.
*
* Default: `auto`
*/
diffAppearance?: DiffAppearance
/**
* Controls how collapsed unchanged regions are rendered in the diff editor.
* - `line-info`: line-info bars with line-number-width reveal buttons
* - `line-info-basic`: legacy line-info bars with full-width reveal rail
* - `metadata`: unified-diff-style hunk metadata such as `@@ -59,9 +59,11 @@`
* - `simple`: minimal gray placeholder bar without text
*
* Default: `line-info`
*/
diffUnchangedRegionStyle?: DiffUnchangedRegionStyle
/**
* Enable hover actions for each diff hunk split part (upper/lower):
* local `revert` and `stage`.
* Default: `false` (must be explicitly enabled).
*/
diffHunkActionsOnHover?: boolean
/**
* Hide delay (ms) for diff hunk hover action widgets after mouse leaves.
* Default: `160`.
*/
diffHunkHoverHideDelayMs?: number
/**
* Optional interception callback for hunk hover actions.
* Return `false` to prevent the built-in model edit behavior.
*/
onDiffHunkAction?: (
context: DiffHunkActionContext,
) => void | boolean | Promise<void | boolean>
/**
* Debounce time (ms) to coalesce multiple reveal requests into a single
* reveal. Useful for streaming/append scenarios. Default: 75
*/
revealDebounceMs?: number
/**
* How to reveal target line when auto-scrolling.
* - 'bottom' : revealLine (closest to bottom)
* - 'centerIfOutside' : revealLineInCenterIfOutsideViewport (default)
* - 'center' : revealLineInCenter
*/
revealStrategy?: 'bottom' | 'centerIfOutside' | 'center'
/**
* If set to a positive number (ms), append/streaming scenarios will delay a final
* "scroll to bottom" until this idle time has passed since the last append. Useful
* to batch many small appends and then perform one final jump to bottom. Default: undefined (disabled).
*/
revealBatchOnIdleMs?: number
/**
* Time window (ms) used to throttle `updateCode` calls in addition to RAF batching.
* - 0 means only RAF-based coalescing (no extra time throttling).
* - Default (library): 50
*/
updateThrottleMs?: number
/**
* Time window (ms) used to throttle diff streaming updates in addition to RAF batching.
* This affects `appendOriginal`/`appendModified` and the fast-path append branches of `updateDiff`.
*
* Why: Monaco's diff computation is async and cancels/restarts when models change.
* If you apply edits every frame (or per token), the diff may only finish once
* streaming stops, so the highlights appear "at the end".
*
* - 0 means only RAF-based coalescing (more responsive, but can starve diff computation).
* - Default (library): 50
*/
diffUpdateThrottleMs?: number
/**
* When attempting the "minimal edit" algorithm, if prev.length + next.length
* exceeds this number the library will fall back to full `setValue` to avoid
* expensive diff computation on very large documents.
*/
minimalEditMaxChars?: number
/**
* When the relative change ratio (|new-prev|/maxLen) exceeds this value the
* library will fall back to full `setValue` instead of attempting minimal edit.
*/
minimalEditMaxChangeRatio?: number
// 添加在编辑器创建之前的钩子
onBeforeCreate?: (
monaco: MonacoModule,
) => monaco.IDisposable[]
/**
* Optional callback that is invoked after a theme change has been applied.
* This callback will be awaited when possible so callers can track completion
* of theme application. It receives the name of the applied theme.
*/
onThemeChange?: (theme: MonacoTheme) => void | Promise<void>
}
// Convenience enum for consumers who prefer a TypeScript constant instead of strings
export enum RevealStrategy {
Bottom = 'bottom',
CenterIfOutside = 'centerIfOutside',
Center = 'center',
}
export type DiffHunkActionKind = 'revert' | 'stage'
export type DiffHunkSide = 'upper' | 'lower'
export interface DiffHunkActionContext {
action: DiffHunkActionKind
side: DiffHunkSide
lineChange: monaco.editor.ILineChange
originalModel: monaco.editor.ITextModel
modifiedModel: monaco.editor.ITextModel
}
export interface UseMonacoReturn {
createEditor: (
container: HTMLElement,
code: string,
language: string,
) => Promise<monaco.editor.IStandaloneCodeEditor>
createDiffEditor: (
container: HTMLElement,
originalCode: string,
modifiedCode: string,
language: string,
) => Promise<monaco.editor.IStandaloneDiffEditor>
cleanupEditor: () => void
safeClean: () => void
updateCode: (newCode: string, codeLanguage: string) => void
appendCode: (appendText: string, codeLanguage?: string) => void
updateDiff: (
originalCode: string,
modifiedCode: string,
codeLanguage?: string,
) => void
updateOriginal: (newCode: string, codeLanguage?: string) => void
updateModified: (newCode: string, codeLanguage?: string) => void
appendOriginal: (appendText: string, codeLanguage?: string) => void
appendModified: (appendText: string, codeLanguage?: string) => void
setDiffModels: (
models: DiffModelPair,
options?: DiffModelTransitionOptions,
) => Promise<void>
setTheme: (theme: MonacoTheme, force?: boolean) => Promise<void>
refreshDiffPresentation: () => void
setLanguage: (language: MonacoLanguage) => void
getCurrentTheme: () => string
getEditor: () => typeof monaco.editor
getEditorView: () => monaco.editor.IStandaloneCodeEditor | null
getDiffEditorView: () => monaco.editor.IStandaloneDiffEditor | null
getDiffModels: () => DiffModels
getMonacoInstance: () => MonacoModule
setUpdateThrottleMs: (ms: number) => void
getUpdateThrottleMs: () => number
getCode: () => MonacoCodeValue
}
@@ -0,0 +1,17 @@
// shallow array equality (order-sensitive)
export function arraysEqual<T>(
a: readonly T[] | null | undefined,
b: readonly T[] | null | undefined,
) {
if (a === b)
return true
if (!a || !b)
return false
if (a.length !== b.length)
return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i])
return false
}
return true
}
@@ -0,0 +1,105 @@
import { log } from './logger'
export function createHeightManager(container: HTMLElement, computeNext: () => number | null) {
// Debug logging is enabled during development but can be controlled via
let raf: number | null = null
let debounceTimer: number | null = null
let lastApplied = -1
let suppressed = false
// larger hysteresis to reduce perceived jitter during incremental growth
const HYSTERESIS_PX = 12
// debounce delay to coalesce frequent height changes (ms)
const DEBOUNCE_MS = 0
function apply() {
const next = computeNext()
if (next == null) {
return
}
log('heightManager', 'computeNext ->', { next, lastApplied })
// Guard against invalid or non-positive heights which can collapse the
// container. Some downstream callers may compute values incorrectly
// during transient states; ignore those to keep the editor visible.
if (!Number.isFinite(next) || next <= 0) {
log('heightManager', 'invalid next height, ignoring', next)
return
}
const currentHeight
= Number.parseFloat(container.style.height || '')
|| container.getBoundingClientRect?.().height
|| 0
if (
currentHeight > 0
&& Math.abs(next - currentHeight) <= HYSTERESIS_PX
) {
lastApplied = next
return
}
if (lastApplied !== -1 && Math.abs(next - lastApplied) <= HYSTERESIS_PX) {
return
}
if (next === lastApplied) {
return
}
suppressed = true
container.style.height = `${next}px`
lastApplied = next
log('heightManager', 'applied height ->', next)
queueMicrotask(() => {
suppressed = false
})
}
function scheduleApply() {
// Cancel any pending debounce timer and schedule a new one that will
// requestAnimationFrame and run `apply`. This batches updates that occur
// within DEBOUNCE_MS to avoid rapid reflows.
if (debounceTimer != null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
if (DEBOUNCE_MS === 0) {
if (raf != null) {
return
}
raf = requestAnimationFrame(() => {
raf = null
apply()
})
return
}
debounceTimer = (setTimeout(() => {
debounceTimer = null
if (raf != null) {
return
}
raf = requestAnimationFrame(() => {
raf = null
apply()
})
}, DEBOUNCE_MS) as unknown) as number
}
function update() {
scheduleApply()
}
function dispose() {
if (raf != null) {
cancelAnimationFrame(raf)
raf = null
}
if (debounceTimer != null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
function isSuppressed() {
return suppressed
}
function getLastApplied() {
return lastApplied
}
return { update, dispose, isSuppressed, getLastApplied }
}
@@ -0,0 +1,29 @@
let seq = 0
// Centralized debug determination. Priority:
// 1. browser global `window.__STREAM_MONACO_DEBUG__` if defined
// Default: enable debug during local development
export const DEBUG: boolean = (() => {
if (typeof window !== 'undefined' && (window as any).__STREAM_MONACO_DEBUG__ !== undefined)
return Boolean((window as any).__STREAM_MONACO_DEBUG__)
return false
})()
export function log(tag: string, ...args: any[]) {
if (!DEBUG)
return
seq += 1
const id = `#${seq}`
const ts = (typeof performance !== 'undefined' && performance.now)
? (performance.now()).toFixed(1)
: Date.now()
// Use console.warn to comply with ESLint console restrictions.
console.warn(`${id} [${tag}] @${ts}ms`, ...args)
}
export function error(tag: string, ...args: any[]) {
if (!DEBUG)
return
console.error(`[${tag}]`, ...args)
}
@@ -0,0 +1,38 @@
export interface TimeSource {
requestAnimationFrame: (cb: FrameRequestCallback) => number
cancelAnimationFrame: (id: number) => void
}
/**
* Create a per-instance RAF scheduler that coalesces tasks by "kind" within
* the same instance. Instances are isolated so different editors won't
* cancel each other's scheduled work.
*/
export function createRafScheduler(timeSource?: TimeSource) {
const ids: Record<string, number | null> = {}
const ts: TimeSource = timeSource ?? {
requestAnimationFrame: (cb: FrameRequestCallback) => requestAnimationFrame(cb),
cancelAnimationFrame: (id: number) => cancelAnimationFrame(id),
}
function schedule(kind: string, cb: FrameRequestCallback) {
const existing = ids[kind]
if (existing != null) {
ts.cancelAnimationFrame(existing)
}
ids[kind] = ts.requestAnimationFrame((t) => {
ids[kind] = null
cb(t)
})
}
function cancel(kind: string) {
const id = ids[kind]
if (id != null) {
ts.cancelAnimationFrame(id)
ids[kind] = null
}
}
return { schedule, cancel }
}
@@ -0,0 +1,398 @@
import type { SpecialTheme, ThemeInput } from 'shiki'
import { shikiToMonaco } from '@shikijs/monaco'
import { createHighlighter } from 'shiki'
import * as monaco from '../monaco-shim'
import { arraysEqual } from './arraysEqual'
const LEGACY_ONIG_INIT_KEY = '__streamMonacoLegacyOnigurumaInit__'
const LEGACY_ENGINE_KEY = '__streamMonacoLegacyShikiEngine__'
const LEGACY_MONACO_LANGS_INIT_KEY = '__streamMonacoLegacyMonacoLangsInit__'
async function awaitLegacyOnigurumaInitIfPresent() {
try {
const p = (globalThis as any)?.[LEGACY_ONIG_INIT_KEY]
if (p && typeof p.then === 'function')
await p
}
catch {}
}
async function awaitLegacyMonacoLanguageContributionsIfPresent() {
try {
const p = (globalThis as any)?.[LEGACY_MONACO_LANGS_INIT_KEY]
if (p && typeof p.then === 'function')
await p
}
catch {}
}
async function getLegacyShikiEngineIfPresent() {
try {
const p = (globalThis as any)?.[LEGACY_ENGINE_KEY]
if (p && typeof p.then === 'function')
return await p
}
catch {}
return null
}
async function createHighlighterWithLegacyEngineIfNeeded(options: any) {
await awaitLegacyOnigurumaInitIfPresent()
await awaitLegacyMonacoLanguageContributionsIfPresent()
const engine = await getLegacyShikiEngineIfPresent()
if (engine)
return createHighlighter({ ...options, engine })
return createHighlighter(options)
}
let languagesRegistered = false
let currentLanguages: string[] = []
// promise that resolves to a shiki highlighter or null when registration completes
let themeRegisterPromise: Promise<import('../type').ShikiHighlighter | null> | null = null
// Serialize registrations to avoid races where multiple calls patch Monaco's
// global `editor.setTheme` with different highlighters in an interleaved order.
let registrationQueue: Promise<unknown> = Promise.resolve()
function enqueueRegistration<T>(task: () => Promise<T>): Promise<T> {
const next = registrationQueue.then(task, task)
// keep queue alive even if a task rejects
registrationQueue = next.then(() => undefined, () => undefined)
return next
}
export function getThemeRegisterPromise() {
return themeRegisterPromise
}
export function setThemeRegisterPromise(p: Promise<import('../type').ShikiHighlighter | null> | null) {
return themeRegisterPromise = p
}
interface HighlighterEntry {
// promise that resolves to a shiki highlighter
promise: Promise<any>
// set of languages this highlighter was created with
languages: Set<string>
}
const highlighterCache = new Map<string, HighlighterEntry>()
// Monaco integration is global (monaco.editor.setTheme is patched by shikiToMonaco).
// When multiple editors/code blocks initialize concurrently with different theme
// sets, repeatedly calling shikiToMonaco with different highlighters causes the
// last one to win, and themes not present in that last highlighter can throw.
//
// To avoid that, keep a single shared highlighter for Monaco and incrementally
// load themes/languages into it.
let monacoHighlighterPromise: Promise<import('../type').ShikiHighlighter> | null = null
let lastPatchedHighlighter: import('../type').ShikiHighlighter | null = null
let lastPatchedLanguages = new Set<string>()
let lastPatchedThemes = new Set<string>()
const monacoThemeByKey = new Map<string, ThemeInput | string | SpecialTheme>()
const monacoLanguageSet = new Set<string>()
function themeKey(t: ThemeInput | string | SpecialTheme) {
return typeof t === 'string' ? t : (t as any).name ?? JSON.stringify(t)
}
async function ensureMonacoHighlighter(
themes: (ThemeInput | string | SpecialTheme)[],
languages: string[],
) {
// track union first
for (const t of themes)
monacoThemeByKey.set(themeKey(t), t)
for (const l of languages)
monacoLanguageSet.add(l)
// create shared highlighter once
if (!monacoHighlighterPromise) {
const initialThemes = Array.from(monacoThemeByKey.values())
const initialLangs = Array.from(monacoLanguageSet.values())
monacoHighlighterPromise = createHighlighterWithLegacyEngineIfNeeded({ themes: initialThemes, langs: initialLangs })
.then(h => h)
}
const h = await monacoHighlighterPromise
// Incrementally load missing themes/langs when supported by shiki.
// If incremental loading isn't available, fall back to re-creating the shared
// highlighter with the full union (still safe because the union only grows).
const wantsThemes = Array.from(monacoThemeByKey.values())
const wantsLangs = Array.from(monacoLanguageSet.values())
const canLoadTheme = typeof (h as any).loadTheme === 'function'
const canLoadLanguage = typeof (h as any).loadLanguage === 'function'
if (canLoadTheme || canLoadLanguage) {
if (canLoadTheme) {
// loadTheme is idempotent in shiki; call only for newly requested theme keys
for (const t of themes) {
const k = themeKey(t)
// If we've already loaded it, skip.
// We can't reliably introspect loaded themes from shiki, so track keys.
if (!(h as any).__streamMonacoLoadedThemes)
(h as any).__streamMonacoLoadedThemes = new Set<string>()
const loaded: Set<string> = (h as any).__streamMonacoLoadedThemes
if (loaded.has(k))
continue
await (h as any).loadTheme(t)
loaded.add(k)
}
}
if (canLoadLanguage) {
for (const l of languages) {
if (!(h as any).__streamMonacoLoadedLangs)
(h as any).__streamMonacoLoadedLangs = new Set<string>()
const loaded: Set<string> = (h as any).__streamMonacoLoadedLangs
if (loaded.has(l))
continue
await (h as any).loadLanguage(l)
loaded.add(l)
}
}
return h
}
// fallback: recreate shared highlighter with union
const p = createHighlighterWithLegacyEngineIfNeeded({ themes: wantsThemes, langs: wantsLangs })
.then(hh => hh)
monacoHighlighterPromise = p
return p
}
/**
* Clear all cached shiki highlighters.
*
* Useful for long-running apps that dynamically create many theme combinations,
* or in tests to ensure a clean state. Call this when you know the highlighters
* are no longer needed (for example on app shutdown) to free memory.
*/
export function clearHighlighterCache() {
highlighterCache.clear()
monacoHighlighterPromise = null
lastPatchedHighlighter = null
lastPatchedLanguages = new Set<string>()
monacoThemeByKey.clear()
monacoLanguageSet.clear()
themeRegisterPromise = null
languagesRegistered = false
currentLanguages = []
}
/**
* Return number of entries currently in the highlighter cache.
* Helpful for tests and debugging.
*/
export function getHighlighterCacheSize() {
return highlighterCache.size
}
function serializeThemes(themes: (ThemeInput | string | SpecialTheme)[]) {
return JSON.stringify(
themes.map(t => typeof t === 'string' ? t : (t as any).name ?? JSON.stringify(t)).sort(),
)
}
async function getOrCreateHighlighter(
themes: (ThemeInput | string | SpecialTheme)[],
languages: string[],
): Promise<import('../type').ShikiHighlighter> {
const key = serializeThemes(themes)
const requestedSet = new Set(languages)
let existing = highlighterCache.get(key)
if (existing) {
// if existing entry already covers requested languages, reuse
let allIncluded = true
for (const l of requestedSet) {
if (!existing.languages.has(l)) {
allIncluded = false
break
}
}
if (allIncluded) {
return existing.promise
}
// double-check cache in case a concurrent request already replaced/expanded the entry
const prev = existing
const current = highlighterCache.get(key)
if (current && current !== prev) {
// if the current cached entry already covers requested languages, reuse it
let allIncludedCurrent = true
for (const l of requestedSet) {
if (!current.languages.has(l)) {
allIncludedCurrent = false
break
}
}
if (allIncludedCurrent) {
return current.promise
}
// otherwise prefer the most recent cached entry for the union creation
existing = current
}
// otherwise create a new highlighter with the union of languages
const union = new Set<string>([...existing.languages, ...requestedSet])
const langsArray = Array.from(union)
const p = createHighlighterWithLegacyEngineIfNeeded({ themes, langs: langsArray })
const newEntry: HighlighterEntry = { promise: p, languages: union }
highlighterCache.set(key, newEntry)
// if creation fails, try to restore previous entry (prev)
p.catch(() => {
if (highlighterCache.get(key) === newEntry && prev) {
highlighterCache.set(key, prev)
}
})
return p
}
// no cached entry, create and cache
const p = createHighlighterWithLegacyEngineIfNeeded({ themes, langs: Array.from(requestedSet) })
const entry: HighlighterEntry = { promise: p, languages: requestedSet }
highlighterCache.set(key, entry)
p.catch(() => {
if (highlighterCache.get(key) === entry) {
highlighterCache.delete(key)
}
})
return p
}
// Exported for callers that need direct access to the shiki highlighter
export { getOrCreateHighlighter }
/**
* Update the theme used by the shiki highlighter for a given themes+languages
* combination. Useful when Monaco themes are already registered (so switching
* Monaco only requires `monaco.editor.setTheme`) but you also want shiki's
* standalone renderer to use the new theme without recreating everything.
*/
// NOTE: setHighlighterTheme removed — switching Monaco theme via
// `monaco.editor.setTheme(themeName)` is sufficient for editor theme changes.
// If consumers need to directly control a shiki highlighter they can use
// `getOrCreateHighlighter(...)` and call methods on the returned object.
export async function registerMonacoThemes(
themes: (ThemeInput | string | SpecialTheme)[],
languages: string[],
): Promise<import('../type').ShikiHighlighter | null> {
return enqueueRegistration(async () => {
registerMonacoLanguages(languages)
const p = (async () => {
const highlighter = await ensureMonacoHighlighter(themes, languages)
// Patch Monaco when:
// - the shared highlighter instance changes, OR
// - new languages were added (incremental load) and need tokens providers.
//
// `shikiToMonaco()` installs tokens providers per language at call-time.
// When we incrementally load languages into the shared highlighter, we
// must re-run `shikiToMonaco()` so Monaco receives providers for the newly
// loaded languages.
const wantsLangs = Array.from(monacoLanguageSet.values())
const loadedThemeIds = typeof highlighter.getLoadedThemes === 'function'
? highlighter.getLoadedThemes()
: []
const needsThemePatch = loadedThemeIds.some((t: string) => !lastPatchedThemes.has(t))
const needsLanguagePatch = lastPatchedHighlighter !== highlighter
|| wantsLangs.some(l => !lastPatchedLanguages.has(l))
|| needsThemePatch
if (needsLanguagePatch) {
if (lastPatchedHighlighter !== highlighter)
lastPatchedLanguages = new Set<string>()
// In some bundlers (notably Webpack 4), Shiki/TextMate tokenization can
// still throw at runtime (e.g. `null.compileAG`) due to regex engine
// initialization quirks. Monaco reports these as "unexpected errors"
// and they crash the app.
//
// We cannot monkey-patch `import * as monaco` namespace exports (ESM
// namespace objects are immutable), so pass a proxy object into
// `shikiToMonaco()` that wraps `setTokensProvider` instead.
const realLanguages: any = (monaco as any).languages
const realEditor: any = (monaco as any).editor
const setTokensProvider = typeof realLanguages?.setTokensProvider === 'function'
? realLanguages.setTokensProvider.bind(realLanguages)
: null
const getLanguages = typeof realLanguages?.getLanguages === 'function'
? realLanguages.getLanguages.bind(realLanguages)
: null
const monacoProxy: any = {
// Forward everything else (Range, Uri, etc.) via prototype lookup.
__proto__: monaco as any,
editor: realEditor,
languages: {
__proto__: realLanguages,
getLanguages,
setTokensProvider(lang: string, provider: any) {
if (provider && typeof provider.tokenize === 'function') {
const originalTokenize = provider.tokenize.bind(provider)
provider = {
...provider,
tokenize(line: string, state: any) {
try {
return originalTokenize(line, state)
}
catch {
return {
endState: state,
tokens: [{ startIndex: 0, scopes: '' }],
}
}
},
}
}
return setTokensProvider?.(lang, provider)
},
},
}
shikiToMonaco(highlighter, monacoProxy)
lastPatchedHighlighter = highlighter
lastPatchedLanguages = new Set(wantsLangs)
lastPatchedThemes = new Set(loadedThemeIds)
}
// Track last language set for Monaco language registration short-circuit.
currentLanguages = languages.slice()
return highlighter
})()
setThemeRegisterPromise(p)
try {
const res = await p
return res
}
catch (e) {
setThemeRegisterPromise(null)
throw e
}
})
}
function registerMonacoLanguages(languages: string[]) {
if (languagesRegistered && arraysEqual(languages, currentLanguages)) {
return
}
const existing = new Set(monaco.languages.getLanguages().map(l => l.id))
for (const lang of languages) {
if (!existing.has(lang)) {
try {
monaco.languages.register({ id: lang })
}
catch {
// ignore unsupported ids
}
}
}
languagesRegistered = true
currentLanguages = languages
}
@@ -0,0 +1,145 @@
import { log } from './logger'
export function createScrollWatcherForEditor(
ed: any,
opts: {
onPause: () => void
onMaybeResume: () => void
getLast: () => number
setLast: (v: number) => void
},
) {
const initial = ed.getScrollTop?.() ?? 0
opts.setLast(initial)
log('scrollWatcher', 'initial scrollTop=', initial)
let suppressedExternally = false
const THRESHOLD_PX = 6
let domNode: HTMLElement | null = null
let interactionListener: ((e: Event) => void) | null = null
const listener = (e: any) => {
if (suppressedExternally) {
log('scrollWatcher', 'suppressedExternally, ignoring event')
return
}
const currentTop = e && typeof e.scrollTop === 'number' ? e.scrollTop : ed.getScrollTop?.() ?? 0
const delta = currentTop - opts.getLast()
// update last seen position immediately
opts.setLast(currentTop)
// ignore very small scroll deltas (usually from layout/height adjustments)
if (Math.abs(delta) < THRESHOLD_PX) {
log('scrollWatcher', 'small delta ignored', delta)
// If the delta is small, check whether the viewport is effectively
// at the bottom of the scrollable area. In that case we should treat
// this as a potential resume so streaming auto-scroll can re-enable.
try {
const scrollHeight = typeof ed.getScrollHeight === 'function' ? ed.getScrollHeight() : undefined
const li = typeof (ed as any).getLayoutInfo === 'function' ? (ed as any).getLayoutInfo() : undefined
const viewportH = li?.height ?? undefined
if (typeof scrollHeight === 'number' && typeof viewportH === 'number') {
const distance = scrollHeight - (currentTop + viewportH)
if (distance <= Math.max(THRESHOLD_PX, 0)) {
log('scrollWatcher', 'small delta but at bottom, maybe resume', { distance })
opts.onMaybeResume()
}
}
}
catch { }
return
}
log('scrollWatcher', 'delta=', delta, 'currentTop=', currentTop)
if (delta < 0) {
log('scrollWatcher', 'pause detected delta=', delta)
opts.onPause()
return
}
log('scrollWatcher', 'maybe resume delta=', delta)
opts.onMaybeResume()
}
const disp = ed.onDidScrollChange?.(listener) ?? null
// Return an object that provides dispose and suppression control. We keep
// the dispose method for compatibility with monaco.IDisposable.
const api: any = {
dispose() {
try {
if (disp && typeof disp.dispose === 'function')
disp.dispose()
else if (typeof disp === 'function')
disp()
}
catch { }
log('scrollWatcher', 'dispose')
},
setSuppressed(v: boolean) {
const newVal = !!v
if (newVal === suppressedExternally)
return
suppressedExternally = newVal
log('scrollWatcher', 'setSuppressed =>', suppressedExternally)
// When suppression is enabled, attach lightweight user-interaction
// listeners so real user input (wheel/pointer/touch) can immediately
// cancel suppression and mark the watcher as paused. This prevents
// programmatic suppression from blocking the user's attempt to scroll up.
try {
// Try to get the editor DOM node if available
if (!domNode && typeof (ed as any).getDomNode === 'function') {
domNode = (ed as any).getDomNode() as HTMLElement | null
}
if (suppressedExternally && domNode) {
if (!interactionListener) {
interactionListener = () => {
try {
log('scrollWatcher', 'user interaction detected while suppressed, cancelling suppression')
// Treat this as a pause (user took control)
opts.onPause()
// clear suppression so subsequent scroll events are observed
suppressedExternally = false
// update last known position to current scrollTop
const cur = ed.getScrollTop?.() ?? 0
opts.setLast(cur)
// If the user interaction already scrolled to bottom, treat as resume
try {
const scrollHeight = typeof (ed as any).getScrollHeight === 'function' ? (ed as any).getScrollHeight() : undefined
const li = typeof (ed as any).getLayoutInfo === 'function' ? (ed as any).getLayoutInfo() : undefined
const viewportH = li?.height ?? undefined
if (typeof scrollHeight === 'number' && typeof viewportH === 'number') {
const distance = scrollHeight - (cur + viewportH)
if (distance <= Math.max(THRESHOLD_PX, 0)) {
log('scrollWatcher', 'interaction moved to bottom, maybe resume', { distance })
opts.onMaybeResume()
}
}
}
catch { }
// remove listeners
if (domNode && interactionListener) {
domNode.removeEventListener('wheel', interactionListener, { passive: true } as any)
domNode.removeEventListener('pointerdown', interactionListener as any)
domNode.removeEventListener('touchstart', interactionListener as any)
}
interactionListener = null
}
catch { }
}
domNode.addEventListener('wheel', interactionListener, { passive: true } as any)
domNode.addEventListener('pointerdown', interactionListener as any)
domNode.addEventListener('touchstart', interactionListener as any)
}
}
else {
if (domNode && interactionListener) {
domNode.removeEventListener('wheel', interactionListener, { passive: true } as any)
domNode.removeEventListener('pointerdown', interactionListener as any)
domNode.removeEventListener('touchstart', interactionListener as any)
interactionListener = null
}
}
}
catch { }
},
}
return api
}