1
0
forked from boos/BoosAPI

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

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
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()
})
}