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:
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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user