forked from boos/BoosAPI
feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user