forked from boos/BoosAPI
feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
// shallow array equality (order-sensitive)
|
||||
export function arraysEqual<T>(
|
||||
a: readonly T[] | null | undefined,
|
||||
b: readonly T[] | null | undefined,
|
||||
) {
|
||||
if (a === b)
|
||||
return true
|
||||
if (!a || !b)
|
||||
return false
|
||||
if (a.length !== b.length)
|
||||
return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i])
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { log } from './logger'
|
||||
|
||||
export function createHeightManager(container: HTMLElement, computeNext: () => number | null) {
|
||||
// Debug logging is enabled during development but can be controlled via
|
||||
|
||||
let raf: number | null = null
|
||||
let debounceTimer: number | null = null
|
||||
let lastApplied = -1
|
||||
let suppressed = false
|
||||
// larger hysteresis to reduce perceived jitter during incremental growth
|
||||
const HYSTERESIS_PX = 12
|
||||
// debounce delay to coalesce frequent height changes (ms)
|
||||
const DEBOUNCE_MS = 0
|
||||
|
||||
function apply() {
|
||||
const next = computeNext()
|
||||
if (next == null) {
|
||||
return
|
||||
}
|
||||
log('heightManager', 'computeNext ->', { next, lastApplied })
|
||||
// Guard against invalid or non-positive heights which can collapse the
|
||||
// container. Some downstream callers may compute values incorrectly
|
||||
// during transient states; ignore those to keep the editor visible.
|
||||
if (!Number.isFinite(next) || next <= 0) {
|
||||
log('heightManager', 'invalid next height, ignoring', next)
|
||||
return
|
||||
}
|
||||
const currentHeight
|
||||
= Number.parseFloat(container.style.height || '')
|
||||
|| container.getBoundingClientRect?.().height
|
||||
|| 0
|
||||
if (
|
||||
currentHeight > 0
|
||||
&& Math.abs(next - currentHeight) <= HYSTERESIS_PX
|
||||
) {
|
||||
lastApplied = next
|
||||
return
|
||||
}
|
||||
if (lastApplied !== -1 && Math.abs(next - lastApplied) <= HYSTERESIS_PX) {
|
||||
return
|
||||
}
|
||||
if (next === lastApplied) {
|
||||
return
|
||||
}
|
||||
suppressed = true
|
||||
container.style.height = `${next}px`
|
||||
lastApplied = next
|
||||
log('heightManager', 'applied height ->', next)
|
||||
queueMicrotask(() => {
|
||||
suppressed = false
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleApply() {
|
||||
// Cancel any pending debounce timer and schedule a new one that will
|
||||
// requestAnimationFrame and run `apply`. This batches updates that occur
|
||||
// within DEBOUNCE_MS to avoid rapid reflows.
|
||||
if (debounceTimer != null) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
if (DEBOUNCE_MS === 0) {
|
||||
if (raf != null) {
|
||||
return
|
||||
}
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = null
|
||||
apply()
|
||||
})
|
||||
return
|
||||
}
|
||||
debounceTimer = (setTimeout(() => {
|
||||
debounceTimer = null
|
||||
if (raf != null) {
|
||||
return
|
||||
}
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = null
|
||||
apply()
|
||||
})
|
||||
}, DEBOUNCE_MS) as unknown) as number
|
||||
}
|
||||
|
||||
function update() {
|
||||
scheduleApply()
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (raf != null) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
if (debounceTimer != null) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
}
|
||||
function isSuppressed() {
|
||||
return suppressed
|
||||
}
|
||||
function getLastApplied() {
|
||||
return lastApplied
|
||||
}
|
||||
return { update, dispose, isSuppressed, getLastApplied }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
let seq = 0
|
||||
|
||||
// Centralized debug determination. Priority:
|
||||
// 1. browser global `window.__STREAM_MONACO_DEBUG__` if defined
|
||||
// Default: enable debug during local development
|
||||
export const DEBUG: boolean = (() => {
|
||||
if (typeof window !== 'undefined' && (window as any).__STREAM_MONACO_DEBUG__ !== undefined)
|
||||
return Boolean((window as any).__STREAM_MONACO_DEBUG__)
|
||||
|
||||
return false
|
||||
})()
|
||||
|
||||
export function log(tag: string, ...args: any[]) {
|
||||
if (!DEBUG)
|
||||
return
|
||||
seq += 1
|
||||
const id = `#${seq}`
|
||||
const ts = (typeof performance !== 'undefined' && performance.now)
|
||||
? (performance.now()).toFixed(1)
|
||||
: Date.now()
|
||||
// Use console.warn to comply with ESLint console restrictions.
|
||||
console.warn(`${id} [${tag}] @${ts}ms`, ...args)
|
||||
}
|
||||
|
||||
export function error(tag: string, ...args: any[]) {
|
||||
if (!DEBUG)
|
||||
return
|
||||
console.error(`[${tag}]`, ...args)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface TimeSource {
|
||||
requestAnimationFrame: (cb: FrameRequestCallback) => number
|
||||
cancelAnimationFrame: (id: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-instance RAF scheduler that coalesces tasks by "kind" within
|
||||
* the same instance. Instances are isolated so different editors won't
|
||||
* cancel each other's scheduled work.
|
||||
*/
|
||||
export function createRafScheduler(timeSource?: TimeSource) {
|
||||
const ids: Record<string, number | null> = {}
|
||||
const ts: TimeSource = timeSource ?? {
|
||||
requestAnimationFrame: (cb: FrameRequestCallback) => requestAnimationFrame(cb),
|
||||
cancelAnimationFrame: (id: number) => cancelAnimationFrame(id),
|
||||
}
|
||||
|
||||
function schedule(kind: string, cb: FrameRequestCallback) {
|
||||
const existing = ids[kind]
|
||||
if (existing != null) {
|
||||
ts.cancelAnimationFrame(existing)
|
||||
}
|
||||
ids[kind] = ts.requestAnimationFrame((t) => {
|
||||
ids[kind] = null
|
||||
cb(t)
|
||||
})
|
||||
}
|
||||
|
||||
function cancel(kind: string) {
|
||||
const id = ids[kind]
|
||||
if (id != null) {
|
||||
ts.cancelAnimationFrame(id)
|
||||
ids[kind] = null
|
||||
}
|
||||
}
|
||||
|
||||
return { schedule, cancel }
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import type { SpecialTheme, ThemeInput } from 'shiki'
|
||||
import { shikiToMonaco } from '@shikijs/monaco'
|
||||
import { createHighlighter } from 'shiki'
|
||||
import * as monaco from '../monaco-shim'
|
||||
import { arraysEqual } from './arraysEqual'
|
||||
|
||||
const LEGACY_ONIG_INIT_KEY = '__streamMonacoLegacyOnigurumaInit__'
|
||||
const LEGACY_ENGINE_KEY = '__streamMonacoLegacyShikiEngine__'
|
||||
const LEGACY_MONACO_LANGS_INIT_KEY = '__streamMonacoLegacyMonacoLangsInit__'
|
||||
|
||||
async function awaitLegacyOnigurumaInitIfPresent() {
|
||||
try {
|
||||
const p = (globalThis as any)?.[LEGACY_ONIG_INIT_KEY]
|
||||
if (p && typeof p.then === 'function')
|
||||
await p
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
async function awaitLegacyMonacoLanguageContributionsIfPresent() {
|
||||
try {
|
||||
const p = (globalThis as any)?.[LEGACY_MONACO_LANGS_INIT_KEY]
|
||||
if (p && typeof p.then === 'function')
|
||||
await p
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
async function getLegacyShikiEngineIfPresent() {
|
||||
try {
|
||||
const p = (globalThis as any)?.[LEGACY_ENGINE_KEY]
|
||||
if (p && typeof p.then === 'function')
|
||||
return await p
|
||||
}
|
||||
catch {}
|
||||
return null
|
||||
}
|
||||
|
||||
async function createHighlighterWithLegacyEngineIfNeeded(options: any) {
|
||||
await awaitLegacyOnigurumaInitIfPresent()
|
||||
await awaitLegacyMonacoLanguageContributionsIfPresent()
|
||||
const engine = await getLegacyShikiEngineIfPresent()
|
||||
if (engine)
|
||||
return createHighlighter({ ...options, engine })
|
||||
return createHighlighter(options)
|
||||
}
|
||||
|
||||
let languagesRegistered = false
|
||||
let currentLanguages: string[] = []
|
||||
// promise that resolves to a shiki highlighter or null when registration completes
|
||||
let themeRegisterPromise: Promise<import('../type').ShikiHighlighter | null> | null = null
|
||||
|
||||
// Serialize registrations to avoid races where multiple calls patch Monaco's
|
||||
// global `editor.setTheme` with different highlighters in an interleaved order.
|
||||
let registrationQueue: Promise<unknown> = Promise.resolve()
|
||||
function enqueueRegistration<T>(task: () => Promise<T>): Promise<T> {
|
||||
const next = registrationQueue.then(task, task)
|
||||
// keep queue alive even if a task rejects
|
||||
registrationQueue = next.then(() => undefined, () => undefined)
|
||||
return next
|
||||
}
|
||||
export function getThemeRegisterPromise() {
|
||||
return themeRegisterPromise
|
||||
}
|
||||
export function setThemeRegisterPromise(p: Promise<import('../type').ShikiHighlighter | null> | null) {
|
||||
return themeRegisterPromise = p
|
||||
}
|
||||
|
||||
interface HighlighterEntry {
|
||||
// promise that resolves to a shiki highlighter
|
||||
promise: Promise<any>
|
||||
// set of languages this highlighter was created with
|
||||
languages: Set<string>
|
||||
}
|
||||
|
||||
const highlighterCache = new Map<string, HighlighterEntry>()
|
||||
|
||||
// Monaco integration is global (monaco.editor.setTheme is patched by shikiToMonaco).
|
||||
// When multiple editors/code blocks initialize concurrently with different theme
|
||||
// sets, repeatedly calling shikiToMonaco with different highlighters causes the
|
||||
// last one to win, and themes not present in that last highlighter can throw.
|
||||
//
|
||||
// To avoid that, keep a single shared highlighter for Monaco and incrementally
|
||||
// load themes/languages into it.
|
||||
let monacoHighlighterPromise: Promise<import('../type').ShikiHighlighter> | null = null
|
||||
let lastPatchedHighlighter: import('../type').ShikiHighlighter | null = null
|
||||
let lastPatchedLanguages = new Set<string>()
|
||||
let lastPatchedThemes = new Set<string>()
|
||||
const monacoThemeByKey = new Map<string, ThemeInput | string | SpecialTheme>()
|
||||
const monacoLanguageSet = new Set<string>()
|
||||
|
||||
function themeKey(t: ThemeInput | string | SpecialTheme) {
|
||||
return typeof t === 'string' ? t : (t as any).name ?? JSON.stringify(t)
|
||||
}
|
||||
|
||||
async function ensureMonacoHighlighter(
|
||||
themes: (ThemeInput | string | SpecialTheme)[],
|
||||
languages: string[],
|
||||
) {
|
||||
// track union first
|
||||
for (const t of themes)
|
||||
monacoThemeByKey.set(themeKey(t), t)
|
||||
for (const l of languages)
|
||||
monacoLanguageSet.add(l)
|
||||
|
||||
// create shared highlighter once
|
||||
if (!monacoHighlighterPromise) {
|
||||
const initialThemes = Array.from(monacoThemeByKey.values())
|
||||
const initialLangs = Array.from(monacoLanguageSet.values())
|
||||
monacoHighlighterPromise = createHighlighterWithLegacyEngineIfNeeded({ themes: initialThemes, langs: initialLangs })
|
||||
.then(h => h)
|
||||
}
|
||||
|
||||
const h = await monacoHighlighterPromise
|
||||
|
||||
// Incrementally load missing themes/langs when supported by shiki.
|
||||
// If incremental loading isn't available, fall back to re-creating the shared
|
||||
// highlighter with the full union (still safe because the union only grows).
|
||||
const wantsThemes = Array.from(monacoThemeByKey.values())
|
||||
const wantsLangs = Array.from(monacoLanguageSet.values())
|
||||
|
||||
const canLoadTheme = typeof (h as any).loadTheme === 'function'
|
||||
const canLoadLanguage = typeof (h as any).loadLanguage === 'function'
|
||||
|
||||
if (canLoadTheme || canLoadLanguage) {
|
||||
if (canLoadTheme) {
|
||||
// loadTheme is idempotent in shiki; call only for newly requested theme keys
|
||||
for (const t of themes) {
|
||||
const k = themeKey(t)
|
||||
// If we've already loaded it, skip.
|
||||
// We can't reliably introspect loaded themes from shiki, so track keys.
|
||||
if (!(h as any).__streamMonacoLoadedThemes)
|
||||
(h as any).__streamMonacoLoadedThemes = new Set<string>()
|
||||
const loaded: Set<string> = (h as any).__streamMonacoLoadedThemes
|
||||
if (loaded.has(k))
|
||||
continue
|
||||
await (h as any).loadTheme(t)
|
||||
loaded.add(k)
|
||||
}
|
||||
}
|
||||
|
||||
if (canLoadLanguage) {
|
||||
for (const l of languages) {
|
||||
if (!(h as any).__streamMonacoLoadedLangs)
|
||||
(h as any).__streamMonacoLoadedLangs = new Set<string>()
|
||||
const loaded: Set<string> = (h as any).__streamMonacoLoadedLangs
|
||||
if (loaded.has(l))
|
||||
continue
|
||||
await (h as any).loadLanguage(l)
|
||||
loaded.add(l)
|
||||
}
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// fallback: recreate shared highlighter with union
|
||||
const p = createHighlighterWithLegacyEngineIfNeeded({ themes: wantsThemes, langs: wantsLangs })
|
||||
.then(hh => hh)
|
||||
monacoHighlighterPromise = p
|
||||
return p
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached shiki highlighters.
|
||||
*
|
||||
* Useful for long-running apps that dynamically create many theme combinations,
|
||||
* or in tests to ensure a clean state. Call this when you know the highlighters
|
||||
* are no longer needed (for example on app shutdown) to free memory.
|
||||
*/
|
||||
export function clearHighlighterCache() {
|
||||
highlighterCache.clear()
|
||||
monacoHighlighterPromise = null
|
||||
lastPatchedHighlighter = null
|
||||
lastPatchedLanguages = new Set<string>()
|
||||
monacoThemeByKey.clear()
|
||||
monacoLanguageSet.clear()
|
||||
themeRegisterPromise = null
|
||||
languagesRegistered = false
|
||||
currentLanguages = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Return number of entries currently in the highlighter cache.
|
||||
* Helpful for tests and debugging.
|
||||
*/
|
||||
export function getHighlighterCacheSize() {
|
||||
return highlighterCache.size
|
||||
}
|
||||
|
||||
function serializeThemes(themes: (ThemeInput | string | SpecialTheme)[]) {
|
||||
return JSON.stringify(
|
||||
themes.map(t => typeof t === 'string' ? t : (t as any).name ?? JSON.stringify(t)).sort(),
|
||||
)
|
||||
}
|
||||
|
||||
async function getOrCreateHighlighter(
|
||||
themes: (ThemeInput | string | SpecialTheme)[],
|
||||
languages: string[],
|
||||
): Promise<import('../type').ShikiHighlighter> {
|
||||
const key = serializeThemes(themes)
|
||||
const requestedSet = new Set(languages)
|
||||
let existing = highlighterCache.get(key)
|
||||
|
||||
if (existing) {
|
||||
// if existing entry already covers requested languages, reuse
|
||||
let allIncluded = true
|
||||
for (const l of requestedSet) {
|
||||
if (!existing.languages.has(l)) {
|
||||
allIncluded = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (allIncluded) {
|
||||
return existing.promise
|
||||
}
|
||||
|
||||
// double-check cache in case a concurrent request already replaced/expanded the entry
|
||||
const prev = existing
|
||||
const current = highlighterCache.get(key)
|
||||
if (current && current !== prev) {
|
||||
// if the current cached entry already covers requested languages, reuse it
|
||||
let allIncludedCurrent = true
|
||||
for (const l of requestedSet) {
|
||||
if (!current.languages.has(l)) {
|
||||
allIncludedCurrent = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (allIncludedCurrent) {
|
||||
return current.promise
|
||||
}
|
||||
// otherwise prefer the most recent cached entry for the union creation
|
||||
existing = current
|
||||
}
|
||||
|
||||
// otherwise create a new highlighter with the union of languages
|
||||
const union = new Set<string>([...existing.languages, ...requestedSet])
|
||||
const langsArray = Array.from(union)
|
||||
const p = createHighlighterWithLegacyEngineIfNeeded({ themes, langs: langsArray })
|
||||
const newEntry: HighlighterEntry = { promise: p, languages: union }
|
||||
highlighterCache.set(key, newEntry)
|
||||
|
||||
// if creation fails, try to restore previous entry (prev)
|
||||
p.catch(() => {
|
||||
if (highlighterCache.get(key) === newEntry && prev) {
|
||||
highlighterCache.set(key, prev)
|
||||
}
|
||||
})
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// no cached entry, create and cache
|
||||
const p = createHighlighterWithLegacyEngineIfNeeded({ themes, langs: Array.from(requestedSet) })
|
||||
const entry: HighlighterEntry = { promise: p, languages: requestedSet }
|
||||
highlighterCache.set(key, entry)
|
||||
p.catch(() => {
|
||||
if (highlighterCache.get(key) === entry) {
|
||||
highlighterCache.delete(key)
|
||||
}
|
||||
})
|
||||
return p
|
||||
}
|
||||
// Exported for callers that need direct access to the shiki highlighter
|
||||
export { getOrCreateHighlighter }
|
||||
|
||||
/**
|
||||
* Update the theme used by the shiki highlighter for a given themes+languages
|
||||
* combination. Useful when Monaco themes are already registered (so switching
|
||||
* Monaco only requires `monaco.editor.setTheme`) but you also want shiki's
|
||||
* standalone renderer to use the new theme without recreating everything.
|
||||
*/
|
||||
// NOTE: setHighlighterTheme removed — switching Monaco theme via
|
||||
// `monaco.editor.setTheme(themeName)` is sufficient for editor theme changes.
|
||||
// If consumers need to directly control a shiki highlighter they can use
|
||||
// `getOrCreateHighlighter(...)` and call methods on the returned object.
|
||||
export async function registerMonacoThemes(
|
||||
themes: (ThemeInput | string | SpecialTheme)[],
|
||||
languages: string[],
|
||||
): Promise<import('../type').ShikiHighlighter | null> {
|
||||
return enqueueRegistration(async () => {
|
||||
registerMonacoLanguages(languages)
|
||||
|
||||
const p = (async () => {
|
||||
const highlighter = await ensureMonacoHighlighter(themes, languages)
|
||||
|
||||
// Patch Monaco when:
|
||||
// - the shared highlighter instance changes, OR
|
||||
// - new languages were added (incremental load) and need tokens providers.
|
||||
//
|
||||
// `shikiToMonaco()` installs tokens providers per language at call-time.
|
||||
// When we incrementally load languages into the shared highlighter, we
|
||||
// must re-run `shikiToMonaco()` so Monaco receives providers for the newly
|
||||
// loaded languages.
|
||||
const wantsLangs = Array.from(monacoLanguageSet.values())
|
||||
const loadedThemeIds = typeof highlighter.getLoadedThemes === 'function'
|
||||
? highlighter.getLoadedThemes()
|
||||
: []
|
||||
const needsThemePatch = loadedThemeIds.some((t: string) => !lastPatchedThemes.has(t))
|
||||
const needsLanguagePatch = lastPatchedHighlighter !== highlighter
|
||||
|| wantsLangs.some(l => !lastPatchedLanguages.has(l))
|
||||
|| needsThemePatch
|
||||
|
||||
if (needsLanguagePatch) {
|
||||
if (lastPatchedHighlighter !== highlighter)
|
||||
lastPatchedLanguages = new Set<string>()
|
||||
|
||||
// In some bundlers (notably Webpack 4), Shiki/TextMate tokenization can
|
||||
// still throw at runtime (e.g. `null.compileAG`) due to regex engine
|
||||
// initialization quirks. Monaco reports these as "unexpected errors"
|
||||
// and they crash the app.
|
||||
//
|
||||
// We cannot monkey-patch `import * as monaco` namespace exports (ESM
|
||||
// namespace objects are immutable), so pass a proxy object into
|
||||
// `shikiToMonaco()` that wraps `setTokensProvider` instead.
|
||||
const realLanguages: any = (monaco as any).languages
|
||||
const realEditor: any = (monaco as any).editor
|
||||
const setTokensProvider = typeof realLanguages?.setTokensProvider === 'function'
|
||||
? realLanguages.setTokensProvider.bind(realLanguages)
|
||||
: null
|
||||
const getLanguages = typeof realLanguages?.getLanguages === 'function'
|
||||
? realLanguages.getLanguages.bind(realLanguages)
|
||||
: null
|
||||
|
||||
const monacoProxy: any = {
|
||||
// Forward everything else (Range, Uri, etc.) via prototype lookup.
|
||||
__proto__: monaco as any,
|
||||
editor: realEditor,
|
||||
languages: {
|
||||
__proto__: realLanguages,
|
||||
getLanguages,
|
||||
setTokensProvider(lang: string, provider: any) {
|
||||
if (provider && typeof provider.tokenize === 'function') {
|
||||
const originalTokenize = provider.tokenize.bind(provider)
|
||||
provider = {
|
||||
...provider,
|
||||
tokenize(line: string, state: any) {
|
||||
try {
|
||||
return originalTokenize(line, state)
|
||||
}
|
||||
catch {
|
||||
return {
|
||||
endState: state,
|
||||
tokens: [{ startIndex: 0, scopes: '' }],
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
return setTokensProvider?.(lang, provider)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
shikiToMonaco(highlighter, monacoProxy)
|
||||
lastPatchedHighlighter = highlighter
|
||||
lastPatchedLanguages = new Set(wantsLangs)
|
||||
lastPatchedThemes = new Set(loadedThemeIds)
|
||||
}
|
||||
|
||||
// Track last language set for Monaco language registration short-circuit.
|
||||
currentLanguages = languages.slice()
|
||||
return highlighter
|
||||
})()
|
||||
|
||||
setThemeRegisterPromise(p)
|
||||
try {
|
||||
const res = await p
|
||||
return res
|
||||
}
|
||||
catch (e) {
|
||||
setThemeRegisterPromise(null)
|
||||
throw e
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function registerMonacoLanguages(languages: string[]) {
|
||||
if (languagesRegistered && arraysEqual(languages, currentLanguages)) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = new Set(monaco.languages.getLanguages().map(l => l.id))
|
||||
for (const lang of languages) {
|
||||
if (!existing.has(lang)) {
|
||||
try {
|
||||
monaco.languages.register({ id: lang })
|
||||
}
|
||||
catch {
|
||||
// ignore unsupported ids
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
languagesRegistered = true
|
||||
currentLanguages = languages
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { log } from './logger'
|
||||
|
||||
export function createScrollWatcherForEditor(
|
||||
ed: any,
|
||||
opts: {
|
||||
onPause: () => void
|
||||
onMaybeResume: () => void
|
||||
getLast: () => number
|
||||
setLast: (v: number) => void
|
||||
},
|
||||
) {
|
||||
const initial = ed.getScrollTop?.() ?? 0
|
||||
opts.setLast(initial)
|
||||
log('scrollWatcher', 'initial scrollTop=', initial)
|
||||
let suppressedExternally = false
|
||||
const THRESHOLD_PX = 6
|
||||
let domNode: HTMLElement | null = null
|
||||
let interactionListener: ((e: Event) => void) | null = null
|
||||
const listener = (e: any) => {
|
||||
if (suppressedExternally) {
|
||||
log('scrollWatcher', 'suppressedExternally, ignoring event')
|
||||
return
|
||||
}
|
||||
const currentTop = e && typeof e.scrollTop === 'number' ? e.scrollTop : ed.getScrollTop?.() ?? 0
|
||||
const delta = currentTop - opts.getLast()
|
||||
// update last seen position immediately
|
||||
opts.setLast(currentTop)
|
||||
// ignore very small scroll deltas (usually from layout/height adjustments)
|
||||
if (Math.abs(delta) < THRESHOLD_PX) {
|
||||
log('scrollWatcher', 'small delta ignored', delta)
|
||||
// If the delta is small, check whether the viewport is effectively
|
||||
// at the bottom of the scrollable area. In that case we should treat
|
||||
// this as a potential resume so streaming auto-scroll can re-enable.
|
||||
try {
|
||||
const scrollHeight = typeof ed.getScrollHeight === 'function' ? ed.getScrollHeight() : undefined
|
||||
const li = typeof (ed as any).getLayoutInfo === 'function' ? (ed as any).getLayoutInfo() : undefined
|
||||
const viewportH = li?.height ?? undefined
|
||||
if (typeof scrollHeight === 'number' && typeof viewportH === 'number') {
|
||||
const distance = scrollHeight - (currentTop + viewportH)
|
||||
if (distance <= Math.max(THRESHOLD_PX, 0)) {
|
||||
log('scrollWatcher', 'small delta but at bottom, maybe resume', { distance })
|
||||
opts.onMaybeResume()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return
|
||||
}
|
||||
log('scrollWatcher', 'delta=', delta, 'currentTop=', currentTop)
|
||||
if (delta < 0) {
|
||||
log('scrollWatcher', 'pause detected delta=', delta)
|
||||
opts.onPause()
|
||||
return
|
||||
}
|
||||
log('scrollWatcher', 'maybe resume delta=', delta)
|
||||
opts.onMaybeResume()
|
||||
}
|
||||
|
||||
const disp = ed.onDidScrollChange?.(listener) ?? null
|
||||
|
||||
// Return an object that provides dispose and suppression control. We keep
|
||||
// the dispose method for compatibility with monaco.IDisposable.
|
||||
const api: any = {
|
||||
dispose() {
|
||||
try {
|
||||
if (disp && typeof disp.dispose === 'function')
|
||||
disp.dispose()
|
||||
else if (typeof disp === 'function')
|
||||
disp()
|
||||
}
|
||||
catch { }
|
||||
log('scrollWatcher', 'dispose')
|
||||
},
|
||||
setSuppressed(v: boolean) {
|
||||
const newVal = !!v
|
||||
if (newVal === suppressedExternally)
|
||||
return
|
||||
suppressedExternally = newVal
|
||||
log('scrollWatcher', 'setSuppressed =>', suppressedExternally)
|
||||
|
||||
// When suppression is enabled, attach lightweight user-interaction
|
||||
// listeners so real user input (wheel/pointer/touch) can immediately
|
||||
// cancel suppression and mark the watcher as paused. This prevents
|
||||
// programmatic suppression from blocking the user's attempt to scroll up.
|
||||
try {
|
||||
// Try to get the editor DOM node if available
|
||||
if (!domNode && typeof (ed as any).getDomNode === 'function') {
|
||||
domNode = (ed as any).getDomNode() as HTMLElement | null
|
||||
}
|
||||
if (suppressedExternally && domNode) {
|
||||
if (!interactionListener) {
|
||||
interactionListener = () => {
|
||||
try {
|
||||
log('scrollWatcher', 'user interaction detected while suppressed, cancelling suppression')
|
||||
// Treat this as a pause (user took control)
|
||||
opts.onPause()
|
||||
// clear suppression so subsequent scroll events are observed
|
||||
suppressedExternally = false
|
||||
// update last known position to current scrollTop
|
||||
const cur = ed.getScrollTop?.() ?? 0
|
||||
opts.setLast(cur)
|
||||
// If the user interaction already scrolled to bottom, treat as resume
|
||||
try {
|
||||
const scrollHeight = typeof (ed as any).getScrollHeight === 'function' ? (ed as any).getScrollHeight() : undefined
|
||||
const li = typeof (ed as any).getLayoutInfo === 'function' ? (ed as any).getLayoutInfo() : undefined
|
||||
const viewportH = li?.height ?? undefined
|
||||
if (typeof scrollHeight === 'number' && typeof viewportH === 'number') {
|
||||
const distance = scrollHeight - (cur + viewportH)
|
||||
if (distance <= Math.max(THRESHOLD_PX, 0)) {
|
||||
log('scrollWatcher', 'interaction moved to bottom, maybe resume', { distance })
|
||||
opts.onMaybeResume()
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
// remove listeners
|
||||
if (domNode && interactionListener) {
|
||||
domNode.removeEventListener('wheel', interactionListener, { passive: true } as any)
|
||||
domNode.removeEventListener('pointerdown', interactionListener as any)
|
||||
domNode.removeEventListener('touchstart', interactionListener as any)
|
||||
}
|
||||
interactionListener = null
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
domNode.addEventListener('wheel', interactionListener, { passive: true } as any)
|
||||
domNode.addEventListener('pointerdown', interactionListener as any)
|
||||
domNode.addEventListener('touchstart', interactionListener as any)
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (domNode && interactionListener) {
|
||||
domNode.removeEventListener('wheel', interactionListener, { passive: true } as any)
|
||||
domNode.removeEventListener('pointerdown', interactionListener as any)
|
||||
domNode.removeEventListener('touchstart', interactionListener as any)
|
||||
interactionListener = null
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
},
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
Reference in New Issue
Block a user