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

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

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

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+2
View File
@@ -0,0 +1,2 @@
github: Simon-He95
custom: ['https://github.com/Simon-He95/sponsor']
+111
View File
@@ -0,0 +1,111 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
merge_group: {}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Set node
uses: actions/setup-node@v4
with:
node-version: 23
cache: pnpm
- name: Setup
run: npm i -g @antfu/ni
- name: Install
run: nci
- name: Lint
run: nr lint
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node_version: [23, lts/*]
include:
- os: macos-latest
node_version: lts/*
- os: windows-latest
node_version: lts/*
fail-fast: false
steps:
- name: Set git to use LF
run: |
git config --global core.autocrlf false
git config --global core.eol lf
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Set node ${{ matrix.node_version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node_version }}
cache: pnpm
- run: corepack enable
- name: Setup
run: npm i -g @antfu/ni
- name: Install
run: nci
- name: Build
run: nr build
- name: Test
run: nr test
- name: Typecheck
run: nr typecheck
playwright-smoke:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Set node
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm
- run: corepack enable
- name: Install
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium
- name: Diff smoke
run: pnpm smoke:diff
@@ -0,0 +1,44 @@
name: Release
permissions:
id-token: write
contents: write
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm
registry-url: https://registry.npmjs.org/
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm vitest run
- run: pnpm typecheck
- run: pnpm dlx changelogithub
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- run: pnpm publish --no-git-checks --access public
if: ${{ secrets.NPM_TOKEN != '' }}
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
NPM_CONFIG_PROVENANCE: true
+16
View File
@@ -0,0 +1,16 @@
*.DS_Store
node_modules
*.log
idea/
*.local
.DS_Store
dist
.cache
.idea
.history
logs
&-debug.log
*-error.log
.eslintcache
tmp
+1
View File
@@ -0,0 +1 @@
ignore-workspace-root-check=true
@@ -0,0 +1,12 @@
packages/*/CHANGELOG.md
playground-temp/
dist/
temp/
LICENSE.md
pnpm-lock.yaml
pnpm-workspace.yaml
playground/tsconfig-json-load-error/has-error/tsconfig.json
playground/html/invalid.html
playground/html/valid.html
playground/worker/classic-worker.js
.history
@@ -0,0 +1,22 @@
{
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 80,
"trailingComma": "all",
"overrides": [
{
"files": ["*.json5"],
"options": {
"singleQuote": false,
"quoteProps": "preserve"
}
},
{
"files": ["*.yml"],
"options": {
"singleQuote": false
}
}
]
}
+22
View File
@@ -0,0 +1,22 @@
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
## [0.0.34] - 2026-04-15
- Added `updateThrottleMs` option (default 50ms) to throttle `updateCode` in addition to RAF-based coalescing. This reduces CPU usage in high-frequency streaming scenarios. Users can set to `0` to restore previous behavior (only RAF merging).
- Exposed `setUpdateThrottleMs(ms)` and `getUpdateThrottleMs()` on the `useMonaco` return value so the throttle can be adjusted at runtime.
- Exposed `minimalEditMaxChars` and `minimalEditMaxChangeRatio` options to control when the library falls back to full `setValue` instead of attempting minimal edits for large documents.
- Stabilized the `useMonaco()` create lifecycle so superseded creates are rejected cleanly and single-editor updates are replayed after creation commits.
- Split diff presentation helpers out of `DiffEditorManager` into focused modules for appearance, hunk behavior, unchanged-region DOM, and viewport logic.
- Expanded automated coverage for diff helpers, unchanged-region behavior, lifecycle cleanup, and minimal-edit option overrides.
- Added a React + Vite example app under `examples/react-demo`.
- Added a Playwright diff smoke job to CI.
- Aligned release metadata and workflow behavior around `v*` tags.
- `clearHighlighterCache()` now resets the shared Monaco/Shiki highlighter state in addition to clearing cached entries.
### Notes
- These changes are non-breaking. To preserve original behavior, set `updateThrottleMs: 0`.
+743
View File
@@ -0,0 +1,743 @@
# stream-monaco
[![NPM version](https://img.shields.io/npm/v/stream-monaco?color=a1b858&label=)](https://www.npmjs.com/package/stream-monaco)
[![中文版](https://img.shields.io/badge/docs-中文文档-blue)](README.zh-CN.md)
[![NPM downloads](https://img.shields.io/npm/dm/stream-monaco)](https://www.npmjs.com/package/stream-monaco)
[![Bundle size](https://img.shields.io/bundlephobia/minzip/stream-monaco)](https://bundlephobia.com/package/stream-monaco)
[![License](https://img.shields.io/npm/l/stream-monaco)](./LICENSE)
Streaming Monaco Editor integration with Shiki syntax highlighting. Framework-agnostic core for Vue, React, Svelte, Solid, Preact, and Vanilla JS.
### Introduction
stream-monaco provides a framework-agnostic core for integrating Monaco Editor with Shiki syntax highlighting, optimized for streaming updates and efficient highlighting. It works great without Vue, while also offering a Vue-friendly API and examples.
IMPORTANT: Since v0.0.32, `updateCode` is time-throttled by default (`updateThrottleMs = 50`) to reduce CPU usage under high-frequency streaming. Set `updateThrottleMs: 0` in `useMonaco()` options to restore previous RAF-only behavior.
Note: Internally, reactivity now uses a thin adapter over `alien-signals`, so Vue is no longer a hard requirement at runtime for the core logic. Vue remains supported, but is an optional peer dependency. This makes the package more portable in non-Vue environments while keeping the same API.
### Features
- 🚀 Works without Vue (framework-agnostic core)
- 🌿 Ready to use with Vue 3 Composition API
- 🔁 Use in any framework: Vue, React, Svelte, Solid, Preact, or plain JS/TS
- 🎨 Shiki highlighting with TextMate grammars and VS Code themes
- 🌓 Dark/Light theme switching
- 📝 Streaming updates (append/minimal-edit)
- 🔀 Diff editor with efficient incremental updates
- 🗑️ Auto cleanup to avoid memory leaks
- 🔧 Highly configurable (all Monaco options)
- 🎯 Full TypeScript support
### Quick API overview
The package exports helpers around theme/highlighter for advanced use:
- `registerMonacoThemes(themes, languages): Promise<Highlighter>` — create or reuse a Shiki highlighter and register themes to Monaco. Returns a Promise resolving to the highlighter for reuse (e.g., rendering snippets).
- `getOrCreateHighlighter(themes, languages): Promise<Highlighter>` — get or create a highlighter (managed by internal cache). If you need to call `codeToHtml` or `setTheme` manually, use this and handle loading/errors.
Note:
- If the target theme is already included in the `themes` you passed to `useMonaco()`, calling `monaco.editor.setTheme(themeName)` is fine.
- If you switch to a theme that was not pre-registered (e.g. dynamic theme name like `andromeeda`), prefer `await setTheme(themeName)` from `useMonaco()`. It will ensure the theme is registered, and when possible it will also try to `loadTheme` on the underlying Shiki highlighter to avoid "Theme not found, you may need to load it first" errors.
Config: `useMonaco()` does not auto-sync an external Shiki highlighter; if you need external Shiki snippets to follow theme changes, call `getOrCreateHighlighter(...)` and `highlighter.setTheme(...)` yourself.
### API Reference
#### useMonaco(options?)
##### Parameters
| Parameter | Type | Default | Description |
| --------------------------- | ------------------------------------------------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------ |
| `MAX_HEIGHT` | `number \| string` | `500` | Maximum editor height |
| `readOnly` | `boolean` | `true` | Whether the editor is read-only |
| `themes` | `MonacoTheme[]` | `['vitesse-dark', 'vitesse-light']` | Theme list; should include at least one dark and one light theme |
| `languages` | `MonacoLanguage[]` | built-in default language list | Languages to register |
| `theme` | `string` | - | Initial theme name |
| `isCleanOnBeforeCreate` | `boolean` | `true` | Whether to dispose previously registered resources before creating a new editor |
| `onBeforeCreate` | `function` | - | Hook called before editor creation |
| `autoScrollOnUpdate` | `boolean` | `true` | Auto-scroll when content updates and the viewport is already near the bottom |
| `autoScrollInitial` | `boolean` | `true` | Whether auto-scroll starts enabled |
| `autoScrollThresholdPx` | `number` | `32` | Pixel threshold for considering the viewport "near bottom" |
| `autoScrollThresholdLines` | `number` | `2` | Line-based threshold for considering the viewport "near bottom" |
| `diffAutoScroll` | `boolean` | `true` | Whether diff auto-scroll is enabled for the modified side |
| `diffHideUnchangedRegions` | `boolean \| object` | `true` | Fold unchanged regions in diff mode; accepts Monaco `hideUnchangedRegions` config too |
| `diffLineStyle` | `'background' \| 'bar'` | `'background'` | Controls whether changed lines are emphasized more as filled blocks or review-style bars |
| `diffAppearance` | `'auto' \| 'light' \| 'dark'` | `'auto'` | Controls the diff shell chrome appearance while token colors still follow the active theme |
| `diffUnchangedRegionStyle` | `'line-info' \| 'line-info-basic' \| 'metadata' \| 'simple'` | `'line-info'` | Controls how collapsed unchanged regions are rendered |
| `diffHunkActionsOnHover` | `boolean` | `false` | Enables split upper/lower hunk hover actions: `Revert` / `Stage` |
| `diffHunkHoverHideDelayMs` | `number` | `160` | Hide delay for hunk hover action widgets |
| `onDiffHunkAction` | `function` | - | Hunk action callback; return `false` to skip built-in edits |
| `revealDebounceMs` | `number` | `75` | Debounce for auto-reveal during streaming updates |
| `revealStrategy` | `'bottom' \| 'centerIfOutside' \| 'center'` | `'centerIfOutside'` | Reveal strategy used when auto-scrolling |
| `revealBatchOnIdleMs` | `number \| undefined` | - | Final idle-time reveal window for append-heavy scenarios |
| `updateThrottleMs` | `number` | `50` | Time-based throttle for `updateCode` |
| `diffUpdateThrottleMs` | `number` | `50` | Time-based throttle for diff streaming updates |
| `minimalEditMaxChars` | `number` | built-in constant | Fallback to `setValue` when documents are too large for minimal-edit diffing |
| `minimalEditMaxChangeRatio` | `number` | built-in constant | Fallback to `setValue` when the change ratio is too large |
`MonacoOptions` also includes Monaco's native editor and diff-editor construction options, so you can pass options such as `renderSideBySide`, `ignoreTrimWhitespace`, `originalEditable`, or `enableSplitViewResizing` directly.
##### Returns
| Method / property | Type | Description |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `createEditor` | `(container: HTMLElement, code: string, language: string) => Promise<MonacoEditorInstance>` | Create and mount a standalone editor |
| `createDiffEditor` | `(container: HTMLElement, original: string, modified: string, language: string) => Promise<MonacoDiffEditorInstance>` | Create and mount a diff editor |
| `cleanupEditor` | `() => void` | Dispose editor instances and cleanup resources |
| `safeClean` | `() => void` | Cancel pending update queues/RAFs without fully tearing down the integration |
| `updateCode` | `(newCode: string, codeLanguage: string) => void` | Update single-editor content and language |
| `appendCode` | `(appendText: string, codeLanguage?: string) => void` | Append to the single editor, optimized for streaming |
| `updateDiff` | `(original: string, modified: string, codeLanguage?: string) => void` | Update both sides of a diff editor |
| `updateOriginal` | `(newCode: string, codeLanguage?: string) => void` | Update only the original side |
| `updateModified` | `(newCode: string, codeLanguage?: string) => void` | Update only the modified side |
| `appendOriginal` | `(appendText: string, codeLanguage?: string) => void` | Append to the original side |
| `appendModified` | `(appendText: string, codeLanguage?: string) => void` | Append to the modified side |
| `setDiffModels` | `(models: DiffModelPair, options?: DiffModelTransitionOptions) => Promise<void>` | Swap the whole diff model pair in place; same-content swaps prewarm Monaco's diff view model and preserve view state by default |
| `setTheme` | `(theme: MonacoTheme, force?: boolean) => Promise<void>` | Switch editor theme; `force=true` re-applies even if the theme is already active |
| `refreshDiffPresentation` | `() => void` | Recompute diff chrome / unchanged overlay presentation without remounting |
| `setLanguage` | `(language: MonacoLanguage) => void` | Switch editor language |
| `getCurrentTheme` | `() => string` | Get the current theme name |
| `getEditor` | `() => typeof monaco.editor` | Get Monaco's static editor API |
| `getEditorView` | `() => MonacoEditorInstance \| null` | Get the current standalone editor instance |
| `getDiffEditorView` | `() => MonacoDiffEditorInstance \| null` | Get the current diff editor instance |
| `getDiffModels` | `() => DiffModels` | Get both diff models |
| `getMonacoInstance` | `() => typeof monaco` | Get the Monaco module instance |
| `setUpdateThrottleMs` | `(ms: number) => void` | Change `updateCode` throttling at runtime |
| `getUpdateThrottleMs` | `() => number` | Get the current `updateCode` throttle value |
| `getCode` | `() => MonacoCodeValue` | Get the latest code value from the current editor or diff editor |
##### Exported TypeScript helpers
The package exports named types for diff integration too:
- `UseMonacoReturn`
- `MonacoOptions`
- `MonacoTheme`
- `MonacoLanguage`
- `DiffHideUnchangedRegions`
- `DiffLineStyle`
- `DiffAppearance`
- `DiffUnchangedRegionStyle`
- `DiffModels`
- `DiffModelPair`
- `DiffModelTransitionOptions`
- `DiffCodeValue`
- `MonacoCodeValue`
- `DiffHunkActionContext`
For a more complete integration walkthrough, see [Diff Integration Guide](docs/diff-integration.md).
#### Diff streaming highlight tip
Monaco's diff computation is async and cancels/restarts when models change. If you stream updates too frequently (e.g. per token / every frame), the diff may only finish once streaming stops, so the difference highlights appear "at the end".
- Set `diffUpdateThrottleMs` to let the diff worker complete intermediate computations during streaming.
- The library defaults to `50ms`.
- When `diffHideUnchangedRegions` is enabled, unchanged-region folding is deferred until diff streaming goes idle to avoid collapsing and re-expanding while content is still arriving.
- Set it to `0` to restore pure RAF batching (most responsive, but may delay diff highlights under heavy streaming).
### Install
```bash
pnpm add stream-monaco
# or
npm install stream-monaco
# or
yarn add stream-monaco
```
Note: Vue is optional. If you don't use Vue, you don't need to install it.
### Example apps
- `examples/streaming-demo` — Vue 3 + Vite demo with streaming, diff, and diff UX routes
- `examples/react-demo` — React + Vite demo with in-place theme/language switching and `appendCode()` streaming
Quick start:
```bash
pnpm -C examples/streaming-demo dev
pnpm -C examples/react-demo dev
```
### Basic usage (Vue)
```vue
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useMonaco } from 'stream-monaco'
const props = defineProps<{
code: string
language: string
}>()
const codeEditor = ref<HTMLElement>()
const { createEditor, updateCode, cleanupEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript', 'typescript', 'vue', 'python'],
readOnly: false,
MAX_HEIGHT: 600,
})
onMounted(async () => {
if (codeEditor.value) {
await createEditor(codeEditor.value, props.code, props.language)
}
})
watch(
() => [props.code, props.language],
([newCode, newLanguage]) => {
updateCode(newCode, newLanguage)
},
)
</script>
<template>
<div ref="codeEditor" class="monaco-editor-container" />
</template>
<style scoped>
.monaco-editor-container {
border: 1px solid #e0e0e0;
border-radius: 4px;
}
</style>
```
### Basic usage (React)
```tsx
import { useEffect, useRef } from 'react'
import { useMonaco } from 'stream-monaco'
export function MonacoEditor() {
const containerRef = useRef<HTMLDivElement>(null)
const { createEditor, cleanupEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['typescript', 'javascript'],
})
useEffect(() => {
if (containerRef.current) {
createEditor(
containerRef.current,
'console.log("Hello, Monaco!")',
'typescript',
)
}
return () => cleanupEditor()
}, [])
return (
<div
ref={containerRef}
style={{ height: 500, border: '1px solid #e0e0e0' }}
/>
)
}
```
Note: Svelte, Solid, and Preact integrations follow the same pattern — create a container element, call `createEditor` on mount, and `cleanupEditor` on unmount.
### Full config example (Vue)
```vue
<script setup lang="ts">
import type { MonacoLanguage, MonacoTheme } from 'stream-monaco'
import { onMounted, ref } from 'vue'
import { useMonaco } from 'stream-monaco'
const editorContainer = ref<HTMLElement>()
const {
createEditor,
updateCode,
setTheme,
setLanguage,
getCurrentTheme,
getEditor,
getEditorView,
getCode,
cleanupEditor,
} = useMonaco({
themes: ['github-dark', 'github-light'],
languages: ['javascript', 'typescript', 'python', 'vue', 'json'],
MAX_HEIGHT: 500,
readOnly: false,
isCleanOnBeforeCreate: true,
onBeforeCreate: (monaco) => {
console.log('Monaco editor is about to be created', monaco)
return []
},
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
minimap: { enabled: false },
scrollbar: {
verticalScrollbarSize: 10,
horizontalScrollbarSize: 10,
alwaysConsumeMouseWheel: false,
},
revealDebounceMs: 75,
})
onMounted(async () => {
if (editorContainer.value) {
const editor = await createEditor(
editorContainer.value,
'console.log("Hello, Monaco!")',
'javascript',
)
console.log('Editor created:', editor)
}
})
async function switchTheme(theme: MonacoTheme) {
await setTheme(theme)
// await setTheme(theme, true) // force re-apply even if same
}
function switchLanguage(language: MonacoLanguage) {
setLanguage(language)
}
function updateEditorCode(code: string, language: string) {
updateCode(code, language)
}
const currentTheme = getCurrentTheme()
console.log('Current theme:', currentTheme)
const monacoEditor = getEditor()
console.log('Monaco editor API:', monacoEditor)
const editorInstance = getEditorView()
console.log('Editor instance:', editorInstance)
// Get current code from editor (useful after user manually edits)
function getCurrentCode() {
const code = getCode()
if (code) {
console.log('Current code:', code)
return code
}
return null
}
</script>
<template>
<div>
<div class="controls">
<button @click="switchTheme('github-dark')">Dark</button>
<button @click="switchTheme('github-light')">Light</button>
<button @click="switchLanguage('typescript')">TypeScript</button>
<button @click="switchLanguage('python')">Python</button>
</div>
<div ref="editorContainer" class="editor" />
</div>
</template>
```
### Get current code (getCode)
After creating an editor, you can retrieve the current code content at any time using `getCode()`. This is especially useful when users manually edit the editor content:
```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useMonaco } from 'stream-monaco'
const container = ref<HTMLElement>()
const { createEditor, updateCode, getCode, cleanupEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript', 'typescript'],
})
onMounted(async () => {
if (container.value) {
await createEditor(container.value, 'console.log("hello")', 'javascript')
}
})
// Get current code after updates or user edits
function handleSubmit() {
const currentCode = getCode()
if (currentCode) {
console.log('Submitting code:', currentCode)
// Send to API, save to storage, etc.
}
}
// Update code programmatically
function replaceCode() {
updateCode('console.log("world")', 'javascript')
// Get the new code
setTimeout(() => {
const newCode = getCode()
console.log('Updated code:', newCode)
}, 100)
}
</script>
<template>
<div>
<div ref="container" class="editor" />
<button @click="handleSubmit">Submit Code</button>
<button @click="replaceCode">Replace Code</button>
</div>
</template>
```
For Diff editors, `getCode()` returns both sides:
```ts
const { createDiffEditor, getCode } = useMonaco()
await createDiffEditor(container, 'old code', 'new code', 'javascript')
const codes = getCode()
// codes = { original: 'old code', modified: 'new code' }
```
### Diff editor quick start (Vue)
```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useMonaco } from 'stream-monaco'
const container = ref<HTMLElement>()
const {
createDiffEditor,
updateDiff,
updateOriginal,
updateModified,
getDiffEditorView,
cleanupEditor,
} = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript', 'typescript'],
readOnly: true,
MAX_HEIGHT: 500,
// Collapse long unchanged regions automatically.
diffHideUnchangedRegions: true,
// Hover a changed hunk to show split upper/lower Revert/Stage actions.
diffHunkActionsOnHover: true,
})
const original = `export function add(a: number, b: number) {\n return a + b\n}`
const modified = `export function add(a: number, b: number) {\n return a + b\n}\n\nexport function sub(a: number, b: number) {\n return a - b\n}`
onMounted(async () => {
if (container.value)
await createDiffEditor(container.value, original, modified, 'typescript')
})
</script>
<template>
<div ref="container" class="diff-editor" />
</template>
```
Diff UX options:
- `diffHideUnchangedRegions` (default `true`): fold unchanged ranges (can pass Monaco `hideUnchangedRegions` object).
- `diffLineStyle` (default `background`): choose the changed-line emphasis style. Use `bar` for a subtler review-style leading bar treatment.
- `diffAppearance` (default `auto`): controls the diff shell chrome; use `light` / `dark` to force the surface style while keeping token colors theme-driven.
- `diffUnchangedRegionStyle` (default `line-info`): choose collapsed unchanged-region rendering: `line-info`, `line-info-basic`, and `metadata` use compact 32px rows, while `line-info-basic` keeps the wider legacy rail and `simple` uses a tighter 28px row.
- `diffHunkActionsOnHover` (default `false`): explicitly set `true` to enable split upper/lower `Revert` and `Stage` on hunk hover.
- `onDiffHunkAction(context)` (optional): return `false` to intercept and skip built-in model edits.
- Built-in `Revert` / `Stage` only edit Monaco models locally. They do not run `git revert`, `git add`, or `git stash` unless you intercept `onDiffHunkAction` and connect your own backend flow.
Full integration guide:
- [Diff Integration Guide](docs/diff-integration.md)
- exported TS helpers include `UseMonacoReturn`, `DiffLineStyle`, `DiffAppearance`, `DiffUnchangedRegionStyle`, `DiffModels`, and `DiffHunkActionContext`
Example 1: enable unchanged folding + hover Revert/Stage
```ts
const { createDiffEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['typescript'],
readOnly: true,
diffHideUnchangedRegions: {
enabled: true,
contextLineCount: 2,
minimumLineCount: 4,
revealLineCount: 2,
},
diffLineStyle: 'bar',
diffHunkActionsOnHover: true,
})
await createDiffEditor(container, original, modified, 'typescript')
```
Example 2: fully intercept Revert/Stage and handle your own Git/stash/patch flow
```ts
useMonaco({
diffHideUnchangedRegions: true,
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
const {
action, // 'revert' | 'stage'
side, // 'upper' | 'lower'
lineChange,
originalModel,
modifiedModel,
} = ctx
await saveHunkAction({
action,
side,
range: lineChange,
original: originalModel.getValue(),
modified: modifiedModel.getValue(),
})
// false => skip built-in edit, fully controlled by user code
// true/undefined => continue built-in edit
return false
},
})
```
Example 3: backend returns refreshed file contents, then the client calls `updateDiff(...)`
```ts
const monaco = useMonaco({
diffHideUnchangedRegions: true,
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
const response = await fetch('/api/git/hunks/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action: ctx.action,
side: ctx.side,
range: ctx.lineChange,
original: ctx.originalModel.getValue(),
modified: ctx.modifiedModel.getValue(),
}),
})
const next = await response.json()
monaco.updateDiff(next.original, next.modified, 'typescript')
return false
},
})
```
Important:
- Hover `Revert` is hunk-level undo, not `git revert <commit>`.
- Hover `Stage` is closer to `git add -p` / `git apply --cached`.
- If you want a stash flow, treat `onDiffHunkAction` as a patch-intent callback and hand the selected half-hunk to your backend stash or patch queue.
- If your backend returns refreshed file contents, `updateDiff(...)` is the most direct way to reflect the Git-applied result in the diff UI. The repo includes `pnpm run validate:diff-hunk-update-diff-flow` for that pattern.
- See [Diff Integration Guide](docs/diff-integration.md) for a concrete Git binding pattern.
Visual review helpers:
- `pnpm run shot:diff-ux -- background /tmp/stream-monaco-diff-reference.png pierre-reference`
Captures the Pierre-like reference scene with a fixed viewport, file chrome, and `-1 / +1` single-hunk diff. Pass an optional fourth argument such as `snazzy-light`.
- `pnpm run compare:diff-ux -- /tmp/stream-monaco-diff-reference.png background pierre-reference`
Re-captures the same scene and prints screenshot metrics such as `diffPixels`, `mismatchRatio`, and `exactMatch`. Pass an optional final theme argument to compare the exact same palette.
### Shiki highlighter (advanced)
If you also render Shiki snippets outside Monaco:
```ts
import { registerMonacoThemes } from 'stream-monaco'
const highlighter = await registerMonacoThemes(allThemes, allLanguages)
// later on theme switch
monaco.editor.setTheme('vitesse-dark')
await highlighter.setTheme('vitesse-dark')
// re-render snippets via highlighter.codeToHtml(...)
```
### Streaming performance tips
After 0.0.32, more fine-grained controls:
- `updateThrottleMs` (default 50): time-based throttle for `updateCode`. Set 0 for RAF-only.
- `minimalEditMaxChars`: cap for attempting minimal replace before falling back to `setValue`.
- `minimalEditMaxChangeRatio`: fallback to full replace when change ratio is high.
```ts
useMonaco({
updateThrottleMs: 50,
minimalEditMaxChars: 200000,
minimalEditMaxChangeRatio: 0.25,
})
```
Auto-reveal options for streaming append:
- `revealDebounceMs` (default 75)
- `revealBatchOnIdleMs` (optional final reveal)
- `revealStrategy`: "bottom" | "centerIfOutside" (default) | "center"
For pure tail-append, prefer explicit `appendCode` / `appendOriginal` / `appendModified`.
### Best practices
1. Performance: only load required languages
```ts
const { createEditor } = useMonaco({
languages: ['javascript', 'typescript'],
themes: ['vitesse-dark', 'vitesse-light'],
})
```
2. Memory management: dispose on unmount
```vue
<script setup>
import { onUnmounted } from 'vue'
import { useMonaco } from 'stream-monaco'
const { cleanupEditor } = useMonaco()
onUnmounted(() => {
cleanupEditor()
})
</script>
```
3. Follow system theme (via your own dark state) and call `setTheme` accordingly.
### Use without Vue (Vanilla)
You can use the core in any environment. Here's a plain TypeScript/HTML example:
```ts
import { useMonaco } from 'stream-monaco'
const container = document.getElementById('editor')!
const { createEditor, updateCode, setTheme, cleanupEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript', 'typescript'],
MAX_HEIGHT: 500,
})
await createEditor(container, 'console.log("Hello")', 'javascript')
updateCode('console.log("World")', 'javascript')
await setTheme('vitesse-light')
// later
cleanupEditor()
```
```html
<div id="editor" style="height: 500px; border: 1px solid #e5e7eb;"></div>
<script type="module" src="/main.ts"></script>
```
### Migration notes
- v0.0.34+: Internal reactivity is implemented via a thin adapter over `alien-signals`, removing the hard dependency on Vue. Vue remains fully supported but is optional. No breaking changes to the public API.
### Troubleshooting
- Editor invisible after build: configure Monaco web workers correctly.
- Diff editor renders blank during early mount/streaming: ensure Monaco workers are configured before `createEditor`/`createDiffEditor` (e.g. call `preloadMonacoWorkers()` as early as possible).
- Theme not applied: ensure theme name is included in `themes`.
- Language highlighting missing: ensure the language is included and supported by Shiki.
#### Vue CLI 4 (Webpack 4)
Webpack 4 cannot parse `import.meta.url`. Use the `stream-monaco/legacy` entry (no `import.meta`) and configure Monaco workers in your app.
Recommended: use `monaco-editor-webpack-plugin`.
```bash
pnpm add -D monaco-editor-webpack-plugin
```
`vue.config.js`:
```js
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin')
module.exports = {
configureWebpack: {
plugins: [
new MonacoWebpackPlugin({
languages: ['json', 'css', 'html', 'typescript'],
}),
],
},
}
```
And call once early (e.g. `main.ts`):
```ts
import { preloadMonacoWorkers } from 'stream-monaco/legacy'
preloadMonacoWorkers()
```
The default `stream-monaco` and `stream-monaco/legacy` entry modules intentionally run a best-effort Monaco worker setup during import. The package metadata marks only those published entry files as side-effectful so tree-shaking stays correct for the rest of the library.
If you load Monaco via CDN/AMD (e.g. `<script src=".../vs/loader.js">`), `stream-monaco/legacy` also includes a best-effort auto worker setup that creates a same-origin `blob:` worker and `importScripts()` Monaco’s `vs/base/worker/workerMain.js`. If auto-detection can’t find your Monaco base URL, call:
```ts
import { ensureMonacoWorkersLegacy } from 'stream-monaco/legacy'
ensureMonacoWorkersLegacy({
baseUrl: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/',
})
```
### Development
```bash
git clone https://github.com/Simon-He95/stream-monaco.git
pnpm install
pnpm dev
pnpm build
```
### :coffee:
[buy me a cup of coffee](https://github.com/Simon-He95/sponsor)
### License
[MIT](./license)
### Sponsors
<p align="center">
<a href="https://cdn.jsdelivr.net/gh/Simon-He95/sponsor/sponsors.svg">
<img src="https://cdn.jsdelivr.net/gh/Simon-He95/sponsor/sponsors.png"/>
</a>
</p>
## Acknowledgements
### Clearing shiki highlighter cache
The library caches Shiki highlighters internally to avoid recreating them for the same theme combinations. In long-running apps that dynamically create many combinations, you can clear the cache to free memory or fully reset the shared highlighter state (e.g., in tests or on shutdown):
- `clearHighlighterCache()` — clears cached highlighters and resets the shared Monaco/Shiki highlighter state
- `getHighlighterCacheSize()` — returns number of cached entries
Call `clearHighlighterCache()` only when highlighters are no longer needed; otherwise, the cache improves performance by reusing instances.
+961
View File
@@ -0,0 +1,961 @@
# stream-monaco
[![NPM version](https://img.shields.io/npm/v/stream-monaco?color=a1b858&label=)](https://www.npmjs.com/package/stream-monaco)
[![English Docs](https://img.shields.io/badge/docs-English-blue)](README.md)
[![NPM downloads](https://img.shields.io/npm/dm/stream-monaco)](https://www.npmjs.com/package/stream-monaco)
[![Bundle size](https://img.shields.io/bundlephobia/minzip/stream-monaco)](https://bundlephobia.com/package/stream-monaco)
[![License](https://img.shields.io/npm/l/stream-monaco)](./LICENSE)
流式更新版 Monaco Editor + Shiki 语法高亮,提供框架无关内核,适配 Vue、React、Svelte、Solid、Preact 与 Vanilla JS。
### 项目简介
`stream-monaco` 提供一个与框架无关的内核来集成 Monaco 编辑器与 Shiki 语法高亮,针对流式更新与高效高亮做了优化;可在无 Vue 的环境下使用,同时也提供对 Vue 3 的友好支持与示例。
IMPORTANT: Since v0.0.32 the library enables a default time-based throttle for `updateCode` (`updateThrottleMs = 50`) to reduce CPU usage under high-frequency streaming. Set `updateThrottleMs: 0` in `useMonaco()` options to restore previous behavior (only RAF-based coalescing).
### 特性
- 🚀 **无需 Vue 也可使用** - 核心与框架无关
- 🌿 **与 Vue 3 组合式 API 兼容** - 提供示例与最佳实践
- 🔁 **可用于任意框架**:Vue、React、Svelte、Solid、Preact,或纯 JS/TS
说明:内部响应式基于 `alien-signals` 的轻薄适配层实现,因此核心逻辑不再强依赖 Vue。Vue 仍然完全支持,但被标记为可选的 peer 依赖,使库在非 Vue 环境也可复用核心能力,且对现有 API 无破坏。
- 🎨 **Shiki 高亮** - 使用 Shiki 实现高效的语法高亮,支持 TextMate 语法和 VS Code 主题
- 📝 **流式更新** - 支持流式输入更新,实时响应代码变化
- `registerMonacoThemes(themes, languages): Promise<Highlighter>` — 使用 shiki 创建或获取高亮器并把主题注册到 Monaco,返回解析为 shiki highlighter 的 Promise,便于复用(例如渲染页面片段)。
`getOrCreateHighlighter(themes, languages): Promise<Highlighter>` — 直接获取或创建一个 highlighter(并受内部缓存管理)。如需直接控制 shiki highlighter(例如调用 `codeToHtml` 或 `setTheme`),请使用此方法并自行处理加载/错误逻辑。
注意:如果你只使用 Monaco 编辑器并在 `createEditor` 时传入了全量 `themes`,通常只需调用 `monaco.editor.setTheme(themeName)` 即可。
补充:如果你要切换到一个**不在初始 `themes` 列表里的主题**(例如运行时才决定的 `andromeeda`),建议使用 `useMonaco()` 返回的 `await setTheme(themeName)`,而不是直接调用 `monaco.editor.setTheme(themeName)`。
`setTheme()` 会确保主题被注册;并且在可能的情况下,会对底层 Shiki highlighter 进行一次 `loadTheme(themeName)` 的兜底,避免出现 `Theme ... not found, you may need to load it first` 这类“主题未加载”的错误。
配置:`useMonaco()` 不会自动同步 Shiki highlighter;如果你需要在切换主题时同步页面中独立的 Shiki 渲染,请手动使用 `getOrCreateHighlighter(...)` 并调用高亮器实例的 `setTheme`。
### 安装
使用 pnpm 安装:
```bash
pnpm add stream-monaco
```
使用 npm 安装:
```bash
npm install stream-monaco
```
使用 yarn 安装:
```bash
yarn add stream-monaco
```
### 示例工程
- `examples/streaming-demo`:Vue 3 + Vite 示例,包含 streaming、diff、diff UX 路由
- `examples/react-demo`:React + Vite 示例,演示不重建编辑器的主题/语言切换与 `appendCode()` 流式追加
快速启动:
```bash
pnpm -C examples/streaming-demo dev
pnpm -C examples/react-demo dev
```
### 基础使用(Vue)
#### 简单示例
```vue
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useMonaco } from 'stream-monaco'
const props = defineProps<{
code: string
language: string
}>()
const codeEditor = ref<HTMLElement>()
const { createEditor, updateCode, cleanupEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript', 'typescript', 'vue', 'python'],
readOnly: false,
MAX_HEIGHT: 600,
})
// 创建编辑器实例
onMounted(async () => {
if (codeEditor.value) {
await createEditor(codeEditor.value, props.code, props.language)
}
})
// 监听代码和语言变化
watch(
() => [props.code, props.language],
([newCode, newLanguage]) => {
updateCode(newCode, newLanguage)
},
)
</script>
<template>
<div ref="codeEditor" class="monaco-editor-container" />
</template>
<style scoped>
.monaco-editor-container {
border: 1px solid #e0e0e0;
border-radius: 4px;
}
</style>
```
#### 完整配置示例
```vue
<script setup lang="ts">
import type { MonacoLanguage, MonacoTheme } from 'stream-monaco'
import { onMounted, ref } from 'vue'
import { useMonaco } from 'stream-monaco'
const editorContainer = ref<HTMLElement>()
const {
createEditor,
updateCode,
setTheme,
setLanguage,
getCurrentTheme,
getEditor,
getEditorView,
getCode,
cleanupEditor,
} = useMonaco({
// 主题配置 - 至少需要两个主题(暗色/亮色)
themes: ['github-dark', 'github-light'],
// 支持的语言列表
languages: ['javascript', 'typescript', 'python', 'vue', 'json'],
// 编辑器最大高度
MAX_HEIGHT: 500,
// 是否只读
readOnly: false,
// 是否在创建前清理之前的资源
isCleanOnBeforeCreate: true,
// 创建前的钩子函数
onBeforeCreate: (monaco) => {
// 可以在这里注册自定义语言、主题等
console.log('Monaco editor is about to be created', monaco)
return [] // 返回需要清理的 disposable 对象数组
},
// Monaco 编辑器原生配置
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
minimap: { enabled: false },
scrollbar: {
verticalScrollbarSize: 10,
horizontalScrollbarSize: 10,
alwaysConsumeMouseWheel: false,
},
// 当使用流式追加(append)或频繁更新时,可通过 revealDebounceMs 合并多次自动滚动请求
// 默认值:75(毫秒)。设置为 0 可关闭合并逻辑(立即 reveal)。增大到 150+ 可进一步减少滚动频率。
revealDebounceMs: 75,
})
onMounted(async () => {
if (editorContainer.value) {
const editor = await createEditor(
editorContainer.value,
'console.log("Hello, Monaco!")',
'javascript',
)
console.log('Editor created:', editor)
}
})
// 主题切换
// 主题切换(示例:异步等待与强制重应用)
// setTheme 返回一个 Promise,resolve 表示主题已经应用到 Monaco(并在可能的情况下同步到 shiki highlighter)
// 如果希望即便当前主题相同也强制重新应用(例如强制重新渲染页面中的 shiki 片段),传入第二个参数 true
async function switchTheme(theme: MonacoTheme) {
// 普通调用(自动跳过与当前相同的主题)
await setTheme(theme)
// 强制应用示例:
// await setTheme(theme, true)
}
// 语言切换
function switchLanguage(language: MonacoLanguage) {
setLanguage(language)
}
// 更新代码
function updateEditorCode(code: string, language: string) {
updateCode(code, language)
}
// 获取当前主题
const currentTheme = getCurrentTheme()
console.log('Current theme:', currentTheme)
// 获取 Monaco 静态 API
const monacoEditor = getEditor()
console.log('Monaco editor API:', monacoEditor)
// 获取编辑器实例
const editorInstance = getEditorView()
console.log('Editor instance:', editorInstance)
// 获取编辑器当前代码(在用户手动编辑后非常有用)
function getCurrentCode() {
const code = getCode()
if (code) {
console.log('当前代码:', code)
return code
}
return null
}
</script>
<template>
<div>
<div class="controls">
<button @click="switchTheme('github-dark')">暗色主题</button>
<button @click="switchTheme('github-light')">亮色主题</button>
<button @click="switchLanguage('typescript')">TypeScript</button>
<button @click="switchLanguage('python')">Python</button>
</div>
<div ref="editorContainer" class="editor" />
</div>
</template>
```
### 在非 Vue 环境使用(Vanilla)
无需安装 Vue,直接在任意 TS/JS 环境中使用:
```ts
import { useMonaco } from 'stream-monaco'
const container = document.getElementById('editor')!
const { createEditor, updateCode, setTheme, cleanupEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript', 'typescript'],
MAX_HEIGHT: 500,
})
await createEditor(container, 'console.log("Hello")', 'javascript')
updateCode('console.log("World")', 'javascript')
await setTheme('vitesse-light')
cleanupEditor()
```
```html
<div id="editor" style="height: 500px; border: 1px solid #e5e7eb;"></div>
<script type="module" src="/main.ts"></script>
```
### React 基础用法
```tsx
import { useEffect, useRef } from 'react'
import { useMonaco } from 'stream-monaco'
export function MonacoEditor() {
const containerRef = useRef<HTMLDivElement>(null)
const { createEditor, cleanupEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['typescript', 'javascript'],
})
useEffect(() => {
if (containerRef.current) {
createEditor(
containerRef.current,
'console.log("Hello, Monaco!")',
'typescript',
)
}
return () => cleanupEditor()
}, [])
return (
<div
ref={containerRef}
style={{ height: 500, border: '1px solid #e0e0e0' }}
/>
)
}
```
说明:Svelte/Solid/Preact 的集成方式与 React 类似——在挂载时创建编辑器实例,卸载时清理即可。
### 获取当前代码(getCode)
创建编辑器后,您可以随时使用 `getCode()` 获取当前的代码内容。这在用户手动编辑编辑器内容时特别有用:
```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useMonaco } from 'stream-monaco'
const container = ref<HTMLElement>()
const { createEditor, updateCode, getCode, cleanupEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript', 'typescript'],
})
onMounted(async () => {
if (container.value) {
await createEditor(container.value, 'console.log("hello")', 'javascript')
}
})
// 在更新或用户编辑后获取当前代码
function handleSubmit() {
const currentCode = getCode()
if (currentCode) {
console.log('提交代码:', currentCode)
// 发送到 API、保存到存储等
}
}
// 程序化更新代码
function replaceCode() {
updateCode('console.log("world")', 'javascript')
// 获取新代码
setTimeout(() => {
const newCode = getCode()
console.log('更新后的代码:', newCode)
}, 100)
}
</script>
<template>
<div>
<div ref="container" class="editor" />
<button @click="handleSubmit">提交代码</button>
<button @click="replaceCode">替换代码</button>
</div>
</template>
```
对于 Diff 编辑器,`getCode()` 返回两侧的代码:
```ts
const { createDiffEditor, getCode } = useMonaco()
await createDiffEditor(container, '旧代码', '新代码', 'javascript')
const codes = getCode()
// codes = { original: '旧代码', modified: '新代码' }
```
### Diff 编辑器使用(Vue)
#### 快速开始
```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useMonaco } from 'stream-monaco'
const container = ref<HTMLElement>()
const {
createDiffEditor,
updateDiff,
updateOriginal,
updateModified,
getDiffEditorView,
cleanupEditor,
} = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript', 'typescript'],
readOnly: true,
MAX_HEIGHT: 500,
// 自动折叠大段未改动区域
diffHideUnchangedRegions: true,
// hover 变更块时显示上下分区的局部 Revert / Stage 操作
diffHunkActionsOnHover: true,
})
const original = `export function add(a: number, b: number) {\n return a + b\n}`
const modified = `export function add(a: number, b: number) {\n return a + b\n}\n\nexport function sub(a: number, b: number) {\n return a - b\n}`
onMounted(async () => {
if (!container.value)
return
await createDiffEditor(container.value, original, modified, 'typescript')
})
```
Diff 体验增强相关配置:
- `diffHideUnchangedRegions`(默认 `true`):折叠未改动区块;也支持直接传 Monaco 的 `hideUnchangedRegions` 对象。
- `diffLineStyle`(默认 `background`):控制变更行的强调方式;传 `bar` 可切换到更克制的 review 风格竖条表现。
- `diffAppearance`(默认 `auto`):控制 diff 外层 chrome 的明暗风格;可强制 `light` / `dark`,代码 token 仍跟随当前主题。
- `diffUnchangedRegionStyle`(默认 `line-info`):控制折叠未改动区块的展示方式;`line-info`、`line-info-basic`、`metadata` 都使用更紧凑的 `32px` 折叠行,其中 `line-info-basic` 仍保留更宽的 legacy rail,`simple` 则是更紧的 `28px` 占位行。
- `diffHunkActionsOnHover`(默认 `false`):仅在显式传 `true` 时,hover 变更 hunk 才会显示上下分区的 `Revert` / `Stage`。
- `onDiffHunkAction(context)`(可选):返回 `false` 可拦截并跳过内置模型编辑逻辑。
- 内置的 `Revert` / `Stage` 默认只会改 Monaco model,不会直接执行 `git revert`、`git add` 或 `git stash`;如果你要接真实 Git,需要通过 `onDiffHunkAction` 接到自己的后端流程。
完整接入文档:
- [Diff 接入指南](docs/diff-integration.zh-CN.md)
- 已导出的 TS 类型包括 `UseMonacoReturn`、`DiffLineStyle`、`DiffAppearance`、`DiffUnchangedRegionStyle`、`DiffModels`、`DiffModelPair`、`DiffModelTransitionOptions`、`DiffHunkActionContext`
示例 1:开启 hidden 区折叠 + hover Revert/Stage(可理解为局部回退与暂存)
```ts
const { createDiffEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['typescript'],
readOnly: true,
diffHideUnchangedRegions: {
enabled: true,
contextLineCount: 2,
minimumLineCount: 4,
revealLineCount: 2,
},
diffLineStyle: 'bar',
diffHunkActionsOnHover: true,
})
await createDiffEditor(container, original, modified, 'typescript')
```
示例 2:完全接管 Revert/Stage 事件(接你自己的 Git / stash / patch API)
```ts
useMonaco({
diffHideUnchangedRegions: true,
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
const {
action, // 'revert' | 'stage'
side, // 'upper' | 'lower'
lineChange,
originalModel,
modifiedModel,
} = ctx
// 这里接你的服务端逻辑,例如提交到 stash / patch 队列
await saveHunkAction({
action,
side,
range: lineChange,
original: originalModel.getValue(),
modified: modifiedModel.getValue(),
})
// 返回 false: 拦截内置编辑(由你完全接管)
// 返回 true/undefined: 继续执行内置编辑
return false
},
})
```
示例 3:服务端返回刷新后的文件内容,前端再调用 `updateDiff(...)`
```ts
const monaco = useMonaco({
diffHideUnchangedRegions: true,
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
const response = await fetch('/api/git/hunks/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action: ctx.action,
side: ctx.side,
range: ctx.lineChange,
original: ctx.originalModel.getValue(),
modified: ctx.modifiedModel.getValue(),
}),
})
const next = await response.json()
monaco.updateDiff(next.original, next.modified, 'typescript')
return false
},
})
```
注意:
- hover `Revert` 表示 hunk 级别的局部回退,不等于 `git revert <commit>`。
- hover `Stage` 更接近 `git add -p` / `git apply --cached`。
- 如果你想做 stash 流程,建议把 `onDiffHunkAction` 当成“生成 patch 意图”的回调,把选中的 half-hunk 交给后端 stash 或 patch 队列。
- 如果你的后端会返回刷新后的整份文件内容,前端最直接的做法就是调用 `updateDiff(...)` 把 Git 已应用后的结果回灌到 diff UI。仓库里也提供了 `pnpm run validate:diff-hunk-update-diff-flow` 来覆盖这条模式。
- 更完整的 Git 绑定方式见 [Diff 接入指南](docs/diff-integration.zh-CN.md)。
视觉校验辅助命令:
- `pnpm run shot:diff-ux -- background /tmp/stream-monaco-diff-reference.png pierre-reference`
抓取 Pierre 风格参考场景,固定视口、文件头和 `-1 / +1` 的单 hunk diff;第四个参数可额外指定 `snazzy-light` 这类 theme。
- `pnpm run compare:diff-ux -- /tmp/stream-monaco-diff-reference.png background pierre-reference`
重新抓取同一场景并输出截图指标,例如 `diffPixels`、`mismatchRatio`、`exactMatch`;最后一个参数可指定同一套 theme 做对比。
### Shiki 高亮器(高级说明)
如果你在页面上除了 Monaco 编辑器外还使用 Shiki 的 highlighter 单独渲染代码片段(例如静态 HTML 片段),推荐的做法是:
- 调用 `registerMonacoThemes(themes, languages)` 在应用启动或编辑器创建前预注册需要的主题和语言,函数会返回一个解析为 shiki highlighter 的 Promise,便于你直接复用高亮器实例。
- 在切换主题时,先调用 `monaco.editor.setTheme(themeName)` 更新编辑器,然后显式调用 highlighter 的 `setTheme(themeName)` 或使用 `codeToHtml` 重新渲染页面片段。错误与加载状态应由调用方自行处理。
示例:
```ts
import { registerMonacoThemes } from 'stream-monaco'
// 在应用启动或创建编辑器前一次性注册全部 themes & langs
const highlighter = await registerMonacoThemes(allThemes, allLanguages)
// 创建编辑器
```
### 浏览器级基准(更接近真实 Monaco)
仓库内还提供了一个 Playwright 脚本 `scripts/playwright-bench.mjs`,它将在 headless Chromium 中加载 Monaco(通过 CDN)并运行高频更新,从而测量真实编辑器下的耗时与 long-task 计数。
安装并运行(本地):
```bash
pnpm add -D playwright
# 若初次安装,请按 Playwright 指示安装浏览器二进制
npx playwright install
# 运行脚本(可指定参数 updates freqHz,第三个参数传 'append' 则使用 append 路径)
pnpm run bench:playwright -- 2000 200
pnpm run bench:playwright -- 2000 200 append
```
注意:该脚本会从 CDN 加载 Monaco(需网络),并在本地 headless Chromium 中执行,适合用于在本机或 CI(带浏览器支持)上做真实性能评估。
## 性能与流式更新建议
在 0.0.32 之后的版本引入了对高频流式更新的更细粒度控制:
- `updateThrottleMs`(number): 控制 `updateCode` 的时间节流窗口(ms)。默认值为 50ms。将其设为 0 表示仅使用 RAF 合并(原始行为)。
- `minimalEditMaxChars`(number): 控制在尝试“最小替换”之前允许的最大字符总和(prev.length + next.length)。超过该值将直接使用全量 `setValue`。可通过 `useMonaco({ minimalEditMaxChars })` 覆盖。
- `minimalEditMaxChangeRatio`(number): 当变更比例(|new-prev|/maxLen)超过此阈值时,放弃最小替换,改为全量替换。
示例:
```ts
useMonaco({
updateThrottleMs: 50, // 推荐:30~100ms,根据场景调优
minimalEditMaxChars: 200000,
minimalEditMaxChangeRatio: 0.25,
})
```
运行时调整节流:
```ts
const { setUpdateThrottleMs, getUpdateThrottleMs } = useMonaco()
// 临时关闭时间节流(仅 RAF 合并)
setUpdateThrottleMs(0)
// 恢复为 50ms
setUpdateThrottleMs(50)
console.log('current throttle', getUpdateThrottleMs())
```
快速 benchmark:仓库内提供了一个轻量脚本 `scripts/stream-benchmark.mjs`,用于在 Node 环境下模拟高频 updateCode 场景(不依赖真实 Monaco,只模拟 wrapper 行为)。运行:
```bash
pnpm run bench
# 可指定参数:pnpm run bench -- 5000 200 50
# 参数含义:updates freqHz throttleMs
```
该脚本输出 JSON,包含总用时、平均每次更新耗时和最终文本长度,便于对比不同 `updateThrottleMs` 下的表现。
// 批量(同帧)更新,两侧同时变化时更方便
function pushNewDiff(newOriginal: string, newModified: string) {
updateDiff(newOriginal, newModified, 'typescript')
}
// 仅更新其中一侧(即时增量)
function pushModifiedChunk(chunk: string) {
updateModified(chunk)
}
</script>
<template>
<div ref="container" class="diff-editor" />
<button @click="() => pushNewDiff(original, `${modified}\n// more`)">
Append
</button>
<button @click="() => pushModifiedChunk(`${modified}\n// chunk`)">
Append modified
</button>
<button @click="cleanupEditor">
Dispose
</button>
</template>
<style scoped>
.diff-editor {
border: 1px solid #e0e0e0;
border-radius: 4px;
}
</style>
````
### 流式追加 + 语言切换(快速示例)
### 自动滚动配置说明
下面是与自动滚动行为相关的可配置项及推荐值:
- `revealDebounceMs` (number, ms)
- 说明:在流式追加或短时间内多次更新时,会把多次 reveal 请求合并成一次。减少滚动频率与抖动。
- 默认:75
- 建议:流式输出时保留 50-150,静态或实时编辑可设为 0 以禁用合并。
- `revealBatchOnIdleMs` (number | undefined)
- 说明:如果设置为正数(例如 200),系统会在最后一次追加后等待该毫秒数再执行一次“最终”滚动。这适合大量小片段追加后一次性滚动到底部。
- 默认:undefined(禁用)
- `revealStrategy` ("bottom" | "centerIfOutside" | "center")
- 说明:控制使用哪种 reveal API。
- `bottom`:使用 `revealLine`(靠近底部,变化明显)
- `centerIfOutside`:使用 `revealLineInCenterIfOutsideViewport`(默认,更温和,只在目标不在视口内时居中)
- `center`:使用 `revealLineInCenter`(总是居中)
这些选项已添加到 `useMonaco()` 的配置中,并可通过 TypeScript 的 `RevealStrategy` 枚举(库导出)进行引用。
```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useMonaco } from 'stream-monaco'
const el = ref<HTMLElement>()
const { createEditor, appendCode, setLanguage, cleanupEditor } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['markdown', 'typescript'],
readOnly: false,
MAX_HEIGHT: 360,
})
let i = 0
let timer: any
onMounted(async () => {
if (!el.value)
return
await createEditor(el.value, '# Stream start\n', 'markdown')
// 模拟流式输出
timer = setInterval(() => {
i++
appendCode(`- line ${i}\\n`)
if (i === 5)
setLanguage('typescript') // 动态切换语言
if (i >= 10) {
clearInterval(timer)
}
}, 300)
})
</script>
<template>
<div ref="el" />
<button @click="cleanupEditor">
Dispose
</button>
<p>前 5 行为 Markdown,随后切换为 TypeScript。</p>
<p>当内容接近底部时自动滚动(可通过 autoScroll* 选项进行控制)。</p>
<p>若是纯末尾追加,内部会走追加快路径,避免全量替换。</p>
</template>
````
更多完整示例请见 examples/ 目录。
#### 行为说明(增量与 RAF)
- `updateDiff` 使用 `requestAnimationFrame` 合并同一帧内的多次调用,减少重排与布局开销。
- 当新内容以旧内容为前缀时,采用“仅追加”的策略,避免全量替换带来的性能损耗。
- 其他情况下执行“最小中段替换”,在模型上计算公共前后缀,只替换中间变化段,减少编辑器刷新范围。
- `updateOriginal` / `updateModified` 为即时增量更新,适合单侧独立流式场景。
- 可通过 options.diffAutoScroll 关闭 Diff 编辑器 modified 侧的自动滚动;默认开启以保持与单编辑器一致的体验。
#### 显式流式追加(推荐)
当你是标准的“持续在末尾追加”场景,建议直接使用显式追加 API,可减少 diff 计算并获得最佳实时性:
```ts
const { createDiffEditor, appendOriginal, appendModified } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['typescript'],
})
await createDiffEditor(container, '', '', 'typescript')
// 只向 original 侧持续追加
appendOriginal('line 1\n')
appendOriginal('line 2\n')
// 只向 modified 侧持续追加
appendModified('out 1\n')
appendModified('out 2\n')
```
提示:在 `updateDiff`/`updateOriginal`/`updateModified` 中,当检测到“语言未变且严格前缀追加”时,内部也会自动走“立即追加”的快路径;否则进入 `requestAnimationFrame` 合并 + 最小替换。
#### 视图模式切换与模型访问
你可以获取 Diff 的两个模型来做更底层控制,或切换视图模式:
```ts
const { createDiffEditor, getDiffEditorView, getDiffModels } = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['typescript'],
})
await createDiffEditor(container, left, right, 'typescript')
// 切换为内联模式
getDiffEditorView()?.updateOptions({ renderSideBySide: false })
// 获取模型:你可以自行订阅内容变化等底层行为
const { original, modified } = getDiffModels()
original?.onDidChangeContent?.(() => {
/* ... */
})
modified?.onDidChangeContent?.(() => {
/* ... */
})
```
### API 参考
#### useMonaco(options?)
##### 参数
| 参数 | 类型 | 默认值 | 描述 |
| -------------------------- | ------------------------------------------------------------ | ----------------------------------- | --------------------------------------------------------------- |
| `MAX_HEIGHT` | `number` | `500` | 编辑器最大高度(像素) |
| `readOnly` | `boolean` | `true` | 是否为只读模式 |
| `themes` | `MonacoTheme[]` | `['vitesse-dark', 'vitesse-light']` | 主题数组,至少包含两个主题 |
| `languages` | `MonacoLanguage[]` | 见默认语言列表 | 支持的编程语言数组 |
| `theme` | `string` | - | 初始主题名称 |
| `isCleanOnBeforeCreate` | `boolean` | `true` | 是否在创建前清理之前注册的资源 |
| `onBeforeCreate` | `function` | - | 编辑器创建前的钩子函数 |
| `autoScrollOnUpdate` | `boolean` | `true` | 更新内容时若接近底部则自动滚动 |
| `autoScrollInitial` | `boolean` | `true` | 是否默认启用自动滚动 |
| `autoScrollThresholdPx` | `number` | `32` | 自动滚动的像素阈值 |
| `autoScrollThresholdLines` | `number` | `2` | 自动滚动的行数阈值 |
| `diffAutoScroll` | `boolean` | `true` | 是否启用 Diff modified 侧自动滚动 |
| `diffHideUnchangedRegions` | `boolean \| object` | `true` | 是否折叠 Diff 中未改动区块(支持传 Monaco 配置对象) |
| `diffLineStyle` | `'background' \| 'bar'` | `'background'` | 控制变更行是偏背景块还是偏 review 竖条风格 |
| `diffAppearance` | `'auto' \| 'light' \| 'dark'` | `'auto'` | 控制 diff 外层 chrome 明暗,代码 token 仍跟随当前主题 |
| `diffUnchangedRegionStyle` | `'line-info' \| 'line-info-basic' \| 'metadata' \| 'simple'` | `'line-info'` | 控制折叠未改动区块的展示方式 |
| `diffHunkActionsOnHover` | `boolean` | `false` | 是否启用 hover hunk 的上下分区局部 Revert / Stage(需显式开启) |
| `diffHunkHoverHideDelayMs` | `number` | `160` | hover 操作浮层离开后的隐藏延迟(毫秒) |
| `onDiffHunkAction` | `function` | - | hunk 操作回调(返回 `false` 可阻止默认编辑) |
| `diffUpdateThrottleMs` | `number` | `50` | Diff 流式更新节流时间(毫秒) |
##### 返回值
| 方法/属性 | 类型 | 描述 |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createEditor` | `(container: HTMLElement, code: string, language: string) => Promise<MonacoEditorInstance>` | 创建并挂载编辑器到指定容器 |
| `createDiffEditor` | `(container: HTMLElement, original: string, modified: string, language: string) => Promise<MonacoDiffEditorInstance>` | 创建并挂载 Diff 编辑器 |
| `cleanupEditor` | `() => void` | 销毁编辑器并清理容器 |
| `updateCode` | `(newCode: string, codeLanguage: string) => void` | 更新编辑器内容和语言(RAF 合并、增量优化) |
| `appendCode` | `(appendText: string, codeLanguage?: string) => void` | 在编辑器末尾追加文本 |
| `updateDiff` | `(original: string, modified: string, codeLanguage?: string) => void` | 批量更新 Diff 内容(RAF 合并、增量优化) |
| `updateOriginal` | `(newCode: string, codeLanguage?: string) => void` | 仅更新 original(即时增量) |
| `updateModified` | `(newCode: string, codeLanguage?: string) => void` | 仅更新 modified(即时增量) |
| `setDiffModels` | `(models: DiffModelPair, options?: DiffModelTransitionOptions) => Promise<void>` | 原地切换整对 Diff models;当新旧内容相同,会先预热 Monaco 的 diff view model,再走保留视图状态的低抖动切换 |
| `setTheme` | `(theme: MonacoTheme, force?: boolean) => Promise<void>` | 切换编辑器主题;`force=true` 时即使当前主题相同也会强制重应用 |
| `refreshDiffPresentation` | `() => void` | 在不 remount 的情况下,重算 diff chrome / unchanged overlay 的表现层 |
| `setLanguage` | `(language: MonacoLanguage) => void` | 切换编辑器语言 |
| `getCurrentTheme` | `() => string` | 获取当前主题名称 |
| `getEditor` | `() => typeof monaco.editor` | 获取 Monaco 的静态 editor 对象 |
| `getEditorView` | `() => MonacoEditorInstance \| null` | 获取当前编辑器实例 |
| `getDiffEditorView` | `() => MonacoDiffEditorInstance \| null` | 获取当前 Diff 编辑器实例 |
| `getDiffModels` | `() => DiffModels` | 获取 Diff 两侧模型 |
| `getMonacoInstance` | `() => typeof monaco` | 获取 Monaco 模块实例 |
| `setUpdateThrottleMs` | `(ms: number) => void` | 动态调整 `updateCode` 的时间节流 |
| `getUpdateThrottleMs` | `() => number` | 获取当前 `updateCode` 节流值 |
| `getCode` | `() => MonacoCodeValue` | **获取编辑器当前代码**<br>- 普通编辑器返回 `string`<br>- Diff 编辑器返回 `{ original, modified }`<br>- 无编辑器返回 `null`<br>**用途**:获取用户手动编辑后的最新代码或程序更新后的内容 |
| `appendOriginal` | `(appendText: string, codeLanguage?: string) => void` | 在 original 末尾追加(显式流式) |
| `appendModified` | `(appendText: string, codeLanguage?: string) => void` | 在 modified 末尾追加(显式流式) |
#### 支持的主题
包括但不限于:
- `vitesse-dark` / `vitesse-light`
- `github-dark` / `github-light`
- `dracula` / `dracula-soft`
- `one-dark-pro` / `one-light`
- `tokyo-night`
- `material-theme` 系列
- `catppuccin` 系列
- 以及更多...
#### 支持的语言
包括但不限于:
- `javascript` / `typescript` / `jsx` / `tsx`
- `vue` / `html` / `css` / `scss` / `less`
- `python` / `java` / `csharp` / `cpp` / `rust` / `go`
- `json` / `yaml` / `toml` / `xml`
- `markdown` / `dockerfile`
- 以及 100+ 种语言...
### 最佳实践
#### 1. 性能优化
```typescript
// 只加载需要的语言,减少包体积
const { createEditor } = useMonaco({
languages: ['javascript', 'typescript'], // 只加载必要的语言
themes: ['vitesse-dark', 'vitesse-light'],
})
```
#### 2. 内存管理
```vue
<script setup>
import { onUnmounted } from 'vue'
const { createEditor, cleanupEditor } = useMonaco()
onUnmounted(() => {
cleanupEditor()
})
</script>
```
### 故障排除
#### 1. 打包后编辑器无法显示
确保正确配置了 Monaco Editor 的 Web Workers。
**Vue CLI 4(Webpack 4)注意:** Webpack 4 无法解析 `import.meta.url`。请改用 `stream-monaco/legacy` 入口(不包含 `import.meta`),并在项目侧配置 Monaco workers。
推荐使用 `monaco-editor-webpack-plugin` 来处理 Monaco workers:
```bash
pnpm add -D monaco-editor-webpack-plugin
```
`vue.config.js`:
```js
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin')
module.exports = {
configureWebpack: {
plugins: [
new MonacoWebpackPlugin({
languages: ['json', 'css', 'html', 'typescript'],
}),
],
},
}
```
并尽早调用一次(例如 `main.ts`):
```ts
import { preloadMonacoWorkers } from 'stream-monaco/legacy'
preloadMonacoWorkers()
```
默认的 `stream-monaco` 与 `stream-monaco/legacy` 入口在模块求值时会做一次 best-effort 的 Monaco worker 安装。包元数据只把这些已发布入口文件标记为有 side effects,其余内部模块仍保持正常 tree-shaking。
#### 2. Diff 编辑器流式更新时内容区空白
确保在调用 `createEditor` / `createDiffEditor` 之前已正确配置 Monaco 的 workers(建议尽早调用 `preloadMonacoWorkers()`)。
#### 3. 主题不生效
检查主题名称是否正确,确保主题已在 `themes` 数组中注册。
#### 4. 语言高亮不工作
确保语言已在 `languages` 数组中包含,并且 Shiki 支持该语言。
### 贡献
欢迎提交 Issue 或 PR 来改进此项目!
### 开发
```bash
# 克隆项目
git clone https://github.com/Simon-He95/stream-monaco.git
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 构建
pnpm build
```
### :coffee:
[buy me a cup of coffee](https://github.com/Simon-He95/sponsor)
### License
[MIT](./license)
### Sponsors
<p align="center">
<a href="https://cdn.jsdelivr.net/gh/Simon-He95/sponsor/sponsors.svg">
<img src="https://cdn.jsdelivr.net/gh/Simon-He95/sponsor/sponsors.png"/>
</a>
</p>
## 致谢
### Clearing shiki highlighter cache
The library caches shiki highlighters internally to avoid recreating them for the same theme combinations. In long-running applications that may dynamically create many distinct theme combinations, you can clear the cache to free memory or fully reset the shared highlighter state (for example in tests or on app shutdown):
- `clearHighlighterCache()` — clears cached highlighters and resets the shared Monaco/Shiki highlighter state
- `getHighlighterCacheSize()` — returns number of cached entries
Call `clearHighlighterCache()` when you are certain highlighters are no longer needed (for example during teardown), otherwise leaving the cache enabled provides a performance benefit by reusing previously-created highlighters.
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node --no-warnings
import('./dist/index.js')
@@ -0,0 +1,420 @@
# Diff Integration Guide
This guide focuses on integrating the `stream-monaco` diff editor, which options to pass, and which TypeScript types are available out of the box.
## 1. Minimal setup
```ts
import { useMonaco } from 'stream-monaco'
const container = document.getElementById('diff')!
const { createDiffEditor, updateDiff, cleanupEditor } = useMonaco({
themes: ['github-dark', 'github-light'],
languages: ['typescript', 'javascript', 'json'],
readOnly: true,
MAX_HEIGHT: 560,
})
await createDiffEditor(
container,
'export const a = 1\n',
'export const a = 2\n',
'typescript',
)
updateDiff('export const a = 1\n', 'export const a = 3\n', 'typescript')
cleanupEditor()
```
The mount/update/unmount pattern is the same in Vue, React, Svelte, Solid, and vanilla JS:
- call `createDiffEditor(...)` on mount
- call `updateDiff(...)` / `updateOriginal(...)` / `updateModified(...)` when content changes
- call `cleanupEditor()` on unmount
## 2. Which options to pass
All options are passed through `useMonaco(options)`.
### Core options
```ts
const monaco = useMonaco({
themes: ['github-dark', 'github-light'],
languages: ['typescript', 'javascript'],
theme: 'github-light',
readOnly: true,
MAX_HEIGHT: 560,
})
```
Most common:
- `themes`: available themes, ideally one dark and one light
- `languages`: languages to register
- `theme`: initial theme
- `readOnly`: usually `true` for review/preview diffs
- `MAX_HEIGHT`: max editor height
### stream-monaco diff UX options
```ts
const monaco = useMonaco({
themes: ['github-dark', 'github-light'],
languages: ['typescript'],
readOnly: true,
diffHideUnchangedRegions: {
enabled: true,
contextLineCount: 3,
minimumLineCount: 3,
revealLineCount: 5,
},
diffLineStyle: 'background',
diffAppearance: 'auto',
diffUnchangedRegionStyle: 'line-info',
diffHunkActionsOnHover: true,
diffHunkHoverHideDelayMs: 160,
diffUpdateThrottleMs: 50,
revealDebounceMs: 75,
})
```
What they do:
- `diffHideUnchangedRegions`
Controls unchanged-region folding. Accepts `true`, `false`, or Monaco's native `hideUnchangedRegions` object.
- `diffLineStyle`
Visual emphasis for changed lines.
- `background`: richer filled blocks
- `bar`: subtler review-style leading bars
- `diffAppearance`
Controls diff chrome appearance.
- `auto`: infer from active Monaco theme
- `light`: force light shell
- `dark`: force dark shell
- `diffUnchangedRegionStyle`
Controls collapsed unchanged-region rendering.
- `line-info`: `71 unmodified lines` with line-number-width reveal buttons in a compact 32px collapsed row
- `line-info-basic`: `71 unmodified lines` with the wider legacy reveal rail in a compact 32px row
- `metadata`: `@@ -59,9 +59,11 @@` in a compact 32px row; lines below the collapsed region reflow to close the extra whitespace
- `simple`: gray placeholder bar in a tighter 28px row
- `diffHunkActionsOnHover`
Enables `Revert / Stage` on hovered hunks
- `diffHunkHoverHideDelayMs`
Hide delay for hunk hover actions
- `diffUpdateThrottleMs`
Diff streaming throttle. Default is `50`.
When `diffHideUnchangedRegions` is enabled, unchanged-region folding is deferred until streaming goes idle so the diff does not collapse while content is still streaming in.
- `revealDebounceMs`
Debounce for reveal/auto-scroll behavior
### Native Monaco diff options
`MonacoOptions` now includes both:
- `monaco.editor.IStandaloneEditorConstructionOptions`
- `monaco.editor.IDiffEditorConstructionOptions`
So you can also pass native Monaco diff options directly:
```ts
useMonaco({
renderSideBySide: true,
enableSplitViewResizing: true,
ignoreTrimWhitespace: false,
originalEditable: false,
})
```
To switch between side-by-side and inline at runtime:
```ts
const diff = monaco.getDiffEditorView()
diff?.updateOptions({ renderSideBySide: false })
```
## 3. Runtime methods
```ts
const {
createDiffEditor,
updateDiff,
updateOriginal,
updateModified,
appendOriginal,
appendModified,
setDiffModels,
setTheme,
refreshDiffPresentation,
getDiffEditorView,
getDiffModels,
getCode,
} = useMonaco()
```
Most useful diff methods:
- `createDiffEditor(container, original, modified, language)`
- `updateDiff(original, modified, language?)`
- `updateOriginal(code, language?)`
- `updateModified(code, language?)`
- `appendOriginal(text, language?)`
- `appendModified(text, language?)`
- `await setDiffModels({ original, modified }, options?)`
- `setTheme(theme, force?)`
- `refreshDiffPresentation()`
- `getDiffEditorView()`
- `getDiffModels()`
- `getCode()`
In diff mode, `getCode()` returns:
```ts
{
original: string
modified: string
}
```
### In-place theme / presentation switches
If you are only changing theme, appearance, line style, or unchanged-region style, prefer the in-place runtime methods instead of remounting the diff editor:
```ts
await monaco.setTheme('github-dark')
monaco.refreshDiffPresentation()
```
This keeps the existing diff shell, scroll position, and unchanged-region overlay alive while recomputing the presentation layer.
Theme and appearance switches should stay visually stable. When you switch between unchanged-region styles with different collapsed heights, such as `metadata` to `simple`, the shell stays mounted but Monaco will reflow the lines below those collapsed regions to match the new height.
### Whole-model swaps without remounting
If your app needs to replace the entire `original/modified` model pair, prefer `await setDiffModels(...)` over calling `diffEditor.setModel(...)` yourself:
```ts
const monacoApi = useMonaco()
const originalModel = monacoApi
.getMonacoInstance()
.editor.createModel(leftText, 'typescript')
const modifiedModel = monacoApi
.getMonacoInstance()
.editor.createModel(rightText, 'typescript')
await monacoApi.setDiffModels(
{
original: originalModel,
modified: modifiedModel,
},
{
codeLanguage: 'typescript',
},
)
```
Why this is better than raw `diffEditor.setModel(...)`:
- the library can precompute Monaco's diff view model before the visible swap, so same-content transitions avoid the first-frame scroll jump
- if the new models contain the same text, `stream-monaco` automatically takes the low-jitter path and preserves view state by default
- unchanged-region state and diff chrome are refreshed together
- internally-owned models are disposed safely, while externally-supplied models are left under your control
Options:
- `codeLanguage`: normalize both models to the target language before swapping
- `preserveViewState`: force preserving the diff view state even when content changed
## 4. Hunk action callback
If you want to intercept `Revert / Stage`, use `onDiffHunkAction`:
```ts
useMonaco({
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
await saveHunk(ctx)
return false
},
})
```
Return semantics:
- `false`: skip built-in edit behavior, fully controlled by your app
- `true` or `undefined`: continue built-in behavior
The callback context type is `DiffHunkActionContext`.
## 5. Binding to real Git (`revert`, `stage`, `stash`)
`stream-monaco` does not talk to Git by default. The built-in hover actions only edit the in-memory Monaco models.
Important distinctions:
- Hover `Revert` is hunk-level undo. It is not `git revert <commit>`, because `git revert` works at commit granularity.
- Hover `Stage` is closest to `git add -p` / `git apply --cached`.
- If you want a stash workflow, treat `onDiffHunkAction` as a patch-intent callback and hand the selected half-hunk to a backend stash API or patch queue.
The practical pattern is:
1. Intercept `onDiffHunkAction`.
2. Send `action`, `side`, `lineChange`, `original`, and `modified` to your backend.
3. On the backend, compute the target file contents for that half-hunk.
4. Turn the current content -> target content delta into a unified patch for that file.
5. Apply it to Git:
- working tree revert: `git apply --recount -`
- index stage: `git apply --cached --recount -`
- custom stash: store the same patch in your own stash service, or wrap a server-side Git stash flow
6. Return the refreshed `original` / `modified` pair to the browser and call `updateDiff(...)` or `setDiffModels(...)`.
Frontend example:
```ts
const monaco = useMonaco({
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
const response = await fetch('/api/git/hunks/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
repoPath,
filePath,
action: ctx.action,
side: ctx.side,
range: ctx.lineChange,
original: ctx.originalModel.getValue(),
modified: ctx.modifiedModel.getValue(),
}),
})
const next = await response.json()
monaco.updateDiff(next.original, next.modified, language)
// Skip the built-in local-only model edit because Git already applied it.
return false
},
})
```
Common variant: backend returns the refreshed file pair, and the client updates the diff in-place with `updateDiff(...)`.
This is usually the simplest integration shape when your server already knows the post-apply file contents for both sides:
```ts
const monaco = useMonaco({
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
const response = await fetch('/api/git/hunks/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action: ctx.action,
side: ctx.side,
range: ctx.lineChange,
original: ctx.originalModel.getValue(),
modified: ctx.modifiedModel.getValue(),
}),
})
const next = await response.json()
// The server returns the refreshed left/right file contents after Git apply.
monaco.updateDiff(next.original, next.modified, language)
return false
},
})
```
When to prefer this:
- your backend already computes the full refreshed `original` / `modified`
- you want to keep the current diff editor instance and just refresh its content
- you do not need to swap to externally-owned Monaco models
If the backend gives you brand-new Monaco models instead of plain strings, use `setDiffModels(...)` instead.
Backend mapping to keep in mind:
- `revert + lower`: remove the modified-side lines from the working tree
- `revert + upper`: restore the original-side lines into the working tree
- `stage + lower`: copy the modified-side lines into the index
- `stage + upper`: remove the original-side lines from the index
For replace hunks that have both upper and lower halves, do not think in terms of a single Git command name. Instead, compute the next desired file text for the selected half-hunk, build a patch for that exact delta, then apply it to the worktree or index.
If your product wants a real "stash this hunk" action, the safest route is usually a backend "saved patch" queue. `git stash push --patch` exists, but it is interactive and repo-scoped, so browser-driven review tools typically wrap it server-side or implement their own stash layer around patch files.
Reference validations in this repo:
- `pnpm run validate:diff-hunk-actions`: verifies the built-in local Revert/Stage semantics
- `pnpm run validate:diff-hunk-custom-flow`: verifies async custom intercept flow where user code mutates the models directly
- `pnpm run validate:diff-hunk-update-diff-flow`: verifies async custom intercept flow where the server returns refreshed file contents and the UI calls `updateDiff(...)`
## 6. TypeScript completeness
The public TS surface for diff integration is now fairly complete. These types are exported:
- `MonacoOptions`
- `UseMonacoReturn`
- `MonacoTheme`
- `MonacoLanguage`
- `MonacoEditorInstance`
- `MonacoDiffEditorInstance`
- `DiffHideUnchangedRegions`
- `DiffLineStyle`
- `DiffAppearance`
- `DiffUnchangedRegionStyle`
- `DiffModels`
- `DiffModelPair`
- `DiffModelTransitionOptions`
- `DiffCodeValue`
- `MonacoCodeValue`
- `DiffHunkActionContext`
### What is covered
- `useMonaco(options)` now has an explicit return type: `UseMonacoReturn`
- custom diff UX options have dedicated union types
- `MonacoOptions` covers both single-editor and native diff-editor construction options
- return values such as `getCode()` and `getDiffModels()` have named exported types
### Practical answer
If your question is "do I still need to write custom declarations for diff integration?", the answer is now basically no.
More directly:
- custom diff options: typed
- `useMonaco()` return value: typed
- native Monaco diff option pass-through: typed
## 6. Recommended typed setup
```ts
import type {
DiffAppearance,
DiffUnchangedRegionStyle,
MonacoOptions,
UseMonacoReturn,
} from 'stream-monaco'
const options: MonacoOptions = {
themes: ['github-dark', 'github-light'],
languages: ['typescript'],
readOnly: true,
diffAppearance: 'auto',
diffLineStyle: 'background',
diffUnchangedRegionStyle: 'line-info',
diffHideUnchangedRegions: true,
diffHunkActionsOnHover: true,
}
const monacoApi: UseMonacoReturn = useMonaco(options)
```
This gives the cleanest IDE autocomplete and keeps later UX upgrades easy to maintain.
@@ -0,0 +1,422 @@
# Diff 接入指南
本文档聚焦 `stream-monaco` 的 Diff Editor 接入方式、参数传法,以及当前可直接使用的 TypeScript 类型。
## 1. 最小接入
```ts
import { useMonaco } from 'stream-monaco'
const container = document.getElementById('diff')!
const { createDiffEditor, updateDiff, cleanupEditor } = useMonaco({
themes: ['github-dark', 'github-light'],
languages: ['typescript', 'javascript', 'json'],
readOnly: true,
MAX_HEIGHT: 560,
})
await createDiffEditor(
container,
'export const a = 1\n',
'export const a = 2\n',
'typescript',
)
updateDiff('export const a = 1\n', 'export const a = 3\n', 'typescript')
cleanupEditor()
```
Vue/React/Svelte/Solid 的接入方式都一样:
- 挂载时调用 `createDiffEditor(...)`
- 内容变化时调用 `updateDiff(...)` / `updateOriginal(...)` / `updateModified(...)`
- 卸载时调用 `cleanupEditor()`
## 2. 常用参数怎么传
所有参数都通过 `useMonaco(options)` 传入。
### 基础参数
```ts
const monaco = useMonaco({
themes: ['github-dark', 'github-light'],
languages: ['typescript', 'javascript'],
theme: 'github-light',
readOnly: true,
MAX_HEIGHT: 560,
})
```
最常用的是:
- `themes`: 主题列表,建议至少提供一套 dark/light
- `languages`: 允许高亮的语言列表
- `theme`: 初始主题
- `readOnly`: Diff 预览通常建议 `true`
- `MAX_HEIGHT`: 编辑器最大高度
### Diff 专属增强参数
```ts
const monaco = useMonaco({
themes: ['github-dark', 'github-light'],
languages: ['typescript'],
readOnly: true,
diffHideUnchangedRegions: {
enabled: true,
contextLineCount: 3,
minimumLineCount: 3,
revealLineCount: 5,
},
diffLineStyle: 'background',
diffAppearance: 'auto',
diffUnchangedRegionStyle: 'line-info',
diffHunkActionsOnHover: true,
diffHunkHoverHideDelayMs: 160,
diffUpdateThrottleMs: 50,
revealDebounceMs: 75,
})
```
这几个参数的作用:
- `diffHideUnchangedRegions`
控制未改动区折叠。支持 `true` / `false`,也支持直接传 Monaco 原生 `hideUnchangedRegions` 配置对象。
- `diffLineStyle`
变更行的强调方式。
- `background`: 背景块更明显
- `bar`: 更接近 review UI 的竖条风格
- `diffAppearance`
控制 diff chrome 的明暗。
- `auto`: 跟随当前 Monaco 主题明暗
- `light`: 强制浅色外框
- `dark`: 强制深色外框
- `diffUnchangedRegionStyle`
折叠未改动区的展示方式。
- `line-info`: `71 unmodified lines`,展开按钮背景宽度跟随行号区,折叠行高为 `32px`
- `line-info-basic`: `71 unmodified lines`,保留当前更宽的 legacy rail 效果,折叠行高为 `32px`
- `metadata`: `@@ -59,9 +59,11 @@`,更紧凑的 `32px` 折叠行;切换过去时,下方内容会一起回流,避免多余留白
- `simple`: 灰色占位条,折叠行高为 `28px`
- `diffHunkActionsOnHover`
是否启用 hunk hover 的 `Revert / Stage`
- `diffHunkHoverHideDelayMs`
hover 浮层隐藏延迟
- `diffUpdateThrottleMs`
流式 diff 更新节流。默认是 `50`。
如果开启未改动区折叠,库会等流式更新进入 idle 后再恢复折叠,避免内容还在持续流入时就开始收起未改动区。
- `revealDebounceMs`
自动滚动 reveal 的合并延迟
### Monaco 原生 Diff 参数
`MonacoOptions` 现在同时包含:
- `monaco.editor.IStandaloneEditorConstructionOptions`
- `monaco.editor.IDiffEditorConstructionOptions`
所以除了上面这些 `stream-monaco` 自己的参数,你也可以直接传 Monaco 原生 diff 参数,例如:
```ts
useMonaco({
renderSideBySide: true,
enableSplitViewResizing: true,
ignoreTrimWhitespace: false,
originalEditable: false,
})
```
如果你想在运行时切换单列/双列,也可以直接操作实例:
```ts
const diff = monaco.getDiffEditorView()
diff?.updateOptions({ renderSideBySide: false })
```
## 3. 运行时常用方法
```ts
const {
createDiffEditor,
updateDiff,
updateOriginal,
updateModified,
appendOriginal,
appendModified,
setDiffModels,
setTheme,
refreshDiffPresentation,
getDiffEditorView,
getDiffModels,
getCode,
} = useMonaco()
```
Diff 常用方法:
- `createDiffEditor(container, original, modified, language)`
- `updateDiff(original, modified, language?)`
- `updateOriginal(code, language?)`
- `updateModified(code, language?)`
- `appendOriginal(text, language?)`
- `appendModified(text, language?)`
- `await setDiffModels({ original, modified }, options?)`
- `setTheme(theme, force?)`
- `refreshDiffPresentation()`
- `getDiffEditorView()`
- `getDiffModels()`
- `getCode()`
`getCode()` 在 diff 模式下会返回:
```ts
{
original: string
modified: string
}
```
### 运行时切换主题 / 展现层
如果你切换的只是 theme、appearance、line style、unchanged-region style 这类“换皮”能力,优先走运行时原地刷新,而不是 remount 整个 diff editor:
```ts
await monaco.setTheme('github-dark')
monaco.refreshDiffPresentation()
```
这样会保留现有 diff 壳、滚动位置和 unchanged overlay,只重算表现层。
theme / appearance 切换本身应该保持稳定不抖动。对于 `metadata`、`simple` 这种折叠高度不同的模式切换,外层 editor shell 仍然原地复用,但 Monaco 会把折叠区下方的内容一起回流到新的高度。
### 整对 model 切换但不 remount
如果你的场景是一次性替换整对 `original / modified` model,优先使用 `await setDiffModels(...)`,不要自己直接调用 `diffEditor.setModel(...)`:
```ts
const monacoApi = useMonaco()
const originalModel = monacoApi
.getMonacoInstance()
.editor.createModel(leftText, 'typescript')
const modifiedModel = monacoApi
.getMonacoInstance()
.editor.createModel(rightText, 'typescript')
await monacoApi.setDiffModels(
{
original: originalModel,
modified: modifiedModel,
},
{
codeLanguage: 'typescript',
},
)
```
它比直接调原生 `setModel(...)` 更适合这套 diff UX,原因是:
- 库会在可见切换前预热 Monaco 的 diff view model,所以同内容切换可以避开首帧 scroll jump
- 如果新旧 models 的文本内容相同,会自动走默认的低抖动路径并保留 view state
- unchanged-region 状态和 diff chrome 会一起刷新,不会只换了 model、overlay 却不同步
- 库自己创建的 models 会安全 dispose,外部传入的 models 仍由调用方自己管理
可选项:
- `codeLanguage`:切换前先把两侧 model 归一到目标语言
- `preserveViewState`:即使内容变了,也强制保留当前 diff 视图状态
## 4. Hunk 操作回调
如果你想接管 `Revert / Stage`,可以用 `onDiffHunkAction`:
```ts
useMonaco({
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
await saveHunk(ctx)
return false
},
})
```
返回值语义:
- `false`: 阻止内置编辑逻辑,完全由你接管
- `true` 或 `undefined`: 继续执行内置逻辑
回调上下文类型是 `DiffHunkActionContext`。
## 5. 如何绑定到真实 Git(`revert` / `stage` / `stash`)
`stream-monaco` 默认并不会直接操作 Git。内置 hover action 只会修改内存中的 Monaco model。
先区分三个概念:
- hover `Revert` 表示 hunk 级别的局部回退,不等于 `git revert <commit>`;后者是 commit 粒度。
- hover `Stage` 更接近 `git add -p` / `git apply --cached`。
- 如果你要做 stash 流程,建议把 `onDiffHunkAction` 当成“生成 patch 意图”的回调,把选中的 half-hunk 交给后端 stash API 或 patch 队列。
一条比较稳妥的真实 Git 绑定链路是:
1. 在前端拦截 `onDiffHunkAction`。
2. 把 `action`、`side`、`lineChange`、`original`、`modified` 发给后端。
3. 后端根据这次 half-hunk 操作,算出目标文件内容。
4. 再把“当前内容 -> 目标内容”的差异生成为该文件的 unified patch。
5. 应用到 Git:
- 回退 working tree:`git apply --recount -`
- 暂存到 index:`git apply --cached --recount -`
- 自定义 stash:把同一份 patch 存进你自己的 stash 服务,或在服务端包装 Git stash 流程
6. 把刷新后的 `original` / `modified` 返回前端,再调用 `updateDiff(...)` 或 `setDiffModels(...)`。
前端示例:
```ts
const monaco = useMonaco({
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
const response = await fetch('/api/git/hunks/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
repoPath,
filePath,
action: ctx.action,
side: ctx.side,
range: ctx.lineChange,
original: ctx.originalModel.getValue(),
modified: ctx.modifiedModel.getValue(),
}),
})
const next = await response.json()
monaco.updateDiff(next.original, next.modified, language)
// Git 已经完成实际修改,这里跳过内置的本地 model 编辑。
return false
},
})
```
另一种很常见的接法是:后端直接返回刷新后的左右文件内容,前端再用 `updateDiff(...)` 原地刷新当前 diff。
当服务端已经能算出 Git apply 之后两侧文件应该是什么内容时,这通常是最直接的一种集成方式:
```ts
const monaco = useMonaco({
diffHunkActionsOnHover: true,
onDiffHunkAction: async (ctx) => {
const response = await fetch('/api/git/hunks/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action: ctx.action,
side: ctx.side,
range: ctx.lineChange,
original: ctx.originalModel.getValue(),
modified: ctx.modifiedModel.getValue(),
}),
})
const next = await response.json()
// 服务端返回 Git 应用后的左右文件内容,前端直接刷新当前 diff。
monaco.updateDiff(next.original, next.modified, language)
return false
},
})
```
这种方式更适合:
- 后端已经能直接给出完整的 `original` / `modified`
- 你希望保留当前 diff editor 实例,只刷新内容
- 你不需要切换成外部持有的 Monaco models
如果后端返回的是全新的 Monaco models,而不是普通字符串内容,那么更适合用 `setDiffModels(...)`。
后端侧的语义映射可以按这张表理解:
- `revert + lower`:把 modified 侧这部分内容从 working tree 里去掉
- `revert + upper`:把 original 侧这部分内容补回 working tree
- `stage + lower`:把 modified 侧这部分内容写进 index
- `stage + upper`:把 original 侧这部分内容从 index 移除
对于同时有 upper/lower 两半的 replace hunk,不建议硬套某一个 Git 子命令名。更稳妥的方式是:先算出“这次点击之后文件应该长什么样”,再针对这份精确 delta 生成 patch,然后把 patch 应用到 worktree 或 index。
如果你产品里真的想要一个“stash 这个 hunk”的动作,通常最稳的实现不是直接在浏览器里调 Git,而是后端维护一层“已保存 patch”队列。`git stash push --patch` 虽然存在,但它是交互式、并且是 repo 级行为,所以 review 工具更常见的做法是服务端包装,或者直接实现自己的 stash 层。
仓库里现在也提供了几条可直接参考的验证脚本:
- `pnpm run validate:diff-hunk-actions`:验证内置 local Revert / Stage 语义
- `pnpm run validate:diff-hunk-custom-flow`:验证异步自定义拦截后,业务代码直接改 model 的流程
- `pnpm run validate:diff-hunk-update-diff-flow`:验证异步自定义拦截后,由服务端返回刷新后的文件内容,再调用 `updateDiff(...)` 的流程
## 6. TS 类型是否完善
目前对外类型已经比较完整,Diff 接入常用到的都已经有正式导出:
- `MonacoOptions`
- `UseMonacoReturn`
- `MonacoTheme`
- `MonacoLanguage`
- `MonacoEditorInstance`
- `MonacoDiffEditorInstance`
- `DiffHideUnchangedRegions`
- `DiffLineStyle`
- `DiffAppearance`
- `DiffUnchangedRegionStyle`
- `DiffModels`
- `DiffModelPair`
- `DiffModelTransitionOptions`
- `DiffCodeValue`
- `MonacoCodeValue`
- `DiffHunkActionContext`
### 当前类型覆盖到什么程度
- `useMonaco(options)` 的返回值现在有显式 `UseMonacoReturn`
- Diff 自定义参数都有独立联合类型,不需要手写字符串
- `MonacoOptions` 同时覆盖单编辑器和原生 Diff Editor 的 construction options
- `getCode()`、`getDiffModels()` 这类返回值都有正式类型
### 结论
如果你的问题是“接入时会不会还要自己补一堆声明”,现在答案基本是不会。
更直接地说:
- 自定义 diff 参数:类型已完善
- `useMonaco()` 返回值:类型已完善
- Monaco 原生 diff 参数透传:类型已补齐
## 6. 建议的接入写法
如果你是在业务里做代码 review / patch 预览,我建议按这套接:
```ts
import type {
DiffAppearance,
DiffUnchangedRegionStyle,
MonacoOptions,
UseMonacoReturn,
} from 'stream-monaco'
const options: MonacoOptions = {
themes: ['github-dark', 'github-light'],
languages: ['typescript'],
readOnly: true,
diffAppearance: 'auto',
diffLineStyle: 'background',
diffUnchangedRegionStyle: 'line-info',
diffHideUnchangedRegions: true,
diffHunkActionsOnHover: true,
}
const monacoApi: UseMonacoReturn = useMonaco(options)
```
这样 IDE 补全会比较完整,后面接 `Reference / Streaming / Metadata / Simple` 这些模式也更稳。
@@ -0,0 +1,19 @@
import antfu from '@antfu/eslint-config'
export default antfu(
{
ignores: [
// eslint ignore globs here
'examples/streaming-demo/src/shims-vue.d.ts',
'src/worker/*.js',
'./test/',
'./scripts/',
'docs/',
],
},
{
rules: {
// overrides
},
},
)
@@ -0,0 +1,16 @@
# React Demo (React + Vite)
A minimal React example for `stream-monaco`.
- Creates a Monaco editor from React
- Switches language and theme without remounting
- Streams content with `appendCode`
## Quick start
```bash
pnpm i
pnpm -C examples/react-demo dev
```
Then open http://localhost:5173
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>stream-monaco React Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,23 @@
{
"name": "react-demo",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --strictPort"
},
"dependencies": {
"monaco-editor": "^0.55.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"stream-monaco": "workspace:*"
},
"devDependencies": {
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
}
@@ -0,0 +1,247 @@
import { useEffect, useRef, useState } from 'react'
import { preloadMonacoWorkers, useMonaco } from 'stream-monaco'
type DemoLanguage = 'typescript' | 'markdown'
type DemoTheme = 'vitesse-dark' | 'vitesse-light'
const initialCodeByLanguage: Record<DemoLanguage, string> = {
typescript: [
'type User = {',
' id: string',
' role: "admin" | "member"',
'}',
'',
'export function formatUser(user: User) {',
String.raw` return \`\${user.id}:\${user.role}\``,
'}',
].join('\n'),
markdown: [
'# stream-monaco',
'',
'- React host example',
'- Monaco editor created from a component',
'- Streaming updates via `appendCode()`',
].join('\n'),
}
const streamTargetByLanguage: Record<DemoLanguage, string> = {
typescript: [
'',
'export async function loadUsers(): Promise<User[]> {',
' return [',
' { id: "u_1", role: "admin" },',
' { id: "u_2", role: "member" },',
' ]',
'}',
'',
'for (const user of await loadUsers()) {',
' console.log(formatUser(user))',
'}',
].join('\n'),
markdown: [
'',
'## Streaming Notes',
'',
'1. The editor is mounted once.',
'2. Theme and language swap in place.',
'3. Chunks append over time to mimic token streaming.',
'',
'```ts',
'appendCode(chunk, language)',
'```',
].join('\n'),
}
preloadMonacoWorkers()
export default function App() {
const containerRef = useRef<HTMLDivElement | null>(null)
const timerRef = useRef<number | null>(null)
const codeRef = useRef(initialCodeByLanguage.typescript)
const apiRef = useRef<ReturnType<typeof useMonaco> | null>(null)
if (!apiRef.current) {
apiRef.current = useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['typescript', 'markdown', 'json'],
readOnly: false,
MAX_HEIGHT: 460,
wordWrap: 'on',
minimap: { enabled: false },
scrollBeyondLastLine: false,
})
}
const [language, setLanguage] = useState<DemoLanguage>('typescript')
const [theme, setTheme] = useState<DemoTheme>('vitesse-dark')
const [streaming, setStreaming] = useState(false)
const [progress, setProgress] = useState(0)
const [status, setStatus] = useState('Ready')
useEffect(() => {
const api = apiRef.current
if (!api)
return
async function mount() {
if (!containerRef.current)
return
await api.createEditor(
containerRef.current,
codeRef.current,
language,
)
}
void mount()
return () => {
if (timerRef.current != null)
window.clearInterval(timerRef.current)
api.cleanupEditor()
}
}, [])
function stopStreaming() {
if (timerRef.current != null) {
window.clearInterval(timerRef.current)
timerRef.current = null
}
setStreaming(false)
}
function applyExample(nextLanguage: DemoLanguage) {
stopStreaming()
const nextCode = initialCodeByLanguage[nextLanguage]
codeRef.current = nextCode
setLanguage(nextLanguage)
apiRef.current?.updateCode(nextCode, nextLanguage)
apiRef.current?.setLanguage(nextLanguage)
setProgress(0)
setStatus(`Loaded ${nextLanguage} example`)
}
function applyTheme(nextTheme: DemoTheme) {
setTheme(nextTheme)
void apiRef.current?.setTheme(nextTheme)
}
function resetEditor() {
stopStreaming()
const nextCode = initialCodeByLanguage[language]
codeRef.current = nextCode
apiRef.current?.updateCode(nextCode, language)
setProgress(0)
setStatus('Reset to initial example')
}
function startStreaming() {
stopStreaming()
const remaining = streamTargetByLanguage[language]
const chunks = remaining.match(/[\s\S]{1,42}/g) ?? [remaining]
setStreaming(true)
setStatus('Streaming...')
let index = 0
timerRef.current = window.setInterval(() => {
const chunk = chunks[index]
if (!chunk) {
stopStreaming()
setProgress(100)
setStatus('Streaming complete')
return
}
apiRef.current?.appendCode(chunk, language)
codeRef.current += chunk
index += 1
setProgress(Math.round((index / chunks.length) * 100))
}, 80)
}
return (
<div className="page-shell">
<section className="hero-card">
<div>
<p className="eyebrow">React Example</p>
<h1>stream-monaco in a real React app</h1>
<p className="lede">
This example mounts Monaco once, swaps theme and language in place,
and streams content with
{' '}
<code>appendCode()</code>
.
</p>
</div>
<div className="control-grid">
<div className="control-group">
<span>Language</span>
<div className="button-row">
<button
className={language === 'typescript' ? 'active' : ''}
onClick={() => applyExample('typescript')}
type="button"
>
TypeScript
</button>
<button
className={language === 'markdown' ? 'active' : ''}
onClick={() => applyExample('markdown')}
type="button"
>
Markdown
</button>
</div>
</div>
<div className="control-group">
<span>Theme</span>
<div className="button-row">
<button
className={theme === 'vitesse-dark' ? 'active' : ''}
onClick={() => applyTheme('vitesse-dark')}
type="button"
>
Dark
</button>
<button
className={theme === 'vitesse-light' ? 'active' : ''}
onClick={() => applyTheme('vitesse-light')}
type="button"
>
Light
</button>
</div>
</div>
<div className="control-group">
<span>Actions</span>
<div className="button-row">
<button onClick={startStreaming} type="button">
Start stream
</button>
<button onClick={resetEditor} type="button">
Reset
</button>
</div>
</div>
</div>
<div className="status-row">
<span>{status}</span>
<span>
{progress}
%
</span>
<span>{streaming ? 'Streaming' : 'Idle'}</span>
</div>
</section>
<section className="editor-card">
<div ref={containerRef} className="editor-frame" />
</section>
</div>
)
}
@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import 'monaco-editor/min/vs/editor/editor.main.css'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
@@ -0,0 +1,169 @@
:root {
color: #14213d;
background:
radial-gradient(circle at top, rgba(249, 233, 199, 0.7), transparent 40%),
linear-gradient(180deg, #f7f2e8 0%, #ece4d5 100%);
font-family:
"Iowan Old Style",
"Palatino Linotype",
"Book Antiqua",
Georgia,
serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
button,
code {
font-family:
"SFMono-Regular",
Consolas,
"Liberation Mono",
monospace;
}
#root {
min-height: 100vh;
}
.page-shell {
width: min(1100px, calc(100vw - 32px));
margin: 0 auto;
padding: 24px 0 40px;
}
.hero-card,
.editor-card {
border: 1px solid rgba(20, 33, 61, 0.14);
border-radius: 20px;
background: rgba(255, 252, 245, 0.9);
box-shadow: 0 18px 40px rgba(67, 54, 28, 0.12);
backdrop-filter: blur(10px);
}
.hero-card {
padding: 24px;
}
.editor-card {
margin-top: 18px;
padding: 18px;
}
.eyebrow {
margin: 0 0 10px;
color: #8c5e34;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.8rem;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 0.95;
}
.lede {
max-width: 52rem;
margin: 16px 0 0;
font-size: 1.02rem;
line-height: 1.65;
}
.control-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-top: 22px;
}
.control-group {
padding: 14px;
border-radius: 16px;
background: rgba(255, 248, 236, 0.92);
border: 1px solid rgba(140, 94, 52, 0.12);
}
.control-group span {
display: block;
margin-bottom: 10px;
font-size: 0.86rem;
color: #6f4c28;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
button {
border: 1px solid rgba(20, 33, 61, 0.15);
border-radius: 999px;
padding: 10px 14px;
background: #fffdf7;
color: #14213d;
cursor: pointer;
transition:
transform 120ms ease,
background-color 120ms ease,
border-color 120ms ease;
}
button:hover {
transform: translateY(-1px);
border-color: rgba(20, 33, 61, 0.32);
}
button.active {
background: #14213d;
color: #fff9ec;
}
.status-row {
display: flex;
gap: 14px;
flex-wrap: wrap;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid rgba(20, 33, 61, 0.1);
font-size: 0.94rem;
}
.editor-frame {
min-height: 460px;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(20, 33, 61, 0.1);
}
@media (max-width: 760px) {
.page-shell {
width: min(100vw - 20px, 1100px);
padding-top: 14px;
}
.hero-card,
.editor-card {
padding: 16px;
border-radius: 16px;
}
.control-grid {
grid-template-columns: 1fr;
}
.editor-frame {
min-height: 380px;
}
}
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "bundler",
"paths": {
"stream-monaco": ["../../src"]
},
"types": ["vite/client"],
"strict": true
},
"include": ["src/**/*", "vite.config.ts"]
}
@@ -0,0 +1,15 @@
import path from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'stream-monaco': path.resolve(__dirname, '../../src'),
},
},
optimizeDeps: {
exclude: ['stream-monaco'],
},
})
@@ -0,0 +1,134 @@
# Example: Monaco + Shiki theme sync
This minimal example shows how to keep Monaco editor themes and standalone Shiki-rendered code snippets in sync.
Goal
- Register (or pre-register) themes and languages for both Monaco and Shiki.
- Optionally reuse the returned Shiki highlighter from `registerMonacoThemes` for rendering HTML snippets.
Files
- A simple Vue component (see snippet below) that:
- calls `registerMonacoThemes` to preload themes & languages,
- creates an editor and uses a preloaded Shiki highlighter for manual preview rendering,
- switches theme via `setTheme` and also renders a Shiki HTML snippet using the returned highlighter.
Quick steps
1. Create a new Vite + Vue project (or use the existing `examples/streaming-demo` as base).
2. Install package dependencies (you need `vue`, `monaco-editor`, `shiki`, and this library locally or from npm).
3. Add the component below and run the dev server.
Running the example
- From the repository root (recommended with pnpm workspaces):
```bash
pnpm install
pnpm --filter ./examples/shiki-sync dev
```
- Or inside the example directory:
```bash
cd examples/shiki-sync
pnpm install
pnpm dev
```
Retry behavior
- The example UI shows a small "Retry" button if loading themes fails (e.g. network or build issue). Click it to re-attempt preloading themes & languages.
Vue component (single-file component)
```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { registerMonacoThemes, useMonaco } from 'stream-monaco'
const container = ref<HTMLElement | null>(null)
const preview = ref<HTMLDivElement | null>(null)
const allThemes = ['vitesse-dark', 'vitesse-light']
const allLanguages = ['javascript']
// preload themes & languages and get a shiki highlighter
let shikiHighlighter: any = null
onMounted(async () => {
shikiHighlighter = await registerMonacoThemes(allThemes, allLanguages)
})
const { createEditor, setTheme } = useMonaco({
themes: allThemes,
languages: allLanguages,
})
onMounted(async () => {
if (container.value) {
await createEditor(container.value, 'console.log("hello world")', 'javascript')
}
})
function applyShikiRender(themeName: string) {
if (!shikiHighlighter || !preview.value)
return
try {
const html = shikiHighlighter.codeToHtml('console.log("hello world")', { lang: 'javascript', theme: themeName })
preview.value.innerHTML = html
}
catch (e) {
// fallback: highlighter may not support codeToHtml in some builds; try setTheme instead
try {
shikiHighlighter.setTheme(themeName)
// user should re-render their snippets accordingly
}
catch { }
}
}
function switchToDark() {
setTheme('vitesse-dark')
applyShikiRender('vitesse-dark')
}
function switchToLight() {
setTheme('vitesse-light')
applyShikiRender('vitesse-light')
}
</script>
<template>
<div>
<div class="controls">
<button @click="switchToDark">
Dark
</button>
<button @click="switchToLight">
Light
</button>
</div>
<div ref="container" style="height:300px;border:1px solid #ddd;margin-top:12px" />
<h3>Shiki preview</h3>
<div ref="preview" />
</div>
</template>
<style scoped>
.controls { display:flex; gap:8px }
</style>
```
Notes
- `registerMonacoThemes` now returns the Shiki highlighter Promise — you can reuse it to render static snippets or to call `setTheme` on the highlighter directly.
If you need to keep standalone Shiki-rendered snippets in sync when changing the editor theme, call `registerMonacoThemes(...)` to obtain the Shiki highlighter and then call its `setTheme(...)` / `codeToHtml(...)` methods yourself. The example demonstrates preloading and manual usage.
- If you preload a *full* set of themes & languages at app start, the library will reuse the same highlighter instance and avoid re-creating it on theme changes.
Troubleshooting
- If Shiki's `codeToHtml` isn't exposed on the highlighter build you use (some builds differ), you can still call `setTheme` on the highlighter and re-run your own rendering pipeline.
- For large numbers of themes/languages, consider on-demand loading to reduce startup cost: preload the common ones and lazily call `registerMonacoThemes` for others.
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>shiki-sync demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,19 @@
{
"name": "shiki-sync-demo",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --strictPort"
},
"dependencies": {
"stream-monaco": "workspace:*",
"vue": "^3.5.33"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
}
@@ -0,0 +1,175 @@
<script setup lang="ts">
import type { ShikiHighlighter } from '../../../src'
import { onMounted, ref } from 'vue'
import { registerMonacoThemes, useMonaco } from '../../../src'
const container = ref<HTMLElement | null>(null)
const preview = ref<HTMLDivElement | null>(null)
const allThemes = [
'andromeeda',
'aurora-x',
'ayu-dark',
'catppuccin-frappe',
'catppuccin-latte',
'catppuccin-macchiato',
'catppuccin-mocha',
'dark-plus',
'dracula',
'dracula-soft',
'everforest-dark',
'everforest-light',
'github-dark',
'github-dark-default',
'github-dark-dimmed',
'github-dark-high-contrast',
'github-light',
'github-light-default',
'github-light-high-contrast',
'gruvbox-dark-hard',
'gruvbox-dark-medium',
'gruvbox-dark-soft',
'gruvbox-light-hard',
'gruvbox-light-medium',
'gruvbox-light-soft',
'houston',
'kanagawa-dragon',
'kanagawa-lotus',
'kanagawa-wave',
'laserwave',
'light-plus',
'material-theme',
'material-theme-darker',
'material-theme-lighter',
'material-theme-ocean',
'material-theme-palenight',
'min-dark',
'min-light',
'monokai',
'night-owl',
'nord',
'one-dark-pro',
'one-light',
'plastic',
'poimandres',
'red',
'rose-pine',
'rose-pine-dawn',
'rose-pine-moon',
'slack-dark',
'slack-ochin',
'snazzy-light',
'solarized-dark',
'solarized-light',
'synthwave-84',
'tokyo-night',
'vesper',
'vitesse-black',
'vitesse-dark',
'vitesse-light',
]
const allLanguages = ['javascript']
let shikiHighlighter: ShikiHighlighter | null = null
const loading = ref(false)
const error = ref<string | null>(null)
const selectedTheme = ref<string>(allThemes[0])
onMounted(async () => {
loading.value = true
error.value = null
try {
shikiHighlighter = await registerMonacoThemes(allThemes, allLanguages)
}
catch (e: any) {
error.value = e?.message || String(e)
}
finally {
loading.value = false
}
})
async function retryLoad() {
loading.value = true
error.value = null
try {
shikiHighlighter = await registerMonacoThemes(allThemes, allLanguages)
}
catch (e: any) {
error.value = e?.message || String(e)
}
finally {
loading.value = false
}
}
const { createEditor, setTheme } = useMonaco({
themes: allThemes,
languages: allLanguages,
theme: selectedTheme.value,
})
onMounted(async () => {
if (container.value) {
await createEditor(container.value, 'console.log("hello world")', 'javascript')
// apply initial theme to preview once editor created
applyShikiRender(selectedTheme.value)
}
})
function applyShikiRender(themeName: string) {
if (!shikiHighlighter || !preview.value)
return
try {
const html = shikiHighlighter.codeToHtml('console.log("hello world")', { lang: 'javascript', theme: themeName })
preview.value.innerHTML = html
}
catch {
try {
shikiHighlighter.setTheme(themeName)
}
catch { }
}
}
function onThemeChange(theme: string) {
selectedTheme.value = theme
setTheme(theme)
applyShikiRender(theme)
}
function onThemeSelectChange(e: Event) {
const el = e.target as HTMLSelectElement | null
if (!el)
return
onThemeChange(el.value)
}
</script>
<template>
<div style="padding:16px">
<div class="controls">
<label for="theme-select">Theme:</label>
<select id="theme-select" v-model="selectedTheme" :disabled="loading || !!error" @change="onThemeSelectChange">
<option v-for="t in allThemes" :key="t" :value="t">
{{ t }}
</option>
</select>
<span v-if="loading" style="margin-left:8px">Loading themes...</span>
<span v-if="error" style="margin-left:8px;color:crimson">Error: {{ error }}</span>
<button v-if="error" style="margin-left:8px" @click="retryLoad">
Retry
</button>
</div>
<div ref="container" style="height:300px;border:1px solid #ddd;margin-top:12px" />
<h3>Shiki preview</h3>
<div ref="preview" />
</div>
</template>
<style scoped>
.controls { display:flex; gap:8px }
</style>
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import 'monaco-editor/min/vs/editor/editor.main.css'
createApp(App).mount('#app')
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"jsx": "preserve",
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"types": ["vite/client"],
"strict": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
@@ -0,0 +1,6 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue()],
})
@@ -0,0 +1,22 @@
# Streaming Demo (Vue 3 + Vite)
A minimal example showing streaming append and language switching using `stream-monaco`.
- Single editor with streaming `appendCode`
- Switch language mid-stream with `setLanguage`
- Optional: Diff editor streaming via `appendOriginal` / `appendModified`
## Quick start
```bash
pnpm i
pnpm -C examples/streaming-demo dev
```
Then open http://localhost:5173
Routes:
- `/` Streaming append demo
- `/diff` Diff streaming stress demo
- `/diff-ux` Diff UX demo (unchanged-region collapse/expand + hover hunk revert/stage)
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Streaming Demo</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,21 @@
{
"name": "streaming-demo",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --strictPort"
},
"dependencies": {
"stream-monaco": "workspace:*",
"vue": "^3.5.33",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"monaco-editor": "^0.55.1",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}
}
@@ -0,0 +1,21 @@
<template>
<div>
<nav style="padding:12px; border-bottom:1px solid #eee; display:flex; gap:12px;">
<router-link to="/">Streaming Demo</router-link>
<router-link to="/diff">Diff Editor Demo</router-link>
<router-link to="/diff-ux">Diff UX Demo</router-link>
</nav>
<main style="padding:12px">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
// Layout only
</script>
<style scoped>
nav a { color: #3b82f6; text-decoration: none }
nav a.router-link-active { font-weight: 600 }
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useMonaco, preloadMonacoWorkers } from '../../../src/index'
const el = ref<HTMLElement | null>(null)
const {
createDiffEditor,
updateDiff,
appendModified,
appendOriginal,
getDiffModels,
cleanupEditor,
} = useMonaco({
// options forwarded to monaco
wordWrap: 'on',
wrappingIndent: 'same',
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['markdown', 'typescript'],
readOnly: true,
MAX_HEIGHT: 400,
diffHideUnchangedRegions: true,
diffHunkActionsOnHover: true,
})
preloadMonacoWorkers()
let timer: any
const leftBase = `// original file\nfunction hello() {\n console.log(\"hello world\")\n}\n`
const rightBase = `// modified file\nfunction hello(name) {\n console.log('hello ' + name)\n}\n`
const repeat = 400
let original = Array.from({ length: repeat }).map(() => leftBase).join('\n')
let modified = Array.from({ length: repeat }).map(() => rightBase).join('\n')
onMounted(async () => {
if (!el.value) return
await createDiffEditor(el.value, original.slice(0, 1), modified.slice(0, 1), 'typescript')
// gradually append to simulate streaming diffs
let i = 0
const combined = modified
timer = setInterval(() => {
i += 50
if (i >= combined.length) {
clearInterval(timer)
return
}
const next = combined.slice(0, i)
const prevOriginal = original.slice(0, i)
updateDiff(prevOriginal, next, 'typescript')
}, 400)
})
// Stress test controls
const running = ref(false)
const result = ref<string | null>(null)
async function runStressTest({ iterations = 2000, blockSize = 50, intervalMs = 1 } = {}) {
if (!el.value) return
if (running.value) return
running.value = true
result.value = null
// prepare base block and expected
const block = rightBase.repeat(Math.ceil(blockSize / rightBase.length)).slice(0, blockSize)
let expected = ''
// ensure editor is initialized to small values
await createDiffEditor(el.value, original.slice(0, 1), modified.slice(0, 1), 'typescript')
for (let j = 0; j < iterations; j++) {
expected += block
updateDiff(original.slice(0, Math.min(expected.length, original.length)), expected, 'typescript')
// yield to event loop and respect interval
await new Promise(r => setTimeout(r, intervalMs))
}
// wait for final flush
await new Promise(r => setTimeout(r, 500))
try {
const models = getDiffModels()
const actual = models.modified?.getValue() ?? ''
if (actual === expected) {
result.value = `PASS - length ${actual.length}`
}
else {
// find first mismatch
let idx = 0
while (idx < Math.min(actual.length, expected.length) && actual[idx] === expected[idx]) idx++
result.value = `FAIL at ${idx} (expected ${expected.length}, actual ${actual.length})`
}
}
catch (err) {
result.value = `ERROR: ${String(err)}`
}
running.value = false
}
</script>
<template>
<div>
<div ref="el" class="editor" />
<div class="actions">
<button @click="cleanupEditor">Dispose</button>
<button @click.prevent="runStressTest({ iterations: 2000, blockSize: 50, intervalMs: 1 })" :disabled="running">Run Stress (1ms,50ch x2000)</button>
<button @click.prevent="runStressTest({ iterations: 2000, blockSize: 50, intervalMs: 4 })" :disabled="running">Run Stress (4ms)</button>
<div style="margin-top:8px">Result: {{ result ?? 'n/a' }}</div>
</div>
</div>
</template>
<style scoped>
.editor { border: 1px solid #e0e0e0; border-radius: 4px; }
.actions { margin-top: 12px }
button { padding: 6px 10px }
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useMonaco, preloadMonacoWorkers } from '../../../src/index'
const perf: any = (globalThis as any).__streamMonacoPerf ??= {
marks: [],
mark(name: string, detail?: Record<string, any>) {
if (typeof performance === 'undefined')
return
this.marks.push({ name, t: performance.now(), ...detail })
},
}
function mark(name: string, detail?: Record<string, any>) {
try {
perf.mark(name, detail)
}
catch {}
}
mark('module_init')
if (typeof window !== 'undefined' && typeof document !== 'undefined' && !perf._observerInstalled) {
perf._observerInstalled = true
try {
let firstTextLen = -1
const markOnce = (name: string, detail?: Record<string, any>) => {
if (perf.marks.some((m: any) => m.name === name))
return
mark(name, detail)
}
const obs = new MutationObserver(() => {
const editor = document.querySelector('.monaco-editor')
if (editor)
markOnce('monaco_dom')
const view = document.querySelector('.monaco-editor .view-lines')
const text = view?.textContent ?? ''
const len = text.trim().length
if (len > 0)
markOnce('view_lines_text', { len })
if (len > 0) {
if (firstTextLen === -1)
firstTextLen = len
else if (len > firstTextLen)
markOnce('view_lines_text_growth', { len })
}
})
obs.observe(document.documentElement, { childList: true, subtree: true, characterData: true })
}
catch {}
try {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries() as any) {
if (entry && entry.duration && entry.duration > 50) {
perf.longTasks = (perf.longTasks || 0) + 1
}
}
})
obs.observe({ type: 'longtask', buffered: true })
}
catch {}
}
const el = ref<HTMLElement | null>(null)
const {
createEditor,
appendCode,
updateCode,
setLanguage,
cleanupEditor,
} = useMonaco({
wordWrap: 'on',
wrappingIndent: 'same',
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['markdown', 'typescript'],
readOnly: true,
MAX_HEIGHT: 400,
})
preloadMonacoWorkers()
mark('preload_workers_called')
let i = 0
let timer: any
const base = `
# Create Vue project
npm create vue@latest electron-vue-chat
# Navigate to project
cd electron-vue-chat
# Install dependencies
npm install
npm install electron electron-builder vue-router
# Install dev dependencies
npm install -D electron-dev-server concurrently wait-on
`
// repeat the base chunk to create a large test document so the editor shows a scrollbar
const repeatCount = 120
const markdown = Array.from({ length: repeatCount }).map(() => base).join('\n')
let contents = ''
onMounted(async () => {
if (!el.value)
return
mark('onMounted_start')
mark('createEditor_start')
await createEditor(el.value, contents, 'shellscript')
mark('createEditor_done')
// append faster in small chunks so the editor fills quickly and scrollbar appears
timer = setInterval(() => {
if (i >= markdown.length) {
clearInterval(timer)
mark('stream_done', { len: contents.length })
return
}
// increase by a few chars per tick for visible streaming without being too slow
i += 50
contents = markdown.slice(0, Math.min(i, markdown.length))
if (i === 50)
mark('stream_first_update', { len: contents.length })
updateCode(contents, 'shellscript')
}, 40)
})
</script>
<template>
<div>
<div ref="el" class="editor" />
<div class="actions">
<button @click="cleanupEditor">
Dispose
</button>
</div>
</div>
</template>
<style scoped>
.editor {
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.actions { margin-top: 12px; }
button { padding: 6px 10px; }
</style>
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
import 'monaco-editor/min/vs/editor/editor.main.css'
createApp(App).use(router).mount('#app')
@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import DiffDemo from './DiffDemo.vue'
import DiffUXDemo from './DiffUXDemo.vue'
import StreamingDemo from './StreamingDemo.vue'
const routes = [
{ path: '/', name: 'stream', component: StreamingDemo },
{ path: '/diff', name: 'diff', component: DiffDemo },
{ path: '/diff-ux', name: 'diff-ux', component: DiffUXDemo },
]
export const router = createRouter({
history: createWebHistory(),
routes,
})
@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": ["ESNext", "DOM"],
"useDefineForClassFields": true,
"baseUrl": ".",
"module": "ESNext",
"moduleResolution": "bundler",
"paths": {
"stream-monaco": ["../../src"]
},
"types": ["vite/client"],
"strict": true
},
"include": ["src/**/*", "vite.config.ts"]
}
@@ -0,0 +1,19 @@
import path from 'node:path'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'stream-monaco': path.resolve(__dirname, '../../src'),
},
},
// Prevent Vite from pre-bundling the local package. If Vite optimizes it into
// node_modules/.vite/deps, relative worker URLs can be rewritten to that
// location and may become unreachable. Excluding the package keeps it as
// source so `new URL('./worker/..', import.meta.url)` resolves correctly.
optimizeDeps: {
exclude: ['stream-monaco'],
},
})
+1
View File
@@ -0,0 +1 @@
module.exports = require('./dist/index.legacy.cjs')
+1
View File
@@ -0,0 +1 @@
export * from './dist/index.legacy'
+1
View File
@@ -0,0 +1 @@
export * from './dist/index.legacy'
+1
View File
@@ -0,0 +1 @@
export * from './dist/index.legacy.js'
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Simon He
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+133
View File
@@ -0,0 +1,133 @@
{
"name": "stream-monaco",
"type": "module",
"version": "0.0.40",
"packageManager": "pnpm@10.33.2",
"description": "A framework-agnostic Monaco Editor integration with Shiki syntax highlighting, built for real-time streaming updates and efficient diff editing.",
"author": "Simon He",
"license": "MIT",
"funding": "https://github.com/sponsors/Simon-He95",
"homepage": "https://github.com/Simon-He95/stream-monaco#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/Simon-He95/stream-monaco.git"
},
"bugs": "https://github.com/Simon-He95/stream-monaco/issues",
"keywords": [
"monaco-editor",
"monaco",
"shiki",
"shiki-monaco",
"syntax-highlighting",
"textmate",
"vscode",
"real-time",
"code-editor",
"diff-editor",
"signals",
"framework-agnostic",
"framework",
"streaming",
"vue",
"react",
"svelte",
"solid",
"preact",
"vanilla"
],
"sideEffects": [
"./dist/index.js",
"./dist/index.cjs",
"./dist/index.legacy.js",
"./dist/index.legacy.cjs",
"./legacy.js",
"./legacy.cjs"
],
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./legacy": {
"import": "./dist/index.legacy.js",
"require": "./dist/index.legacy.cjs"
}
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"typesVersions": {
"*": {
"*": [
"./dist/*",
"./dist/index.d.ts"
]
}
},
"bin": {
"run": "cli.mjs"
},
"files": [
"dist",
"legacy.cjs",
"legacy.d.cts",
"legacy.d.ts",
"legacy.js"
],
"scripts": {
"build": "tsdown",
"dev": "npm run build -- --watch src",
"format": "prettier --write --cache .",
"lint": "eslint . --cache",
"lint:fix": "pnpm run lint --fix",
"prepublishOnly": "pnpm build",
"release": "bumpp --tag \"v%s\"",
"publish:package": "pnpm build && npm publish",
"start": "esno src/index.ts",
"bench": "node scripts/stream-benchmark.mjs",
"bench:playwright": "node scripts/playwright-bench.mjs",
"smoke:diff": "node scripts/playwright-diff-smoke.mjs",
"shot:diff-ux": "node scripts/playwright-diff-ux-shot.mjs",
"validate:diff-gutter": "node scripts/playwright-diff-gutter-validate.mjs",
"validate:diff-hunk-actions": "node scripts/playwright-diff-hunk-actions-validate.mjs",
"validate:diff-hunk-custom-flow": "node scripts/playwright-diff-hunk-custom-flow-validate.mjs",
"validate:diff-hunk-update-diff-flow": "node scripts/playwright-diff-hunk-update-diff-flow-validate.mjs",
"validate:diff-inline": "node scripts/playwright-diff-inline-validate.mjs",
"validate:diff-transition": "node scripts/playwright-diff-transition-validate.mjs",
"validate:diff-unchanged-expand": "node scripts/playwright-diff-unchanged-expand-validate.mjs",
"validate:diff-unchanged-reveal": "node scripts/playwright-diff-unchanged-reveal-validate.mjs",
"validate:markstream-diff-theme": "node scripts/playwright-markstream-diff-theme-validate.mjs",
"compare:diff-ux": "node scripts/playwright-diff-ux-compare.mjs",
"test": "vitest",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"monaco-editor": ">=0.52.2 <0.56.0"
},
"dependencies": {
"@shikijs/monaco": "^3.23.0",
"alien-signals": "^2.0.8",
"shiki": "^3.23.0"
},
"devDependencies": {
"@antfu/eslint-config": "^5.4.1",
"@types/node": "^18.19.130",
"bumpp": "^8.2.1",
"eslint": "^9.39.4",
"lint-staged": "^13.3.0",
"picocolors": "^1.1.1",
"playwright": "1.38.0",
"prettier": "^2.8.8",
"rimraf": "^3.0.2",
"tsdown": "^0.12.9",
"tsx": "^3.14.0",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
"lint-staged": {
"*": [
"prettier --write --cache --ignore-unknown"
],
"*.{vue,js,ts,jsx,tsx,md,json}": "eslint --fix"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
packages:
- playground
- examples/*
@@ -0,0 +1,143 @@
// Fuzz runner to repeatedly simulate streaming updates and detect duplicated tails
// The simulation mirrors the logic used in EditorManager/index: use optimistic
// lastKnownCode but appendBuffer flush happens only on frame boundaries.
function createSim() {
let lastKnownCode = ''
let pendingUpdate = null
const appendBuffer = []
let appendBufferScheduled = false
function appendCode(s) {
appendBuffer.push(s)
if (!appendBufferScheduled) appendBufferScheduled = true
}
function flushAppendBuffer(model) {
if (!appendBufferScheduled) return
appendBufferScheduled = false
const text = appendBuffer.join('')
appendBuffer.length = 0
model.value += text
}
function flushPendingUpdate(model) {
if (!pendingUpdate) return
const newCode = pendingUpdate
pendingUpdate = null
// Use authoritative model value when appendBuffer pending
const prevCode = (appendBuffer.length > 0) ? model.value : (lastKnownCode ?? model.value)
if (prevCode === newCode) return
if (newCode.startsWith(prevCode) && prevCode.length < newCode.length) {
const suffix = newCode.slice(prevCode.length)
if (suffix) appendCode(suffix)
lastKnownCode = newCode
return
}
model.value = newCode
lastKnownCode = newCode
}
function updateCode(code) {
pendingUpdate = code
}
return { updateCode, flushPendingUpdate, flushAppendBuffer }
}
const markdown = `
# Create Vue project
npm create vue@latest electron-vue-chat
# Navigate to project
cd electron-vue-chat
# Install dependencies
npm install
npm install electron electron-builder vue-router
# Install dev dependencies
npm install -D electron-dev-server concurrently wait-on
`
function oneRun(opts) {
// opts: { flushEvery, randomizeFlush, jitter }
const sim = createSim()
const model = { value: '' }
let i = 0
const len = markdown.length
let step = 0
for (i = 1; i <= len; i++) {
const contents = markdown.slice(0, i)
sim.updateCode(contents)
// decide whether to flush this frame
let doFlush = false
if (opts.randomizeFlush) {
// random flush with probability p
doFlush = Math.random() < opts.flushProbability
}
else {
doFlush = (i % opts.flushEvery) === 0
}
if (doFlush) {
// apply pending update then flush buffer
sim.flushPendingUpdate(model)
// optionally apply jitter: maybe flush append buffer now or delayed
if (opts.jitter) {
if (Math.random() < 0.5) sim.flushAppendBuffer(model)
// else we delay and allow next flush to consume
}
else {
sim.flushAppendBuffer(model)
}
}
}
// final flush at the end
sim.flushPendingUpdate(model)
sim.flushAppendBuffer(model)
return model.value
}
function detectDup(s) {
// heuristics: look for repeated substrings of length >= 3 at the end
// e.g., 'wait-on' -> 'wait-t-onon' contains 'onon'
// we'll search for any repeat of last 2..6 char sequences
for (let k = 2; k <= 8; k++) {
if (s.length < k * 2) continue
const a = s.slice(-k)
const b = s.slice(-k * 2, -k)
if (a === b) return { dup: true, fragment: a }
}
return { dup: false }
}
function runFuzz(runs = 1000) {
let failures = []
for (let r = 0; r < runs; r++) {
const opts = {}
// randomly choose a flush strategy
if (Math.random() < 0.5) {
opts.randomizeFlush = false
// flushEvery between 1 and 200
opts.flushEvery = Math.floor(Math.random() * 200) + 1
}
else {
opts.randomizeFlush = true
opts.flushProbability = Math.random() * 0.5 + 0.01
}
opts.jitter = Math.random() < 0.4
const out = oneRun(opts)
const res = detectDup(out)
if (res.dup) {
failures.push({ run: r, opts, dup: res.fragment, out })
}
}
return failures
}
const runs = Number(process.argv[2] || 1000)
console.log('starting fuzz runs:', runs)
const fails = runFuzz(runs)
console.log('failures:', fails.length)
if (fails.length > 0) console.log(fails.slice(0, 10))
else console.log('no duplicates detected')
@@ -0,0 +1,261 @@
#!/usr/bin/env node
// Headless Playwright benchmark that creates a Monaco editor in a page and
// runs a high-frequency update scenario to collect timing and long-task info.
import { chromium } from 'playwright'
async function run({ updates = 2000, freqHz = 200, append = false } = {}) {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
// minimal HTML that loads Monaco from CDN and creates a container
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>html,body,#editor{height:100%;margin:0;padding:0}</style>
</head>
<body>
<div id="editor"></div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js"></script>
<script>
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
window.__benchReady = new Promise((res) => {
require(['vs/editor/editor.main'], function () {
window.editor = monaco.editor.create(document.getElementById('editor'), {
value: '',
language: 'javascript',
automaticLayout: true,
});
res(true)
})
})
</script>
</body>
</html>`
await page.setContent(html, { waitUntil: 'load' })
await page.evaluate(() => window.__benchReady)
// run the benchmark inside the page
const result = await page.evaluate(async ({ updates, freqHz, append, payloadSize, random }) => {
const editor = window.editor
const model = editor.getModel()
// observe long tasks
let longTasks = 0
try {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration && entry.duration > 50)
longTasks++
}
})
obs.observe({ type: 'longtask', buffered: true })
}
catch { }
const interval = 1000 / freqHz
const start = performance.now()
for (let i = 0; i < updates; i++) {
const text = 'a'.repeat(i % 1000)
if (append) {
// append via executeEdits
const lastLine = model.getLineCount()
const lastCol = model.getLineMaxColumn(lastLine)
model.applyEdits([{ range: new monaco.Range(lastLine, lastCol, lastLine, lastCol), text }])
}
else {
model.setValue(text)
}
// busy wait to emulate frequency
const t0 = performance.now()
while (performance.now() - t0 < interval) {}
}
// allow observers to flush
await new Promise(r => setTimeout(r, 200))
const end = performance.now()
return { updates, freqHz, elapsedMs: Math.round(end - start), longTasks }
}, { updates, freqHz, append })
await browser.close()
return result
}
if (import.meta.url === `file://${process.argv[1]}` || (process.argv[1] && process.argv[1].endsWith('playwright-bench.mjs'))) {
// Robust CLI parsing: support running directly or via `pnpm run bench:playwright -- ...`
// process.argv can contain additional npm/pnpm wrapper args, so parse slice(2)
const raw = process.argv.slice(2)
// Accept: <updates> <freqHz> [append]
let updates = 2000
let freqHz = 200
let append = false
if (raw.length >= 1) {
const n = Number(raw[0])
if (!Number.isNaN(n) && isFinite(n))
updates = n
}
if (raw.length >= 2) {
const n = Number(raw[1])
if (!Number.isNaN(n) && isFinite(n))
freqHz = n
}
// allow append to appear anywhere in args
if (raw.some(a => a === 'append' || a === '--append'))
append = true
// If caller asked for 'compare', run three benches with different throttle values
const rawArgs = raw
if (rawArgs.includes('compare') || rawArgs.includes('--compare')) {
// parse optional payload size and random flag from args
// payloadSize: any numeric arg after the first two (updates, freqHz)
let payloadSize = 1000
const numericArgs = rawArgs.map(a => Number(a)).filter(n => !Number.isNaN(n) && isFinite(n))
if (numericArgs.length >= 3)
payloadSize = numericArgs[2]
const random = rawArgs.includes('random') || rawArgs.includes('--random')
// throttle values to compare (ms)
const throttleValues = [0, 50, 100]
const results = []
for (const t of throttleValues) {
try {
const r = await runWithThrottle({ updates, freqHz, append, throttle: t, payloadSize, random })
results.push({ throttle: t, payloadSize, random, result: r })
}
catch (err) {
console.error('compare run failed for throttle', t, err)
results.push({ throttle: t, payloadSize, random, error: String(err) })
}
}
console.log(JSON.stringify({ compare: true, updates, freqHz, append, payloadSize, random, runs: results }, null, 2))
process.exit(0)
}
run({ updates, freqHz, append }).then(r => console.log(JSON.stringify(r, null, 2))).catch((err) => { console.error(err); process.exit(1) })
}
// Helper that loads the page, sets up a throttled update wrapper in-page, and runs the bench
async function runWithThrottle({ updates = 2000, freqHz = 200, append = false, throttle = 50, payloadSize = 1000, random = false } = {}) {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>html,body,#editor{height:100%;margin:0;padding:0}</style>
</head>
<body>
<div id="editor"></div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js"></script>
<script>
require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });
window.__benchReady = new Promise((res) => {
require(['vs/editor/editor.main'], function () {
window.editor = monaco.editor.create(document.getElementById('editor'), {
value: '',
language: 'javascript',
automaticLayout: true,
});
// create a throttled updateCode wrapper that batches setValue calls
(function(){
let pending = null
let timer = null
const throttle = ${throttle}
const payloadSize = ${payloadSize}
const random = ${random}
function makePayload(i) {
if (random) {
// simple pseudo-random string of payloadSize
let s = ''
for (let k = 0; k < payloadSize; k++) s += String.fromCharCode(97 + (Math.floor(Math.random() * 26)))
return s
}
return 'x'.repeat(payloadSize)
}
window.__updateWithThrottle = function(text){
if (throttle === 0) {
try { window.editor.getModel().setValue(text) } catch(e) {}
return
}
pending = text
if (timer != null) return
timer = setTimeout(() => {
try { window.editor.getModel().setValue(pending || '') } catch(e) {}
pending = null
timer = null
}, throttle)
}
})()
res(true)
})
})
</script>
</body>
</html>`
await page.setContent(html, { waitUntil: 'load' })
await page.evaluate(() => window.__benchReady)
const result = await page.evaluate(async ({ updates, freqHz, append, payloadSize, random }) => {
const editor = window.editor
const model = editor.getModel()
// observe long tasks
let longTasks = 0
try {
const obs = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration && entry.duration > 50)
longTasks++
}
})
obs.observe({ type: 'longtask', buffered: true })
}
catch { }
const interval = 1000 / freqHz
const start = performance.now()
for (let i = 0; i < updates; i++) {
let text
if (random) {
// simple deterministic-ish random per iteration
const arr = new Array(payloadSize)
for (let k = 0; k < payloadSize; k++) arr[k] = String.fromCharCode(97 + ((i + k) % 26))
text = arr.join('')
}
else {
text = 'x'.repeat(payloadSize)
}
if (append) {
const lastLine = model.getLineCount()
const lastCol = model.getLineMaxColumn(lastLine)
model.applyEdits([{ range: new monaco.Range(lastLine, lastCol, lastLine, lastCol), text }])
}
else {
// use the throttled API if present
if (typeof window.__updateWithThrottle === 'function') {
window.__updateWithThrottle(text)
}
else {
model.setValue(text)
}
}
const t0 = performance.now()
while (performance.now() - t0 < interval) {}
}
await new Promise(r => setTimeout(r, 200))
const end = performance.now()
return { updates, freqHz, elapsedMs: Math.round(end - start), longTasks }
}, { updates, freqHz, append, payloadSize, random })
await browser.close()
return result
}
@@ -0,0 +1,272 @@
#!/usr/bin/env node
// Validate reference diff gutter spacing across multiple line-count scales.
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 5181, end = 5190) {
for (let port = start; port <= end; port++) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 20000) {
const start = Date.now()
while (true) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (open) return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
} catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
} catch {}
}, 3000).unref?.()
}
function parseTargets(args) {
const values = args
.map((arg) => Number(arg))
.filter((value) => Number.isInteger(value) && value >= 6)
return values.length > 0 ? values : [6, 48, 512, 4096]
}
function round(value) {
return Math.round(value * 1000) / 1000
}
async function collectMetrics(page, lines, port) {
const url = `http://127.0.0.1:${port}/diff-ux?style=background&scenario=pierre-reference&theme=snazzy-light&lines=${encodeURIComponent(
String(lines),
)}&capture=1`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await page.waitForTimeout(1400)
const screenshotPath = path.join('/tmp', `stream-monaco-diff-gutter-${lines}.png`)
await page.locator('.editor-card').screenshot({ path: screenshotPath })
const metrics = await page.evaluate(() => {
const getTextRect = (el) => {
const range = document.createRange()
range.selectNodeContents(el)
const rect = range.getBoundingClientRect()
return { left: rect.left, right: rect.right, width: rect.width }
}
const firstVisibleTokenRect = (line) => {
const spans = Array.from(line.querySelectorAll('span'))
for (const span of spans) {
const text = span.textContent || ''
if (!text.trim()) continue
const rect = getTextRect(span)
if (rect.width > 0) return rect
}
return null
}
const root = document.querySelector('.editor')
const markerWidth =
Number.parseFloat(
getComputedStyle(root ?? document.body).getPropertyValue(
'--stream-monaco-gutter-marker-width',
),
) || 4
const getPaneMetrics = (rootSelector) => {
const pane = document.querySelector(rootSelector)
const margin = pane?.querySelector('.margin')
const lineNumber = pane?.querySelector('.line-numbers')
const viewLine = pane?.querySelector('.view-lines .view-line')
if (!margin || !lineNumber || !viewLine) return null
const marginRect = margin.getBoundingClientRect()
const lineNumberBoxRect = lineNumber.getBoundingClientRect()
const lineNumberTextRect = getTextRect(lineNumber)
const tokenRect = firstVisibleTokenRect(viewLine)
if (!tokenRect) return null
return {
marginWidth: marginRect.width,
numberBoxWidth: lineNumberBoxRect.width,
numberTextWidth: lineNumberTextRect.width,
visibleLineNumber: lineNumber.textContent?.trim() ?? '',
boxLeftGap: lineNumberBoxRect.left - marginRect.left,
boxRightGap: tokenRect.left - lineNumberBoxRect.right,
textStripeGap: lineNumberTextRect.left - marginRect.left - markerWidth,
textCodeGap: tokenRect.left - lineNumberTextRect.right,
}
}
return {
original: getPaneMetrics('.editor.original'),
modified: getPaneMetrics('.editor.modified'),
}
})
const original = metrics.original
const modified = metrics.modified
if (!original || !modified) {
return {
ok: false,
lines,
screenshotPath,
reason: 'Unable to resolve pane metrics',
}
}
const summary = {
lines,
screenshotPath,
original: {
marginWidth: round(original.marginWidth),
numberBoxWidth: round(original.numberBoxWidth),
visibleLineNumber: original.visibleLineNumber,
boxLeftGap: round(original.boxLeftGap),
boxRightGap: round(original.boxRightGap),
textStripeGap: round(original.textStripeGap),
textCodeGap: round(original.textCodeGap),
symmetryDelta: round(Math.abs(original.textStripeGap - original.textCodeGap)),
},
modified: {
marginWidth: round(modified.marginWidth),
numberBoxWidth: round(modified.numberBoxWidth),
visibleLineNumber: modified.visibleLineNumber,
boxLeftGap: round(modified.boxLeftGap),
boxRightGap: round(modified.boxRightGap),
textStripeGap: round(modified.textStripeGap),
textCodeGap: round(modified.textCodeGap),
symmetryDelta: round(Math.abs(modified.textStripeGap - modified.textCodeGap)),
},
}
const marginWidthDelta = Math.abs(
summary.original.marginWidth - summary.modified.marginWidth,
)
const symmetryDelta = Math.max(
summary.original.symmetryDelta,
summary.modified.symmetryDelta,
)
const paneDelta = Math.max(
Math.abs(summary.original.textStripeGap - summary.modified.textStripeGap),
Math.abs(summary.original.textCodeGap - summary.modified.textCodeGap),
)
return {
...summary,
ok: marginWidthDelta < 0.5 && symmetryDelta < 0.5 && paneDelta < 0.5,
marginWidthDelta: round(marginWidthDelta),
paneDelta: round(paneDelta),
}
}
async function run() {
const targets = parseTargets(process.argv.slice(2))
const port = process.env.PORT
? Number(process.env.PORT)
: await findFreePort()
const vite = spawn(
'pnpm',
[
'-C',
demoDir,
'dev',
'--host',
'127.0.0.1',
'--port',
String(port),
'--strictPort',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
const logs = []
vite.stdout.on('data', (d) => logs.push(String(d)))
vite.stderr.on('data', (d) => logs.push(String(d)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({
viewport: { width: 1960, height: 720 },
deviceScaleFactor: 2,
})
const results = []
for (const lines of targets) {
// eslint-disable-next-line no-await-in-loop
results.push(await collectMetrics(page, lines, port))
}
await browser.close()
const ok = results.every((result) => result.ok)
console.log(
JSON.stringify(
{
ok,
targets,
results,
},
null,
2,
),
)
if (!ok) process.exitCode = 1
} catch (error) {
console.error('Diff gutter validation failed.')
console.error(logs.slice(-40).join(''))
throw error
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,373 @@
#!/usr/bin/env node
// Validate default Diff UX hunk actions against Monaco model contents.
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
const fixtures = {
inserted: {
pair: {
original: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const inserted = 99',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.modified',
hoverLineText: 'inserted = 99',
},
deleted: {
pair: {
original: [
'const alpha = 1',
'const legacy = 0',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.original',
hoverLineText: 'legacy = 0',
},
}
const cases = [
{
name: 'revert-lower-removes-inserted-lines',
fixture: 'inserted',
side: 'lower',
action: 'revert',
expected: {
original: fixtures.inserted.pair.original,
modified: fixtures.inserted.pair.original,
},
expectedDiffCount: 0,
},
{
name: 'revert-upper-restores-deleted-lines',
fixture: 'deleted',
side: 'upper',
action: 'revert',
expected: {
original: fixtures.deleted.pair.original,
modified: fixtures.deleted.pair.original,
},
expectedDiffCount: 0,
},
{
name: 'stage-lower-copies-inserted-lines-to-original',
fixture: 'inserted',
side: 'lower',
action: 'stage',
expected: {
original: fixtures.inserted.pair.modified,
modified: fixtures.inserted.pair.modified,
},
expectedDiffCount: 0,
},
{
name: 'stage-upper-drops-deleted-lines-from-original',
fixture: 'deleted',
side: 'upper',
action: 'stage',
expected: {
original: fixtures.deleted.pair.modified,
modified: fixtures.deleted.pair.modified,
},
expectedDiffCount: 0,
},
]
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 5173, end = 5290) {
for (let port = start; port <= end; port++) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 30000) {
const start = Date.now()
while (true) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (open) return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
} catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
} catch {}
}, 3000).unref?.()
}
async function invokeDiffTestApi(page, methodName, ...args) {
return page.evaluate(
({ methodName, args }) => {
const api = window.__streamMonacoDiffTestApi
const method = api?.[methodName]
if (typeof method !== 'function')
throw new Error(`Missing diff test API method: ${methodName}`)
return method(...args)
},
{ methodName, args },
)
}
async function waitForDiffFixture(page) {
await page.waitForFunction(() => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original !== values.modified &&
summary.diffCount > 0
)
}, { timeout: 8000 })
}
async function hoverDiffLine(page, rootSelector, lineText) {
const line = page
.locator(`${rootSelector} .view-lines .view-line`)
.filter({ hasText: lineText })
.first()
await line.waitFor({ state: 'visible', timeout: 5000 })
await line.hover()
await page.waitForTimeout(400)
}
async function clickHunkAction(page, side, action) {
const button = page.locator(
`.stream-monaco-diff-hunk-actions[data-side="${side}"] button[data-action="${action}"]`,
)
await button.waitFor({ state: 'visible', timeout: 5000 })
if (await button.isDisabled()) {
throw new Error(`Expected ${side}/${action} button to be enabled`)
}
await button.click()
}
async function waitForExpectedState(page, expected, expectedDiffCount) {
await page.waitForFunction(
({ expected, expectedDiffCount }) => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original === expected.original &&
values.modified === expected.modified &&
summary.diffCount === expectedDiffCount
)
},
{ expected, expectedDiffCount },
{ timeout: 8000 },
)
}
async function runCase(page, scenario) {
const fixture = fixtures[scenario.fixture]
if (!fixture) throw new Error(`Unknown fixture: ${scenario.fixture}`)
let before = null
try {
await invokeDiffTestApi(page, 'setDiffPair', fixture.pair, {
preserveViewState: false,
})
await waitForDiffFixture(page)
before = await invokeDiffTestApi(page, 'getDiffValues')
await hoverDiffLine(page, fixture.hoverRootSelector, fixture.hoverLineText)
await clickHunkAction(page, scenario.side, scenario.action)
await waitForExpectedState(
page,
scenario.expected,
scenario.expectedDiffCount ?? 0,
)
const after = await invokeDiffTestApi(page, 'getDiffValues')
const summary = await invokeDiffTestApi(page, 'getDiffSummary')
const actionLogs = await invokeDiffTestApi(page, 'getActionLogs')
const expectedPrefix = `${scenario.action.toUpperCase()} ${scenario.side} |`
const firstLog = actionLogs[0] ?? null
const ok =
after.original === scenario.expected.original &&
after.modified === scenario.expected.modified &&
summary.diffCount === (scenario.expectedDiffCount ?? 0) &&
typeof firstLog === 'string' &&
firstLog.startsWith(expectedPrefix)
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok,
firstLog,
before,
after,
summary,
}
} catch (error) {
const after =
await invokeDiffTestApi(page, 'getDiffValues').catch(() => null)
const summary =
await invokeDiffTestApi(page, 'getDiffSummary').catch(() => null)
const actionLogs =
await invokeDiffTestApi(page, 'getActionLogs').catch(() => [])
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok: false,
error: String(error),
before,
after,
summary,
actionLogs,
}
}
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
if (!Number.isFinite(port))
throw new Error(`Invalid PORT: ${process.env.PORT}`)
const vite = spawn(
'pnpm',
[
'-C',
demoDir,
'dev',
'--host',
'127.0.0.1',
'--port',
String(port),
'--strictPort',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
const logs = []
vite.stdout.on('data', (chunk) => logs.push(String(chunk)))
vite.stderr.on('data', (chunk) => logs.push(String(chunk)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({
viewport: { width: 1440, height: 1100 },
deviceScaleFactor: 1,
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
const url = `http://127.0.0.1:${port}/diff-ux?style=background&scenario=streaming&theme=vitesse-light&appearance=light`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await page.waitForFunction(
() => typeof window.__streamMonacoDiffTestApi?.getDiffValues === 'function',
{ timeout: 20000 },
)
await page.waitForTimeout(800)
const results = []
for (const scenario of cases) {
// eslint-disable-next-line no-await-in-loop
results.push(await runCase(page, scenario))
}
await browser.close()
const ok =
pageErrors.length === 0 &&
consoleErrors.length === 0 &&
results.every((result) => result.ok)
const result = {
ok,
url,
pageErrors,
consoleErrors,
results,
}
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error('Diff hunk action validation failed; recent Vite logs:\n')
console.error(logs.slice(-60).join(''))
process.exit(1)
}
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,366 @@
#!/usr/bin/env node
// Validate async custom onDiffHunkAction patch/stash interception flow.
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
const fixtures = {
inserted: {
pair: {
original: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const inserted = 99',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.modified',
hoverLineText: 'inserted = 99',
},
deleted: {
pair: {
original: [
'const alpha = 1',
'const legacy = 0',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.original',
hoverLineText: 'legacy = 0',
},
}
const cases = [
{
name: 'custom-patch-revert-lower',
fixture: 'inserted',
side: 'lower',
action: 'revert',
expected: {
original: fixtures.inserted.pair.original,
modified: fixtures.inserted.pair.original,
},
expectedDiffCount: 0,
expectedActionLogPrefix: 'REVERT lower |',
expectedPatchLogPrefix: 'PATCH FLOW REVERT lower |',
},
{
name: 'custom-patch-stage-upper',
fixture: 'deleted',
side: 'upper',
action: 'stage',
expected: {
original: fixtures.deleted.pair.modified,
modified: fixtures.deleted.pair.modified,
},
expectedDiffCount: 0,
expectedActionLogPrefix: 'STAGE upper |',
expectedPatchLogPrefix: 'PATCH FLOW STAGE upper |',
},
]
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 5173, end = 5290) {
for (let port = start; port <= end; port++) {
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 30000) {
const start = Date.now()
while (true) {
const open = await isPortOpen(port)
if (open) return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
await new Promise((resolve) => setTimeout(resolve, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
} catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
} catch {}
}, 3000).unref?.()
}
async function invokeDiffTestApi(page, methodName, ...args) {
return page.evaluate(
({ methodName, args }) => {
const api = window.__streamMonacoDiffTestApi
const method = api?.[methodName]
if (typeof method !== 'function')
throw new Error(`Missing diff test API method: ${methodName}`)
return method(...args)
},
{ methodName, args },
)
}
async function waitForDiffFixture(page) {
await page.waitForFunction(() => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original !== values.modified &&
summary.diffCount > 0
)
}, { timeout: 8000 })
}
async function hoverDiffLine(page, rootSelector, lineText) {
const line = page
.locator(`${rootSelector} .view-lines .view-line`)
.filter({ hasText: lineText })
.first()
await line.waitFor({ state: 'visible', timeout: 5000 })
await line.hover()
await page.waitForTimeout(400)
}
async function clickHunkAction(page, side, action) {
const button = page.locator(
`.stream-monaco-diff-hunk-actions[data-side="${side}"] button[data-action="${action}"]`,
)
await button.waitFor({ state: 'visible', timeout: 5000 })
if (await button.isDisabled()) {
throw new Error(`Expected ${side}/${action} button to be enabled`)
}
await button.click()
}
async function waitForExpectedState(page, expected, expectedDiffCount) {
await page.waitForFunction(
({ expected, expectedDiffCount }) => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original === expected.original &&
values.modified === expected.modified &&
summary.diffCount === expectedDiffCount
)
},
{ expected, expectedDiffCount },
{ timeout: 8000 },
)
}
async function runCase(page, scenario) {
const fixture = fixtures[scenario.fixture]
if (!fixture) throw new Error(`Unknown fixture: ${scenario.fixture}`)
let before = null
try {
await invokeDiffTestApi(page, 'setHunkActionMode', 'custom-patch')
await invokeDiffTestApi(page, 'setDiffPair', fixture.pair, {
preserveViewState: false,
})
await waitForDiffFixture(page)
before = await invokeDiffTestApi(page, 'getDiffValues')
await hoverDiffLine(page, fixture.hoverRootSelector, fixture.hoverLineText)
await clickHunkAction(page, scenario.side, scenario.action)
await waitForExpectedState(
page,
scenario.expected,
scenario.expectedDiffCount,
)
const after = await invokeDiffTestApi(page, 'getDiffValues')
const summary = await invokeDiffTestApi(page, 'getDiffSummary')
const actionLogs = await invokeDiffTestApi(page, 'getActionLogs')
const patchFlowLogs = await invokeDiffTestApi(page, 'getPatchFlowLogs')
const mode = await invokeDiffTestApi(page, 'getHunkActionMode')
const firstActionLog = actionLogs[0] ?? null
const firstPatchLog = patchFlowLogs[0] ?? null
const ok =
mode === 'custom-patch'
&& after.original === scenario.expected.original
&& after.modified === scenario.expected.modified
&& summary.diffCount === scenario.expectedDiffCount
&& typeof firstActionLog === 'string'
&& firstActionLog.startsWith(scenario.expectedActionLogPrefix)
&& typeof firstPatchLog === 'string'
&& firstPatchLog.startsWith(scenario.expectedPatchLogPrefix)
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok,
mode,
firstActionLog,
firstPatchLog,
before,
after,
summary,
}
} catch (error) {
const after =
await invokeDiffTestApi(page, 'getDiffValues').catch(() => null)
const summary =
await invokeDiffTestApi(page, 'getDiffSummary').catch(() => null)
const actionLogs =
await invokeDiffTestApi(page, 'getActionLogs').catch(() => [])
const patchFlowLogs =
await invokeDiffTestApi(page, 'getPatchFlowLogs').catch(() => [])
const mode =
await invokeDiffTestApi(page, 'getHunkActionMode').catch(() => null)
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok: false,
error: String(error),
mode,
before,
after,
summary,
actionLogs,
patchFlowLogs,
}
}
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
if (!Number.isFinite(port))
throw new Error(`Invalid PORT: ${process.env.PORT}`)
const vite = spawn(
'pnpm',
[
'-C',
demoDir,
'dev',
'--host',
'127.0.0.1',
'--port',
String(port),
'--strictPort',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
const logs = []
vite.stdout.on('data', (chunk) => logs.push(String(chunk)))
vite.stderr.on('data', (chunk) => logs.push(String(chunk)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({
viewport: { width: 1440, height: 1100 },
deviceScaleFactor: 1,
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
const url = `http://127.0.0.1:${port}/diff-ux?style=background&scenario=streaming&theme=vitesse-light&appearance=light`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await page.waitForFunction(
() => typeof window.__streamMonacoDiffTestApi?.setHunkActionMode === 'function',
{ timeout: 20000 },
)
await page.waitForTimeout(800)
const results = []
for (const scenario of cases) {
results.push(await runCase(page, scenario))
}
await invokeDiffTestApi(page, 'setHunkActionMode', 'default')
await browser.close()
const ok =
pageErrors.length === 0
&& consoleErrors.length === 0
&& results.every((result) => result.ok)
const result = {
ok,
url,
pageErrors,
consoleErrors,
results,
}
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error('Custom diff hunk patch-flow validation failed; recent Vite logs:\n')
console.error(logs.slice(-60).join(''))
process.exit(1)
}
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,368 @@
#!/usr/bin/env node
// Validate async custom onDiffHunkAction server updateDiff(...) flow.
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
const fixtures = {
inserted: {
pair: {
original: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const inserted = 99',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.modified',
hoverLineText: 'inserted = 99',
},
deleted: {
pair: {
original: [
'const alpha = 1',
'const legacy = 0',
'const beta = 2',
'const gamma = 3',
].join('\n'),
modified: [
'const alpha = 1',
'const beta = 2',
'const gamma = 3',
].join('\n'),
},
hoverRootSelector: '.editor.original',
hoverLineText: 'legacy = 0',
},
}
const cases = [
{
name: 'custom-update-diff-revert-lower',
fixture: 'inserted',
side: 'lower',
action: 'revert',
expected: {
original: fixtures.inserted.pair.original,
modified: fixtures.inserted.pair.original,
},
expectedDiffCount: 0,
expectedActionLogPrefix: 'REVERT lower |',
expectedPatchLogPrefix: 'UPDATE DIFF FLOW REVERT lower |',
},
{
name: 'custom-update-diff-stage-upper',
fixture: 'deleted',
side: 'upper',
action: 'stage',
expected: {
original: fixtures.deleted.pair.modified,
modified: fixtures.deleted.pair.modified,
},
expectedDiffCount: 0,
expectedActionLogPrefix: 'STAGE upper |',
expectedPatchLogPrefix: 'UPDATE DIFF FLOW STAGE upper |',
},
]
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 5173, end = 5290) {
for (let port = start; port <= end; port++) {
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 30000) {
const start = Date.now()
while (true) {
const open = await isPortOpen(port)
if (open) return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
await new Promise((resolve) => setTimeout(resolve, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
} catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
} catch {}
}, 3000).unref?.()
}
async function invokeDiffTestApi(page, methodName, ...args) {
return page.evaluate(
({ methodName, args }) => {
const api = window.__streamMonacoDiffTestApi
const method = api?.[methodName]
if (typeof method !== 'function')
throw new Error(`Missing diff test API method: ${methodName}`)
return method(...args)
},
{ methodName, args },
)
}
async function waitForDiffFixture(page) {
await page.waitForFunction(() => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original !== values.modified &&
summary.diffCount > 0
)
}, { timeout: 8000 })
}
async function hoverDiffLine(page, rootSelector, lineText) {
const line = page
.locator(`${rootSelector} .view-lines .view-line`)
.filter({ hasText: lineText })
.first()
await line.waitFor({ state: 'visible', timeout: 5000 })
await line.hover()
await page.waitForTimeout(400)
}
async function clickHunkAction(page, side, action) {
const button = page.locator(
`.stream-monaco-diff-hunk-actions[data-side="${side}"] button[data-action="${action}"]`,
)
await button.waitFor({ state: 'visible', timeout: 5000 })
if (await button.isDisabled()) {
throw new Error(`Expected ${side}/${action} button to be enabled`)
}
await button.click()
}
async function waitForExpectedState(page, expected, expectedDiffCount) {
await page.waitForFunction(
({ expected, expectedDiffCount }) => {
const api = window.__streamMonacoDiffTestApi
const values = api?.getDiffValues?.()
const summary = api?.getDiffSummary?.()
return (
values &&
summary &&
values.original === expected.original &&
values.modified === expected.modified &&
summary.diffCount === expectedDiffCount
)
},
{ expected, expectedDiffCount },
{ timeout: 8000 },
)
}
async function runCase(page, scenario) {
const fixture = fixtures[scenario.fixture]
if (!fixture) throw new Error(`Unknown fixture: ${scenario.fixture}`)
let before = null
try {
await invokeDiffTestApi(page, 'setHunkActionMode', 'custom-update-diff')
await invokeDiffTestApi(page, 'setDiffPair', fixture.pair, {
preserveViewState: false,
})
await waitForDiffFixture(page)
before = await invokeDiffTestApi(page, 'getDiffValues')
await hoverDiffLine(page, fixture.hoverRootSelector, fixture.hoverLineText)
await clickHunkAction(page, scenario.side, scenario.action)
await waitForExpectedState(
page,
scenario.expected,
scenario.expectedDiffCount,
)
const after = await invokeDiffTestApi(page, 'getDiffValues')
const summary = await invokeDiffTestApi(page, 'getDiffSummary')
const actionLogs = await invokeDiffTestApi(page, 'getActionLogs')
const patchFlowLogs = await invokeDiffTestApi(page, 'getPatchFlowLogs')
const mode = await invokeDiffTestApi(page, 'getHunkActionMode')
const firstActionLog = actionLogs[0] ?? null
const firstPatchLog = patchFlowLogs[0] ?? null
const ok =
mode === 'custom-update-diff'
&& after.original === scenario.expected.original
&& after.modified === scenario.expected.modified
&& summary.diffCount === scenario.expectedDiffCount
&& typeof firstActionLog === 'string'
&& firstActionLog.startsWith(scenario.expectedActionLogPrefix)
&& typeof firstPatchLog === 'string'
&& firstPatchLog.startsWith(scenario.expectedPatchLogPrefix)
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok,
mode,
firstActionLog,
firstPatchLog,
before,
after,
summary,
}
} catch (error) {
const after =
await invokeDiffTestApi(page, 'getDiffValues').catch(() => null)
const summary =
await invokeDiffTestApi(page, 'getDiffSummary').catch(() => null)
const actionLogs =
await invokeDiffTestApi(page, 'getActionLogs').catch(() => [])
const patchFlowLogs =
await invokeDiffTestApi(page, 'getPatchFlowLogs').catch(() => [])
const mode =
await invokeDiffTestApi(page, 'getHunkActionMode').catch(() => null)
return {
name: scenario.name,
side: scenario.side,
action: scenario.action,
ok: false,
error: String(error),
mode,
before,
after,
summary,
actionLogs,
patchFlowLogs,
}
}
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
if (!Number.isFinite(port))
throw new Error(`Invalid PORT: ${process.env.PORT}`)
const vite = spawn(
'pnpm',
[
'-C',
demoDir,
'dev',
'--host',
'127.0.0.1',
'--port',
String(port),
'--strictPort',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
const logs = []
vite.stdout.on('data', (chunk) => logs.push(String(chunk)))
vite.stderr.on('data', (chunk) => logs.push(String(chunk)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({
viewport: { width: 1440, height: 1100 },
deviceScaleFactor: 1,
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
const url = `http://127.0.0.1:${port}/diff-ux?style=background&scenario=streaming&theme=vitesse-light&appearance=light`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await page.waitForFunction(
() => typeof window.__streamMonacoDiffTestApi?.setHunkActionMode === 'function',
{ timeout: 20000 },
)
await page.waitForTimeout(800)
const results = []
for (const scenario of cases) {
results.push(await runCase(page, scenario))
}
await invokeDiffTestApi(page, 'setHunkActionMode', 'default')
await browser.close()
const ok =
pageErrors.length === 0
&& consoleErrors.length === 0
&& results.every((result) => result.ok)
const result = {
ok,
url,
pageErrors,
consoleErrors,
results,
}
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error(
'Custom diff hunk updateDiff-flow validation failed; recent Vite logs:\n',
)
console.error(logs.slice(-60).join(''))
process.exit(1)
}
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,391 @@
#!/usr/bin/env node
// Validate narrow-width inline diff layout and hover behavior.
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 5181, end = 5190) {
for (let port = start; port <= end; port++) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 20000) {
const start = Date.now()
while (true) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (open) return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
} catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
} catch {}
}, 3000).unref?.()
}
function round(value) {
return Math.round(value * 1000) / 1000
}
function parseScenario(value) {
if (value === 'streaming') return 'streaming'
if (value === 'pierre-reference' || value === 'reference')
return 'pierre-reference'
return 'line-info-reference'
}
function parseUnchangedStyle(value) {
if (value === 'line-info-basic') return 'line-info-basic'
if (value === 'metadata') return 'metadata'
if (value === 'simple') return 'simple'
return 'line-info'
}
function parseWidths(values) {
const parsed = values
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value >= 320)
return parsed.length > 0 ? parsed : [980, 760, 620, 520, 420]
}
async function forceFrameWidth(page, width) {
await page.evaluate((targetWidth) => {
const frame = document.querySelector('.line-info-compare-frame')
const card = document.querySelector('.editor-card')
const pageRoot = document.querySelector('.page')
if (frame instanceof HTMLElement) {
frame.style.width = `${targetWidth}px`
frame.style.maxWidth = `${targetWidth}px`
}
if (card instanceof HTMLElement) {
card.style.width = `${targetWidth}px`
card.style.maxWidth = `${targetWidth}px`
}
if (pageRoot instanceof HTMLElement) {
pageRoot.style.width = `${targetWidth}px`
pageRoot.style.maxWidth = `${targetWidth}px`
}
}, width)
}
async function hoverFirstVisibleDiff(page) {
const rect = await page.evaluate(() => {
const isVisible = (node) => {
if (!(node instanceof HTMLElement)) return false
const style = getComputedStyle(node)
if (style.display === 'none' || style.visibility === 'hidden')
return false
const bounds = node.getBoundingClientRect()
return bounds.width > 0 && bounds.height > 0
}
const selectors = [
'.editor.modified .char-insert',
'.editor.modified .char-delete',
'.editor.modified .inline-deleted-text',
'.editor.modified .line-insert',
'.editor.modified .line-delete',
'.editor.original .inline-deleted-text',
'.editor.original .line-delete',
]
for (const selector of selectors) {
const node = Array.from(document.querySelectorAll(selector)).find(
isVisible,
)
if (!(node instanceof HTMLElement)) continue
const bounds = node.getBoundingClientRect()
return {
x: bounds.left + Math.min(bounds.width * 0.5, 180),
y: bounds.top + bounds.height / 2,
}
}
return null
})
if (!rect) return false
await page.mouse.move(rect.x, rect.y)
await page.waitForTimeout(320)
return true
}
async function collectMetrics(page, options) {
const {
port,
scenario,
width,
style = 'background',
theme = 'snazzy-light',
lines = '6',
unchangedStyle = 'line-info',
} = options
const url = `http://127.0.0.1:${port}/diff-ux?style=${style}&scenario=${scenario}&theme=${encodeURIComponent(
theme,
)}&lines=${encodeURIComponent(lines)}&unchangedStyle=${encodeURIComponent(
unchangedStyle,
)}&capture=1`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await forceFrameWidth(page, width)
await page.waitForTimeout(1400)
await hoverFirstVisibleDiff(page)
const screenshotPath = path.join(
'/tmp',
`stream-monaco-inline-${scenario}-${unchangedStyle}-${width}.png`,
)
await page
.locator(
scenario === 'line-info-reference'
? '.line-info-compare-frame'
: '.editor-card',
)
.screenshot({ path: screenshotPath })
const metrics = await page.evaluate(() => {
const isVisible = (node) => {
if (!(node instanceof HTMLElement)) return false
const style = getComputedStyle(node)
if (style.display === 'none' || style.visibility === 'hidden')
return false
const rect = node.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
const rectData = (node) => {
if (!(node instanceof HTMLElement)) return null
const rect = node.getBoundingClientRect()
return {
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
}
}
const countVisible = (selector) =>
Array.from(document.querySelectorAll(selector)).filter(isVisible).length
const frame =
document.querySelector('.line-info-compare-frame') ??
document.querySelector('.editor-card')
const card = document.querySelector('.editor-card')
const tabs = document.querySelector('.line-info-tabs')
const fileStats = document.querySelector('.file-stats')
const diffRoot = document.querySelector('.monaco-diff-editor')
const original = document.querySelector('.editor.original')
const modified = document.querySelector('.editor.modified')
return {
diffClass: diffRoot?.className || '',
frame: rectData(frame),
card: rectData(card),
tabs: rectData(tabs),
fileStats: rectData(fileStats),
original: rectData(original),
modified: rectData(modified),
originalCenters: countVisible(
'.editor.original .diff-hidden-lines .center',
),
originalWidgets: countVisible(
'.editor.original .diff-hidden-lines-widget, .editor.original .fold-unchanged',
),
bridgeCount: countVisible('.stream-monaco-diff-unchanged-bridge'),
paneDividerCount: countVisible('.stream-monaco-unchanged-pane-divider'),
deletedMarginCount: countVisible('.inline-deleted-margin-view-zone'),
tooltipCount: countVisible(
'.monaco-hover, .monaco-editor-hover, .hover-widget, [role="tooltip"]',
),
visibleHunkActionCount: countVisible('.stream-monaco-diff-hunk-actions'),
rootClasses:
document.querySelector('.editor')?.className ||
document.querySelector('.editor-card')?.className ||
'',
}
})
const frameWidth = round(metrics.frame?.width ?? width)
const cardWidth = round(metrics.card?.width ?? 0)
const tabsWidth = round(metrics.tabs?.width ?? 0)
const fileStatsRight = round(metrics.fileStats?.right ?? 0)
const cardRight = round(metrics.card?.right ?? 0)
const cardOverflow = round(Math.max(0, cardWidth - frameWidth))
const tabsOverflow = round(
Math.max(0, (metrics.tabs?.right ?? 0) - (metrics.frame?.right ?? 0)),
)
const fileStatsOverflow = round(Math.max(0, fileStatsRight - cardRight))
const inlineMode =
!metrics.diffClass.includes('side-by-side') ||
(metrics.original?.width ?? 0) < 24
let ok = cardOverflow <= 1 && tabsOverflow <= 1 && fileStatsOverflow <= 1
if (inlineMode) {
ok =
ok &&
metrics.originalCenters === 0 &&
metrics.originalWidgets === 0 &&
metrics.bridgeCount === 0 &&
metrics.paneDividerCount === 0 &&
metrics.tooltipCount <= 1 &&
metrics.visibleHunkActionCount <= 1
}
return {
ok,
width,
inlineMode,
screenshotPath,
frameWidth,
cardWidth,
tabsWidth,
cardOverflow,
tabsOverflow,
fileStatsOverflow,
diffClass: metrics.diffClass,
originalWidth: round(metrics.original?.width ?? 0),
modifiedWidth: round(metrics.modified?.width ?? 0),
originalCenters: metrics.originalCenters,
originalWidgets: metrics.originalWidgets,
bridgeCount: metrics.bridgeCount,
paneDividerCount: metrics.paneDividerCount,
deletedMarginCount: metrics.deletedMarginCount,
tooltipCount: metrics.tooltipCount,
visibleHunkActionCount: metrics.visibleHunkActionCount,
}
}
async function run() {
const scenario = parseScenario(process.argv[2])
const unchangedStyle = parseUnchangedStyle(process.argv[3])
const widths = parseWidths(process.argv.slice(4))
const port = process.env.PORT
? Number(process.env.PORT)
: await findFreePort()
const vite = spawn(
'pnpm',
[
'-C',
demoDir,
'dev',
'--host',
'127.0.0.1',
'--port',
String(port),
'--strictPort',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
const logs = []
vite.stdout.on('data', (d) => logs.push(String(d)))
vite.stderr.on('data', (d) => logs.push(String(d)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({
viewport: { width: 1600, height: 1700 },
deviceScaleFactor: 2,
})
const results = []
for (const width of widths) {
// eslint-disable-next-line no-await-in-loop
results.push(
await collectMetrics(page, {
port,
scenario,
width,
unchangedStyle,
}),
)
}
await browser.close()
const ok = results.every((result) => result.ok)
console.log(
JSON.stringify(
{
ok,
scenario,
unchangedStyle,
widths,
results,
},
null,
2,
),
)
if (!ok) process.exitCode = 1
} catch (error) {
console.error(
JSON.stringify(
{
ok: false,
scenario,
unchangedStyle,
widths,
error: error instanceof Error ? error.message : String(error),
logs: logs.slice(-40),
},
null,
2,
),
)
process.exitCode = 1
} finally {
onExit()
}
}
run()
@@ -0,0 +1,140 @@
#!/usr/bin/env node
// Smoke test for DiffEditor streaming rendering.
// Starts the Vite demo, opens /diff in headless Chromium, and asserts Monaco renders text.
import net from 'node:net'
import process from 'node:process'
import { spawn } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 5173, end = 5190) {
for (let port = start; port <= end; port++) {
// If nothing is listening, consider it free.
// (We only bind after Vite starts, so this is best-effort.)
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open)
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 20000) {
const start = Date.now()
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (open)
return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
// eslint-disable-next-line no-await-in-loop
await new Promise(r => setTimeout(r, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch { }
// Fallback hard kill after a short grace period.
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch { }
}, 3000).unref?.()
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
if (!Number.isFinite(port))
throw new Error(`Invalid PORT: ${process.env.PORT}`)
const vite = spawn(
'pnpm',
['-C', demoDir, 'dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
const logs = []
vite.stdout.on('data', d => logs.push(String(d)))
vite.stderr.on('data', d => logs.push(String(d)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
try {
await waitForPort(port)
const url = `http://127.0.0.1:${port}/diff`
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await page.waitForTimeout(1200)
const before = await page.evaluate(() => {
const viewLines = Array.from(document.querySelectorAll('.monaco-editor .view-lines'))
const text = viewLines.map(v => (v.textContent ?? '')).join('\n').trim()
return { viewLinesCount: viewLines.length, textLen: text.length, sample: text.slice(0, 120) }
})
// Wait for at least one streaming tick from the demo.
await page.waitForTimeout(700)
const after = await page.evaluate(() => {
const viewLines = Array.from(document.querySelectorAll('.monaco-editor .view-lines'))
const text = viewLines.map(v => (v.textContent ?? '')).join('\n').trim()
return { viewLinesCount: viewLines.length, textLen: text.length }
})
await browser.close()
// We only assert that Monaco renders *some* text and stays rendered after
// streaming ticks. Diff line wrapping/reflow can change `textLen`, so do
// not assert monotonic growth.
const ok = before.textLen > 0 && after.textLen > 0
const result = { ok, url, before, after }
console.log(JSON.stringify(result, null, 2))
if (!ok) {
console.error('Diff smoke failed; recent Vite logs:\n', logs.slice(-40).join(''))
process.exit(1)
}
}
finally {
killProcessTree(vite)
}
}
run().catch((err) => {
console.error(err)
process.exit(1)
})
@@ -0,0 +1,259 @@
#!/usr/bin/env node
// Validate that streaming appends do not leave stale full-tail diff
// highlights when only sparse earlier lines actually differ.
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4205, end = 4215) {
for (let port = start; port <= end; port++) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeoutMs = 20000) {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
// eslint-disable-next-line no-await-in-loop
if (await isPortOpen(port)) return
// eslint-disable-next-line no-await-in-loop
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for port ${port}`)
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
async function main() {
const port = await findFreePort()
const vite = spawn(
'pnpm',
['-C', demoDir, 'dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'],
{
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
},
)
const logs = []
vite.stdout.on('data', data => logs.push(String(data)))
vite.stderr.on('data', data => logs.push(String(data)))
const onExit = () => killProcessTree(vite)
process.on('exit', onExit)
process.on('SIGINT', () => process.exit(130))
process.on('SIGTERM', () => process.exit(143))
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 1800, height: 1100 } })
await page.goto(`http://127.0.0.1:${port}/diff`, { waitUntil: 'networkidle' })
const result = await page.evaluate(async () => {
const mod = await import('/@fs/Users/Simon/Github/stream-monaco/src/index.ts')
document.body.innerHTML = '<div id="probe" style="width:1600px;height:900px"></div>'
const container = document.getElementById('probe')
if (!(container instanceof HTMLElement))
throw new Error('Missing probe container')
mod.preloadMonacoWorkers?.()
const api = mod.useMonaco({
readOnly: true,
theme: 'vitesse-light',
themes: ['vitesse-dark', 'vitesse-light'],
diffAlgorithm: 'legacy',
renderSideBySide: true,
diffLineStyle: 'background',
diffHideUnchangedRegions: false,
diffUpdateThrottleMs: 120,
ignoreTrimWhitespace: false,
renderIndicators: true,
})
const readChangedLineNumbers = () => {
const readSide = (selector) =>
[...new Set(
Array.from(document.querySelectorAll(selector))
.map((node) => ({
line: Number.parseInt(node.textContent?.trim() || '', 10),
className: node.className,
}))
.filter((entry) =>
Number.isFinite(entry.line) &&
/line-delete|line-insert|stream-monaco-fallback-line-number-delete|stream-monaco-fallback-line-number-insert/.test(
entry.className,
),
)
.map(entry => entry.line),
)].sort((a, b) => a - b)
return {
original: readSide('.editor.original .line-numbers'),
modified: readSide('.editor.modified .line-numbers'),
stale: document.querySelector('.stream-monaco-diff-root')?.classList.contains(
'stream-monaco-diff-native-stale',
) ?? false,
}
}
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
const buildLines = (count) =>
Array.from({ length: count }, (_, index) => {
const lineNumber = index + 1
return ` "line${String(lineNumber).padStart(2, '0')}": "value-${String(lineNumber).padStart(2, '0')}",`
})
const scenarios = [
{
name: 'single-early-diff',
originalLines: ['{', ...buildLines(22), '}'],
mutate(modifiedLines) {
modifiedLines[3] = ' "line03": "value-03-updated",'
},
startLine: 4,
sampleUntilLine: 24,
expectedAtLine(uptoLine) {
return uptoLine >= 4 ? [4] : []
},
},
{
name: 'sparse-double-diff',
originalLines: ['{', ...buildLines(22), '}'],
mutate(modifiedLines) {
modifiedLines[3] = ' "line03": "value-03-updated",'
modifiedLines[19] = ' "line19": "value-19-updated",'
},
startLine: 4,
sampleUntilLine: 24,
expectedAtLine(uptoLine) {
if (uptoLine < 4) return []
if (uptoLine < 20) return [4]
return [4, 20]
},
},
]
const runScenario = async (scenario) => {
const modifiedLines = [...scenario.originalLines]
scenario.mutate(modifiedLines)
await api.createDiffEditor(
container,
scenario.originalLines.slice(0, scenario.startLine).join('\n'),
modifiedLines.slice(0, scenario.startLine).join('\n'),
'json',
)
const frames = []
for (let lineNumber = scenario.startLine + 1; lineNumber <= scenario.sampleUntilLine; lineNumber++) {
api.updateDiff(
scenario.originalLines.slice(0, lineNumber).join('\n'),
modifiedLines.slice(0, lineNumber).join('\n'),
'json',
)
// Intentionally sample before Monaco's native diff is guaranteed to
// have settled so we validate the stale-diff fallback path.
// eslint-disable-next-line no-await-in-loop
await wait(40)
frames.push({
uptoLine: lineNumber,
expected: scenario.expectedAtLine(lineNumber),
...readChangedLineNumbers(),
})
}
await wait(240)
const settled = {
expected: scenario.expectedAtLine(scenario.sampleUntilLine),
...readChangedLineNumbers(),
}
return {
name: scenario.name,
frames,
settled,
}
}
const scenarioResults = []
for (const scenario of scenarios) {
// eslint-disable-next-line no-await-in-loop
scenarioResults.push(await runScenario(scenario))
}
return { scenarios: scenarioResults }
})
const scenarios = result.scenarios.map((scenario) => {
const framesOk = scenario.frames.every((frame) => {
return (
JSON.stringify(frame.original) === JSON.stringify(frame.expected) &&
JSON.stringify(frame.modified) === JSON.stringify(frame.expected)
)
})
const settledOk
= JSON.stringify(scenario.settled.original) === JSON.stringify(scenario.settled.expected)
&& JSON.stringify(scenario.settled.modified) === JSON.stringify(scenario.settled.expected)
return {
...scenario,
ok: framesOk && settledOk,
}
})
const ok = scenarios.every(scenario => scenario.ok)
console.log(JSON.stringify({ ok, scenarios }, null, 2))
await browser.close()
killProcessTree(vite)
process.exit(ok ? 0 : 1)
}
catch (error) {
killProcessTree(vite)
if (logs.length > 0) console.error(logs.join(''))
console.error(error)
process.exit(1)
}
}
main()
@@ -0,0 +1,774 @@
#!/usr/bin/env node
// Validate that diff presentation toggles refresh in place without remounting
// the Monaco diff tree or shifting the host geometry.
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 5173, end = 5190) {
for (let port = start; port <= end; port++) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 30000) {
const start = Date.now()
while (true) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (open) return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
} catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
} catch {}
}, 3000).unref?.()
}
function round(value) {
return Math.round(value * 1000) / 1000
}
function colorLuminance(color) {
const channels = String(color || '').match(/\d+(?:\.\d+)?/g)
if (!channels || channels.length < 3) return null
const [r, g, b] = channels.slice(0, 3).map(Number)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
function rectDelta(a, b) {
if (!a || !b) return Number.POSITIVE_INFINITY
return Math.max(
Math.abs((a.left ?? 0) - (b.left ?? 0)),
Math.abs((a.top ?? 0) - (b.top ?? 0)),
Math.abs((a.width ?? 0) - (b.width ?? 0)),
Math.abs((a.height ?? 0) - (b.height ?? 0)),
)
}
function maxRectDelta(frames, key, baseline) {
return frames.reduce((max, frame) => {
return Math.max(max, rectDelta(frame[key], baseline[key]))
}, 0)
}
function allFramesTrue(frames, selector) {
return frames.every((frame) => selector(frame) === true)
}
async function invokeDiffTestApi(page, methodName, ...args) {
await page.evaluate(
({ methodName, args }) => {
const api = window.__streamMonacoDiffTestApi
const method = api?.[methodName]
if (typeof method !== 'function')
throw new Error(`Missing diff test API method: ${methodName}`)
return method(...args)
},
{ methodName, args },
)
}
async function primeModifiedScroll(page, top = 240) {
await invokeDiffTestApi(page, 'setModifiedScrollTop', top)
await page.waitForTimeout(180)
const actual = await page.evaluate(() => {
return window.__streamMonacoDiffTestApi?.getModifiedScrollTop?.() ?? 0
})
if (actual < Math.min(40, top * 0.2)) {
throw new Error(
`Failed to prime modified scroll position. expected around ${top}, got ${actual}`,
)
}
}
async function startTransitionProbe(page, frameCount = 42) {
await page.evaluate((requestedFrames) => {
const rectData = (node) => {
if (!(node instanceof HTMLElement)) return null
const rect = node.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}
}
const isVisible = (node) => {
if (!(node instanceof HTMLElement)) return false
const style = getComputedStyle(node)
if (style.display === 'none' || style.visibility === 'hidden')
return false
const rect = node.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
const countVisible = (selector) =>
Array.from(document.querySelectorAll(selector)).filter(isVisible).length
const readVisibleWidgets = (rootSelector) => {
const root = document.querySelector(rootSelector)
if (!(root instanceof HTMLElement)) return []
const rootRect = root.getBoundingClientRect()
return Array.from(root.querySelectorAll('.diff-hidden-lines-widget'))
.map((node) => {
if (!(node instanceof HTMLElement)) return null
const rect = node.getBoundingClientRect()
if (
rect.height <= 0 ||
rect.bottom <= rootRect.top + 1 ||
rect.top >= rootRect.bottom - 1
) {
return null
}
return {
topOffset: rect.top - rootRect.top,
height: rect.height,
}
})
.filter(Boolean)
.sort((a, b) => a.topOffset - b.topOffset)
}
const readVisibleAnchor = (rootSelector) => {
const root = document.querySelector(rootSelector)
if (!(root instanceof HTMLElement)) return null
const rootRect = root.getBoundingClientRect()
const anchor = Array.from(root.querySelectorAll('.line-numbers'))
.map((node) => {
const rect = node.getBoundingClientRect()
const lineNumber = Number.parseInt(node.textContent?.trim() || '', 10)
return { lineNumber, rect }
})
.filter(({ lineNumber, rect }) => {
return (
Number.isFinite(lineNumber) &&
rect.height > 0 &&
rect.bottom > rootRect.top + 1 &&
rect.top < rootRect.bottom - 1
)
})
.sort((a, b) => a.rect.top - b.rect.top)[0]
if (!anchor) return null
return {
lineNumber: anchor.lineNumber,
topOffset: anchor.rect.top - rootRect.top,
}
}
const readVisibleLineTops = (rootSelector) => {
const root = document.querySelector(rootSelector)
if (!(root instanceof HTMLElement)) return {}
const rootRect = root.getBoundingClientRect()
return Object.fromEntries(
Array.from(root.querySelectorAll('.line-numbers'))
.map((node) => {
if (!(node instanceof HTMLElement)) return null
const lineNumber = Number.parseInt(
node.textContent?.trim() || '',
10,
)
const rect = node.getBoundingClientRect()
if (
!Number.isFinite(lineNumber) ||
rect.height <= 0 ||
rect.bottom <= rootRect.top + 1 ||
rect.top >= rootRect.bottom - 1
) {
return null
}
return [String(lineNumber), rect.top - rootRect.top]
})
.filter(Boolean),
)
}
const readVisibleSummaryStates = (selector) => {
return Array.from(document.querySelectorAll(selector))
.filter(isVisible)
.map((node) => {
if (!(node instanceof HTMLButtonElement)) return null
const style = getComputedStyle(node)
return {
cursor: style.cursor,
pointerEvents: style.pointerEvents,
disabled: node.disabled,
}
})
.filter(Boolean)
}
const baselineNodes = {
host: document.querySelector('.editor-card'),
diffRoot: document.querySelector('.monaco-diff-editor'),
original: document.querySelector('.editor.original'),
modified: document.querySelector('.editor.modified'),
overlay: document.querySelector('.stream-monaco-diff-unchanged-overlay'),
}
const capture = () => {
const host = document.querySelector('.editor-card')
const frame = document.querySelector('.line-info-compare-frame')
const diffRoot = document.querySelector('.monaco-diff-editor')
const original = document.querySelector('.editor.original')
const modified = document.querySelector('.editor.modified')
const overlay = document.querySelector(
'.stream-monaco-diff-unchanged-overlay',
)
const backgroundProbe =
diffRoot?.querySelector('.monaco-editor .monaco-editor-background') ??
diffRoot?.querySelector('.monaco-editor-background')
const rootContainer = document.querySelector('.stream-monaco-diff-root')
return {
frameRect: rectData(frame),
hostRect: rectData(host),
diffRect: rectData(diffRoot),
overlayRect: rectData(overlay),
modifiedScrollTop:
window.__streamMonacoDiffTestApi?.getModifiedScrollTop?.() ?? 0,
modifiedAnchor: readVisibleAnchor('.editor.modified'),
modifiedVisibleWidgets: readVisibleWidgets('.editor.modified'),
modifiedVisibleLineTops: readVisibleLineTops('.editor.modified'),
visibleBridgeRailCount: countVisible(
'.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-rail',
),
visibleBridgeRevealCount: countVisible(
'.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-reveal',
),
visibleMetadataSummaryStates: readVisibleSummaryStates(
'.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-summary-metadata',
),
visibleSimpleSummaryStates: readVisibleSummaryStates(
'.stream-monaco-diff-unchanged-bridge .stream-monaco-unchanged-summary-simple',
),
bridgeCount: countVisible('.stream-monaco-diff-unchanged-bridge'),
backgroundColor:
backgroundProbe instanceof HTMLElement
? getComputedStyle(backgroundProbe).backgroundColor
: '',
rootClasses:
rootContainer instanceof HTMLElement
? Array.from(rootContainer.classList)
: [],
nodes: {
hostSame: host === baselineNodes.host,
diffRootSame: diffRoot === baselineNodes.diffRoot,
originalSame: original === baselineNodes.original,
modifiedSame: modified === baselineNodes.modified,
overlaySame: overlay === baselineNodes.overlay,
},
}
}
const baseline = capture()
window.__streamMonacoTransitionProbe = new Promise((resolve) => {
const frames = []
let remaining = Math.max(1, requestedFrames)
const tick = () => {
frames.push(capture())
remaining--
if (remaining <= 0) {
resolve({ baseline, frames })
return
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
}, frameCount)
}
async function collectTransition(page, trigger) {
await startTransitionProbe(page)
await trigger()
return page.evaluate(() => window.__streamMonacoTransitionProbe)
}
function summarizeTransition(name, result, options = {}) {
const {
expectedRootClass,
geometryBudget = 0.75,
requireGeometryStable = true,
expectLuminance = null,
requireModifiedScrollStable = true,
scrollBudget = 1,
requireModifiedAnchorStable = false,
anchorBudget = 1,
expectedVisibleWidgetHeight = null,
expectAnyModifiedLineShiftUpAtLeast = null,
expectedVisibleRailCount = null,
expectedVisibleRevealCount = null,
requireMetadataSummaryNonInteractive = false,
requireSimpleSummaryNonInteractive = false,
} = options
const baseline = result.baseline
const frames = result.frames
const finalFrame = frames.at(-1) ?? baseline
const hostMaxDelta = maxRectDelta(frames, 'hostRect', baseline)
const diffMaxDelta = maxRectDelta(frames, 'diffRect', baseline)
const frameMaxDelta = maxRectDelta(frames, 'frameRect', baseline)
const overlayMaxDelta =
baseline.overlayRect && finalFrame.overlayRect
? maxRectDelta(frames, 'overlayRect', baseline)
: 0
const modifiedScrollMaxDelta = frames.reduce((max, frame) => {
return Math.max(
max,
Math.abs(
(frame.modifiedScrollTop ?? 0) - (baseline.modifiedScrollTop ?? 0),
),
)
}, 0)
const anchorLineStable = frames.every((frame) => {
return (
frame.modifiedAnchor?.lineNumber != null &&
frame.modifiedAnchor?.lineNumber === baseline.modifiedAnchor?.lineNumber
)
})
const modifiedAnchorMaxDelta = frames.reduce((max, frame) => {
if (
baseline.modifiedAnchor?.lineNumber == null ||
frame.modifiedAnchor?.lineNumber !== baseline.modifiedAnchor?.lineNumber
) {
return Number.POSITIVE_INFINITY
}
return Math.max(
max,
Math.abs(
(frame.modifiedAnchor?.topOffset ?? 0) -
(baseline.modifiedAnchor?.topOffset ?? 0),
),
)
}, 0)
const finalWidgetHeights = (finalFrame.modifiedVisibleWidgets ?? []).map(
(widget) => round(widget.height),
)
const widgetHeightOk =
expectedVisibleWidgetHeight == null
? true
: finalWidgetHeights.length > 0 &&
finalWidgetHeights.every(
(height) => Math.abs(height - expectedVisibleWidgetHeight) <= 1,
)
const baselineVisibleLineTops = baseline.modifiedVisibleLineTops ?? {}
const finalVisibleLineTops = finalFrame.modifiedVisibleLineTops ?? {}
const sharedLineShiftDeltas = Object.keys(baselineVisibleLineTops)
.filter((lineNumber) => lineNumber in finalVisibleLineTops)
.map((lineNumber) => {
return round(
finalVisibleLineTops[lineNumber] - baselineVisibleLineTops[lineNumber],
)
})
const minSharedLineShift =
sharedLineShiftDeltas.length > 0 ? Math.min(...sharedLineShiftDeltas) : null
const maxSharedLineShift =
sharedLineShiftDeltas.length > 0 ? Math.max(...sharedLineShiftDeltas) : null
const lineShiftOk =
expectAnyModifiedLineShiftUpAtLeast == null
? true
: sharedLineShiftDeltas.some(
(delta) => delta <= -expectAnyModifiedLineShiftUpAtLeast,
)
const visibleRailCountOk =
expectedVisibleRailCount == null
? true
: (finalFrame.visibleBridgeRailCount ?? 0) === expectedVisibleRailCount
const visibleRevealCountOk =
expectedVisibleRevealCount == null
? true
: (finalFrame.visibleBridgeRevealCount ?? 0) ===
expectedVisibleRevealCount
const metadataSummaryStates = finalFrame.visibleMetadataSummaryStates ?? []
const metadataSummaryNonInteractiveOk = requireMetadataSummaryNonInteractive
? metadataSummaryStates.length > 0 &&
metadataSummaryStates.every((state) => {
return (
state.cursor !== 'pointer' &&
state.pointerEvents === 'none' &&
state.disabled === true
)
})
: true
const simpleSummaryStates = finalFrame.visibleSimpleSummaryStates ?? []
const simpleSummaryNonInteractiveOk = requireSimpleSummaryNonInteractive
? simpleSummaryStates.length > 0 &&
simpleSummaryStates.every((state) => {
return (
state.cursor !== 'pointer' &&
state.pointerEvents === 'none' &&
state.disabled === true
)
})
: true
const nodeStable = {
host: allFramesTrue(frames, (frame) => frame.nodes.hostSame),
diffRoot: allFramesTrue(frames, (frame) => frame.nodes.diffRootSame),
original: allFramesTrue(frames, (frame) => frame.nodes.originalSame),
modified: allFramesTrue(frames, (frame) => frame.nodes.modifiedSame),
overlay: allFramesTrue(frames, (frame) => frame.nodes.overlaySame),
}
const finalLuminance = colorLuminance(finalFrame.backgroundColor)
const luminanceOk =
expectLuminance == null
? true
: expectLuminance === 'dark'
? finalLuminance != null && finalLuminance < 120
: finalLuminance != null && finalLuminance > 150
const geometryOk = requireGeometryStable
? hostMaxDelta <= geometryBudget &&
diffMaxDelta <= geometryBudget &&
frameMaxDelta <= geometryBudget
: true
const scrollOk = requireModifiedScrollStable
? modifiedScrollMaxDelta <= scrollBudget
: true
const anchorOk = requireModifiedAnchorStable
? anchorLineStable && modifiedAnchorMaxDelta <= anchorBudget
: true
const rootClassOk = expectedRootClass
? finalFrame.rootClasses.includes(expectedRootClass)
: true
const ok =
Object.values(nodeStable).every(Boolean) &&
geometryOk &&
scrollOk &&
anchorOk &&
widgetHeightOk &&
lineShiftOk &&
visibleRailCountOk &&
visibleRevealCountOk &&
metadataSummaryNonInteractiveOk &&
simpleSummaryNonInteractiveOk &&
rootClassOk &&
luminanceOk
return {
name,
ok,
expectedRootClass,
geometryBudget,
requireGeometryStable,
requireModifiedScrollStable,
scrollBudget,
requireModifiedAnchorStable,
anchorBudget,
rootClassOk,
scrollOk,
anchorOk,
widgetHeightOk,
lineShiftOk,
visibleRailCountOk,
visibleRevealCountOk,
metadataSummaryNonInteractiveOk,
simpleSummaryNonInteractiveOk,
luminanceOk,
finalLuminance: round(finalLuminance ?? Number.NaN),
nodeStable,
geometry: {
hostMaxDelta: round(hostMaxDelta),
diffMaxDelta: round(diffMaxDelta),
frameMaxDelta: round(frameMaxDelta),
overlayMaxDelta: round(overlayMaxDelta),
},
scroll: {
before: round(baseline.modifiedScrollTop ?? 0),
after: round(finalFrame.modifiedScrollTop ?? 0),
maxDelta: round(modifiedScrollMaxDelta),
},
anchor: {
before: baseline.modifiedAnchor ?? null,
after: finalFrame.modifiedAnchor ?? null,
lineStable: anchorLineStable,
maxDelta: round(modifiedAnchorMaxDelta),
},
unchangedWidgets: {
expectedHeight: expectedVisibleWidgetHeight,
finalHeights: finalWidgetHeights,
},
lineShift: {
min: minSharedLineShift,
max: maxSharedLineShift,
expectedShiftUpAtLeast: expectAnyModifiedLineShiftUpAtLeast,
},
bridgeChrome: {
expectedVisibleRailCount,
actualVisibleRailCount: finalFrame.visibleBridgeRailCount ?? 0,
expectedVisibleRevealCount,
actualVisibleRevealCount: finalFrame.visibleBridgeRevealCount ?? 0,
},
metadataSummary: {
requireNonInteractive: requireMetadataSummaryNonInteractive,
states: metadataSummaryStates,
},
simpleSummary: {
requireNonInteractive: requireSimpleSummaryNonInteractive,
states: simpleSummaryStates,
},
bridgeCount: {
before: baseline.bridgeCount,
after: finalFrame.bridgeCount,
},
finalRootClasses: finalFrame.rootClasses,
}
}
async function main() {
const port = process.env.PORT
? Number(process.env.PORT)
: await findFreePort()
const vite = spawn(
'pnpm',
[
'-C',
demoDir,
'dev',
'--host',
'127.0.0.1',
'--port',
String(port),
'--strictPort',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
const logs = []
vite.stdout.on('data', (d) => logs.push(String(d)))
vite.stderr.on('data', (d) => logs.push(String(d)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({
viewport: { width: 1960, height: 1600 },
deviceScaleFactor: 2,
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
const url = `http://127.0.0.1:${port}/diff-ux?style=background&scenario=line-info-reference&theme=vitesse-light&appearance=light&unchangedStyle=line-info`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector('.editor-card', { timeout: 20000 })
await page.waitForSelector('.monaco-diff-editor', { timeout: 20000 })
await page.waitForSelector('.stream-monaco-diff-unchanged-overlay', {
timeout: 20000,
})
await page.waitForTimeout(1400)
const results = []
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'appearance-light-to-dark',
await collectTransition(page, async () => {
await page.getByRole('button', { name: /^Dark$/ }).click()
}),
{
expectedRootClass: 'stream-monaco-diff-appearance-dark',
requireGeometryStable: true,
expectLuminance: 'dark',
requireModifiedScrollStable: true,
expectedVisibleWidgetHeight: 32,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'appearance-dark-to-light',
await collectTransition(page, async () => {
await page.getByRole('button', { name: /^Light$/ }).click()
}),
{
expectedRootClass: 'stream-monaco-diff-appearance-light',
requireGeometryStable: true,
expectLuminance: 'light',
requireModifiedScrollStable: true,
expectedVisibleWidgetHeight: 32,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'style-line-info-to-basic',
await collectTransition(page, async () => {
await page.getByRole('button', { name: /^Line Info Basic$/ }).click()
}),
{
expectedRootClass:
'stream-monaco-diff-unchanged-style-line-info-basic',
requireGeometryStable: true,
requireModifiedScrollStable: true,
expectedVisibleWidgetHeight: 32,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'style-basic-to-metadata',
await collectTransition(page, async () => {
await page
.getByRole('button', { name: /^Metadata$/ })
.first()
.click()
}),
{
expectedRootClass: 'stream-monaco-diff-unchanged-style-metadata',
requireGeometryStable: true,
requireModifiedScrollStable: true,
expectedVisibleWidgetHeight: 32,
expectedVisibleRailCount: 0,
expectedVisibleRevealCount: 0,
requireMetadataSummaryNonInteractive: true,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'set-diff-models-same-content',
await collectTransition(page, async () => {
await invokeDiffTestApi(page, 'swapModelsSameContent')
}),
{
expectedRootClass: 'stream-monaco-diff-unchanged-style-metadata',
requireGeometryStable: true,
requireModifiedScrollStable: false,
requireModifiedAnchorStable: true,
anchorBudget: 1,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'set-diff-models-changed-content',
await collectTransition(page, async () => {
await invokeDiffTestApi(page, 'swapModelsChangedContent')
}),
{
expectedRootClass: 'stream-monaco-diff-unchanged-style-metadata',
requireGeometryStable: true,
requireModifiedScrollStable: false,
},
),
)
await primeModifiedScroll(page)
results.push(
summarizeTransition(
'style-metadata-to-simple',
await collectTransition(page, async () => {
await page
.getByRole('button', { name: /^Simple$/ })
.first()
.click()
}),
{
expectedRootClass: 'stream-monaco-diff-unchanged-style-simple',
requireGeometryStable: false,
requireModifiedScrollStable: true,
expectedVisibleRailCount: 0,
expectedVisibleRevealCount: 0,
requireSimpleSummaryNonInteractive: true,
},
),
)
await browser.close()
const ok =
pageErrors.length === 0 &&
consoleErrors.length === 0 &&
results.every((result) => result.ok)
const output = {
ok,
url,
pageErrors,
consoleErrors,
results,
}
console.log(JSON.stringify(output, null, 2))
if (!ok) {
console.error('Transition validation failed; recent Vite logs:\n')
console.error(logs.slice(-60).join(''))
process.exit(1)
}
} catch (error) {
console.error('Transition validation startup/runtime error:')
console.error(error)
if (logs.length > 0) {
console.error('Recent Vite logs:\n')
console.error(logs.slice(-60).join(''))
}
throw error
} finally {
killProcessTree(vite)
}
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,257 @@
#!/usr/bin/env node
// Validate that expanded unchanged regions disappear immediately and stay gone while scrolling.
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4205, end = 4215) {
for (let port = start; port <= end; port++) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 20000) {
const start = Date.now()
while (true) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (open) return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
async function main() {
const targetLabel = process.argv[2] || '71 unmodified lines'
const scenario = process.argv[3] || 'streaming'
const style = process.argv[4] || 'background'
const theme = process.argv[5] || 'snazzy-light'
const unchangedStyle = process.argv[6] || 'line-info'
const port = await findFreePort()
const vite = spawn(
process.execPath,
['./node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', String(port)],
{
cwd: demoDir,
stdio: ['ignore', 'pipe', 'pipe'],
},
)
const logs = []
vite.stdout.on('data', (data) => logs.push(String(data)))
vite.stderr.on('data', (data) => logs.push(String(data)))
const onExit = () => killProcessTree(vite)
process.on('exit', onExit)
process.on('SIGINT', () => process.exit(130))
process.on('SIGTERM', () => process.exit(143))
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 1465, height: 1100 } })
const url = `http://127.0.0.1:${port}/diff-ux?style=${style}&scenario=${scenario}&theme=${encodeURIComponent(
theme,
)}&unchangedStyle=${encodeURIComponent(unchangedStyle)}`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector(
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge',
{ timeout: 20000 },
)
const visibleTextsBefore = await page
.locator(
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden]) .stream-monaco-unchanged-summary',
)
.allTextContents()
await page
.locator('.stream-monaco-diff-unchanged-overlay .stream-monaco-unchanged-summary')
.filter({ hasText: targetLabel })
.first()
.click()
await page.waitForTimeout(20)
const immediateScrollTarget = page
.locator('.stream-monaco-diff-unchanged-overlay .stream-monaco-unchanged-summary')
.filter({ hasText: '66 unmodified lines' })
.first()
if (await immediateScrollTarget.count()) {
const box = await immediateScrollTarget.boundingBox()
if (box) {
await page.mouse.move(
box.x + box.width * 0.5,
box.y + box.height * 0.5,
)
await page.mouse.wheel(0, 180)
}
}
await page.waitForTimeout(180)
const afterClick = await page.evaluate((label) => {
const visibleTexts = Array.from(
document.querySelectorAll(
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden]) .stream-monaco-unchanged-summary',
),
).map((node) => node.textContent?.trim())
const visibleTarget = visibleTexts.includes(label)
const centerTarget = Array.from(
document.querySelectorAll('.diff-hidden-lines .center'),
).some((node) => node.textContent?.trim() === label)
return { visibleTexts, visibleTarget, centerTarget }
}, targetLabel)
for (let i = 0; i < 8; i++) {
// eslint-disable-next-line no-await-in-loop
await page.mouse.wheel(0, 120)
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(100)
}
await page.evaluate(() => {
const root = document.querySelector('.stream-monaco-diff-root')
if (root instanceof HTMLElement && root.scrollHeight > root.clientHeight) {
root.scrollTop = Math.min(root.scrollTop + 160, root.scrollHeight)
root.dispatchEvent(new Event('scroll', { bubbles: true }))
}
const scrollables = Array.from(
document.querySelectorAll('.monaco-scrollable-element.editor-scrollable'),
)
scrollables.forEach((node) => {
if (!(node instanceof HTMLElement)) return
node.scrollTop = 1200
node.dispatchEvent(new Event('scroll', { bubbles: true }))
})
})
await page.waitForTimeout(40)
const afterScroll = await page.evaluate((label) => {
const visibleTexts = Array.from(
document.querySelectorAll(
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden]) .stream-monaco-unchanged-summary',
),
).map((node) => node.textContent?.trim())
const bridgeNodes = Array.from(
document.querySelectorAll(
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden])',
),
)
const centerNodes = Array.from(
document.querySelectorAll('.diff-hidden-lines .center'),
)
const measurements = visibleTexts
.filter(Boolean)
.map((text) => {
const bridge = bridgeNodes.find(
node => node.textContent?.trim() === text,
)
const center = centerNodes.find(
node => node.textContent?.trim() === text,
)
const bridgeRect = bridge?.getBoundingClientRect()
const centerRect = center?.getBoundingClientRect()
return {
text,
hasCenter: Boolean(center),
deltaTop:
bridgeRect && centerRect
? +(bridgeRect.top - centerRect.top).toFixed(2)
: null,
}
})
return {
visibleTexts,
visibleTarget: visibleTexts.includes(label),
bridgeCount: document.querySelectorAll(
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden])',
).length,
measurements,
}
}, targetLabel)
const ok =
visibleTextsBefore.includes(targetLabel) &&
afterClick.visibleTarget === false &&
afterClick.centerTarget === false &&
afterScroll.visibleTarget === false &&
afterScroll.measurements.every(
item => item.hasCenter && item.deltaTop != null && Math.abs(item.deltaTop) <= 12,
)
console.log(
JSON.stringify(
{
ok,
targetLabel,
visibleTextsBefore,
afterClick,
afterScroll,
},
null,
2,
),
)
await browser.close()
killProcessTree(vite)
process.exit(ok ? 0 : 1)
}
catch (error) {
killProcessTree(vite)
if (logs.length > 0) console.error(logs.join(''))
console.error(error)
process.exit(1)
}
}
main()
@@ -0,0 +1,288 @@
#!/usr/bin/env node
// Validate that unchanged-region rail arrows reveal only the targeted region
// and reveal exactly five lines in the expected direction.
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4205, end = 4215) {
for (let port = start; port <= end; port++) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 20000) {
const start = Date.now()
while (true) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (open) return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
// eslint-disable-next-line no-await-in-loop
await new Promise(resolve => setTimeout(resolve, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function parseCount(label) {
const match = label.match(/\d+/)
return match ? Number.parseInt(match[0], 10) : Number.NaN
}
function formatCountLabel(count) {
return `${count} unmodified ${count === 1 ? 'line' : 'lines'}`
}
function buildExpectedCounts(beforeLabels, targetLabel, delta) {
const counts = new Map()
beforeLabels.forEach((label) => {
counts.set(label, (counts.get(label) || 0) + 1)
})
counts.set(targetLabel, (counts.get(targetLabel) || 0) - 1)
if (counts.get(targetLabel) === 0) counts.delete(targetLabel)
const nextLabel = formatCountLabel(parseCount(targetLabel) - delta)
counts.set(nextLabel, (counts.get(nextLabel) || 0) + 1)
return Object.fromEntries(Array.from(counts.entries()).sort())
}
async function collectState(page, targetLabel) {
return page.evaluate((label) => {
const bridgeSelector =
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge:not([hidden])'
const bridges = Array.from(document.querySelectorAll(bridgeSelector))
const modifiedEditor =
document.querySelector('.editor.modified') ||
document.querySelector('.monaco-editor.modified') ||
document.querySelector('.monaco-editor')
const findNeighbors = (bridge) => {
const rect = bridge.getBoundingClientRect()
let previousVisibleLine = null
let nextVisibleLine = null
const lineNumbers = modifiedEditor?.querySelectorAll('.line-numbers') || []
lineNumbers.forEach((node) => {
const lineNumber = Number.parseInt(node.textContent?.trim() || '', 10)
if (!Number.isFinite(lineNumber)) return
const top = node.getBoundingClientRect().top
if (top < rect.top - 1) {
previousVisibleLine =
previousVisibleLine == null
? lineNumber
: Math.max(previousVisibleLine, lineNumber)
}
else if (top > rect.bottom + 1) {
nextVisibleLine =
nextVisibleLine == null
? lineNumber
: Math.min(nextVisibleLine, lineNumber)
}
})
return { previousVisibleLine, nextVisibleLine }
}
const items = bridges.map((bridge) => {
const summary = bridge.querySelector('.stream-monaco-unchanged-summary')
const labelText = summary?.textContent?.trim() || ''
const rect = bridge.getBoundingClientRect()
const { previousVisibleLine, nextVisibleLine } = findNeighbors(bridge)
return {
label: labelText,
top: +rect.top.toFixed(2),
previousVisibleLine,
nextVisibleLine,
}
})
const counts = {}
items.forEach((item) => {
counts[item.label] = (counts[item.label] || 0) + 1
})
const target = items.find(item => item.label === label) || null
return {
items,
labels: items.map(item => item.label),
counts,
target,
}
}, targetLabel)
}
async function runDirectionCase(page, targetLabel, direction) {
const before = await collectState(page, targetLabel)
if (!before.target)
throw new Error(`Could not find target unchanged block: ${targetLabel}`)
const button = page
.locator('.stream-monaco-diff-unchanged-bridge:not([hidden])')
.filter({ has: page.locator('.stream-monaco-unchanged-summary', { hasText: targetLabel }) })
.locator(`.stream-monaco-unchanged-reveal[data-direction="${direction}"]`)
.first()
await button.click()
await page.waitForTimeout(350)
const after = await collectState(page, targetLabel)
const revealCount = 5
const targetAfterLabel = formatCountLabel(
parseCount(targetLabel) - revealCount,
)
const expectedCounts = buildExpectedCounts(
before.labels,
targetLabel,
revealCount,
)
const expectedPreviousVisibleLine =
direction === 'down'
? before.target.previousVisibleLine + revealCount
: before.target.previousVisibleLine
const expectedNextVisibleLine =
direction === 'up'
? before.target.nextVisibleLine - revealCount
: before.target.nextVisibleLine
const matchingTarget = after.items.find(
item =>
item.label === targetAfterLabel &&
item.previousVisibleLine === expectedPreviousVisibleLine &&
item.nextVisibleLine === expectedNextVisibleLine,
)
const normalizedAfterCounts = Object.fromEntries(
Object.entries(after.counts).sort(([left], [right]) =>
left.localeCompare(right),
),
)
const ok =
before.target.previousVisibleLine != null &&
before.target.nextVisibleLine != null &&
Boolean(matchingTarget) &&
JSON.stringify(normalizedAfterCounts) === JSON.stringify(expectedCounts)
return {
ok,
direction,
targetLabel,
targetAfterLabel,
before,
after,
expectedCounts,
expectedPreviousVisibleLine,
expectedNextVisibleLine,
matchingTarget,
}
}
async function main() {
const targetLabel = process.argv[2] || '71 unmodified lines'
const scenario = process.argv[3] || 'streaming'
const style = process.argv[4] || 'background'
const theme = process.argv[5] || 'snazzy-light'
const unchangedStyle = process.argv[6] || 'line-info'
const port = await findFreePort()
const vite = spawn(
process.execPath,
['./node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', String(port)],
{
cwd: demoDir,
stdio: ['ignore', 'pipe', 'pipe'],
},
)
const logs = []
vite.stdout.on('data', data => logs.push(String(data)))
vite.stderr.on('data', data => logs.push(String(data)))
const onExit = () => killProcessTree(vite)
process.on('exit', onExit)
process.on('SIGINT', () => process.exit(130))
process.on('SIGTERM', () => process.exit(143))
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const runCase = async (direction) => {
const page = await browser.newPage({ viewport: { width: 1465, height: 1100 } })
const url =
`http://127.0.0.1:${port}/diff-ux?style=${style}&scenario=${scenario}` +
`&theme=${encodeURIComponent(theme)}&unchangedStyle=${encodeURIComponent(unchangedStyle)}`
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForSelector(
'.stream-monaco-diff-unchanged-overlay .stream-monaco-diff-unchanged-bridge',
{ timeout: 20000 },
)
const result = await runDirectionCase(page, targetLabel, direction)
await page.close()
return result
}
const down = await runCase('down')
const up = await runCase('up')
await browser.close()
const ok = down.ok && up.ok
console.log(
JSON.stringify(
{
ok,
targetLabel,
down,
up,
},
null,
2,
),
)
if (!ok) process.exitCode = 1
}
catch (error) {
console.error(String(error))
if (logs.length > 0) console.error(logs.join(''))
process.exitCode = 1
}
finally {
killProcessTree(vite)
}
}
main()
@@ -0,0 +1,384 @@
#!/usr/bin/env node
// Capture the diff UX demo and compare it against a reference screenshot.
import fs from 'node:fs/promises'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 5173, end = 5190) {
for (let port = start; port <= end; port++) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 20000) {
const start = Date.now()
while (true) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (open) return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
} catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
} catch {}
}, 3000).unref?.()
}
function parseScenario(value) {
if (value === 'streaming') return 'streaming'
if (value === 'line-info-reference' || value === 'line-info')
return 'line-info-reference'
return 'pierre-reference'
}
function captureSelectorForScenario(scenario) {
return scenario === 'line-info-reference'
? '.line-info-compare-frame'
: '.editor-card'
}
function viewportForScenario(scenario) {
return scenario === 'line-info-reference'
? { width: 1960, height: 1600 }
: { width: 1960, height: 720 }
}
function parseUnchangedStyle(value) {
if (value === 'line-info-basic') return 'line-info-basic'
if (value === 'simple') return 'simple'
return value === 'metadata' ? 'metadata' : 'line-info'
}
async function captureScreenshot(
browser,
port,
style,
scenario,
theme,
unchangedStyle,
outputPath,
) {
const page = await browser.newPage({
viewport: viewportForScenario(scenario),
deviceScaleFactor: 2,
})
const url = `http://127.0.0.1:${port}/diff-ux?style=${style}&scenario=${scenario}&theme=${encodeURIComponent(
theme,
)}&unchangedStyle=${encodeURIComponent(unchangedStyle)}&capture=1`
await page.goto(url, { waitUntil: 'networkidle' })
const captureSelector = captureSelectorForScenario(scenario)
await page.waitForSelector(captureSelector, { timeout: 20000 })
await page.waitForTimeout(1400)
const target = page.locator(captureSelector)
await target.screenshot({ path: outputPath })
const stats = await page.evaluate(() => {
const values = Array.from(
document.querySelectorAll('.file-stats .delta'),
).map((node) => node.textContent?.trim() ?? '')
return {
deltas: values,
title: document.querySelector('.file-name')?.textContent?.trim() ?? '',
}
})
await page.close()
return { outputPath, url, captureSelector, unchangedStyle, stats }
}
async function compareImages(
browser,
actualPath,
referencePath,
diffPath,
threshold,
) {
const [actualBase64, referenceBase64] = await Promise.all([
fs.readFile(actualPath, 'base64'),
fs.readFile(referencePath, 'base64'),
])
const page = await browser.newPage({
viewport: { width: 1200, height: 900 },
})
await page.setContent('<!doctype html><html><body></body></html>')
const result = await page.evaluate(
async ({ actualBase64, referenceBase64, threshold }) => {
const loadImage = (src) =>
new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () =>
reject(new Error(`Failed to load image: ${src.slice(0, 32)}...`))
img.src = src
})
const actual = await loadImage(`data:image/png;base64,${actualBase64}`)
const reference = await loadImage(
`data:image/png;base64,${referenceBase64}`,
)
const width = Math.max(actual.width, reference.width)
const height = Math.max(actual.height, reference.height)
const actualCanvas = document.createElement('canvas')
actualCanvas.width = width
actualCanvas.height = height
const actualCtx = actualCanvas.getContext('2d')
actualCtx.clearRect(0, 0, width, height)
actualCtx.drawImage(actual, 0, 0)
const referenceCanvas = document.createElement('canvas')
referenceCanvas.width = width
referenceCanvas.height = height
const referenceCtx = referenceCanvas.getContext('2d')
referenceCtx.clearRect(0, 0, width, height)
referenceCtx.drawImage(reference, 0, 0)
const diffCanvas = document.createElement('canvas')
diffCanvas.width = width
diffCanvas.height = height
const diffCtx = diffCanvas.getContext('2d')
const actualData = actualCtx.getImageData(0, 0, width, height).data
const referenceData = referenceCtx.getImageData(0, 0, width, height).data
const diffImage = diffCtx.createImageData(width, height)
let diffPixels = 0
let totalDelta = 0
let maxChannelDelta = 0
for (let i = 0; i < actualData.length; i += 4) {
const ar = actualData[i]
const ag = actualData[i + 1]
const ab = actualData[i + 2]
const aa = actualData[i + 3]
const rr = referenceData[i]
const rg = referenceData[i + 1]
const rb = referenceData[i + 2]
const ra = referenceData[i + 3]
const deltaR = Math.abs(ar - rr)
const deltaG = Math.abs(ag - rg)
const deltaB = Math.abs(ab - rb)
const deltaA = Math.abs(aa - ra)
const pixelDelta = deltaR + deltaG + deltaB + deltaA
totalDelta += pixelDelta
maxChannelDelta = Math.max(
maxChannelDelta,
deltaR,
deltaG,
deltaB,
deltaA,
)
const outIndex = i
const baseR = Math.round(rr * 0.82 + ar * 0.18)
const baseG = Math.round(rg * 0.82 + ag * 0.18)
const baseB = Math.round(rb * 0.82 + ab * 0.18)
if (pixelDelta > threshold) {
diffPixels += 1
diffImage.data[outIndex] = 255
diffImage.data[outIndex + 1] = Math.max(80, Math.round(baseG * 0.45))
diffImage.data[outIndex + 2] = 160
diffImage.data[outIndex + 3] = 255
continue
}
diffImage.data[outIndex] = baseR
diffImage.data[outIndex + 1] = baseG
diffImage.data[outIndex + 2] = baseB
diffImage.data[outIndex + 3] = Math.max(40, ra)
}
diffCtx.putImageData(diffImage, 0, 0)
return {
width,
height,
sameDimensions:
actual.width === reference.width &&
actual.height === reference.height,
actualSize: { width: actual.width, height: actual.height },
referenceSize: { width: reference.width, height: reference.height },
diffPixels,
totalPixels: width * height,
mismatchRatio: diffPixels / (width * height),
meanChannelDelta: totalDelta / (width * height * 4),
maxChannelDelta,
diffDataUrl: diffCanvas.toDataURL('image/png'),
}
},
{ actualBase64, referenceBase64, threshold },
)
const diffBase64 = result.diffDataUrl.replace(/^data:image\/png;base64,/, '')
await fs.writeFile(diffPath, Buffer.from(diffBase64, 'base64'))
await page.close()
const { diffDataUrl: _diffDataUrl, ...metrics } = result
return {
...metrics,
diffPath,
exactMatch: metrics.diffPixels === 0,
}
}
async function run() {
const referenceArg = process.argv[2]
if (!referenceArg) {
throw new Error(
'Usage: node scripts/playwright-diff-ux-compare.mjs <reference.png> [background|bar] [pierre-reference|streaming|line-info-reference] [actual.png] [diff.png] [pixel-threshold] [theme] [line-info|line-info-basic|metadata|simple]',
)
}
const styleArg = process.argv[3] === 'bar' ? 'bar' : 'background'
const scenarioArg = parseScenario(process.argv[4])
const referencePath = path.resolve(referenceArg)
const actualPath = process.argv[5]
? path.resolve(process.argv[5])
: path.join('/tmp', `stream-monaco-diff-ux-${scenarioArg}-${styleArg}.png`)
const diffPath = process.argv[6]
? path.resolve(process.argv[6])
: path.join(
'/tmp',
`stream-monaco-diff-ux-${scenarioArg}-${styleArg}-diff.png`,
)
const rawThreshold = Number(process.argv[7] ?? '28')
const threshold = Number.isFinite(rawThreshold) ? rawThreshold : 28
const themeArg = process.argv[8] || 'snazzy-light'
const unchangedStyleArg = parseUnchangedStyle(process.argv[9])
const port = process.env.PORT
? Number(process.env.PORT)
: await findFreePort()
await fs.access(referencePath)
const vite = spawn(
'pnpm',
[
'-C',
demoDir,
'dev',
'--host',
'127.0.0.1',
'--port',
String(port),
'--strictPort',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
const logs = []
vite.stdout.on('data', (d) => logs.push(String(d)))
vite.stderr.on('data', (d) => logs.push(String(d)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
let browser
try {
await waitForPort(port)
browser = await chromium.launch({ headless: true })
const capture = await captureScreenshot(
browser,
port,
styleArg,
scenarioArg,
themeArg,
unchangedStyleArg,
actualPath,
)
const comparison = await compareImages(
browser,
actualPath,
referencePath,
diffPath,
threshold,
)
await browser.close()
console.log(
JSON.stringify(
{
ok: true,
style: styleArg,
scenario: scenarioArg,
theme: themeArg,
unchangedStyle: unchangedStyleArg,
threshold,
referencePath,
actualPath,
diffPath,
capture,
comparison,
},
null,
2,
),
)
} catch (error) {
if (browser) {
try {
await browser.close()
} catch {}
}
console.error('Diff UX comparison failed.')
console.error(logs.slice(-40).join(''))
throw error
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,295 @@
#!/usr/bin/env node
// Capture a deterministic screenshot of the diff UX demo for visual review.
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 5173, end = 5190) {
for (let port = start; port <= end; port++) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open) return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 20000) {
const start = Date.now()
while (true) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (open) return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed) return
try {
child.kill('SIGTERM')
} catch {}
setTimeout(() => {
try {
if (!child.killed) child.kill('SIGKILL')
} catch {}
}, 3000).unref?.()
}
function round(value) {
return Math.round(value * 1000) / 1000
}
function parseScenario(value) {
if (value === 'streaming') return 'streaming'
if (value === 'line-info-reference' || value === 'line-info')
return 'line-info-reference'
return 'pierre-reference'
}
function captureSelectorForScenario(scenario) {
return scenario === 'line-info-reference'
? '.line-info-compare-frame'
: '.editor-card'
}
function viewportForScenario(scenario) {
return scenario === 'line-info-reference'
? { width: 1960, height: 1600 }
: { width: 1960, height: 720 }
}
function parseUnchangedStyle(value) {
if (value === 'line-info-basic') return 'line-info-basic'
if (value === 'simple') return 'simple'
return value === 'metadata' ? 'metadata' : 'line-info'
}
async function run() {
const styleArg = process.argv[2] === 'bar' ? 'bar' : 'background'
const outputArg = process.argv[3]
const scenarioArg = parseScenario(process.argv[4])
const themeArg = process.argv[5] || 'snazzy-light'
const linesArg = process.argv[6] || '6'
const unchangedStyleArg = parseUnchangedStyle(process.argv[7])
const port = process.env.PORT
? Number(process.env.PORT)
: await findFreePort()
const outputPath = outputArg
? path.resolve(outputArg)
: path.join(
'/tmp',
`stream-monaco-diff-ux-${scenarioArg}-${themeArg}-${styleArg}.png`,
)
const vite = spawn(
'pnpm',
[
'-C',
demoDir,
'dev',
'--host',
'127.0.0.1',
'--port',
String(port),
'--strictPort',
],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env },
)
const logs = []
vite.stdout.on('data', (d) => logs.push(String(d)))
vite.stderr.on('data', (d) => logs.push(String(d)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
try {
await waitForPort(port)
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({
viewport: viewportForScenario(scenarioArg),
deviceScaleFactor: 2,
})
const url = `http://127.0.0.1:${port}/diff-ux?style=${styleArg}&scenario=${scenarioArg}&theme=${encodeURIComponent(
themeArg,
)}&lines=${encodeURIComponent(
linesArg,
)}&unchangedStyle=${encodeURIComponent(unchangedStyleArg)}&capture=1`
await page.goto(url, { waitUntil: 'networkidle' })
const captureSelector = captureSelectorForScenario(scenarioArg)
await page.waitForSelector(captureSelector, { timeout: 20000 })
await page.waitForTimeout(1400)
const target = page.locator(captureSelector)
await target.screenshot({ path: outputPath })
const stats = await page.evaluate(() => {
const getTextRect = (el) => {
const range = document.createRange()
range.selectNodeContents(el)
const rect = range.getBoundingClientRect()
return { left: rect.left, right: rect.right, width: rect.width }
}
const firstVisibleTokenRect = (line) => {
const spans = Array.from(line.querySelectorAll('span'))
for (const span of spans) {
const text = span.textContent || ''
if (!text.trim()) continue
const rect = getTextRect(span)
if (rect.width > 0) return rect
}
return null
}
const root = document.querySelector('.editor')
const markerWidth =
Number.parseFloat(
getComputedStyle(root ?? document.body).getPropertyValue(
'--stream-monaco-gutter-marker-width',
),
) || 4
const getPaneMetrics = (rootSelector) => {
const pane = document.querySelector(rootSelector)
const margin = pane?.querySelector('.margin')
const lineNumber = pane?.querySelector('.line-numbers')
const viewLine = pane?.querySelector('.view-lines .view-line')
if (!margin || !lineNumber || !viewLine) return null
const marginRect = margin.getBoundingClientRect()
const lineNumberBoxRect = lineNumber.getBoundingClientRect()
const lineNumberTextRect = getTextRect(lineNumber)
const tokenRect = firstVisibleTokenRect(viewLine)
if (!tokenRect) return null
return {
marginWidth: marginRect.width,
numberBoxWidth: lineNumberBoxRect.width,
visibleLineNumber: lineNumber.textContent?.trim() ?? '',
boxLeftGap: lineNumberBoxRect.left - marginRect.left,
boxRightGap: tokenRect.left - lineNumberBoxRect.right,
textStripeGap:
lineNumberTextRect.left - marginRect.left - markerWidth,
textCodeGap: tokenRect.left - lineNumberTextRect.right,
}
}
const values = Array.from(
document.querySelectorAll('.file-stats .delta'),
).map((node) => node.textContent?.trim() ?? '')
return {
deltas: values,
title: document.querySelector('.file-name')?.textContent?.trim() ?? '',
gutter: {
original: getPaneMetrics('.editor.original'),
modified: getPaneMetrics('.editor.modified'),
},
}
})
await browser.close()
console.log(
JSON.stringify(
{
ok: true,
style: styleArg,
scenario: scenarioArg,
theme: themeArg,
lines: linesArg,
unchangedStyle: unchangedStyleArg,
captureSelector,
outputPath,
url,
stats: {
...stats,
gutter:
stats.gutter?.original && stats.gutter?.modified
? {
original: {
marginWidth: round(stats.gutter.original.marginWidth),
numberBoxWidth: round(
stats.gutter.original.numberBoxWidth,
),
visibleLineNumber:
stats.gutter.original.visibleLineNumber,
boxLeftGap: round(stats.gutter.original.boxLeftGap),
boxRightGap: round(stats.gutter.original.boxRightGap),
textStripeGap: round(stats.gutter.original.textStripeGap),
textCodeGap: round(stats.gutter.original.textCodeGap),
symmetryDelta: round(
Math.abs(
stats.gutter.original.textStripeGap -
stats.gutter.original.textCodeGap,
),
),
},
modified: {
marginWidth: round(stats.gutter.modified.marginWidth),
numberBoxWidth: round(
stats.gutter.modified.numberBoxWidth,
),
visibleLineNumber:
stats.gutter.modified.visibleLineNumber,
boxLeftGap: round(stats.gutter.modified.boxLeftGap),
boxRightGap: round(stats.gutter.modified.boxRightGap),
textStripeGap: round(stats.gutter.modified.textStripeGap),
textCodeGap: round(stats.gutter.modified.textCodeGap),
symmetryDelta: round(
Math.abs(
stats.gutter.modified.textStripeGap -
stats.gutter.modified.textCodeGap,
),
),
},
}
: null,
},
},
null,
2,
),
)
} catch (error) {
console.error('Diff UX screenshot capture failed.')
console.error(logs.slice(-40).join(''))
throw error
} finally {
killProcessTree(vite)
}
}
run().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,288 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const markstreamRoot = '/Users/Simon/Github/markstream-vue'
const playgroundDir = path.join(markstreamRoot, 'playground')
const host = '127.0.0.1'
const node23Bin = '/Users/Simon/.local/share/fnm/node-versions/v23.11.0/installation/bin'
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ host, port })
socket.on('connect', () => {
socket.end()
resolve(true)
})
socket.on('error', () => {
socket.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 4236, end = 4280) {
for (let port = start; port <= end; port++) {
if (!await isPortOpen(port))
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, timeout = 30000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeout) {
if (await isPortOpen(port))
return
await new Promise(resolve => setTimeout(resolve, 150))
}
throw new Error(`Timed out waiting for ${host}:${port}`)
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function resolveChromeLaunchOptions() {
const candidates = [
process.env.PLAYWRIGHT_CHROME_PATH,
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
].filter(Boolean)
for (const candidate of candidates) {
if (existsSync(candidate)) {
return {
executablePath: candidate,
headless: true,
}
}
}
return {
channel: 'chrome',
headless: true,
}
}
function colorLuminance(color) {
const channels = String(color || '').match(/\d+(?:\.\d+)?/g)
if (!channels || channels.length < 3)
return null
const [r, g, b] = channels.slice(0, 3).map(Number)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
async function snapshot(page) {
return page.evaluate(() => {
function visibleTexts(selector) {
return Array.from(document.querySelectorAll(selector))
.filter((node) => {
if (!(node instanceof HTMLElement))
return false
const text = node.innerText?.trim() ?? ''
if (!text)
return false
const style = window.getComputedStyle(node)
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number.parseFloat(style.opacity || '1') > 0.05
})
.map(node => node.innerText.trim())
}
const container = document.querySelector('.code-block-container')
const header = document.querySelector('.code-block-header')
const diffRoot = document.querySelector('.stream-monaco-diff-root')
const editorBackground = diffRoot?.querySelector('.monaco-editor .monaco-editor-background')
const themeSelect = document.querySelector('select[aria-label="Code block theme"]')
const settingsPanel = document.querySelector('.settings-toggle')?.parentElement
const containerStyle = container instanceof HTMLElement ? window.getComputedStyle(container) : null
const headerStyle = header instanceof HTMLElement ? window.getComputedStyle(header) : null
const editorStyle = editorBackground instanceof HTMLElement ? window.getComputedStyle(editorBackground) : null
return {
containerClass: container instanceof HTMLElement ? container.className : '',
rootClasses: diffRoot instanceof HTMLElement ? Array.from(diffRoot.classList) : [],
containerBg: containerStyle?.backgroundColor || '',
headerBg: headerStyle?.backgroundColor || '',
editorBg: editorStyle?.backgroundColor || '',
selectedTheme: themeSelect instanceof HTMLSelectElement ? themeSelect.value : '',
settingsOpen: settingsPanel instanceof HTMLElement
? settingsPanel.querySelector('select[aria-label="Code block theme"]') instanceof HTMLElement
: false,
visibleNativeCompactTexts: visibleTexts('.diff-hidden-lines-compact .text'),
visibleNativeCenterTexts: visibleTexts('.diff-hidden-lines .center'),
visibleHiddenLinesInBody: /hidden lines/i.test(document.body.innerText || ''),
}
})
}
async function openSettings(page) {
const toggle = page.locator('.settings-toggle').first()
await toggle.waitFor({ state: 'visible', timeout: 30000 })
const select = page.locator('select[aria-label="Code block theme"]')
if (await select.count()) {
const visible = await select.first().isVisible().catch(() => false)
if (visible)
return
}
await toggle.click()
await select.first().waitFor({ state: 'visible', timeout: 30000 })
}
async function toggleDarkMode(page) {
await openSettings(page)
const darkModeRow = page.locator('label', { hasText: 'Dark Mode' }).locator('..')
const button = darkModeRow.locator('button').first()
await button.waitFor({ state: 'visible', timeout: 30000 })
await button.click()
}
async function captureTransition(page) {
const frames = []
for (let i = 0; i < 72; i++) {
await page.waitForTimeout(16)
frames.push(await snapshot(page))
}
return frames
}
async function ensureSelectedTheme(page, value) {
await openSettings(page)
const select = page.locator('select[aria-label="Code block theme"]').first()
await select.selectOption(value)
await page.waitForTimeout(1200)
}
async function main() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
const vite = spawn(
'pnpm',
['-C', playgroundDir, 'dev', '--host', host, '--port', String(port), '--strictPort'],
{
cwd: repoRoot,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
PATH: `${node23Bin}:${process.env.PATH || ''}`,
},
},
)
const logs = []
vite.stdout.on('data', chunk => logs.push(String(chunk)))
vite.stderr.on('data', chunk => logs.push(String(chunk)))
const cleanup = () => killProcessTree(vite)
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('exit', cleanup)
try {
await waitForPort(port)
const browser = await chromium.launch(resolveChromeLaunchOptions())
const page = await browser.newPage({
viewport: { width: 1600, height: 1200 },
colorScheme: 'light',
})
const pageErrors = []
const consoleErrors = []
page.on('pageerror', (error) => {
pageErrors.push(String(error))
})
page.on('console', (message) => {
if (message.type() === 'error')
consoleErrors.push(message.text())
})
await page.addInitScript(() => {
localStorage.setItem('vmr-settings-selected-theme', 'vitesse-dark')
localStorage.setItem('vueuse-color-scheme', 'light')
})
await page.goto(`http://${host}:${port}/`, { waitUntil: 'networkidle' })
await page.waitForSelector('.monaco-diff-editor', { timeout: 30000 })
await page.waitForTimeout(1600)
await ensureSelectedTheme(page, 'vitesse-dark')
const initial = await snapshot(page)
await toggleDarkMode(page)
const toDarkFrames = await captureTransition(page)
const afterDark = toDarkFrames[toDarkFrames.length - 1] ?? await snapshot(page)
await toggleDarkMode(page)
const toLightFrames = await captureTransition(page)
const afterLight = toLightFrames[toLightFrames.length - 1] ?? await snapshot(page)
await browser.close()
const allFrames = [initial, ...toDarkFrames, ...toLightFrames]
const anyHiddenFlash = allFrames.some(frame =>
frame.visibleHiddenLinesInBody
|| frame.visibleNativeCompactTexts.length > 0
|| frame.visibleNativeCenterTexts.some(text => /hidden lines/i.test(text)),
)
const initialLum = colorLuminance(initial.containerBg)
const darkLum = colorLuminance(afterDark.containerBg)
const lightLum = colorLuminance(afterLight.containerBg)
const result = {
ok:
initial.selectedTheme === 'vitesse-dark'
&& initial.rootClasses.includes('stream-monaco-diff-appearance-light')
&& initialLum != null && initialLum > 170
&& afterDark.rootClasses.includes('stream-monaco-diff-appearance-dark')
&& darkLum != null && darkLum < 90
&& afterLight.rootClasses.includes('stream-monaco-diff-appearance-light')
&& lightLum != null && lightLum > 170
&& !anyHiddenFlash
&& pageErrors.length === 0
&& consoleErrors.length === 0,
initial,
afterDark,
afterLight,
anyHiddenFlash,
pageErrors,
consoleErrors,
}
console.log(JSON.stringify(result, null, 2))
if (!result.ok)
process.exitCode = 1
}
catch (error) {
const message = error instanceof Error ? error.stack || error.message : String(error)
console.error(message)
process.exitCode = 1
}
finally {
cleanup()
}
}
main()
@@ -0,0 +1,237 @@
#!/usr/bin/env node
// Measure Monaco load/render/stream timings using the streaming-demo.
import net from 'node:net'
import process from 'node:process'
import { spawn } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const repoRoot = path.resolve(__dirname, '..')
const demoDir = path.join(repoRoot, 'examples', 'streaming-demo')
function isPortOpen(port, host = '127.0.0.1') {
return new Promise((resolve) => {
const sock = net.createConnection({ host, port })
sock.on('connect', () => {
sock.end()
resolve(true)
})
sock.on('error', () => {
sock.destroy()
resolve(false)
})
})
}
async function findFreePort(start = 5173, end = 5190) {
for (let port = start; port <= end; port++) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (!open)
return port
}
throw new Error(`No free port found in ${start}-${end}`)
}
async function waitForPort(port, ms = 20000) {
const start = Date.now()
while (true) {
// eslint-disable-next-line no-await-in-loop
const open = await isPortOpen(port)
if (open)
return
if (Date.now() - start > ms)
throw new Error(`Timed out waiting for port ${port}`)
// eslint-disable-next-line no-await-in-loop
await new Promise(r => setTimeout(r, 150))
}
}
function killProcessTree(child) {
if (!child || child.killed)
return
try {
if (child.pid)
process.kill(-child.pid, 'SIGTERM')
else
child.kill('SIGTERM')
}
catch {}
setTimeout(() => {
try {
if (child.pid)
process.kill(-child.pid, 'SIGKILL')
else if (!child.killed)
child.kill('SIGKILL')
}
catch {}
}, 3000).unref?.()
}
function waitForExit(child, ms = 5000) {
return new Promise((resolve) => {
if (!child)
return resolve(true)
if (child.exitCode != null)
return resolve(true)
const timer = setTimeout(() => resolve(false), ms)
child.once('exit', () => {
clearTimeout(timer)
resolve(true)
})
})
}
function spanFor(entries) {
if (!entries.length)
return null
const start = Math.min(...entries.map(e => e.startTime))
const end = Math.max(...entries.map(e => e.startTime + e.duration))
return { count: entries.length, start, end, duration: end - start }
}
function markMap(marks) {
const map = {}
for (const m of marks || [])
map[m.name] = m.t
return map
}
async function run() {
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort()
if (!Number.isFinite(port))
throw new Error(`Invalid PORT: ${process.env.PORT}`)
const vite = spawn(
'pnpm',
['-C', demoDir, 'dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'],
{ stdio: ['ignore', 'pipe', 'pipe'], env: process.env, detached: true },
)
const logs = []
vite.stdout.on('data', d => logs.push(String(d)))
vite.stderr.on('data', d => logs.push(String(d)))
const onExit = () => killProcessTree(vite)
process.on('SIGINT', onExit)
process.on('SIGTERM', onExit)
process.on('exit', onExit)
try {
await waitForPort(port)
const url = `http://127.0.0.1:${port}/`
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
await page.goto(url, { waitUntil: 'networkidle' })
// Wait for key marks (best-effort)
await page.waitForFunction(() => {
const m = (window).__streamMonacoPerf?.marks || []
return m.some(x => x.name === 'createEditor_done')
}, null, { timeout: 20000 })
await page.waitForFunction(() => {
const m = (window).__streamMonacoPerf?.marks || []
return m.some(x => x.name === 'stream_done')
}, null, { timeout: 60000 })
const pageResult = await page.evaluate(() => {
const perf = (window).__streamMonacoPerf || { marks: [] }
const marks = perf.marks || []
const nav = performance.getEntriesByType('navigation')[0]
const resources = performance.getEntriesByType('resource')
const by = (re) => resources.filter(r => re.test(r.name))
const monaco = by(/monaco-editor/i)
const shiki = by(/shiki|@shikijs/i)
const workers = by(/worker\.js|workerMain\.js/i)
return {
url: location.href,
marks,
longTasks: perf.longTasks || 0,
nav: nav ? {
startTime: nav.startTime,
domContentLoaded: nav.domContentLoadedEventEnd,
loadEvent: nav.loadEventEnd,
} : null,
resourceSpans: {
monaco: monaco.length ? {
count: monaco.length,
start: Math.min(...monaco.map(e => e.startTime)),
end: Math.max(...monaco.map(e => e.startTime + e.duration)),
} : null,
shiki: shiki.length ? {
count: shiki.length,
start: Math.min(...shiki.map(e => e.startTime)),
end: Math.max(...shiki.map(e => e.startTime + e.duration)),
} : null,
workers: workers.length ? {
count: workers.length,
start: Math.min(...workers.map(e => e.startTime)),
end: Math.max(...workers.map(e => e.startTime + e.duration)),
} : null,
},
}
})
await browser.close()
const m = markMap(pageResult.marks)
const durations = {
to_monaco_dom: m.monaco_dom ?? null,
to_view_lines_text: m.view_lines_text ?? null,
to_view_lines_text_growth: m.view_lines_text_growth ?? null,
to_create_editor_done: m.createEditor_done ?? null,
create_editor_duration: (m.createEditor_done != null && m.createEditor_start != null)
? (m.createEditor_done - m.createEditor_start)
: null,
to_first_stream_update: m.stream_first_update ?? null,
to_stream_done: m.stream_done ?? null,
stream_duration: (m.stream_done != null && m.stream_first_update != null)
? (m.stream_done - m.stream_first_update)
: null,
}
const spans = pageResult.resourceSpans
const spansOut = {
monaco: spans.monaco
? { ...spans.monaco, duration: spans.monaco.end - spans.monaco.start }
: null,
shiki: spans.shiki
? { ...spans.shiki, duration: spans.shiki.end - spans.shiki.start }
: null,
workers: spans.workers
? { ...spans.workers, duration: spans.workers.end - spans.workers.start }
: null,
}
const out = {
ok: true,
url: pageResult.url,
longTasks: pageResult.longTasks,
nav: pageResult.nav,
durations,
resourceSpans: spansOut,
marks: pageResult.marks,
}
console.log(JSON.stringify(out, null, 2))
}
catch (err) {
console.error(err)
console.error('Recent Vite logs:\n', logs.slice(-40).join(''))
process.exitCode = 1
}
finally {
killProcessTree(vite)
await waitForExit(vite, 5000)
}
}
run().catch((err) => {
console.error(err)
process.exit(1)
})
@@ -0,0 +1,86 @@
// Simulate EditorManager append/flush logic to reproduce duplication bug
function createSim() {
let lastKnownCode = ''
let pendingUpdate = null
const appendBuffer = []
let appendBufferScheduled = false
function appendCode(s) {
appendBuffer.push(s)
if (!appendBufferScheduled) appendBufferScheduled = true
}
function flushAppendBuffer(model) {
if (!appendBufferScheduled) return
appendBufferScheduled = false
const text = appendBuffer.join('')
appendBuffer.length = 0
model.value += text
}
function flushPendingUpdate(model) {
if (!pendingUpdate) return
const newCode = pendingUpdate
pendingUpdate = null
const prevCode = lastKnownCode ?? model.value
if (prevCode === newCode) return
if (newCode.startsWith(prevCode) && prevCode.length < newCode.length) {
const suffix = newCode.slice(prevCode.length)
if (suffix) appendCode(suffix)
lastKnownCode = newCode
return
}
model.value = newCode
lastKnownCode = newCode
}
function updateCode(code) {
pendingUpdate = code
// emulate raf -> we won't auto flush here; test harness controls flush timing
}
return { updateCode, flushPendingUpdate, flushAppendBuffer, getState: () => ({ lastKnownCode, appendBuffer: appendBuffer.slice() }) }
}
const markdown = `
# Create Vue project
npm create vue@latest electron-vue-chat
# Navigate to project
cd electron-vue-chat
# Install dependencies
npm install
npm install electron electron-builder vue-router
# Install dev dependencies
npm install -D electron-dev-server concurrently wait-on
`
function run(simulateFrameEvery) {
const sim = createSim()
const model = { value: '' }
let contents = ''
let i = 0
const len = markdown.length
let frames = 0
while (i <= len) {
i++
contents = markdown.slice(0, i)
sim.updateCode(contents)
if (i % simulateFrameEvery === 0 || i === len) {
// flush pending and apply appendBuffer
sim.flushPendingUpdate(model)
sim.flushAppendBuffer(model)
frames++
}
}
return { final: model.value, frames }
}
console.log('simulate flush every 1 (per char):', run(1))
console.log('simulate flush every 5:', run(5))
console.log('simulate flush every 10:', run(10))
console.log('simulate flush every 50:', run(50))
console.log('simulate flush every 200:', run(200))
console.log('simulate flush every 1000:', run(1000))
@@ -0,0 +1,146 @@
import { performance } from 'node:perf_hooks'
function sleep(ms) {
return new Promise(r => setTimeout(r, ms))
}
function createMockEditor() {
let value = ''
return {
getModel() {
return {
getValue() { return value },
setValue(v) { value = v },
getLineCount() { return value.split('\n').length },
getLanguageId() { return 'javascript' },
getLineMaxColumn(line) { return (value.split('\n')[line - 1] || '').length + 1 },
}
},
getValue() { return value },
executeEdits(_, edits) {
for (const e of edits) {
value += e.text
}
},
getOption() { return false },
}
}
function createWrapper(opts = {}) {
const appendBuffer = []
let appendBufferScheduled = false
let pendingUpdate = null
let lastKnownCode = ''
const throttleMs = opts.updateThrottleMs ?? 0
let lastFlushTime = 0
let throttleTimer = null
const editorView = createMockEditor()
function flushPendingUpdate() {
lastFlushTime = Date.now()
if (!pendingUpdate)
return
const model = editorView.getModel()
const { code } = pendingUpdate
pendingUpdate = null
// Build prevCode including buffered (mirror recent EditorManager fix)
const buffered = appendBuffer.length > 0 ? appendBuffer.join('') : ''
const prevCode = (appendBuffer.length > 0) ? (editorView.getValue() + buffered) : (lastKnownCode || editorView.getValue())
if (prevCode === code)
return
if (code.startsWith(prevCode) && prevCode.length < code.length) {
const suffix = code.slice(prevCode.length)
if (suffix)
appendCode(suffix)
lastKnownCode = code
return
}
// fallback full replace
model.setValue(code)
lastKnownCode = code
}
function flushAppendBuffer() {
if (appendBuffer.length === 0)
return
const parts = appendBuffer.splice(0)
const text = parts.join('')
appendBuffer.length = 0
editorView.executeEdits('append', [{ range: null, text }])
// sync lastKnownCode
try { lastKnownCode = editorView.getModel().getValue() } catch {}
}
function scheduleAppendFlush() {
if (appendBufferScheduled)
return
appendBufferScheduled = true
// emulate raf frame
setTimeout(() => {
appendBufferScheduled = false
flushAppendBuffer()
}, 0)
}
function appendCode(appendText) {
if (!appendText) return
appendBuffer.push(appendText)
scheduleAppendFlush()
}
function updateCode(code) {
pendingUpdate = { code }
// emulate RAF-like immediate schedule
setImmediate(() => {
if (!throttleMs) { flushPendingUpdate(); return }
const now = Date.now()
const since = now - lastFlushTime
if (since >= throttleMs) { flushPendingUpdate(); return }
if (throttleTimer != null) return
const wait = throttleMs - since
throttleTimer = setTimeout(() => { throttleTimer = null; setImmediate(flushPendingUpdate) }, wait)
})
}
return { updateCode, appendCode, flushAppendBuffer, editorView }
}
async function run({ iterations = 200, blockSize = 50, intervalMs = 1, throttleMs = 0 }) {
const wrapper = createWrapper({ updateThrottleMs: throttleMs })
let expected = ''
const block = 'x'.repeat(blockSize)
const start = performance.now()
for (let i = 0; i < iterations; i++) {
expected += block
wrapper.updateCode(expected)
await sleep(intervalMs)
}
// wait for pending timers and flushes
await sleep(500 + throttleMs)
const end = performance.now()
const finalValue = wrapper.editorView.getModel().getValue()
const ok = finalValue === expected
console.log(JSON.stringify({ iterations, blockSize, intervalMs, throttleMs, ok, expectedLength: expected.length, finalLength: finalValue.length, elapsedMs: Math.round(end - start) }, null, 2))
if (!ok) {
// print small diff info
let i = 0
while (i < Math.min(finalValue.length, expected.length) && finalValue[i] === expected[i]) i++
console.log('first mismatch at index', i)
console.log('expected slice', expected.slice(Math.max(0, i - 40), i + 40))
console.log('actual slice', finalValue.slice(Math.max(0, i - 40), i + 40))
}
}
if (process.argv.includes('--help')) {
console.log('Usage: node scripts/stream-benchmark-aggressive.mjs [iterations] [blockSize] [intervalMs] [throttleMs]')
process.exit(0)
}
const iterations = Number(process.argv[2] || 200)
const blockSize = Number(process.argv[3] || 50)
const intervalMs = Number(process.argv[4] || 1)
const throttleMs = Number(process.argv[5] || 0)
run({ iterations, blockSize, intervalMs, throttleMs }).catch(err => { console.error(err); process.exit(1) })
@@ -0,0 +1,142 @@
// Lightweight benchmark for updateCode throttling and batching logic.
// This script mocks a minimal subset of Monaco editor API used by useMonaco
// and exercises updateCode at high frequency to compare CPU/time behavior.
import { performance } from 'node:perf_hooks'
// Load the compiled source if available, otherwise load TS source via ts-node is out of scope.
// We'll directly import the source file (ESM) assuming Node can load it; however the module
// depends on 'monaco-shim' and browser APIs. Instead, we'll directly benchmark the
// core flush/update logic by copying minimal parts into this script as a function.
// For simplicity, we simulate a minimal "editor wrapper" with similar logic to useMonaco
// focusing on updateCode/flushPendingUpdate/append buffering behavior.
function createMockEditor() {
let value = ''
return {
getModel() {
return {
getValue() { return value },
setValue(v) { value = v },
getLineCount() { return value.split('\n').length },
getLanguageId() { return 'javascript' },
getLineMaxColumn(line) { return (value.split('\n')[line - 1] || '').length + 1 },
}
},
getValue() { return value },
executeEdits(_, edits) {
for (const e of edits) {
// append-only benchmark: assume appends only
value += e.text
}
},
getOption() { return false },
}
}
// Minimal wrapper implementing only the paths used by the benchmark
function createWrapper(opts = {}) {
const appendBuffer = []
const appendBufferScheduled = false
let pendingUpdate = null
let lastKnownCode = ''
const throttleMs = opts.updateThrottleMs ?? 50
let lastFlushTime = 0
let throttleTimer = null
const editorView = createMockEditor()
function flushPendingUpdate() {
lastFlushTime = Date.now()
if (!pendingUpdate)
return
const model = editorView.getModel()
const { code } = pendingUpdate
pendingUpdate = null
const prev = lastKnownCode || model.getValue()
if (prev === code)
return
if (code.startsWith(prev)) {
const suffix = code.slice(prev.length)
if (suffix) {
appendBuffer.push(suffix)
}
lastKnownCode = code
return
}
// fallback full replace
model.setValue(code)
lastKnownCode = code
}
function flushAppendBuffer() {
if (appendBuffer.length === 0)
return
const text = appendBuffer.join('')
appendBuffer.length = 0
editorView.executeEdits('append', [{ range: null, text }])
}
function updateCode(code) {
pendingUpdate = { code }
// schedule via RAF-like (here immediate) then apply throttle
// emulate raf by setImmediate
setImmediate(() => {
if (!throttleMs) {
flushPendingUpdate(); return
}
const now = Date.now()
const since = now - lastFlushTime
if (since >= throttleMs) { flushPendingUpdate(); return }
if (throttleTimer != null)
return
const wait = throttleMs - since
throttleTimer = setTimeout(() => { throttleTimer = null; setImmediate(flushPendingUpdate) }, wait)
})
}
return { updateCode, flushAppendBuffer, editorView }
}
// Run benchmark: send N updates at freq Hz
async function run({ updates = 2000, freqHz = 200, throttleMs = 50 }) {
const wrapper = createWrapper({ updateThrottleMs: throttleMs })
const intervalMs = 1000 / freqHz
const sent = 0
const start = performance.now()
for (let i = 0; i < updates; i++) {
wrapper.updateCode(''.padEnd(i + 1, 'a'))
// busy wait to respect frequency
const t0 = performance.now()
while (performance.now() - t0 < intervalMs) { /* noop */ }
}
// allow pending timers to flush
await new Promise(r => setTimeout(r, throttleMs + 100))
const end = performance.now()
const finalValue = wrapper.editorView.getModel().getValue()
const elapsed = end - start
const avgMsPerUpdate = elapsed / updates
const out = {
updates,
freqHz,
throttleMs,
elapsedMs: Math.round(elapsed),
avgMsPerUpdate: avgMsPerUpdate.toFixed(3),
finalLength: finalValue.length,
}
console.log(JSON.stringify(out, null, 2))
}
if (process.argv.includes('--help')) {
console.log('Usage: node scripts/stream-benchmark.mjs [updates] [freqHz] [throttleMs]')
process.exit(0)
}
const updates = Number(process.argv[2] || 2000)
const freqHz = Number(process.argv[3] || 200)
const throttleMs = Number(process.argv[4] || 50)
run({ updates, freqHz, throttleMs }).catch((err) => { console.error(err); process.exit(1) })
@@ -0,0 +1,30 @@
// Invoked on the commit-msg git hook by simple-git-hooks.
import { readFileSync } from 'node:fs'
import process from 'node:process'
import colors from 'picocolors'
// get $1 from commit-msg script
const msgPath = process.argv[2]
const msg = readFileSync(msgPath, 'utf-8').trim()
const releaseRE = /^v\d/
const commitRE
= /^(?:revert: )?(?:feat|fix|docs|dx|refactor|perf|test|workflow|build|ci|chore|types|wip|release|deps)(?:\(.+\))?!?: .{1,50}/
if (!releaseRE.test(msg) && !commitRE.test(msg)) {
console.log()
console.error(
` ${colors.bgRed(colors.white(' ERROR '))} ${colors.red(
'invalid commit message format.',
)}\n\n${colors.red(
' Proper commit message format is required for automated changelog generation. Examples:\n\n',
)} ${colors.green('feat: add \'comments\' option')}\n`
+ ` ${colors.green(
'fix: handle events on blur (close #28)',
)}\n\n${colors.red(
' See .github/commit-convention.md for more details.\n',
)}`,
)
process.exit(1)
}
@@ -0,0 +1,180 @@
/**
* @module detect
* (Language detector)
*/
/**
* Supported language identifiers
*/
export type CodeLanguage
= | 'bash'
| 'html'
| 'http'
| 'js'
| 'ts'
| 'py'
| 'sql'
| 'pl'
| 'lua'
| 'make'
| 'uri'
| 'css'
| 'diff'
| 'md'
| 'docker'
| 'xml'
| 'c'
| 'rs'
| 'go'
| 'java'
| 'asm'
| 'json'
| 'yaml'
| 'toml'
| 'mermaid'
| 'plain'
/**
* Language detection feature with pattern and score
*/
export type LanguageFeature = [RegExp, number]
/**
* Language definition with identifier and detection features
*/
export type LanguageDefinition = [CodeLanguage, ...LanguageFeature[]]
/**
* Language detection definitions
*/
export const languages: LanguageDefinition[] = [
[
'bash',
[/#!(\/usr)?\/bin\/bash/g, 500],
[/\b(if|elif|then|fi|echo)\b|\$/g, 10],
],
['html', [/<\/?[a-z-][^\n>]*>/g, 10], [/^\s+<!DOCTYPE\s+html/g, 500]],
['http', [/^(GET|HEAD|POST|PUT|DELETE|PATCH|HTTP)\b/g, 500]],
[
'js',
[
/\b(console|await|async|function|export|import|this|class|for|let|const|map|join|require)\b/g,
10,
],
],
[
'ts',
[
/\b(console|await|async|function|export|import|this|class|for|let|const|map|join|require|implements|interface|namespace)\b/g,
10,
],
],
['py', [/\b(def|print|class|and|or|lambda)\b/g, 10]],
['sql', [/\b(SELECT|INSERT|FROM)\b/g, 50]],
['pl', [/#!(\/usr)?\/bin\/perl/g, 500], [/\b(use|print)\b|\$/g, 10]],
['lua', [/#!(\/usr)?\/bin\/lua/g, 500]],
[
'make',
[/\b(ifneq|endif|if|elif|then|fi|echo|.PHONY|^[a-z]+ ?:$)\b|\$/gm, 10],
],
['uri', [/https?:|mailto:|tel:|ftp:/g, 30]],
['css', [/^(@import|@page|@media|(\.|#)[a-z]+)/gm, 20]],
['diff', [/^[+><-]/gm, 10], [/^@@[-+,0-9 ]+@@/gm, 25]],
['md', [/^(>|\t\*|\t\d+.|#{1,6} |-\s+|\*\s+)/gm, 25], [/\[.*\](.*)/g, 10]],
['docker', [/^(FROM|ENTRYPOINT|RUN)/gm, 500]],
['xml', [/<\/?[a-z-][^\n>]*>/g, 10], [/^<\?xml/g, 500]],
['c', [/#include\b|\bprintf\s+\(/g, 100]],
['rs', [/^\s+(use|fn|mut|match)\b/gm, 100]],
['go', [/\b(func|fmt|package)\b/g, 100]],
['java', [/^import\s+java/gm, 500]],
['asm', [/^(section|global main|extern|\t(call|mov|ret))/gm, 100]],
['css', [/^(@import|@page|@media|(\.|#)[a-z]+)/gm, 20]],
['json', [/\b(true|false|null|\{\})\b|"[^"]+":/g, 10]],
['yaml', [/^(\s+)?[a-z][a-z0-9]*:/gim, 10]],
['toml', [/^\s*\[.*\]\s*$/gm, 100], [/^\s*[\w-]+ *= */gm, 20]],
[
'mermaid',
[
/^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|mindmap)/gm,
500,
],
[/\b(-->|--o|--x|=>|\[\]|[{}])\b/g, 10],
],
]
/**
* Try to find the language the given code belongs to
*
* @param {string} code The code to analyze
* @param {LanguageDefinition[]} [additionalLanguages] Additional language definitions to supplement the built-in ones
* @returns {CodeLanguage} The detected language of the code
*/
export function detectLanguage(
code: string,
additionalLanguages?: LanguageDefinition[],
): CodeLanguage {
const allLanguages = additionalLanguages
? [...languages, ...additionalLanguages]
: languages
return (
allLanguages
.map(
([lang, ...features]) =>
[
lang,
features.reduce(
(acc: number, [match, score]: LanguageFeature) =>
acc + [...code.matchAll(match)].length * score,
0,
),
] as [CodeLanguage, number],
)
.filter(([, score]) => score > 20)
.sort((a, b) => b[1] - a[1])[0]?.[0] || 'plain'
)
}
export function processedLanguage(language: string) {
// Legacy (Webpack 4) fallback: Monaco doesn't ship a built-in `vue` tokenizer.
// When Shiki integration is unreliable, mapping to `html` preserves at least
// a reasonable highlight for .vue-like blocks.
try {
if ((globalThis as any)?.__streamMonacoLegacy__ && /^vue$/i.test(language))
return 'html'
}
catch {}
// eslint-disable-next-line regexp/no-dupe-disjunctions
if (/^(?:shellscript|bash|sh|shell|zsh)/i.test(language))
return 'shellscript'
if (/^(?:powershell|ps1?)/i.test(language))
return 'powershell'
return language.split(':')[0]
}
/**
* 使用示例:
*
* // 基本用法
* const language1 = detectLanguage('console.log("hello")') // 'js'
*
* // 使用自定义语言检测规则
* const customLanguages: LanguageDefinition[] = [
* ['vue', [/<template>/g, 100], [/<script>/g, 50], [/<style>/g, 50]],
* ['kotlin', [/\b(fun|class|val|var)\b/g, 20]]
* ]
*
* const language2 = detectLanguage(`
* <template>
* <div>Hello Vue</div>
* </template>
* `, customLanguages) // 'vue'
*
* const language3 = detectLanguage(`
* fun main() {
* val name = "Kotlin"
* println("Hello $name")
* }
* `, customLanguages) // 'kotlin'
*/
@@ -0,0 +1,70 @@
import type { MonacoTheme } from './type'
export const defaultLanguages = [
'jsx',
'tsx',
'vue',
'csharp',
'python',
'java',
'c',
'cpp',
'rust',
'go',
'shell',
'shellscript',
'powershell',
'sql',
'json',
'html',
'javascript',
'typescript',
'css',
'markdown',
'xml',
'yaml',
'toml',
'dockerfile',
'kotlin',
'objective-c',
'objective-cpp',
'php',
'ruby',
'scala',
'svelte',
'swift',
'erlang',
'angular-html',
'angular-ts',
'dart',
'lua',
'mermaid',
'cmake',
'nginx',
]
export const defaultThemes: MonacoTheme[] = ['vitesse-dark', 'vitesse-light']
export const defaultScrollbar = {
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
handleMouseWheel: true,
/**
* 是否始终消费鼠标滚轮事件,默认为 false
* 如果为 true,则鼠标滚轮事件不会传递给其他元素
*/
alwaysConsumeMouseWheel: false,
}
export const padding = 16
// default debounce for reveal (ms) used when revealDebounceMs is not provided
export const defaultRevealDebounceMs = 75
// Default idle batching for reveal: when set, many small appends will delay
// the final "scroll to bottom" until this idle period has passed. A higher
// value reduces layout churn during rapid streaming. Default: 200ms.
export const defaultRevealBatchOnIdleMs = 200
// When strings exceed this length, avoid expensive character-by-character
// minimal edit computation and instead perform a full model.setValue(). This
// avoids O(n) CPU on extremely large documents during frequent updates.
export const minimalEditMaxChars = 200_000
// If the absolute length difference between prev and next exceeds this ratio
// of the larger string, prefer full setValue to avoid long scans.
export const minimalEditMaxChangeRatio = 0.5
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,937 @@
import type { MonacoLanguage, MonacoOptions } from '../type'
import { processedLanguage } from '../code.detect'
import { defaultRevealBatchOnIdleMs, defaultRevealDebounceMs, defaultScrollbar, minimalEditMaxChangeRatio, minimalEditMaxChars, padding } from '../constant'
import { computeMinimalEdit } from '../minimalEdit'
import * as monaco from '../monaco-shim'
import { createHeightManager } from '../utils/height'
import { error, log } from '../utils/logger'
import { createRafScheduler } from '../utils/raf'
import { createScrollWatcherForEditor } from '../utils/scroll'
export class EditorManager {
private editorView: monaco.editor.IStandaloneCodeEditor | null = null
private lastContainer: HTMLElement | null = null
private lastKnownCode: string | null = null
private pendingUpdate: { code: string, lang: string } | null = null
private _hasScrollBar = false
private updateThrottleMs = 50
private minimalEditMaxCharsValue = minimalEditMaxChars
private minimalEditMaxChangeRatioValue = minimalEditMaxChangeRatio
private lastUpdateFlushTime = 0
private updateThrottleTimer: number | null = null
private shouldAutoScroll = true
private scrollWatcher: any = null
private scrollWatcherSuppressionTimer: number | null = null
private lastScrollTop = 0
private cachedScrollHeight: number | null = null
private cachedLineHeight: number | null = null
private cachedComputedHeight: number | null = null
private cachedLineCount: number | null = null
private lastKnownCodeDirty = false
private programmaticContentChangeDepth = 0
private debug = false
// read a small set of viewport/layout metrics once to avoid repeated DOM reads
private measureViewport() {
if (!this.editorView)
return null
const li = this.editorView.getLayoutInfo?.() ?? null
const lineHeight = this.cachedLineHeight ?? this.editorView.getOption(
monaco.editor.EditorOption.lineHeight,
)
const scrollTop = this.editorView.getScrollTop?.() ?? this.lastScrollTop ?? 0
const scrollHeight = this.editorView.getScrollHeight?.() ?? this.cachedScrollHeight ?? (li?.height ?? 0)
const computedHeight = this.cachedComputedHeight ?? this.computedHeight(this.editorView)
// update caches
this.cachedLineHeight = lineHeight
this.cachedScrollHeight = scrollHeight
this.cachedComputedHeight = computedHeight
this.lastScrollTop = scrollTop
return { li, lineHeight, scrollTop, scrollHeight, computedHeight }
}
private appendBuffer: string[] = []
private appendBufferScheduled = false
private rafScheduler = createRafScheduler()
private editorHeightManager: ReturnType<typeof createHeightManager> | null = null
// debounce id for reveal to coalesce rapid calls (ms)
private revealDebounceId: number | null = null
private readonly revealDebounceMs = defaultRevealDebounceMs
// idle timer for final batch reveal
private revealIdleTimerId: number | null = null
private revealTicket = 0
private revealStrategyOption?: 'bottom' | 'centerIfOutside' | 'center'
private revealBatchOnIdleMsOption?: number
private readonly scrollWatcherSuppressionMs = 500
constructor(
private options: MonacoOptions,
private maxHeightValue: number,
private maxHeightCSS: string,
private autoScrollOnUpdate: boolean,
private autoScrollInitial: boolean,
private autoScrollThresholdPx: number,
private autoScrollThresholdLines: number,
private revealDebounceMsOption?: number,
private updateThrottleMsOption?: number,
) {
this.updateThrottleMs
= this.updateThrottleMsOption
?? (this.options as any).updateThrottleMs
?? 50
this.minimalEditMaxCharsValue
= (this.options as any).minimalEditMaxChars
?? minimalEditMaxChars
this.minimalEditMaxChangeRatioValue
= (this.options as any).minimalEditMaxChangeRatio
?? minimalEditMaxChangeRatio
}
private cancelRafs() {
this.rafScheduler.cancel('update')
this.rafScheduler.cancel('sync-last-known')
this.rafScheduler.cancel('content-size-change')
this.rafScheduler.cancel('maybe-scroll')
this.rafScheduler.cancel('reveal')
this.rafScheduler.cancel('immediate-reveal')
this.rafScheduler.cancel('maybe-resume')
this.rafScheduler.cancel('append')
}
private clearRevealTimers() {
if (this.revealDebounceId != null) {
clearTimeout(this.revealDebounceId)
this.revealDebounceId = null
}
if (this.revealIdleTimerId != null) {
clearTimeout(this.revealIdleTimerId)
this.revealIdleTimerId = null
}
}
private resetAppendState() {
this.appendBufferScheduled = false
this.appendBuffer.length = 0
}
private clearAsyncWork() {
this.cancelRafs()
this.pendingUpdate = null
this.lastKnownCodeDirty = false
this.resetAppendState()
this.clearRevealTimers()
if (this.scrollWatcherSuppressionTimer != null) {
clearTimeout(this.scrollWatcherSuppressionTimer)
this.scrollWatcherSuppressionTimer = null
}
if (this.updateThrottleTimer != null) {
clearTimeout(this.updateThrottleTimer)
this.updateThrottleTimer = null
}
}
// initialize debug flag: prefer explicit runtime flag, then options, fallback to false
private initDebugFlag() {
// Browser global override: `window.__STREAM_MONACO_DEBUG__` (true/false)
if (typeof window !== 'undefined' && (window as any).__STREAM_MONACO_DEBUG__ !== undefined) {
this.debug = Boolean((window as any).__STREAM_MONACO_DEBUG__)
return
}
// Option-level override: `options.debug`
if (this.options && (this.options as any).debug !== undefined) {
this.debug = Boolean((this.options as any).debug)
return
}
this.debug = false
}
// instance-level debug logger helper to avoid repetitive guards
private dlog(...args: any[]) {
if (!this.debug)
return
log('EditorManager', ...args)
}
private runAsProgrammaticContentChange<T>(fn: () => T): T {
this.programmaticContentChangeDepth += 1
try {
return fn()
}
finally {
this.programmaticContentChangeDepth -= 1
}
}
private hasVerticalScrollbar(): boolean {
if (!this.editorView)
return false
if (this._hasScrollBar)
return true
const m = this.measureViewport()
if (!m)
return false
return this._hasScrollBar = (m.scrollHeight > m.computedHeight + padding / 2)
}
private userIsNearBottom(): boolean {
if (!this.editorView)
return true
const m = this.measureViewport()
if (!m || !m.li)
return true
const lineThreshold = (this.autoScrollThresholdLines ?? 0) * m.lineHeight
const threshold = Math.max(lineThreshold || 0, this.autoScrollThresholdPx || 0)
const distance = m.scrollHeight - (m.scrollTop + m.li.height)
return distance <= threshold
}
private computedHeight(editorView: monaco.editor.IStandaloneCodeEditor) {
const contentHeight = editorView.getContentHeight?.()
if (
typeof contentHeight === 'number'
&& Number.isFinite(contentHeight)
&& contentHeight > 0
) {
const height = Math.min(contentHeight, this.maxHeightValue)
log('EditorManager.computedHeight', { contentHeight, computed: height, maxHeightValue: this.maxHeightValue })
return height
}
const lineCount = this.cachedLineCount ?? editorView.getModel()?.getLineCount() ?? 1
const lineHeight = editorView.getOption(monaco.editor.EditorOption.lineHeight)
const height = Math.min(lineCount * lineHeight + padding, this.maxHeightValue)
// log is defensive itself; no need for an extra try/catch here
log('EditorManager.computedHeight', { lineCount, lineHeight, computed: height, maxHeightValue: this.maxHeightValue })
return height
}
private maybeScrollToBottom(targetLine?: number) {
// Defer measurement and reveal work to the raf scheduler so we avoid forcing sync layout
// during hot update paths. This coalesces multiple calls into one frame.
this.rafScheduler.schedule('maybe-scroll', () => {
const hasVS = this.hasVerticalScrollbar()
this.dlog('maybeScrollToBottom called', { autoScrollOnUpdate: this.autoScrollOnUpdate, shouldAutoScroll: this.shouldAutoScroll, hasVerticalScrollbar: hasVS, targetLine })
if (!(this.autoScrollOnUpdate && this.shouldAutoScroll && this.hasVerticalScrollbar())) {
this.dlog('maybeScrollToBottom skipped (auto-scroll conditions not met)')
return
}
const model = this.editorView!.getModel()
const line = targetLine ?? model?.getLineCount() ?? 1
// if revealBatchOnIdleMs is provided, use idle-batching: delay final reveal until idle
const batchMs = this.revealBatchOnIdleMsOption ?? this.options.revealBatchOnIdleMs ?? defaultRevealBatchOnIdleMs
if (typeof batchMs === 'number' && batchMs > 0) {
if (this.revealIdleTimerId != null) {
clearTimeout(this.revealIdleTimerId)
}
const ticket = ++this.revealTicket
this.dlog('scheduled idle reveal ticket=', ticket, 'line=', line, 'batchMs=', batchMs)
this.revealIdleTimerId = (setTimeout(() => {
this.revealIdleTimerId = null
this.dlog('idle reveal timer firing, ticket=', ticket, 'line=', line)
this.performReveal(line, ticket)
}, batchMs) as unknown) as number
return
}
// otherwise use debounce behavior
if (this.revealDebounceId != null) {
clearTimeout(this.revealDebounceId)
this.revealDebounceId = null
}
const ms = (typeof this.revealDebounceMs === 'number' && this.revealDebounceMs > 0)
? this.revealDebounceMs
: (typeof this.revealDebounceMsOption === 'number' && this.revealDebounceMsOption > 0)
? this.revealDebounceMsOption
: this.revealDebounceMs
this.revealDebounceId = (setTimeout(() => {
this.revealDebounceId = null
const ticket = ++this.revealTicket
this.dlog('scheduled debounce reveal ticket=', ticket, 'line=', line, 'ms=', ms)
this.performReveal(line, ticket)
}, ms) as unknown) as number
})
}
private performReveal(line: number, ticket: number) {
this.rafScheduler.schedule('reveal', () => {
if (ticket !== this.revealTicket) {
this.dlog('performReveal skipped, stale ticket', ticket, 'current', this.revealTicket)
return
}
this.dlog('performReveal executing, ticket=', ticket, 'line=', line)
const strategy = this.revealStrategyOption ?? this.options.revealStrategy ?? 'centerIfOutside'
this.dlog('performReveal strategy=', strategy)
const ScrollType: any = (monaco as any).ScrollType || (monaco as any).editor?.ScrollType
const smooth = (ScrollType && typeof ScrollType.Smooth !== 'undefined') ? ScrollType.Smooth : undefined
try {
if (strategy === 'bottom') {
if (typeof smooth !== 'undefined')
this.editorView!.revealLine(line, smooth)
else this.editorView!.revealLine(line)
}
else if (strategy === 'center') {
if (typeof smooth !== 'undefined')
this.editorView!.revealLineInCenter(line, smooth)
else this.editorView!.revealLineInCenter(line)
}
else {
if (typeof smooth !== 'undefined')
this.editorView!.revealLineInCenterIfOutsideViewport(line, smooth)
else this.editorView!.revealLineInCenterIfOutsideViewport(line)
}
}
catch {
// fallback to simple revealLine
try {
this.editorView!.revealLine(line)
}
catch { }
}
})
}
// Try to reveal immediately (synchronous) to keep the scroll in-step with
// edits when we know an update just appended or changed line count.
// This is a fast-path to reduce the perceived lag before the debounced
// reveal kicks in. It still leaves the debounced/idle reveal as a fallback
// for coalescing frequent updates.
private performImmediateReveal(line: number, ticket: number) {
this.dlog('performImmediateReveal line=', line, 'ticket=', ticket)
try {
if (!this.editorView)
return
if (ticket !== this.revealTicket) {
this.dlog('performImmediateReveal skipped, stale ticket', ticket, 'current', this.revealTicket)
return
}
// Prefer an immediate (non-animated) reveal here to avoid visual
// jitter caused by smooth scroll animations. Some Monaco builds expose
// a ScrollType with Immediate; otherwise calling revealLine without
// a Smooth type typically performs an instant jump.
const ScrollType: any = (monaco as any).ScrollType || (monaco as any).editor?.ScrollType
const immediate = (ScrollType && typeof ScrollType.Immediate !== 'undefined') ? ScrollType.Immediate : undefined
if (typeof immediate !== 'undefined') {
this.editorView.revealLine(line, immediate)
}
else {
this.editorView.revealLine(line)
}
}
catch { }
// Update cached viewport metrics to keep internal state consistent
try {
this.measureViewport()
}
catch { }
}
// Force an immediate (synchronous) reveal of a line, bypassing the
// ticketing/debounce mechanism. Used when we explicitly applied the
// container to the max height and need the scroll to follow immediately.
private forceReveal(line: number) {
try {
if (!this.editorView)
return
const ScrollType: any = (monaco as any).ScrollType || (monaco as any).editor?.ScrollType
const immediate = (ScrollType && typeof ScrollType.Immediate !== 'undefined') ? ScrollType.Immediate : undefined
if (typeof immediate !== 'undefined')
this.editorView.revealLine(line, immediate)
else this.editorView.revealLine(line)
}
catch { }
try {
this.measureViewport()
}
catch { }
// After forcing a reveal, ensure auto-scroll state is on and the
// lastScrollTop is synchronized so the scrollWatcher doesn't treat
// this programmatic scroll as user input.
try {
this.shouldAutoScroll = true
this.lastScrollTop = this.editorView?.getScrollTop?.() ?? this.lastScrollTop
}
catch { }
}
private isOverflowAuto() {
try {
return !!this.lastContainer && this.lastContainer.style.overflow === 'auto'
}
catch { return false }
}
private shouldPerformImmediateReveal() {
return this.autoScrollOnUpdate && this.shouldAutoScroll && this.hasVerticalScrollbar() && this.isOverflowAuto()
}
private syncNonOverflowingLayout() {
if (!this.editorView || !this.lastContainer)
return null
const computed = this.computedHeight(this.editorView)
if (computed >= this.maxHeightValue - 1) {
this.lastContainer.style.height = `${this.maxHeightValue}px`
return computed
}
const currentHeight
= Number.parseFloat(this.lastContainer.style.height || '')
|| this.lastContainer.getBoundingClientRect?.().height
|| 0
if (currentHeight <= 0 || Math.abs(computed - currentHeight) > 12)
this.lastContainer.style.height = `${computed}px`
this.lastContainer.style.overflow = 'hidden'
this._hasScrollBar = false
try {
this.editorView.layout?.()
}
catch {}
try {
if ((this.editorView.getScrollTop?.() ?? 0) !== 0)
this.editorView.setScrollTop?.(0)
this.lastScrollTop = 0
}
catch {}
return computed
}
async createEditor(
container: HTMLElement,
code: string,
language: string,
currentTheme: string,
) {
this.cleanup()
this.lastContainer = container
this.initDebugFlag()
this.dlog('createEditor container, maxHeight', this.maxHeightValue)
// Start with hidden overflow so we don't show a scrollbar before the
// editor reaches its configured max height. We'll toggle to `auto`
// when the computed height reaches `maxHeightValue`.
container.style.overflow = 'hidden'
container.style.maxHeight = this.maxHeightCSS
this.editorView = monaco.editor.create(container, {
value: code,
language: processedLanguage(language) || language,
theme: currentTheme,
scrollBeyondLastLine: false,
minimap: { enabled: false },
automaticLayout: true,
readOnly: this.options.readOnly ?? true,
contextmenu: false,
scrollbar: {
...defaultScrollbar,
...(this.options.scrollbar || {}),
},
...this.options,
})
monaco.editor.setTheme(currentTheme)
this.lastKnownCode = this.editorView.getValue()
if (this.editorHeightManager) {
try {
this.editorHeightManager.dispose()
}
catch { }
this.editorHeightManager = null
}
// clear any pending reveal debounce
if (this.revealDebounceId != null) {
clearTimeout(this.revealDebounceId)
this.revealDebounceId = null
}
if (this.revealIdleTimerId != null) {
clearTimeout(this.revealIdleTimerId)
}
this.revealIdleTimerId = null
// Compute and apply the editor's content height. Consumers such as
// CodeBlockNode may also sync this same host height from Monaco, so avoid
// enforcing a separate minimum that would fight the host.
this.editorHeightManager = createHeightManager(container, () => {
const computed = this.computedHeight(this.editorView!)
return Math.min(computed, this.maxHeightValue)
})
this.editorHeightManager.update()
// If the initial computed height already reaches (or is very near) the
// configured max height, apply it immediately so the UI shows the
// scrolled state without waiting for the debounced height manager.
const initialComputed = this.computedHeight(this.editorView)
if (initialComputed >= this.maxHeightValue - 1) {
container.style.height = `${this.maxHeightValue}px`
container.style.overflow = 'auto'
this.dlog('applied immediate maxHeight on createEditor', this.maxHeightValue)
}
this.cachedScrollHeight = this.editorView.getScrollHeight?.() ?? null
this.cachedLineHeight = this.editorView.getOption?.(monaco.editor.EditorOption.lineHeight) ?? null
this.cachedComputedHeight = this.computedHeight(this.editorView)
this.cachedLineCount = this.editorView.getModel()?.getLineCount() ?? null
this.editorView.onDidContentSizeChange?.(() => {
this._hasScrollBar = false
// Defer expensive measurements to RAF to avoid forcing sync layout on
// the content-size-change event which can fire frequently.
this.rafScheduler.schedule('content-size-change', () => {
try {
this.dlog('content-size-change frame')
this.cachedLineCount = this.editorView?.getModel()?.getLineCount() ?? this.cachedLineCount
this.cachedComputedHeight = null
const m = this.measureViewport()
this.dlog('content-size-change measure', m)
if (this.editorHeightManager?.isSuppressed()) {
this.dlog('content-size-change skipped height update (suppressed)')
return
}
this.dlog('content-size-change calling heightManager.update')
this.editorHeightManager?.update()
// Toggle overflow based on whether we've effectively reached max height.
const computed = this.computedHeight(this.editorView!)
if (this.lastContainer) {
const prevOverflow = this.lastContainer.style.overflow
const newOverflow = computed >= this.maxHeightValue - 1 ? 'auto' : 'hidden'
if (prevOverflow !== newOverflow) {
this.lastContainer.style.overflow = newOverflow
// If we just switched to visible overflow and auto-scroll is on,
// schedule a scroll to bottom so the user sees the latest content.
if (newOverflow === 'auto' && this.shouldAutoScroll) {
this.maybeScrollToBottom()
}
}
}
}
catch (err) { error('EditorManager', 'content-size-change error', err) }
})
})
// Avoid calling getValue() synchronously on every content change; instead
// mark dirty and sync once per RAF frame to reduce CPU and style recalcs.
this.editorView.onDidChangeModelContent(() => {
if (this.programmaticContentChangeDepth > 0)
return
this.lastKnownCodeDirty = true
this.rafScheduler.schedule('sync-last-known', () => this.syncLastKnownCode())
})
this.shouldAutoScroll = !!this.autoScrollInitial
if (this.scrollWatcher) {
this.scrollWatcher.dispose()
this.scrollWatcher = null
}
this.scrollWatcher = createScrollWatcherForEditor(this.editorView, {
onPause: () => { this.shouldAutoScroll = false },
onMaybeResume: () => {
// defer the expensive userIsNearBottom check to the raf scheduler
this.rafScheduler.schedule('maybe-resume', () => {
this.shouldAutoScroll = this.userIsNearBottom()
})
},
getLast: () => this.lastScrollTop,
setLast: (v: number) => { this.lastScrollTop = v },
})
this.maybeScrollToBottom()
return this.editorView
}
private syncLastKnownCode() {
if (!this.editorView || !this.lastKnownCodeDirty)
return
const model = this.editorView.getModel()
if (model) {
this.lastKnownCode = model.getValue()
this.cachedLineCount = model.getLineCount() ?? this.cachedLineCount
this.cachedComputedHeight = null
}
this.lastKnownCodeDirty = false
}
private suppressScrollWatcher(ms: number) {
if (!this.scrollWatcher || typeof this.scrollWatcher.setSuppressed !== 'function')
return
this.dlog('suppressScrollWatcher', ms)
// clear existing timer
if (this.scrollWatcherSuppressionTimer != null) {
clearTimeout(this.scrollWatcherSuppressionTimer)
this.scrollWatcherSuppressionTimer = null
}
this.scrollWatcher.setSuppressed(true)
this.scrollWatcherSuppressionTimer = (setTimeout(() => {
if (this.scrollWatcher && typeof this.scrollWatcher.setSuppressed === 'function') {
this.scrollWatcher.setSuppressed(false)
this.dlog('suppressScrollWatcher cleared')
}
this.scrollWatcherSuppressionTimer = null
}, ms) as unknown) as number
}
// Schedule an immediate reveal after layout has settled (two RAFs).
// This reduces races where a reveal runs before the editor/container
// has applied its new size, which can cause reveal to be a no-op or
// be applied in the wrong order relative to other reveals.
private scheduleImmediateRevealAfterLayout(line: number) {
const ticket = ++this.revealTicket
this.dlog('scheduleImmediateRevealAfterLayout ticket=', ticket, 'line=', line)
this.rafScheduler.schedule('immediate-reveal', async () => {
try {
// If we have a height manager, compute the expected applied height
const target = this.editorView && this.editorHeightManager
? Math.min(this.computedHeight(this.editorView), this.maxHeightValue)
: -1
if (target !== -1 && this.editorHeightManager) {
// Wait until the height manager reports the target as applied
await this.waitForHeightApplied(target, 500)
}
else {
// fallback: wait two frames to let layout settle
await new Promise<void>(r => requestAnimationFrame(() => requestAnimationFrame(() => r())))
}
this.dlog('running delayed immediate reveal', 'ticket=', ticket, 'line=', line)
this.performImmediateReveal(line, ticket)
}
catch (err) { error('EditorManager', 'scheduleImmediateRevealAfterLayout error', err) }
})
}
private waitForHeightApplied(target: number, timeoutMs = 500) {
return new Promise<void>((resolve) => {
const start = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()
const check = () => {
try {
const last = this.editorHeightManager?.getLastApplied?.() ?? -1
if (last !== -1 && Math.abs(last - target) <= 12) {
this.dlog('waitForHeightApplied satisfied', last, 'target=', target)
resolve()
return
}
const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()
if (now - start > timeoutMs) {
log('EditorManager', 'waitForHeightApplied timeout', last, 'target=', target)
resolve()
return
}
}
catch { }
requestAnimationFrame(check)
}
check()
})
}
updateCode(newCode: string, codeLanguage: string) {
this.pendingUpdate = { code: newCode, lang: codeLanguage }
this.rafScheduler.schedule('update', () => {
if (!this.updateThrottleMs) {
this.flushPendingUpdate()
return
}
const now = Date.now()
const since = now - this.lastUpdateFlushTime
if (since >= this.updateThrottleMs) {
this.flushPendingUpdate()
return
}
if (this.updateThrottleTimer != null)
return
const wait = this.updateThrottleMs - since
this.updateThrottleTimer = (setTimeout(() => {
this.updateThrottleTimer = null
this.rafScheduler.schedule('update', () => this.flushPendingUpdate())
}, wait) as unknown) as number
})
}
private flushPendingUpdate() {
if (!this.pendingUpdate || !this.editorView)
return
const model = this.editorView.getModel()
if (!model)
return
const { code: newCode, lang: codeLanguage } = this.pendingUpdate
this.pendingUpdate = null
this.lastUpdateFlushTime = Date.now()
const processedCodeLanguage = processedLanguage(codeLanguage)
const languageId = model.getLanguageId()
if (languageId !== processedCodeLanguage) {
if (processedCodeLanguage)
monaco.editor.setModelLanguage(model, processedCodeLanguage)
const prevLineCount = model.getLineCount()
this.runAsProgrammaticContentChange(() => {
model.setValue(newCode)
})
this.lastKnownCode = newCode
const newLineCount = model.getLineCount()
this.cachedLineCount = newLineCount
this.cachedComputedHeight = null
if (newLineCount !== prevLineCount) {
// Temporarily suppress the scroll watcher while we run the
// immediate reveal to avoid the watcher interpreting the layout
// driven scroll as user input and toggling auto-scroll state.
const shouldImmediate = this.shouldPerformImmediateReveal()
if (shouldImmediate)
this.suppressScrollWatcher(this.scrollWatcherSuppressionMs)
const computed = this.syncNonOverflowingLayout()
if (computed != null && computed >= this.maxHeightValue - 1)
this.forceReveal(newLineCount)
}
return
}
let prevCode: string
if (this.appendBuffer.length > 0) {
this.resetAppendState()
this.rafScheduler.cancel('append')
try {
prevCode = model.getValue()
this.lastKnownCode = prevCode
}
catch {
prevCode = this.lastKnownCode ?? ''
}
}
else if (this.lastKnownCode != null) {
prevCode = this.lastKnownCode
}
else {
prevCode = this.editorView.getValue()
this.lastKnownCode = prevCode
}
if (prevCode === newCode)
return
if (newCode.startsWith(prevCode) && prevCode.length < newCode.length) {
const suffix = newCode.slice(prevCode.length)
if (suffix)
this.appendCode(suffix, codeLanguage)
return
}
const prevLineCount = model.getLineCount()
this.applyMinimalEdit(prevCode, newCode)
this.lastKnownCode = newCode
const newLineCount = model.getLineCount()
this.cachedLineCount = newLineCount
this.cachedComputedHeight = null
if (newLineCount !== prevLineCount) {
const shouldImmediate = this.shouldPerformImmediateReveal()
if (shouldImmediate)
this.suppressScrollWatcher(this.scrollWatcherSuppressionMs)
this.syncNonOverflowingLayout()
if (shouldImmediate) {
this.scheduleImmediateRevealAfterLayout(newLineCount)
}
else {
this.maybeScrollToBottom(newLineCount)
}
}
}
appendCode(appendText: string, codeLanguage?: string) {
if (!this.editorView)
return
const model = this.editorView.getModel()
if (!model)
return
const processedCodeLanguage = codeLanguage ? processedLanguage(codeLanguage) : model.getLanguageId()
if (processedCodeLanguage && model.getLanguageId() !== processedCodeLanguage)
monaco.editor.setModelLanguage(model, processedCodeLanguage)
// Buffer-only; actual edit is applied in flushAppendBuffer once per frame
if (appendText) {
this.appendBuffer.push(appendText)
if (!this.appendBufferScheduled) {
this.appendBufferScheduled = true
this.rafScheduler.schedule('append', () => this.flushAppendBuffer())
}
}
}
private applyMinimalEdit(prev: string, next: string) {
if (!this.editorView)
return
const model = this.editorView.getModel()
if (!model)
return
// Avoid expensive minimal edit calculation for extremely large documents
// or when the size delta is very large; fallback to full setValue.
const maxChars = this.minimalEditMaxCharsValue
const ratio = this.minimalEditMaxChangeRatioValue
const maxLen = Math.max(prev.length, next.length)
const changeRatio = maxLen > 0 ? Math.abs(next.length - prev.length) / maxLen : 0
if (prev.length + next.length > maxChars || changeRatio > ratio) {
const prevLineCount = model.getLineCount()
this.runAsProgrammaticContentChange(() => {
model.setValue(next)
})
this.lastKnownCode = next
const newLineCount = model.getLineCount()
this.cachedLineCount = newLineCount
if (newLineCount !== prevLineCount) {
this.syncNonOverflowingLayout()
this.maybeScrollToBottom(newLineCount)
}
return
}
const res = computeMinimalEdit(prev, next)
if (!res)
return
const { start, endPrevIncl, replaceText } = res
const rangeStart = model.getPositionAt(start)
const rangeEnd = model.getPositionAt(endPrevIncl + 1)
const range = new monaco.Range(
rangeStart.lineNumber,
rangeStart.column,
rangeEnd.lineNumber,
rangeEnd.column,
)
const isReadOnly = this.editorView.getOption(monaco.editor.EditorOption.readOnly)
const edit = [{ range, text: replaceText, forceMoveMarkers: true }]
this.runAsProgrammaticContentChange(() => {
if (isReadOnly)
model.applyEdits(edit)
else this.editorView!.executeEdits('minimal-replace', edit)
})
}
private flushAppendBuffer() {
if (!this.editorView)
return
if (this.appendBuffer.length === 0)
return
this.appendBufferScheduled = false
const model = this.editorView.getModel()
if (!model) {
this.appendBuffer.length = 0
return
}
const text = this.appendBuffer.join('')
this.appendBuffer.length = 0
const lastLine = model.getLineCount()
const lastColumn = model.getLineMaxColumn(lastLine)
const range = new monaco.Range(lastLine, lastColumn, lastLine, lastColumn)
const isReadOnly = this.editorView.getOption(monaco.editor.EditorOption.readOnly)
this.runAsProgrammaticContentChange(() => {
if (isReadOnly) {
model.applyEdits([{ range, text, forceMoveMarkers: true }])
}
else {
this.editorView!.executeEdits('append', [{ range, text, forceMoveMarkers: true }])
}
})
if (this.lastKnownCode != null)
this.lastKnownCode = this.lastKnownCode + text
else
this.lastKnownCode = model.getValue()
const newLineCount = model.getLineCount()
if (lastLine !== newLineCount) {
this.cachedLineCount = newLineCount
this.cachedComputedHeight = null
const shouldImmediate = this.shouldPerformImmediateReveal()
if (shouldImmediate)
this.suppressScrollWatcher(this.scrollWatcherSuppressionMs)
this.syncNonOverflowingLayout()
if (shouldImmediate) {
try {
this.forceReveal(newLineCount)
}
catch { }
}
else {
this.maybeScrollToBottom(newLineCount)
}
}
}
setLanguage(language: MonacoLanguage, languages: MonacoLanguage[]) {
if (languages.includes(language)) {
if (this.editorView) {
const model = this.editorView.getModel()
if (model && model.getLanguageId() !== language)
monaco.editor.setModelLanguage(model, language)
}
}
else {
console.warn(`Language "${language}" is not registered. Available languages: ${languages.join(', ')}`)
}
}
getEditorView() {
return this.editorView
}
getCode() {
return this.editorView?.getModel()?.getValue() ?? null
}
setUpdateThrottleMs(ms: number) {
this.updateThrottleMs = ms
if (!this.updateThrottleMs && this.updateThrottleTimer != null) {
clearTimeout(this.updateThrottleTimer)
this.updateThrottleTimer = null
this.rafScheduler.schedule('update', () => this.flushPendingUpdate())
}
}
getUpdateThrottleMs() {
return this.updateThrottleMs
}
cleanup() {
this.clearAsyncWork()
if (this.editorView) {
this.editorView.dispose()
this.editorView = null
}
this.lastKnownCode = null
if (this.lastContainer) {
this.lastContainer.style.minHeight = ''
this.lastContainer.innerHTML = ''
this.lastContainer = null
}
if (this.scrollWatcher) {
this.scrollWatcher.dispose()
this.scrollWatcher = null
}
if (this.editorHeightManager) {
this.editorHeightManager.dispose()
this.editorHeightManager = null
}
}
safeClean() {
this.clearAsyncWork()
// Cancel/cleanup watchers and timers
if (this.scrollWatcher) {
try {
this.scrollWatcher.dispose()
}
catch { }
this.scrollWatcher = null
}
this._hasScrollBar = false
this.shouldAutoScroll = !!this.autoScrollInitial
this.lastScrollTop = 0
if (this.editorHeightManager) {
this.editorHeightManager.dispose()
this.editorHeightManager = null
}
}
}
@@ -0,0 +1,357 @@
import type * as monaco from '../monaco-shim'
type DiffAppearance = 'light' | 'dark'
type DiffAppearanceOption = DiffAppearance | 'auto' | undefined
interface DiffUnchangedLineInfoRailMetrics {
leftInset: number
width: number | null
}
interface ApplyDiffRootAppearanceClassOptions {
container: HTMLElement | null
diffEditorView: monaco.editor.IStandaloneDiffEditor | null
diffAppearance: DiffAppearanceOption
themeName: string | null | undefined
currentSignature: string | null
lineStyle: 'background' | 'bar'
unchangedRegionStyle: 'line-info' | 'line-info-basic' | 'metadata' | 'simple'
isInlineMode: boolean
lineStyleClasses: readonly string[]
unchangedRegionStyleClasses: readonly string[]
layoutModeClasses: readonly string[]
appearanceClasses: readonly string[]
}
function parseCssColorRgb(color: string): [number, number, number] | null {
const normalized = color.trim().toLowerCase()
const rgbMatch = normalized.match(
/^rgba?\(\s*([+\-.\d]+)\s*,\s*([+\-.\d]+)\s*,\s*([+\-.\d]+)/,
)
if (rgbMatch) {
return [
Number.parseFloat(rgbMatch[1]),
Number.parseFloat(rgbMatch[2]),
Number.parseFloat(rgbMatch[3]),
]
}
const hexMatch = normalized.match(/^#([\da-f]{3,8})$/i)
if (!hexMatch)
return null
const hex = hexMatch[1]
if (hex.length === 3 || hex.length === 4) {
return [
Number.parseInt(`${hex[0]}${hex[0]}`, 16),
Number.parseInt(`${hex[1]}${hex[1]}`, 16),
Number.parseInt(`${hex[2]}${hex[2]}`, 16),
]
}
if (hex.length === 6 || hex.length === 8) {
return [
Number.parseInt(hex.slice(0, 2), 16),
Number.parseInt(hex.slice(2, 4), 16),
Number.parseInt(hex.slice(4, 6), 16),
]
}
return null
}
function resolveCssColorLuminance(color: string): number | null {
const rgb = parseCssColorRgb(color)
if (!rgb)
return null
const channel = (value: number) => {
const normalized = Math.max(0, Math.min(255, value)) / 255
return normalized <= 0.03928
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4
}
const [r, g, b] = rgb
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}
function looksLikeDarkThemeName(themeName: string | null | undefined) {
if (!themeName)
return false
const normalized = themeName.toLowerCase()
return (
[
'dark',
'night',
'moon',
'black',
'dracula',
'mocha',
'frappe',
'macchiato',
'palenight',
'ocean',
'poimandres',
'monokai',
'laserwave',
'tokyo',
'slack-dark',
'rose-pine',
'github-dark',
'material-theme',
'one-dark',
'catppuccin-mocha',
'catppuccin-frappe',
'catppuccin-macchiato',
].some(token => normalized.includes(token))
&& !normalized.includes('light')
&& !normalized.includes('latte')
&& !normalized.includes('dawn')
&& !normalized.includes('lotus')
)
}
function looksLikeLightThemeName(themeName: string | null | undefined) {
if (!themeName)
return false
const normalized = themeName.toLowerCase()
return [
'light',
'day',
'dawn',
'latte',
'solarized-light',
'github-light',
'rose-pine-dawn',
'catppuccin-latte',
'one-light',
'vitesse-light',
'snazzy-light',
'material-lighter',
'material-theme-lighter',
'lotus',
].some(token => normalized.includes(token))
}
function resolveDiffAppearance({
container,
diffAppearance,
diffEditorView,
themeName,
}: Pick<
ApplyDiffRootAppearanceClassOptions,
'container' | 'diffAppearance' | 'diffEditorView' | 'themeName'
>): DiffAppearance {
if (diffAppearance === 'light')
return 'light'
if (diffAppearance === 'dark')
return 'dark'
if (looksLikeDarkThemeName(themeName))
return 'dark'
if (looksLikeLightThemeName(themeName))
return 'light'
const appearanceProbeNodes = [
diffEditorView?.getModifiedEditor().getContainerDomNode?.(),
diffEditorView?.getOriginalEditor().getContainerDomNode?.(),
container,
]
for (const node of appearanceProbeNodes) {
if (!(node instanceof HTMLElement))
continue
const style = globalThis.getComputedStyle(node)
const editorSurface = node.querySelector<HTMLElement>(
'.monaco-editor .monaco-editor-background, .monaco-editor .margin, .monaco-editor .lines-content',
)
const candidates = [
style.getPropertyValue('--stream-monaco-editor-bg'),
style.getPropertyValue('--vscode-editor-background'),
editorSurface
? globalThis.getComputedStyle(editorSurface).backgroundColor
: '',
style.backgroundColor,
]
for (const color of candidates) {
const luminance = resolveCssColorLuminance(color)
if (luminance == null)
continue
return luminance <= 0.42 ? 'dark' : 'light'
}
}
return looksLikeDarkThemeName(themeName) ? 'dark' : 'light'
}
function syncDiffRootThemeVariables(
container: HTMLElement,
diffEditorView: monaco.editor.IStandaloneDiffEditor | null,
appearance: DiffAppearance,
) {
const probeNodes = [
diffEditorView?.getModifiedEditor().getContainerDomNode?.(),
diffEditorView?.getOriginalEditor().getContainerDomNode?.(),
container,
]
const containerStyle = globalThis.getComputedStyle(container)
const fixedBackgroundColor = containerStyle
.getPropertyValue('--stream-monaco-fixed-editor-bg')
.trim() || null
let backgroundColor: string | null = null
let foregroundColor: string | null = null
for (const node of probeNodes) {
if (!(node instanceof HTMLElement))
continue
const backgroundProbe = node.querySelector<HTMLElement>(
'.monaco-editor-background, .margin, .lines-content',
) ?? node
const foregroundProbe = node.querySelector<HTMLElement>(
'.view-lines, .monaco-editor, .view-overlays',
) ?? node
const nextBackground = globalThis.getComputedStyle(backgroundProbe)
.backgroundColor
if (
!backgroundColor
&& resolveCssColorLuminance(nextBackground) != null
) {
backgroundColor = nextBackground
}
const nextForeground = globalThis.getComputedStyle(foregroundProbe).color
if (
!foregroundColor
&& resolveCssColorLuminance(nextForeground) != null
) {
foregroundColor = nextForeground
}
if (backgroundColor && foregroundColor)
break
}
const resolvedBackgroundColor = fixedBackgroundColor
|| backgroundColor
|| (appearance === 'dark' ? 'rgb(10 10 11)' : 'rgb(255 255 255)')
if (resolvedBackgroundColor) {
container.style.setProperty(
'--stream-monaco-editor-bg',
resolvedBackgroundColor,
)
}
else {
container.style.removeProperty('--stream-monaco-editor-bg')
}
if (foregroundColor) {
container.style.setProperty('--stream-monaco-editor-fg', foregroundColor)
}
else {
container.style.removeProperty('--stream-monaco-editor-fg')
}
}
export function resolveDiffUnchangedLineInfoRailMetrics(
node: HTMLElement,
): DiffUnchangedLineInfoRailMetrics {
const editorRoot = node.closest<HTMLElement>('.monaco-editor')
if (!editorRoot) {
return {
leftInset: 0,
width: null,
}
}
const editorRect = editorRoot.getBoundingClientRect()
const lineNumberNode = Array.from(
editorRoot.querySelectorAll<HTMLElement>('.line-numbers'),
).find((candidate) => {
const rect = candidate.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
})
if (!lineNumberNode) {
return {
leftInset: 0,
width: null,
}
}
const lineNumberRect = lineNumberNode.getBoundingClientRect()
return {
leftInset: Math.max(0, lineNumberRect.left - editorRect.left),
width: Math.max(0, lineNumberRect.width) || null,
}
}
export function applyDiffRootAppearanceClass({
appearanceClasses,
container,
currentSignature,
diffAppearance,
diffEditorView,
isInlineMode,
layoutModeClasses,
lineStyle,
lineStyleClasses,
themeName,
unchangedRegionStyle,
unchangedRegionStyleClasses,
}: ApplyDiffRootAppearanceClassOptions): string | null {
if (!container)
return currentSignature
const resolvedAppearance = resolveDiffAppearance({
container,
diffAppearance,
diffEditorView,
themeName,
})
syncDiffRootThemeVariables(container, diffEditorView, resolvedAppearance)
const containerClassList = container.classList
const activeLineStyleClass = `stream-monaco-diff-style-${lineStyle}`
const activeUnchangedRegionStyleClass
= `stream-monaco-diff-unchanged-style-${unchangedRegionStyle}`
const activeLayoutModeClass = isInlineMode
? 'stream-monaco-diff-inline'
: 'stream-monaco-diff-side-by-side'
const activeAppearanceClass
= `stream-monaco-diff-appearance-${resolvedAppearance}`
const nextSignature = [
activeLineStyleClass,
activeUnchangedRegionStyleClass,
activeLayoutModeClass,
activeAppearanceClass,
].join('|')
if (
currentSignature === nextSignature
&& containerClassList.contains('stream-monaco-diff-root')
) {
return currentSignature
}
containerClassList.add('stream-monaco-diff-root')
for (const className of lineStyleClasses) {
containerClassList.toggle(className, className === activeLineStyleClass)
}
for (const className of unchangedRegionStyleClasses) {
containerClassList.toggle(
className,
className === activeUnchangedRegionStyleClass,
)
}
for (const className of layoutModeClasses) {
containerClassList.toggle(className, className === activeLayoutModeClass)
}
for (const className of appearanceClasses) {
containerClassList.toggle(className, className === activeAppearanceClass)
}
return nextSignature
}
@@ -0,0 +1,307 @@
import type {
DiffHunkActionContext,
DiffHunkActionKind,
DiffHunkSide,
} from '../type'
import * as monaco from '../monaco-shim'
export type DiffEditorSide = 'original' | 'modified'
export function createDiffHunkActionNode(
side: DiffHunkSide,
onAction: (side: DiffHunkSide, action: DiffHunkActionKind) => void,
): HTMLDivElement {
const node = document.createElement('div')
node.className = 'stream-monaco-diff-hunk-actions'
node.dataset.side = side
const createButton = (action: DiffHunkActionKind, label: string) => {
const button = document.createElement('button')
button.type = 'button'
button.textContent = label
button.dataset.action = action
button.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
onAction(side, action)
})
return button
}
node.append(
createButton('revert', 'Revert'),
createButton('stage', 'Stage'),
)
return node
}
export function hasOriginalLines(change: monaco.editor.ILineChange) {
return (
change.originalStartLineNumber > 0
&& change.originalEndLineNumber >= change.originalStartLineNumber
)
}
export function hasModifiedLines(change: monaco.editor.ILineChange) {
return (
change.modifiedStartLineNumber > 0
&& change.modifiedEndLineNumber >= change.modifiedStartLineNumber
)
}
export function inferInlineDiffHunkHoverSide(
change: monaco.editor.ILineChange,
hoverLine: number,
targetElement: HTMLElement | null,
): DiffHunkSide {
if (
targetElement?.closest(
'.line-delete, .char-delete, .inline-deleted-text, .inline-deleted-margin-view-zone',
)
) {
return 'upper'
}
if (
targetElement?.closest(
'.line-insert, .char-insert, .gutter-insert, .view-line',
)
) {
return 'lower'
}
if (!hasModifiedLines(change))
return 'upper'
if (!hasOriginalLines(change))
return 'lower'
const modifiedAnchor = Math.max(
1,
change.modifiedStartLineNumber || change.modifiedEndLineNumber || 1,
)
return hoverLine < modifiedAnchor ? 'upper' : 'lower'
}
function distanceToLineChange(
side: DiffEditorSide,
change: monaco.editor.ILineChange,
line: number,
) {
const hasRange = side === 'original'
? hasOriginalLines(change)
: hasModifiedLines(change)
const start = side === 'original'
? change.originalStartLineNumber
: change.modifiedStartLineNumber
const end = side === 'original'
? change.originalEndLineNumber
: change.modifiedEndLineNumber
if (hasRange) {
if (line < start)
return start - line
if (line > end)
return line - end
return 0
}
const fallbackAnchor = Math.max(1, start || end || 1)
return Math.abs(line - fallbackAnchor)
}
export function findLineChangeByHoverLine(
lineChanges: monaco.editor.ILineChange[],
side: DiffEditorSide,
line: number,
) {
if (lineChanges.length === 0)
return null
let best: monaco.editor.ILineChange | null = null
let bestDistance = Number.POSITIVE_INFINITY
for (const change of lineChanges) {
const distance = distanceToLineChange(side, change, line)
if (distance < bestDistance) {
bestDistance = distance
best = change
if (distance === 0)
break
}
}
if (bestDistance > 2)
return null
return best
}
export function getFullLineRange(
model: monaco.editor.ITextModel,
startLine: number,
endLine: number,
) {
if (endLine < startLine)
return null
const lineCount = model.getLineCount()
if (lineCount < 1)
return null
const start = Math.max(1, Math.min(startLine, lineCount))
const end = Math.max(start, Math.min(endLine, lineCount))
if (end < lineCount)
return new monaco.Range(start, 1, end + 1, 1)
return new monaco.Range(start, 1, end, model.getLineMaxColumn(end))
}
export function getLinesText(
model: monaco.editor.ITextModel,
startLine: number,
endLine: number,
) {
const range = getFullLineRange(model, startLine, endLine)
if (!range)
return ''
return model.getValueInRange(range)
}
export function getInsertRangeBeforeLine(
model: monaco.editor.ITextModel,
lineNumber: number,
) {
const lineCount = model.getLineCount()
if (lineNumber <= 1)
return new monaco.Range(1, 1, 1, 1)
if (lineNumber <= lineCount)
return new monaco.Range(lineNumber, 1, lineNumber, 1)
const lastLine = lineCount
const lastColumn = model.getLineMaxColumn(lastLine)
return new monaco.Range(lastLine, lastColumn, lastLine, lastColumn)
}
export function getInsertRangeAfterLine(
model: monaco.editor.ITextModel,
lineNumber: number,
) {
const lineCount = model.getLineCount()
if (lineNumber < 1)
return new monaco.Range(1, 1, 1, 1)
if (lineNumber < lineCount)
return new monaco.Range(lineNumber + 1, 1, lineNumber + 1, 1)
const lastLine = lineCount
const lastColumn = model.getLineMaxColumn(lastLine)
return new monaco.Range(lastLine, lastColumn, lastLine, lastColumn)
}
export function applyDefaultDiffHunkAction(context: DiffHunkActionContext) {
const { action, side, lineChange, originalModel, modifiedModel } = context
const hasOriginal = hasOriginalLines(lineChange)
const hasModified = hasModifiedLines(lineChange)
if (action === 'revert' && side === 'upper') {
if (!hasOriginal)
return
const text = getLinesText(
originalModel,
lineChange.originalStartLineNumber,
lineChange.originalEndLineNumber,
)
if (!text)
return
const range = hasModified
? getInsertRangeBeforeLine(
modifiedModel,
lineChange.modifiedStartLineNumber,
)
: getInsertRangeAfterLine(
modifiedModel,
Math.max(
0,
lineChange.modifiedStartLineNumber
|| lineChange.modifiedEndLineNumber,
),
)
modifiedModel.applyEdits([{ range, text, forceMoveMarkers: true }])
return
}
if (action === 'revert' && side === 'lower') {
if (!hasModified)
return
const range = getFullLineRange(
modifiedModel,
lineChange.modifiedStartLineNumber,
lineChange.modifiedEndLineNumber,
)
if (!range)
return
modifiedModel.applyEdits([{ range, text: '', forceMoveMarkers: true }])
return
}
if (action === 'stage' && side === 'upper') {
if (!hasOriginal)
return
const range = getFullLineRange(
originalModel,
lineChange.originalStartLineNumber,
lineChange.originalEndLineNumber,
)
if (!range)
return
originalModel.applyEdits([{ range, text: '', forceMoveMarkers: true }])
return
}
if (action === 'stage' && side === 'lower') {
if (!hasModified)
return
const text = getLinesText(
modifiedModel,
lineChange.modifiedStartLineNumber,
lineChange.modifiedEndLineNumber,
)
if (!text)
return
const anchor = hasOriginal
? lineChange.originalEndLineNumber
: Math.max(0, lineChange.originalStartLineNumber)
const range = getInsertRangeAfterLine(originalModel, anchor)
originalModel.applyEdits([{ range, text, forceMoveMarkers: true }])
}
}
export function setDiffHunkNodeEnabled(
node: HTMLDivElement | null,
enabled: boolean,
) {
if (!node)
return
const buttons = node.querySelectorAll('button')
buttons.forEach((button) => {
;(button as HTMLButtonElement).disabled = !enabled
})
}
export function positionDiffHunkNode(
node: HTMLDivElement,
editor: monaco.editor.IStandaloneCodeEditor,
anchorLine: number,
extraOffsetY = 0,
) {
const host = editor.getContainerDomNode()
const line = Math.max(1, anchorLine)
const rawTop = editor.getTopForLineNumber(line) - (editor.getScrollTop?.() ?? 0)
const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight)
const nodeWidth = node.offsetWidth || 130
const nodeHeight = node.offsetHeight || 30
const left = host.offsetLeft + Math.max(6, host.clientWidth - nodeWidth - 10)
const hostTop = host.offsetTop
const minTop = hostTop + 4
const maxTop = hostTop + Math.max(4, host.clientHeight - nodeHeight - 4)
const top = Math.min(
maxTop,
Math.max(
minTop,
hostTop + rawTop + Math.round(lineHeight * 0.2) + extraOffsetY,
),
)
node.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`
node.style.display = 'flex'
}
@@ -0,0 +1,279 @@
import type * as monaco from '../monaco-shim'
import type { DiffUnchangedRegionStyle } from '../type'
export interface DiffUnchangedSurroundingLines {
previousVisibleLine: number | null
nextVisibleLine: number | null
}
interface BuildDiffHunkMetadataLabelOptions {
contextLineCount: number
originalTotalLines: number
modifiedTotalLines: number
}
interface DiffHunkMetadataLabel {
modifiedStart: number
originalStart: number
label: string
}
interface ResolveDiffMetadataLabelOptions
extends BuildDiffHunkMetadataLabelOptions {
lineChanges: monaco.editor.ILineChange[]
pairIndex: number
primaryNode: HTMLElement
}
interface ResolveDiffUnchangedRevealLayoutOptions {
countText: string
modelLineCount: number | null
pairCount: number
pairIndex: number
primaryNode: HTMLElement
}
interface ResolveDiffUnchangedSummaryLabelOptions
extends ResolveDiffMetadataLabelOptions {
countText: string
unchangedRegionStyle: DiffUnchangedRegionStyle
}
interface ResolveDiffUnchangedMergeRoleOptions {
diffRoot: Element | null
modifiedHost: HTMLElement | null | undefined
node: HTMLElement
originalHost: HTMLElement | null | undefined
}
export function formatDiffUnchangedCountLabel(text: string) {
const match = text.match(/\d+/)
const count = match ? Number.parseInt(match[0], 10) : Number.NaN
if (Number.isFinite(count))
return `${count} unmodified ${count === 1 ? 'line' : 'lines'}`
return text.replace(/hidden/gi, 'unmodified')
}
export function countDiffLines(startLineNumber: number, endLineNumber: number) {
return endLineNumber >= startLineNumber
? endLineNumber - startLineNumber + 1
: 0
}
export function measureDiffUnchangedSurroundingLines(
primaryNode: HTMLElement,
): DiffUnchangedSurroundingLines {
const editorRoot
= primaryNode.closest<HTMLElement>('.editor.modified')
?? primaryNode.closest<HTMLElement>('.monaco-editor')
if (!editorRoot) {
return {
previousVisibleLine: null,
nextVisibleLine: null,
}
}
const widgetRect = primaryNode.getBoundingClientRect()
let previousVisibleLine: number | null = null
let nextVisibleLine: number | null = null
const lineNumberNodes = editorRoot.querySelectorAll<HTMLElement>('.line-numbers')
lineNumberNodes.forEach((node) => {
const lineNumber = Number.parseInt(node.textContent?.trim() || '', 10)
if (!Number.isFinite(lineNumber))
return
const top = node.getBoundingClientRect().top
if (top < widgetRect.top - 1) {
previousVisibleLine = previousVisibleLine == null
? lineNumber
: Math.max(previousVisibleLine, lineNumber)
}
else if (top > widgetRect.bottom + 1) {
nextVisibleLine = nextVisibleLine == null
? lineNumber
: Math.min(nextVisibleLine, lineNumber)
}
})
return {
previousVisibleLine,
nextVisibleLine,
}
}
function formatDiffMetadataRange(startLineNumber: number, lineCount: number) {
return `${startLineNumber},${Math.max(0, lineCount)}`
}
export function buildDiffHunkMetadataLabel(
change: monaco.editor.ILineChange,
options: BuildDiffHunkMetadataLabelOptions,
): DiffHunkMetadataLabel {
const { contextLineCount, modifiedTotalLines, originalTotalLines } = options
const originalChangedCount = countDiffLines(
change.originalStartLineNumber,
change.originalEndLineNumber,
)
const modifiedChangedCount = countDiffLines(
change.modifiedStartLineNumber,
change.modifiedEndLineNumber,
)
const originalAnchor = Math.min(
Math.max(change.originalStartLineNumber, 1),
Math.max(1, originalTotalLines + 1),
)
const modifiedAnchor = Math.min(
Math.max(change.modifiedStartLineNumber, 1),
Math.max(1, modifiedTotalLines + 1),
)
const originalStart = Math.max(1, originalAnchor - contextLineCount)
const modifiedStart = Math.max(1, modifiedAnchor - contextLineCount)
const originalEnd = originalChangedCount > 0
? Math.min(originalTotalLines, change.originalEndLineNumber + contextLineCount)
: Math.min(originalTotalLines, originalAnchor + contextLineCount - 1)
const modifiedEnd = modifiedChangedCount > 0
? Math.min(modifiedTotalLines, change.modifiedEndLineNumber + contextLineCount)
: Math.min(modifiedTotalLines, modifiedAnchor + contextLineCount - 1)
const originalDisplayCount = originalEnd >= originalStart
? originalEnd - originalStart + 1
: 0
const modifiedDisplayCount = modifiedEnd >= modifiedStart
? modifiedEnd - modifiedStart + 1
: 0
return {
modifiedStart,
originalStart,
label: `@@ -${formatDiffMetadataRange(
originalStart,
originalDisplayCount,
)} +${formatDiffMetadataRange(modifiedStart, modifiedDisplayCount)} @@`,
}
}
export function resolveDiffMetadataLabel(
options: ResolveDiffMetadataLabelOptions,
) {
const {
contextLineCount,
lineChanges,
modifiedTotalLines,
originalTotalLines,
pairIndex,
primaryNode,
} = options
if (lineChanges.length === 0)
return null
const metadataEntries = lineChanges.map(change =>
buildDiffHunkMetadataLabel(change, {
contextLineCount,
modifiedTotalLines,
originalTotalLines,
}),
)
const { nextVisibleLine } = measureDiffUnchangedSurroundingLines(primaryNode)
if (nextVisibleLine != null) {
const candidateStarts = [nextVisibleLine, nextVisibleLine - 1].filter(
value => value >= 1,
)
for (const candidateStart of candidateStarts) {
const matching = metadataEntries.find(
entry => entry.modifiedStart === candidateStart,
)
if (matching)
return matching.label
}
}
return metadataEntries[Math.min(pairIndex, metadataEntries.length - 1)]?.label ?? null
}
export function resolveDiffUnchangedSummaryLabel(
options: ResolveDiffUnchangedSummaryLabelOptions,
) {
const { countText, unchangedRegionStyle, ...metadataOptions } = options
if (unchangedRegionStyle !== 'metadata')
return countText
return resolveDiffMetadataLabel(metadataOptions) ?? countText
}
export function resolveDiffUnchangedRevealLayout(
options: ResolveDiffUnchangedRevealLayoutOptions,
) {
const { countText, modelLineCount, pairCount, pairIndex, primaryNode } = options
let showTopHandle = pairCount === 1 || pairIndex > 0
let showBottomHandle = pairCount === 1 || pairIndex < pairCount - 1
const countMatch = countText.match(/\d+/)
const hiddenCount = countMatch
? Number.parseInt(countMatch[0], 10)
: Number.NaN
if (!Number.isFinite(hiddenCount))
return { showTopHandle, showBottomHandle }
const { previousVisibleLine, nextVisibleLine }
= measureDiffUnchangedSurroundingLines(primaryNode)
if (previousVisibleLine == null && nextVisibleLine != null) {
showTopHandle = false
showBottomHandle = true
return { showTopHandle, showBottomHandle }
}
if (nextVisibleLine == null && previousVisibleLine != null) {
showTopHandle = true
showBottomHandle = false
return { showTopHandle, showBottomHandle }
}
if (nextVisibleLine != null && nextVisibleLine - hiddenCount === 1) {
showTopHandle = false
showBottomHandle = true
}
if (
previousVisibleLine != null
&& modelLineCount != null
&& previousVisibleLine + hiddenCount === modelLineCount
) {
showTopHandle = true
showBottomHandle = false
}
return { showTopHandle, showBottomHandle }
}
export function resolveDiffUnchangedMergeRole(
options: ResolveDiffUnchangedMergeRoleOptions,
): 'none' | 'primary' | 'secondary' {
const { diffRoot, modifiedHost, node, originalHost } = options
if (typeof HTMLElement === 'undefined')
return 'none'
if (!(diffRoot instanceof HTMLElement))
return 'none'
const nodeRect = node.getBoundingClientRect()
const nodeCenter = nodeRect.left + nodeRect.width / 2
if (originalHost instanceof HTMLElement && modifiedHost instanceof HTMLElement) {
const originalRect = originalHost.getBoundingClientRect()
const modifiedRect = modifiedHost.getBoundingClientRect()
const originalCenter = originalRect.left + originalRect.width / 2
const modifiedCenter = modifiedRect.left + modifiedRect.width / 2
return Math.abs(nodeCenter - originalCenter)
<= Math.abs(nodeCenter - modifiedCenter)
? 'secondary'
: 'primary'
}
const diffRect = diffRoot.getBoundingClientRect()
return nodeCenter < diffRect.left + diffRect.width / 2
? 'secondary'
: 'primary'
}
@@ -0,0 +1,461 @@
import type { DiffUnchangedRegionStyle } from '../type'
const diffUnchangedSummaryStyleClasses = [
'stream-monaco-unchanged-summary-line-info',
'stream-monaco-unchanged-summary-line-info-basic',
'stream-monaco-unchanged-summary-metadata',
'stream-monaco-unchanged-summary-simple',
] as const
interface DiffUnchangedBridgeRect {
height: number
left: number
right: number
top: number
width: number
}
interface DiffUnchangedBridgeTextStyle {
color: string
fontFamily: string
fontSize: string
lineHeight: string
}
interface SyncDiffUnchangedBridgeNodeOptions {
bridge: HTMLDivElement
bridgeLeftInset: number
bridgeRailWidth: number | null
containerRect: Pick<DOMRect, 'left' | 'top'>
containerScrollLeft: number
containerScrollTop: number
editorBackgroundColor: string
primaryAnchorRect: DiffUnchangedBridgeRect
primaryStyle: DiffUnchangedBridgeTextStyle
secondaryAnchorRect: DiffUnchangedBridgeRect
unchangedRegionStyle: DiffUnchangedRegionStyle
}
export function createDiffUnchangedBridgeScaffold() {
const bridge = document.createElement('div')
bridge.className = 'stream-monaco-diff-unchanged-bridge'
bridge.setAttribute('role', 'group')
syncDiffUnchangedBridgeVisibility(bridge, false)
const summary = document.createElement('button')
summary.type = 'button'
summary.className = 'stream-monaco-unchanged-summary'
const visualMeta = document.createElement('div')
visualMeta.className = 'stream-monaco-unchanged-meta'
summary.append(visualMeta)
const divider = document.createElement('span')
divider.className = 'stream-monaco-unchanged-pane-divider'
divider.setAttribute('aria-hidden', 'true')
bridge.append(summary, divider)
return {
bridge,
summary,
visualMeta,
divider,
}
}
export function createDiffUnchangedBridgeOverlay() {
const overlay = document.createElement('div')
overlay.className = 'stream-monaco-diff-unchanged-overlay'
return overlay
}
export function syncDiffUnchangedBridgeVisibility(
bridge: HTMLDivElement,
visible: boolean,
) {
bridge.hidden = !visible
bridge.toggleAttribute('aria-hidden', !visible)
}
export function clearDiffUnchangedBridgeSourceClasses(container: HTMLElement) {
const bridgedCenters = container.querySelectorAll<HTMLElement>(
'.stream-monaco-unchanged-bridge-source',
)
bridgedCenters.forEach(node =>
node.classList.remove('stream-monaco-unchanged-bridge-source'),
)
}
export function resetDiffUnchangedOverlayTransform(
overlay: HTMLDivElement | null | undefined,
) {
if (!overlay)
return
overlay.style.transform = 'translate3d(0px, 0px, 0px)'
}
export function resolveDiffUnchangedViewZoneHeight(
unchangedRegionStyle: DiffUnchangedRegionStyle,
) {
return unchangedRegionStyle === 'simple' ? 28 : 32
}
export function collectDiffUnchangedViewZoneIds(
editorRoot: HTMLElement,
scrollTop: number,
) {
const widgetTopValues = Array.from(
editorRoot.querySelectorAll<HTMLElement>('.diff-hidden-lines-widget'),
)
.map(node => Number.parseFloat(node.style.top || 'NaN'))
.filter(value => Number.isFinite(value) && value > -100000)
if (widgetTopValues.length === 0)
return []
return Array.from(
editorRoot.querySelectorAll<HTMLElement>(
'.view-zones > div[monaco-view-zone][monaco-visible-view-zone="true"]',
),
)
.filter((node) => {
const zoneTop = Number.parseFloat(node.style.top || 'NaN')
const currentHeight = Number.parseFloat(node.style.height || '0')
return (
Number.isFinite(zoneTop)
&& Number.isFinite(currentHeight)
&& currentHeight > 0
&& widgetTopValues.some(
widgetTop => Math.abs(zoneTop - scrollTop - widgetTop) < 0.5,
)
)
})
.map(node => node.getAttribute('monaco-view-zone'))
.filter((value): value is string => Boolean(value))
}
export function findDiffUnchangedActivationAction(
...roots: Array<ParentNode | null | undefined>
) {
for (const root of roots) {
const action = root?.querySelector<HTMLElement>('a, button') ?? null
if (action)
return action
}
return null
}
export function findDiffUnchangedExpandAction(
root: ParentNode | null | undefined,
) {
return root?.querySelector<HTMLElement>('a') ?? null
}
export function shouldIgnoreDiffUnchangedCenterClickTarget(
target: EventTarget | null,
) {
return target instanceof HTMLElement
&& Boolean(target.closest('a, .breadcrumb-item'))
}
export function shouldHandleDiffUnchangedCenterClick(
event: Pick<MouseEvent, 'button' | 'target'>,
) {
return (
event.button === 0
&& !shouldIgnoreDiffUnchangedCenterClickTarget(event.target)
)
}
export function activateDiffUnchangedExpandAction(
root: ParentNode | null | undefined,
onBeforeActivate?: (action: HTMLElement) => void,
) {
const action = findDiffUnchangedExpandAction(root)
if (!action)
return false
onBeforeActivate?.(action)
action.click()
return true
}
export function syncDiffUnchangedCenterNode(
node: HTMLElement,
mergeRole: 'none' | 'primary' | 'secondary',
) {
node.classList.add('stream-monaco-clickable')
node.title = 'Click to expand all unmodified lines'
node.classList.toggle(
'stream-monaco-unchanged-merged-secondary',
mergeRole === 'secondary',
)
node.classList.toggle(
'stream-monaco-unchanged-merged-primary',
mergeRole === 'primary',
)
}
export function syncDiffUnchangedMetaNode(
metaNode: HTMLElement,
unchangedRegionStyle: DiffUnchangedRegionStyle,
summaryLabel: string,
) {
const lastStyle = metaNode.dataset.style
const lastLabel = metaNode.dataset.label
if (lastStyle === unchangedRegionStyle && lastLabel === summaryLabel)
return
metaNode.dataset.style = unchangedRegionStyle
metaNode.dataset.label = summaryLabel
metaNode.replaceChildren()
metaNode.classList.toggle(
'stream-monaco-unchanged-meta-simple',
unchangedRegionStyle === 'simple',
)
if (unchangedRegionStyle === 'simple') {
const simpleBar = document.createElement('span')
simpleBar.className = 'stream-monaco-unchanged-simple-bar'
simpleBar.setAttribute('aria-hidden', 'true')
metaNode.append(simpleBar)
return
}
const label = document.createElement('span')
label.className = unchangedRegionStyle === 'metadata'
? 'stream-monaco-unchanged-metadata-label'
: 'stream-monaco-unchanged-count'
label.textContent = summaryLabel
metaNode.append(label)
}
export function syncDiffUnchangedSummaryButton(
summary: HTMLButtonElement,
unchangedRegionStyle: DiffUnchangedRegionStyle,
summaryLabel: string,
) {
summary.classList.remove(...diffUnchangedSummaryStyleClasses)
summary.classList.add(
`stream-monaco-unchanged-summary-${unchangedRegionStyle}`,
)
const summaryInteractive
= unchangedRegionStyle === 'line-info'
|| unchangedRegionStyle === 'line-info-basic'
summary.disabled = !summaryInteractive
summary.tabIndex = summaryInteractive ? 0 : -1
if (summaryInteractive) {
summary.removeAttribute('aria-hidden')
summary.setAttribute(
'aria-label',
`${summaryLabel}. Expand all unmodified lines`,
)
summary.title = 'Expand all unmodified lines'
return
}
if (unchangedRegionStyle === 'simple') {
summary.setAttribute('aria-hidden', 'true')
summary.removeAttribute('aria-label')
summary.title = ''
return
}
summary.removeAttribute('aria-hidden')
summary.removeAttribute('aria-label')
summary.title = ''
}
export function syncDiffUnchangedExpandAction(
action: HTMLElement,
hidden: boolean,
) {
action.classList.add('stream-monaco-unchanged-expand')
action.dataset.streamMonacoLabel = 'Expand all'
action.title = 'Expand all unmodified lines'
action.setAttribute('aria-label', 'Expand all unmodified lines')
action.toggleAttribute('aria-hidden', hidden)
action.tabIndex = hidden ? -1 : 0
}
export function createDiffUnchangedRevealButton(direction: 'up' | 'down') {
const button = document.createElement('button')
button.type = 'button'
button.className = 'stream-monaco-unchanged-reveal'
button.innerHTML = `<span class="codicon codicon-chevron-${direction}"></span>`
button.dataset.direction = direction
return button
}
export function syncDiffUnchangedRevealButtonNode(
button: HTMLButtonElement,
handle: HTMLElement | null,
label: string,
) {
button.hidden = !handle
button.disabled = !handle
button.toggleAttribute('aria-hidden', !handle)
button.title = handle ? label : ''
button.setAttribute('aria-label', handle ? label : '')
}
export function bindDiffUnchangedRevealButtonAction(
button: HTMLButtonElement,
handle: HTMLElement | null,
onActivate: (handle: HTMLElement) => void,
) {
button.onclick = handle
? (event) => {
event.preventDefault()
event.stopPropagation()
onActivate(handle)
}
: null
}
export function shouldHandleDiffUnchangedWheel(
event: Pick<WheelEvent, 'deltaX' | 'deltaY'>,
) {
return Math.abs(event.deltaY) >= 0.5 || Math.abs(event.deltaX) >= 0.5
}
export function resolveDiffUnchangedWheelScrollTarget(
scrollTop: number,
scrollLeft: number,
event: Pick<WheelEvent, 'deltaX' | 'deltaY'>,
) {
return {
targetScrollTop: scrollTop + event.deltaY,
targetScrollLeft: scrollLeft + event.deltaX,
syncHorizontal: Math.abs(event.deltaX) >= 0.5,
}
}
export function syncDiffUnchangedBridgeNode(
options: SyncDiffUnchangedBridgeNodeOptions,
) {
const {
bridge,
bridgeLeftInset,
bridgeRailWidth,
containerRect,
containerScrollLeft,
containerScrollTop,
editorBackgroundColor,
primaryAnchorRect,
primaryStyle,
secondaryAnchorRect,
unchangedRegionStyle,
} = options
bridge.className = 'stream-monaco-diff-unchanged-bridge'
bridge.classList.add(
`stream-monaco-diff-unchanged-bridge-${unchangedRegionStyle}`,
)
bridge.style.left = `${
secondaryAnchorRect.left
- containerRect.left
+ containerScrollLeft
+ bridgeLeftInset
}px`
bridge.style.top = `${
primaryAnchorRect.top - containerRect.top + containerScrollTop
}px`
bridge.style.width = `${Math.max(
0,
primaryAnchorRect.right - secondaryAnchorRect.left - bridgeLeftInset,
)}px`
bridge.style.height = `${Math.max(
secondaryAnchorRect.height,
primaryAnchorRect.height,
)}px`
bridge.style.color = primaryStyle.color
bridge.style.fontFamily = primaryStyle.fontFamily
bridge.style.fontSize = primaryStyle.fontSize
bridge.style.lineHeight = primaryStyle.lineHeight
bridge.style.setProperty('--stream-monaco-unchanged-fg', primaryStyle.color)
bridge.style.setProperty(
'--stream-monaco-editor-bg',
editorBackgroundColor,
)
bridge.style.setProperty(
'--stream-monaco-unchanged-split-offset',
`${Math.max(0, secondaryAnchorRect.width - bridgeLeftInset)}px`,
)
if (bridgeRailWidth) {
bridge.style.setProperty(
'--stream-monaco-unchanged-rail-width',
`${bridgeRailWidth}px`,
)
return
}
bridge.style.removeProperty('--stream-monaco-unchanged-rail-width')
}
export function syncDiffUnchangedRailNode(
rail: HTMLDivElement,
showTopHandle: boolean,
showBottomHandle: boolean,
) {
const shouldRenderRail = showTopHandle || showBottomHandle
rail.hidden = !shouldRenderRail
rail.toggleAttribute('aria-hidden', !shouldRenderRail)
rail.classList.toggle(
'stream-monaco-unchanged-rail-top-only',
showTopHandle && !showBottomHandle,
)
rail.classList.toggle(
'stream-monaco-unchanged-rail-bottom-only',
!showTopHandle && showBottomHandle,
)
rail.classList.toggle(
'stream-monaco-unchanged-rail-both',
showTopHandle && showBottomHandle,
)
}
export function dispatchSyntheticPrimaryMouseDown(node: HTMLElement) {
const view = node.ownerDocument.defaultView
if (!view)
return
const rect = node.getBoundingClientRect()
node.dispatchEvent(
new view.MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
button: 0,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
}),
)
}
export function dispatchSyntheticPrimaryMouseTap(node: HTMLElement) {
const view = node.ownerDocument.defaultView
if (!view)
return
const rect = node.getBoundingClientRect()
const clientX = rect.left + rect.width / 2
const clientY = rect.top + rect.height / 2
node.dispatchEvent(
new view.MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
button: 0,
clientX,
clientY,
}),
)
node.dispatchEvent(
new view.MouseEvent('mouseup', {
bubbles: true,
cancelable: true,
button: 0,
clientX,
clientY,
}),
)
}
@@ -0,0 +1,169 @@
import { padding } from '../constant'
import * as monaco from '../monaco-shim'
interface ComputeDiffRawHeightOptions {
diffEditorView: monaco.editor.IStandaloneDiffEditor | null
maxHeightValue: number
}
interface ComputeDiffHeightOptions {
rawHeight: number
isInlineMode: boolean
inlineDiffStreamingPresentationActive: boolean
inlineDiffStreamingHeightFloor: number
}
interface DiffViewportMeasurement {
computedHeight: number
lineHeight: number
scrollHeight: number
scrollTop: number
viewportHeight: number
}
interface NearBottomOptions {
autoScrollThresholdLines: number
autoScrollThresholdPx: number
}
export function computeDiffRawHeight({
diffEditorView,
maxHeightValue,
}: ComputeDiffRawHeightOptions): number {
if (!diffEditorView)
return Math.min(18 + padding, maxHeightValue)
const modifiedEditor = diffEditorView.getModifiedEditor()
const originalEditor = diffEditorView.getOriginalEditor()
const lineHeight = modifiedEditor.getOption(
monaco.editor.EditorOption.lineHeight,
)
const originalLineCount = originalEditor.getModel()?.getLineCount() ?? 1
const modifiedLineCount = modifiedEditor.getModel()?.getLineCount() ?? 1
const lineCount = Math.max(originalLineCount, modifiedLineCount)
const fromLines = lineCount * lineHeight + padding
const scrollHeight = Math.max(
originalEditor.getScrollHeight?.() ?? 0,
modifiedEditor.getScrollHeight?.() ?? 0,
)
return Math.min(Math.max(fromLines, scrollHeight), maxHeightValue)
}
export function computeDiffHeight({
inlineDiffStreamingHeightFloor,
inlineDiffStreamingPresentationActive,
isInlineMode,
rawHeight,
}: ComputeDiffHeightOptions) {
if (
!isInlineMode
|| (
!inlineDiffStreamingPresentationActive
&& inlineDiffStreamingHeightFloor <= 0
)
) {
return {
height: rawHeight,
nextInlineDiffStreamingHeightFloor: inlineDiffStreamingHeightFloor,
}
}
const nextInlineDiffStreamingHeightFloor = Math.max(
rawHeight,
inlineDiffStreamingHeightFloor,
)
return {
height: nextInlineDiffStreamingHeightFloor,
nextInlineDiffStreamingHeightFloor,
}
}
export function readContainerLayoutSize(container: HTMLElement) {
const rect = container.getBoundingClientRect?.()
return {
width: container.clientWidth || rect?.width || 0,
height: container.clientHeight || rect?.height || 0,
}
}
export function hasVerticalScrollbar(measurement: {
computedHeight: number
lineHeight: number
scrollHeight: number
}) {
const epsilon = Math.max(2, Math.round(measurement.lineHeight / 8))
return measurement.scrollHeight
> measurement.computedHeight + Math.max(padding / 2, epsilon)
}
export function isUserNearBottom(
measurement: DiffViewportMeasurement,
options: NearBottomOptions,
) {
const lineThreshold
= options.autoScrollThresholdLines * measurement.lineHeight
const threshold = Math.max(lineThreshold || 0, options.autoScrollThresholdPx)
const distance = measurement.scrollHeight
- (measurement.scrollTop + measurement.viewportHeight)
return distance <= threshold
}
export function revealEditorLine(
editor: monaco.editor.ICodeEditor,
line: number,
strategy: 'bottom' | 'centerIfOutside' | 'center',
scrollType?: unknown,
) {
if (strategy === 'bottom') {
if (typeof scrollType !== 'undefined')
editor.revealLine(line, scrollType as any)
else
editor.revealLine(line)
return
}
if (strategy === 'center') {
if (typeof scrollType !== 'undefined')
editor.revealLineInCenter(line, scrollType as any)
else
editor.revealLineInCenter(line)
return
}
if (typeof scrollType !== 'undefined')
editor.revealLineInCenterIfOutsideViewport(line, scrollType as any)
else
editor.revealLineInCenterIfOutsideViewport(line)
}
export function waitForElementHeightApplied(
element: HTMLElement | null,
target: number,
timeoutMs = 500,
) {
return new Promise<void>((resolve) => {
const start = typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now()
const check = () => {
const applied = element
? Number.parseFloat((element.style.height || '').replace('px', '')) || 0
: -1
if (applied >= target - 1) {
resolve()
return
}
const now = typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now()
if (now - start > timeoutMs) {
resolve()
return
}
requestAnimationFrame(check)
}
check()
})
}
@@ -0,0 +1,169 @@
function normalizeBaseUrl(baseUrl: string): string {
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
}
function tryDeriveBaseUrlFromVsPath(vsPath: string): string | undefined {
if (!vsPath)
return undefined
// Common: ".../min/vs" or ".../vs"
const trimmed = vsPath.endsWith('/') ? vsPath.slice(0, -1) : vsPath
if (trimmed.endsWith('/vs'))
return normalizeBaseUrl(trimmed.slice(0, -3))
const vsIndex = trimmed.indexOf('/vs/')
if (vsIndex > 0)
return normalizeBaseUrl(trimmed.slice(0, vsIndex))
return undefined
}
function detectMonacoBaseUrlFromScripts(): string | undefined {
if (typeof document === 'undefined')
return undefined
const scripts = Array.from(document.getElementsByTagName('script'))
for (const script of scripts) {
const src = script.getAttribute('src') || ''
if (!src)
continue
const loaderMatch = src.match(/^(.*)\/vs\/loader\.js.*$/)
if (loaderMatch)
return normalizeBaseUrl(loaderMatch[1])
const vsIndex = src.indexOf('/vs/')
if (vsIndex > 0)
return normalizeBaseUrl(src.slice(0, vsIndex))
}
return undefined
}
function detectMonacoBaseUrlFromAmdRequire(): string | undefined {
// Monaco AMD loader exposes a global `require` with config paths.
const amdRequire = (globalThis as any).require
const vsPath = amdRequire?.s?.contexts?._?.config?.paths?.vs
if (typeof vsPath !== 'string' || !vsPath)
return undefined
try {
return tryDeriveBaseUrlFromVsPath(String(new URL(vsPath, location.href)))
}
catch {
return tryDeriveBaseUrlFromVsPath(vsPath)
}
}
function detectMonacoBaseUrlFromExistingWorkerUrl(
getWorkerUrl: (moduleId: string, label: string) => string,
): string | undefined {
let workerUrl: string | undefined
try {
workerUrl = getWorkerUrl('', 'editorWorkerService')
}
catch {
// ignore
}
if (!workerUrl) {
try {
workerUrl = getWorkerUrl('', 'typescript')
}
catch {
// ignore
}
}
if (!workerUrl)
return undefined
try {
const resolved = new URL(workerUrl, location.href)
const vsIndex = resolved.pathname.indexOf('/vs/')
if (vsIndex <= 0)
return undefined
return normalizeBaseUrl(
`${resolved.origin}${resolved.pathname.slice(0, vsIndex)}`,
)
}
catch {
return undefined
}
}
function makeWorkerUrl(baseUrl: string): string | undefined {
if (typeof URL === 'undefined' || typeof Blob === 'undefined')
return undefined
const normalized = normalizeBaseUrl(baseUrl)
const workerMainUrl = `${normalized}vs/base/worker/workerMain.js`
const source = `self.MonacoEnvironment={baseUrl:${JSON.stringify(normalized)}};importScripts(${JSON.stringify(workerMainUrl)});`
try {
return URL.createObjectURL(
new Blob([source], { type: 'text/javascript' }),
)
}
catch {
return undefined
}
}
export function ensureMonacoWorkersLegacy(options?: { baseUrl?: string }): void {
if (typeof window === 'undefined' || typeof document === 'undefined')
return
try {
// eslint-disable-next-line no-restricted-globals
const existing = (self as any).MonacoEnvironment
if (existing?.getWorker)
return
// If the host already provided a getWorkerUrl but it points at a different
// origin (common with CDN setups), Monaco's Worker constructor can fail.
// In that case, we switch to the recommended blob wrapper that uses
// importScripts(workerMain.js).
const existingGetWorkerUrl = existing?.getWorkerUrl as
| undefined
| ((moduleId: string, label: string) => string)
if (typeof existingGetWorkerUrl === 'function') {
const existingBaseUrl = detectMonacoBaseUrlFromExistingWorkerUrl(
existingGetWorkerUrl,
)
if (existingBaseUrl) {
const workerUrl = makeWorkerUrl(existingBaseUrl)
if (workerUrl) {
// eslint-disable-next-line no-restricted-globals
;(self as any).MonacoEnvironment = {
getWorkerUrl() {
return workerUrl
},
}
}
}
return
}
const baseUrl
= options?.baseUrl
?? detectMonacoBaseUrlFromScripts()
?? detectMonacoBaseUrlFromAmdRequire()
if (!baseUrl)
return
const workerUrl = makeWorkerUrl(baseUrl)
if (!workerUrl) {
return
}
;(globalThis as any).MonacoEnvironment = {
getWorkerUrl() {
return workerUrl
},
}
}
catch {
// ignore - best effort
}
}
@@ -0,0 +1,35 @@
import { editorWorkerPath, workerPathByLabel } from './preloadMonacoWorkers.shared'
export function ensureMonacoWorkers(): void {
if (typeof window === 'undefined' || typeof document === 'undefined')
return
// IMPORTANT: install synchronously. Many consumers import and immediately
// create editors without awaiting any async setup.
try {
// eslint-disable-next-line no-restricted-globals
const existing = (self as any).MonacoEnvironment
// Respect consumer configuration (e.g. monaco-editor-webpack-plugin).
if (existing?.getWorker || existing?.getWorkerUrl)
return
const workerUrlByLabel: Record<string, URL> = Object.fromEntries(
Object.entries(workerPathByLabel).map(([label, path]) => [
label,
new URL(path, import.meta.url),
]),
)
const workerUrlEditor = new URL(editorWorkerPath, import.meta.url)
// eslint-disable-next-line no-restricted-globals
;(self as any).MonacoEnvironment = {
getWorker(_: any, label: string) {
const url = workerUrlByLabel[label] ?? workerUrlEditor
return new Worker(url, { type: 'module' })
},
}
}
catch {
// ignore - best effort
}
}
@@ -0,0 +1,850 @@
import type {
DiffModelPair,
DiffModelTransitionOptions,
MonacoLanguage,
MonacoOptions,
MonacoTheme,
UseMonacoReturn,
} from './type'
import { detectLanguage } from './code.detect'
import {
defaultLanguages,
defaultRevealDebounceMs,
defaultThemes,
} from './constant'
import { DiffEditorManager } from './core/DiffEditorManager'
import { EditorManager } from './core/EditorManager'
import * as monaco from './monaco-shim'
import { computed } from './reactivity'
import {
clearHighlighterCache,
getOrCreateHighlighter,
registerMonacoThemes,
} from './utils/registerMonacoThemes'
// Monaco theme is effectively global within a runtime (monaco.editor.setTheme).
// When many code blocks mount/virtualize concurrently, some editors may be
// created after a theme switch and miss the per-instance setTheme call.
// Keep a module-level "last requested theme" so late-created editors can
// initialize with the latest theme instead of falling back to themes[0].
let globalRequestedThemeName: string | null = null
let globalThemeRequestSeq = 0
// Best-effort tracking of what this library last applied via monaco.editor.setTheme.
// This must be module-level because Monaco theme is global, while useMonaco() is per-instance.
let globalAppliedThemeName: string | null = null
/**
* useMonaco 组合式函数
*
* 提供 Monaco 编辑器的创建、销毁、内容/主题/语言更新等能力。
* 支持主题自动切换、语言高亮、代码更新等功能。
*
* @param {MonacoOptions} [monacoOptions] - 编辑器初始化配置,支持 Monaco 原生配置及扩展项
* @param {number | string} [monacoOptions.MAX_HEIGHT] - 编辑器最大高度,可以是数字(像素)或 CSS 字符串(如 '100%', 'calc(100vh - 100px)')
* @param {boolean} [monacoOptions.readOnly] - 是否为只读模式
* @param {MonacoTheme[]} [monacoOptions.themes] - 主题数组,至少包含两个主题:[暗色主题, 亮色主题]
* @param {MonacoLanguage[]} [monacoOptions.languages] - 支持的编程语言数组
* @param {string} [monacoOptions.theme] - 初始主题名称
* @param {boolean} [monacoOptions.isCleanOnBeforeCreate] - 是否在创建前清理之前注册的资源, 默认为 true
* @param {(monaco: typeof import('monaco-editor')) => monaco.IDisposable[]} [monacoOptions.onBeforeCreate] - 编辑器创建前的钩子函数
*
* @returns {{
* createEditor: (container: HTMLElement, code: string, language: string) => Promise<monaco.editor.IStandaloneCodeEditor>,
* createDiffEditor: (
* container: HTMLElement,
* originalCode: string,
* modifiedCode: string,
* language: string,
* ) => Promise<monaco.editor.IStandaloneDiffEditor>,
* cleanupEditor: () => void,
* updateCode: (newCode: string, codeLanguage: string) => void,
* appendCode: (appendText: string, codeLanguage?: string) => void,
* updateDiff: (
* originalCode: string,
* modifiedCode: string,
* codeLanguage?: string,
* ) => void,
* updateOriginal: (newCode: string, codeLanguage?: string) => void,
* updateModified: (newCode: string, codeLanguage?: string) => void,
* appendOriginal: (appendText: string, codeLanguage?: string) => void,
* appendModified: (appendText: string, codeLanguage?: string) => void,
* setDiffModels: (models: DiffModelPair, options?: DiffModelTransitionOptions) => Promise<void>,
* setTheme: (theme: MonacoTheme) => Promise<void>,
* refreshDiffPresentation: () => void,
* setLanguage: (language: MonacoLanguage) => void,
* getCurrentTheme: () => string,
* getEditor: () => typeof monaco.editor,
* getEditorView: () => monaco.editor.IStandaloneCodeEditor | null,
* getDiffEditorView: () => monaco.editor.IStandaloneDiffEditor | null,
* getDiffModels: () => { original: monaco.editor.ITextModel | null, modified: monaco.editor.ITextModel | null },
* getCode: () => string | { original: string, modified: string } | null,
* }} 返回对象包含以下方法和属性:
*
* @property {Function} createEditor - 创建并挂载 Monaco 编辑器到指定容器
* @property {Function} cleanupEditor - 销毁编辑器并清理容器
* @property {Function} updateCode - 更新编辑器内容和语言,必要时滚动到底部
* @property {Function} appendCode - 在编辑器末尾追加文本,必要时滚动到底部
* @property {Function} createDiffEditor - 创建并挂载 Diff 编辑器
* @property {Function} updateDiff - 更新 Diff 编辑器的 original/modified 内容(RAF 合并、增量更新)
* @property {Function} updateOriginal - 仅更新 Diff 的 original 内容(增量更新)
* @property {Function} updateModified - 仅更新 Diff 的 modified 内容(增量更新)
* @property {Function} appendOriginal - 在 Diff 的 original 末尾追加(显式流式场景)
* @property {Function} appendModified - 在 Diff 的 modified 末尾追加(显式流式场景)
* @property {Function} setDiffModels - 切换为一对新的 Diff models;当内容未变化时自动走保留视图状态的无抖动路径
* @property {Function} setTheme - 切换编辑器主题,返回 Promise,在主题应用完成时 resolve
* @property {Function} refreshDiffPresentation - 在不 remount 的情况下,重算 diff chrome / unchanged overlay 的表现层
* @property {Function} setLanguage - 切换编辑器语言
* @property {Function} getCurrentTheme - 获取当前主题名称
* @property {Function} getEditor - 获取 Monaco 的静态 editor 对象(用于静态方法调用)
* @property {Function} getEditorView - 获取当前编辑器实例
* @property {Function} getDiffEditorView - 获取当前 Diff 编辑器实例
* @property {Function} getDiffModels - 获取 Diff 的 original/modified 两个模型
* @property {Function} getCode - 获取当前编辑器或 Diff 编辑器中的代码内容
*
* @throws {Error} 当主题数组不是数组或长度小于2时抛出错误
*
* @example
* ```typescript
* import { useMonaco } from 'stream-monaco'
*
* const { createEditor, updateCode, setTheme } = useMonaco({
* themes: ['vitesse-dark', 'vitesse-light'],
* languages: ['javascript', 'typescript'],
* readOnly: false
* })
*
* // 创建编辑器
* const editor = await createEditor(containerRef.value, 'console.log("hello")', 'javascript')
*
* // 更新代码
* updateCode('console.log("world")', 'javascript')
*
* // 切换主题
* setTheme('vitesse-light')
* ```
*/
function useMonaco(monacoOptions: MonacoOptions = {}): UseMonacoReturn {
// per-instance disposables (avoid cross-instance interference)
const disposals: monaco.IDisposable[] = []
const pendingCreateDisposables = new Map<number, monaco.IDisposable[]>()
let editorView: monaco.editor.IStandaloneCodeEditor | null = null
let editorMgr: EditorManager | null = null
let diffEditorView: monaco.editor.IStandaloneDiffEditor | null = null
let diffMgr: DiffEditorManager | null = null
let originalModel: monaco.editor.ITextModel | null = null
let modifiedModel: monaco.editor.ITextModel | null = null
const themes
= monacoOptions.themes && monacoOptions.themes?.length
? monacoOptions.themes
: defaultThemes
if (!Array.isArray(themes) || themes.length < 2) {
throw new Error(
'Monaco themes must be an array with at least two themes: [darkTheme, lightTheme]',
)
}
const languages = monacoOptions.languages ?? defaultLanguages
const MAX_HEIGHT = monacoOptions.MAX_HEIGHT ?? 500
const autoScrollOnUpdate = monacoOptions.autoScrollOnUpdate ?? true
const autoScrollInitial = monacoOptions.autoScrollInitial ?? true
const autoScrollThresholdPx = monacoOptions.autoScrollThresholdPx ?? 32
const autoScrollThresholdLines = monacoOptions.autoScrollThresholdLines ?? 2
const diffAutoScroll = monacoOptions.diffAutoScroll ?? true
// 处理 MAX_HEIGHT,转换为数值和CSS字符串
const getMaxHeightValue = (): number => {
if (typeof MAX_HEIGHT === 'number') {
return MAX_HEIGHT
}
// 如果是字符串,尝试解析数值部分(用于高度比较)
const match = MAX_HEIGHT.match(/^(\d+(?:\.\d+)?)/)
return match ? Number.parseFloat(match[1]) : 500 // 默认值
}
const getMaxHeightCSS = (): string => {
if (typeof MAX_HEIGHT === 'number') {
return `${MAX_HEIGHT}px`
}
return MAX_HEIGHT
}
const maxHeightValue = getMaxHeightValue()
const maxHeightCSS = getMaxHeightCSS()
let createRequestSeq = 0
let activeCreateRequestId: number | null = null
let activeCreateKind: 'editor' | 'diff' | null = null
let queuedEditorUpdateDuringCreate: {
requestId: number
code: string
lang: string
} | null = null
const currentTheme = computed<string>(
() =>
monacoOptions.theme
?? (typeof themes[0] === 'string' ? themes[0] : (themes[0] as any).name),
)
// Track the latest theme requested via setTheme(). This prevents the
// initialization path (which otherwise falls back to `themes[0]`) from
// overwriting a theme chosen before createEditor/createDiffEditor runs.
let requestedThemeName: string | null
= monacoOptions.theme ?? globalRequestedThemeName ?? currentTheme.value
// Internal helper that applies a theme and invokes MonacoOptions.onThemeChange
// after the theme has been applied. Exposed internally so watchers can call
// the same logic and callers can await exported setTheme for completion.
async function tryLoadAndSetShikiTheme(highlighter: any, themeName: string) {
if (!highlighter || typeof highlighter.setTheme !== 'function')
return
try {
await highlighter.setTheme(themeName)
}
catch (err: any) {
const message = err?.message ? String(err.message) : String(err)
// Shiki throws: "Theme <name> not found, you may need to load it first"
// If the highlighter supports incremental loading, attempt it once.
const lower = message.toLowerCase()
const looksLikeMissingThemeError
= lower.includes('theme') && lower.includes('not found')
if (
typeof highlighter.loadTheme === 'function'
&& looksLikeMissingThemeError
) {
await highlighter.loadTheme(themeName)
await highlighter.setTheme(themeName)
return
}
throw err
}
}
async function setThemeInternal(
theme: MonacoTheme,
force = false,
): Promise<void> {
const themeName = typeof theme === 'string' ? theme : (theme as any).name
globalThemeRequestSeq += 1
const token = globalThemeRequestSeq
// Persist the user's intent so editor initialization won't override it.
requestedThemeName = themeName
globalRequestedThemeName = themeName
// Monaco theme is global. Per-instance globalAppliedThemeName can become stale
// when another useMonaco() instance changes the theme.
if (!force && themeName === globalAppliedThemeName) {
return
}
// Last-write-wins: if another setTheme() call happened after this one
// started, abort before doing any further work.
if (token !== globalThemeRequestSeq) {
return
}
await registerMonacoThemes(themes, languages).catch(() => undefined)
if (token !== globalThemeRequestSeq) {
return
}
const availableNames = themes.map(t =>
typeof t === 'string' ? t : (t as any).name,
)
if (!availableNames.includes(themeName)) {
try {
const extended = availableNames.concat(themeName)
const maybeHighlighter = await registerMonacoThemes(
extended as any,
languages,
)
await tryLoadAndSetShikiTheme(maybeHighlighter, themeName).catch(
() => undefined,
)
}
catch {
console.warn(
`Theme "${themeName}" is not registered and automatic registration failed. Available themes: ${availableNames.join(
', ',
)}`,
)
return
}
}
if (token !== globalThemeRequestSeq) {
return
}
try {
monaco.editor.setTheme(themeName)
globalAppliedThemeName = themeName
monacoOptions.theme = themeName as any
}
catch {
try {
const maybeHighlighter = await registerMonacoThemes(themes, languages)
if (token !== globalThemeRequestSeq) {
return
}
monaco.editor.setTheme(themeName)
globalAppliedThemeName = themeName
monacoOptions.theme = themeName as any
await tryLoadAndSetShikiTheme(maybeHighlighter, themeName).catch(
() => undefined,
)
}
catch (err2) {
console.warn(`Failed to set theme "${themeName}":`, err2)
return
}
}
if (token !== globalThemeRequestSeq) {
return
}
try {
diffMgr?.notifyThemeChange(themeName as any)
}
catch (err) {
console.warn('diff theme sync threw an error:', err)
}
// call user callback if provided; await to allow callers to observe completion
try {
if (typeof monacoOptions.onThemeChange === 'function') {
await monacoOptions.onThemeChange(themeName as any)
}
}
catch (err) {
console.warn('onThemeChange callback threw an error:', err)
}
}
async function ensureThemeRegistered(themeName: string) {
const availableNames = themes.map(t =>
typeof t === 'string' ? t : (t as any).name,
)
const list = availableNames.includes(themeName)
? themes
: (themes.concat(themeName) as any)
await registerMonacoThemes(list as any, languages)
}
function resolveRequestedThemeName() {
return (
requestedThemeName
?? globalRequestedThemeName
?? monacoOptions.theme
?? currentTheme.value
)
}
function commitAppliedTheme(themeName: string) {
requestedThemeName = themeName
globalRequestedThemeName = themeName
globalAppliedThemeName = themeName
monacoOptions.theme = themeName as any
}
async function notifyThemeApplied(themeName: string) {
if (typeof monacoOptions.onThemeChange !== 'function')
return
try {
await monacoOptions.onThemeChange(themeName as any)
}
catch (err) {
console.warn('onThemeChange callback threw an error:', err)
}
}
function disposeDisposables(
items: monaco.IDisposable[] | null | undefined,
) {
if (!items?.length)
return
for (const item of items) {
try {
item.dispose()
}
catch {}
}
}
function takePendingCreateDisposables(requestId: number) {
const items = pendingCreateDisposables.get(requestId) ?? []
pendingCreateDisposables.delete(requestId)
return items
}
function disposeAllPendingCreateDisposables() {
for (const requestId of Array.from(pendingCreateDisposables.keys())) {
disposeDisposables(takePendingCreateDisposables(requestId))
}
}
function cleanupInstances() {
if (editorMgr) {
editorMgr.cleanup()
editorMgr = null
}
else if (editorView) {
try {
editorView.dispose()
}
catch {}
}
if (diffMgr) {
diffMgr.cleanup()
diffMgr = null
}
else {
try {
diffEditorView?.dispose()
}
catch {}
try {
originalModel?.dispose()
}
catch {}
try {
modifiedModel?.dispose()
}
catch {}
}
editorView = null
diffEditorView = null
originalModel = null
modifiedModel = null
}
function createSupersededError() {
const err = new Error('Editor creation was superseded')
;(err as any).name = 'AbortError'
;(err as any).code = 'STREAM_MONACO_CREATE_SUPERSEDED'
return err
}
function isCreateActive(
requestId: number,
kind: 'editor' | 'diff',
) {
return (
activeCreateRequestId === requestId
&& activeCreateKind === kind
)
}
function assertCreateStillActive(
requestId: number,
kind: 'editor' | 'diff',
) {
if (!isCreateActive(requestId, kind)) {
throw createSupersededError()
}
}
function cancelPendingCreates() {
activeCreateRequestId = null
activeCreateKind = null
queuedEditorUpdateDuringCreate = null
disposeAllPendingCreateDisposables()
}
async function resolveCreateThemeName(
requestId: number,
kind: 'editor' | 'diff',
) {
let themeName = resolveRequestedThemeName()
while (true) {
assertCreateStillActive(requestId, kind)
await ensureThemeRegistered(themeName)
assertCreateStillActive(requestId, kind)
const latestThemeName = resolveRequestedThemeName()
if (latestThemeName === themeName)
return themeName
themeName = latestThemeName
}
}
async function createEditor(
container: HTMLElement,
code: string,
language: string,
) {
cancelPendingCreates()
cleanupInstances()
const requestId = ++createRequestSeq
activeCreateRequestId = requestId
activeCreateKind = 'editor'
if (monacoOptions.isCleanOnBeforeCreate ?? true) {
disposeDisposables(disposals.splice(0))
}
const requestDisposables = monacoOptions.onBeforeCreate?.(monaco) ?? []
if (requestDisposables.length) {
pendingCreateDisposables.set(requestId, requestDisposables)
}
let nextEditorMgr: EditorManager | null = null
try {
const initialThemeName = await resolveCreateThemeName(requestId, 'editor')
nextEditorMgr = new EditorManager(
monacoOptions,
maxHeightValue,
maxHeightCSS,
autoScrollOnUpdate,
autoScrollInitial,
autoScrollThresholdPx,
autoScrollThresholdLines,
monacoOptions.revealDebounceMs,
monacoOptions.updateThrottleMs,
)
const nextEditorView = await nextEditorMgr.createEditor(
container,
code,
language,
initialThemeName,
)
assertCreateStillActive(requestId, 'editor')
editorMgr = nextEditorMgr
editorView = nextEditorView
diffMgr = null
diffEditorView = null
originalModel = null
modifiedModel = null
commitAppliedTheme(initialThemeName)
const committedDisposables = takePendingCreateDisposables(requestId)
if (committedDisposables.length) {
disposals.push(...committedDisposables)
}
activeCreateRequestId = null
activeCreateKind = null
const queuedUpdate = queuedEditorUpdateDuringCreate
if (queuedUpdate?.requestId === requestId) {
queuedEditorUpdateDuringCreate = null
editorMgr.updateCode(queuedUpdate.code, queuedUpdate.lang)
}
await notifyThemeApplied(initialThemeName)
return nextEditorView
}
catch (error) {
if (nextEditorMgr) {
try {
nextEditorMgr.cleanup()
}
catch {}
}
disposeDisposables(takePendingCreateDisposables(requestId))
if (activeCreateRequestId === requestId) {
activeCreateRequestId = null
activeCreateKind = null
queuedEditorUpdateDuringCreate = null
}
throw error
}
}
async function createDiffEditor(
container: HTMLElement,
originalCode: string,
modifiedCode: string,
language: string,
) {
cancelPendingCreates()
cleanupInstances()
const requestId = ++createRequestSeq
activeCreateRequestId = requestId
activeCreateKind = 'diff'
if (monacoOptions.isCleanOnBeforeCreate ?? true) {
disposeDisposables(disposals.splice(0))
}
const requestDisposables = monacoOptions.onBeforeCreate?.(monaco) ?? []
if (requestDisposables.length) {
pendingCreateDisposables.set(requestId, requestDisposables)
}
let nextDiffMgr: DiffEditorManager | null = null
try {
const initialThemeName = await resolveCreateThemeName(requestId, 'diff')
nextDiffMgr = new DiffEditorManager(
monacoOptions,
maxHeightValue,
maxHeightCSS,
autoScrollOnUpdate,
autoScrollInitial,
autoScrollThresholdPx,
autoScrollThresholdLines,
diffAutoScroll,
monacoOptions.revealDebounceMs,
monacoOptions.diffUpdateThrottleMs,
)
const nextDiffEditorView = await nextDiffMgr.createDiffEditor(
container,
originalCode,
modifiedCode,
language,
initialThemeName,
)
assertCreateStillActive(requestId, 'diff')
diffMgr = nextDiffMgr
diffEditorView = nextDiffEditorView
editorMgr = null
editorView = null
const models = diffMgr.getDiffModels()
originalModel = models.original
modifiedModel = models.modified
commitAppliedTheme(initialThemeName)
const committedDisposables = takePendingCreateDisposables(requestId)
if (committedDisposables.length) {
disposals.push(...committedDisposables)
}
activeCreateRequestId = null
activeCreateKind = null
await notifyThemeApplied(initialThemeName)
return nextDiffEditorView
}
catch (error) {
if (nextDiffMgr) {
try {
nextDiffMgr.cleanup()
}
catch {}
}
disposeDisposables(takePendingCreateDisposables(requestId))
if (activeCreateRequestId === requestId) {
activeCreateRequestId = null
activeCreateKind = null
}
throw error
}
}
function cleanupEditor() {
cancelPendingCreates()
cleanupInstances()
disposeDisposables(disposals.splice(0))
}
function appendCode(appendText: string, codeLanguage?: string) {
if (editorMgr) {
editorMgr.appendCode(appendText, codeLanguage)
}
}
function updateCode(newCode: string, codeLanguage: string) {
if (editorMgr) {
editorMgr.updateCode(newCode, codeLanguage)
return
}
if (activeCreateRequestId != null && activeCreateKind === 'editor') {
queuedEditorUpdateDuringCreate = {
requestId: activeCreateRequestId,
code: newCode,
lang: codeLanguage,
}
}
}
function setUpdateThrottleMs(ms: number) {
monacoOptions.updateThrottleMs = ms
editorMgr?.setUpdateThrottleMs(ms)
}
function getUpdateThrottleMs() {
return editorMgr?.getUpdateThrottleMs() ?? monacoOptions.updateThrottleMs ?? 50
}
function updateDiff(
originalCode: string,
modifiedCode: string,
codeLanguage?: string,
) {
if (diffMgr)
diffMgr.updateDiff(originalCode, modifiedCode, codeLanguage)
}
function updateOriginal(newCode: string, codeLanguage?: string) {
if (diffMgr)
diffMgr.updateOriginal(newCode, codeLanguage)
}
function updateModified(newCode: string, codeLanguage?: string) {
if (diffMgr)
diffMgr.updateModified(newCode, codeLanguage)
}
function appendOriginal(appendText: string, codeLanguage?: string) {
if (diffMgr)
diffMgr.appendOriginal(appendText, codeLanguage)
}
function appendModified(appendText: string, codeLanguage?: string) {
if (diffMgr)
diffMgr.appendModified(appendText, codeLanguage)
}
async function setDiffModels(
models: DiffModelPair,
options?: DiffModelTransitionOptions,
) {
if (!diffMgr)
return
await diffMgr.setDiffModels(models, options)
const activeModels = diffMgr.getDiffModels()
originalModel = activeModels.original
modifiedModel = activeModels.modified
}
function refreshDiffPresentation() {
if (diffMgr)
diffMgr.refreshDiffPresentation()
}
return {
createEditor,
createDiffEditor,
cleanupEditor,
safeClean() {
if (editorMgr) {
try {
editorMgr.safeClean()
}
catch {}
}
if (diffMgr) {
try {
diffMgr.safeClean()
}
catch {}
}
},
updateCode,
appendCode,
updateDiff,
updateOriginal,
updateModified,
appendOriginal,
appendModified,
setDiffModels,
setTheme: setThemeInternal,
refreshDiffPresentation,
setLanguage(language: MonacoLanguage) {
if (editorMgr) {
editorMgr.setLanguage(language, languages as any)
return
}
if (diffMgr) {
diffMgr.setLanguage(language, languages as any)
return
}
if (languages.includes(language)) {
if (editorView) {
const model = editorView.getModel()
if (model && model.getLanguageId() !== language)
monaco.editor.setModelLanguage(model, language)
}
if (originalModel && originalModel.getLanguageId() !== language)
monaco.editor.setModelLanguage(originalModel, language)
if (modifiedModel && modifiedModel.getLanguageId() !== language)
monaco.editor.setModelLanguage(modifiedModel, language)
}
else {
console.warn(
`Language "${language}" is not registered. Available languages: ${languages.join(
', ',
)}`,
)
}
},
getCurrentTheme() {
return (
globalAppliedThemeName
?? requestedThemeName
?? globalRequestedThemeName
?? currentTheme.value
)
},
getEditor() {
return monaco.editor
},
getEditorView() {
return editorMgr?.getEditorView() ?? editorView
},
getDiffEditorView() {
return diffMgr?.getDiffEditorView() ?? diffEditorView
},
getDiffModels() {
if (diffMgr)
return diffMgr.getDiffModels()
return { original: originalModel, modified: modifiedModel }
},
getMonacoInstance() {
return monaco
},
setUpdateThrottleMs,
getUpdateThrottleMs,
getCode() {
if (editorMgr)
return editorMgr.getCode()
if (editorView) {
try {
return editorView.getModel()?.getValue() ?? null
}
catch {
return null
}
}
const diffModels = diffMgr?.getDiffModels() ?? {
original: originalModel,
modified: modifiedModel,
}
if (diffEditorView || (diffModels.original && diffModels.modified)) {
try {
return {
original: diffModels.original?.getValue() ?? '',
modified: diffModels.modified?.getValue() ?? '',
}
}
catch {
return null
}
}
return null
},
}
}
export {
clearHighlighterCache,
defaultRevealDebounceMs,
detectLanguage,
getOrCreateHighlighter,
registerMonacoThemes,
useMonaco,
}
export * from './type'
@@ -0,0 +1,229 @@
import { conf as cppConf, language as cppLanguage } from 'monaco-editor/esm/vs/basic-languages/cpp/cpp'
// Eagerly register Monarch tokenizers as a robust fallback. The basic-languages
// contributions use lazy dynamic imports which can fail in some legacy bundler
// setups (Webpack 4), resulting in a single default token type (no colors).
import { conf as jsConf, language as jsLanguage } from 'monaco-editor/esm/vs/basic-languages/javascript/javascript'
import { conf as psConf, language as psLanguage } from 'monaco-editor/esm/vs/basic-languages/powershell/powershell'
import { conf as pyConf, language as pyLanguage } from 'monaco-editor/esm/vs/basic-languages/python/python'
import { conf as shConf, language as shLanguage } from 'monaco-editor/esm/vs/basic-languages/shell/shell'
import { conf as tsConf, language as tsLanguage } from 'monaco-editor/esm/vs/basic-languages/typescript/typescript'
import { IOutlineModelService } from 'monaco-editor/esm/vs/editor/contrib/documentSymbols/browser/outlineModel'
import { ISuggestMemoryService } from 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestMemory'
import { processedLanguage } from './code.detect'
import { ensureMonacoWorkersLegacy } from './ensureMonacoWorkers.legacy'
import * as monaco from './monaco-shim'
// Force Monaco's built-in tokenizers to be registered in legacy (Webpack 4)
// builds. Without these contributions, Monaco falls back to a single default
// token type (no colors) when Shiki providers fail to install.
import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution'
import 'monaco-editor/esm/vs/basic-languages/python/python.contribution'
import 'monaco-editor/esm/vs/basic-languages/cpp/cpp.contribution'
import 'monaco-editor/esm/vs/basic-languages/shell/shell.contribution'
import 'monaco-editor/esm/vs/basic-languages/powershell/powershell.contribution'
import 'monaco-editor/esm/vs/language/json/monaco.contribution'
import 'monaco-editor/esm/vs/language/typescript/monaco.contribution'
import 'monaco-editor/esm/vs/language/html/monaco.contribution'
import 'monaco-editor/esm/vs/language/css/monaco.contribution'
export { ensureMonacoWorkersLegacy } from './ensureMonacoWorkers.legacy'
export * from './index.base'
export { preloadMonacoWorkers } from './preloadMonacoWorkers.legacy'
const LEGACY_ONIG_INIT_KEY = '__streamMonacoLegacyOnigurumaInit__'
const LEGACY_ENGINE_KEY = '__streamMonacoLegacyShikiEngine__'
const LEGACY_ERROR_FILTER_KEY = '__streamMonacoLegacyMonacoErrorFilterInstalled__'
const LEGACY_FLAG_KEY = '__streamMonacoLegacy__'
const LEGACY_MONACO_LANGS_INIT_KEY = '__streamMonacoLegacyMonacoLangsInit__'
const LEGACY_TOKENS_PROVIDER_GUARD_KEY = '__streamMonacoLegacyTokensProviderGuardInstalled__'
// Ensure these modules are not tree-shaken in legacy builds. Their module side
// effects register critical services used by editor contributions.
void ISuggestMemoryService
void IOutlineModelService
function ensureLegacyOnigurumaInit() {
if (typeof globalThis === 'undefined')
return
const g = globalThis as any
if (g[LEGACY_ONIG_INIT_KEY])
return
// Kick off best-effort Shiki regex engine init early, but don't block module eval.
// Webpack 4 consumers can otherwise hit a race where Monaco tokenization starts
// before the TextMate regex engine is ready, causing `null.compileAG`.
//
// Strategy:
// Legacy (Webpack 4) environments are particularly prone to Oniguruma WASM
// initialization issues that manifest as `null.compileAG` at runtime.
// To provide deterministic behavior, we default to Shiki's JavaScript regex
// engine for legacy builds. This avoids WASM entirely and restores syntax
// highlighting reliably (with a performance trade-off).
const init = (async () => {
try {
const shiki = await import('shiki')
if (typeof (shiki as any).createJavaScriptRegexEngine === 'function')
return (shiki as any).createJavaScriptRegexEngine()
return null
}
catch {
return null
}
})()
g[LEGACY_ONIG_INIT_KEY] = init.then(() => true, () => false)
g[LEGACY_ENGINE_KEY] = init
}
function ensureLegacyMonacoLanguageContributions() {
if (typeof globalThis === 'undefined')
return
const g = globalThis as any
if (g[LEGACY_MONACO_LANGS_INIT_KEY])
return
const init = Promise.resolve().then(() => {
// Register a minimal set of Monarch tokenizers synchronously to guarantee
// syntax highlighting in legacy runtimes (even when Shiki/TextMate fails).
try {
const langs: any = (monaco as any).languages
if (typeof langs?.setMonarchTokensProvider === 'function') {
langs.setMonarchTokensProvider('javascript', jsLanguage as any)
langs.setLanguageConfiguration?.('javascript', jsConf as any)
langs.setMonarchTokensProvider('typescript', tsLanguage as any)
langs.setLanguageConfiguration?.('typescript', tsConf as any)
langs.setMonarchTokensProvider('python', pyLanguage as any)
langs.setLanguageConfiguration?.('python', pyConf as any)
langs.setMonarchTokensProvider('cpp', cppLanguage as any)
langs.setLanguageConfiguration?.('cpp', cppConf as any)
// basic-languages uses `shell` id
langs.setMonarchTokensProvider('shell', shLanguage as any)
langs.setLanguageConfiguration?.('shell', shConf as any)
langs.setMonarchTokensProvider('powershell', psLanguage as any)
langs.setLanguageConfiguration?.('powershell', psConf as any)
}
}
catch {}
// Re-apply language ids to force re-tokenization for already-created models.
// Also apply legacy language normalization (e.g. vue -> html).
try {
for (const model of monaco.editor.getModels()) {
const current = model.getLanguageId()
const next = processedLanguage(current) || current
if (next !== current)
monaco.editor.setModelLanguage(model, next)
else
monaco.editor.setModelLanguage(model, current)
}
}
catch {}
})
g[LEGACY_MONACO_LANGS_INIT_KEY] = init
}
function ensureLegacyTokensProviderGuard() {
if (typeof globalThis === 'undefined')
return
const g = globalThis as any
if (g[LEGACY_TOKENS_PROVIDER_GUARD_KEY])
return
g[LEGACY_TOKENS_PROVIDER_GUARD_KEY] = true
try {
const langs: any = (monaco as any).languages
const setTokensProvider = typeof langs?.setTokensProvider === 'function'
? langs.setTokensProvider.bind(langs)
: null
if (!setTokensProvider)
return
langs.setTokensProvider = (lang: string, provider: any) => {
// Wrap tokenization so runtime failures won't crash the app.
if (provider && typeof provider.tokenize === 'function') {
const originalTokenize = provider.tokenize.bind(provider)
const getInitialState = typeof provider.getInitialState === 'function'
? provider.getInitialState.bind(provider)
: null
const wrappedProvider = {
...provider,
tokenize(line: string, state: any) {
try {
return originalTokenize(line, state)
}
catch {
return {
endState: state,
tokens: [{ startIndex: 0, scopes: '' }],
}
}
},
}
// If this provider throws immediately (e.g. missing Oniguruma/WASM causing
// `null.compileAG`), don't install it at all so Monaco's built-in
// tokenizers (loaded via contributions) can remain active.
try {
const st = getInitialState ? getInitialState() : null
wrappedProvider.tokenize('const a = 1', st)
}
catch {
return { dispose() {} } as any
}
return setTokensProvider(lang, wrappedProvider)
}
return setTokensProvider(lang, provider)
}
}
catch {}
}
function ensureLegacyMonacoErrorFilter() {
if (typeof globalThis === 'undefined')
return
const g = globalThis as any
if (g[LEGACY_ERROR_FILTER_KEY])
return
g[LEGACY_ERROR_FILTER_KEY] = true
// Best-effort: prevent Monaco from crashing the app on known Shiki/TextMate
// tokenization failures in legacy (Webpack 4) setups.
//
// Monaco funnels internal failures through `vs/base/common/errors`.
// That module is not public API, but this patch is legacy-only and guarded.
;(async () => {
try {
const mod: any = await import('monaco-editor/esm/vs/base/common/errors.js')
const handler = mod?.errorHandler
if (!handler || typeof handler.unexpectedErrorHandler !== 'function')
return
const prev = handler.unexpectedErrorHandler.bind(handler)
handler.unexpectedErrorHandler = (err: any) => {
const msg = err?.message ? String(err.message) : String(err)
if (msg.includes('compileAG') || msg.includes('Cannot read properties of null (reading'))
return
return prev(err)
}
}
catch {}
})()
}
ensureLegacyOnigurumaInit()
;(globalThis as any)[LEGACY_FLAG_KEY] = true
ensureLegacyMonacoLanguageContributions()
ensureLegacyTokensProviderGuard()
ensureLegacyMonacoErrorFilter()
ensureMonacoWorkersLegacy()
+11
View File
@@ -0,0 +1,11 @@
// Best-effort: install worker hook during module evaluation to avoid the
// "Could not create web worker(s)" fallback when consumers don't explicitly
// call `preloadMonacoWorkers()`.
import { ensureMonacoWorkers } from './ensureMonacoWorkers'
export { ensureMonacoWorkers } from './ensureMonacoWorkers'
export * from './index.base'
export { preloadMonacoWorkers } from './preloadMonacoWorkers'
ensureMonacoWorkers()
@@ -0,0 +1,40 @@
export interface MinimalEditResult {
start: number
endPrevIncl: number
endNextIncl: number
replaceText: string
}
/**
* Compute the minimal middle replacement between two UTF-16 strings.
* Returns null when prev === next (no edits required).
*/
export function computeMinimalEdit(
prev: string,
next: string,
): MinimalEditResult | null {
if (prev === next)
return null
let start = 0
const minLen = Math.min(prev.length, next.length)
while (start < minLen && prev.charCodeAt(start) === next.charCodeAt(start))
start++
let endPrev = prev.length - 1
let endNext = next.length - 1
while (
endPrev >= start
&& endNext >= start
&& prev.charCodeAt(endPrev) === next.charCodeAt(endNext)
) {
endPrev--
endNext--
}
return {
start,
endPrevIncl: endPrev,
endNextIncl: endNext,
replaceText: next.slice(start, endNext + 1),
}
}
@@ -0,0 +1,41 @@
declare module 'monaco-editor/esm/vs/basic-languages/cpp/cpp' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/basic-languages/javascript/javascript' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/basic-languages/powershell/powershell' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/basic-languages/python/python' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/basic-languages/shell/shell' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/basic-languages/typescript/typescript' {
export const conf: any
export const language: any
}
declare module 'monaco-editor/esm/vs/editor/contrib/documentSymbols/browser/outlineModel' {
export const IOutlineModelService: any
}
declare module 'monaco-editor/esm/vs/editor/contrib/suggest/browser/suggestMemory' {
export const ISuggestMemoryService: any
}
declare module 'monaco-editor/esm/vs/base/common/errors.js' {
export const errorHandler: any
}
@@ -0,0 +1,25 @@
// Small shim to centralize the monaco import. Tests can mock this file to
// avoid resolving the real 'monaco-editor' package during vitest runs.
//
// IMPORTANT:
// Use the ESM `editor.api` entrypoint so Monaco's language contributions under
// `monaco-editor/esm/vs/**` register against the same singleton instance.
// In legacy bundlers (Webpack 4), mixing `import 'monaco-editor'` with
// `import 'monaco-editor/esm/vs/...'` can create duplicate module instances,
// causing tokenizers/providers to register on the "other" instance (no colors).
import * as _monaco from 'monaco-editor/esm/vs/editor/editor.api'
// re-export the original module but also provide a `monaco` namespace default
// so existing imports like `import * as monaco from '.../monaco-shim'` keep
// the `monaco.editor` typing available.
const monaco: typeof _monaco = _monaco as any
const monacoAny = monaco as any
for (const name of ['css', 'html', 'json', 'typescript']) {
monacoAny.languages[name] ??= monacoAny[name]
}
export const ScrollType
= Reflect.get(_monaco as object, 'ScrollType') ?? (_monaco as any).editor?.ScrollType
export { monaco as default }
export * from 'monaco-editor/esm/vs/editor/editor.api'
@@ -0,0 +1,53 @@
import { ensureMonacoWorkersLegacy } from './ensureMonacoWorkers.legacy'
import { workerPathByLabel } from './preloadMonacoWorkers.shared'
// Webpack 4 (Vue CLI 4) cannot parse `import.meta.url`. This legacy build
// relies on the host app configuring Monaco workers (e.g. via
// monaco-editor-webpack-plugin), but includes a best-effort auto-config for
// CDN/AMD setups.
export async function preloadMonacoWorkers(): Promise<void> {
if (typeof window === 'undefined' || typeof document === 'undefined')
return
// Best-effort: if the host app didn't configure workers, try to install a
// same-origin blob worker URL based on detected Monaco baseUrl.
ensureMonacoWorkersLegacy()
// eslint-disable-next-line no-restricted-globals
const env = (self as any).MonacoEnvironment as
| undefined
| { getWorkerUrl?: (moduleId: string, label: string) => string }
const getWorkerUrl = env?.getWorkerUrl
if (typeof getWorkerUrl !== 'function')
return
// Monaco usually asks for `editorWorkerService` as the default label when no
// specialized worker exists.
const labelsToWarm = Array.from(
new Set([...Object.keys(workerPathByLabel), 'editorWorkerService']),
)
const urls = labelsToWarm
.map((label) => {
try {
return getWorkerUrl('', label)
}
catch {
return undefined
}
})
.filter(Boolean)
const unique = Array.from(new Set(urls.filter(Boolean).map(String)))
try {
await Promise.all(
unique.map(u =>
fetch(u, { method: 'GET', cache: 'force-cache' }).catch(() => undefined),
),
)
}
catch {
// swallow errors - preloading is best-effort
}
}
@@ -0,0 +1,17 @@
export const editorWorkerPath = 'monaco-editor/esm/vs/editor/editor.worker.js'
export const workerPathByLabel: Record<string, string> = {
json: 'monaco-editor/esm/vs/language/json/json.worker.js',
css: 'monaco-editor/esm/vs/language/css/css.worker.js',
scss: 'monaco-editor/esm/vs/language/css/css.worker.js',
less: 'monaco-editor/esm/vs/language/css/css.worker.js',
html: 'monaco-editor/esm/vs/language/html/html.worker.js',
handlebars: 'monaco-editor/esm/vs/language/html/html.worker.js',
razor: 'monaco-editor/esm/vs/language/html/html.worker.js',
typescript: 'monaco-editor/esm/vs/language/typescript/ts.worker.js',
javascript: 'monaco-editor/esm/vs/language/typescript/ts.worker.js',
}
export const uniqueWorkerPaths: string[] = Array.from(
new Set([editorWorkerPath, ...Object.values(workerPathByLabel)]),
)
@@ -0,0 +1,28 @@
import { ensureMonacoWorkers } from './ensureMonacoWorkers'
// Expose a function so consumers can proactively preload worker loaders
// without relying on module evaluation timing.
import { uniqueWorkerPaths } from './preloadMonacoWorkers.shared'
export async function preloadMonacoWorkers(): Promise<void> {
if (typeof window === 'undefined' || typeof document === 'undefined')
return
const unique = uniqueWorkerPaths.map(p => String(new URL(p, import.meta.url)))
// IMPORTANT: install synchronously (before the first `await`).
ensureMonacoWorkers()
try {
// best-effort fetch to warm caches; do not throw on individual failures
await Promise.all(
unique.map(u =>
fetch(u, { method: 'GET', cache: 'force-cache' }).catch(
() => undefined,
),
),
)
}
catch {
// swallow errors - preloading is best-effort
}
}
@@ -0,0 +1,57 @@
// Thin adapter over alien-signals to provide Vue-like APIs used internally.
import { computed as aComputed, effect as aEffect, signal as aSignal } from 'alien-signals'
export type WatchStopHandle = () => void
export interface Ref<T> { value: T }
export function ref<T>(initial: T): Ref<T> {
const s = aSignal<T>(initial)
return Object.defineProperty({} as Ref<T>, 'value', {
get() { return s() },
set(v: T) { s(v) },
enumerable: true,
configurable: false,
})
}
export function computed<T>(getter: () => T): Ref<T> {
const c = aComputed<T>(() => getter())
return Object.defineProperty({} as Ref<T>, 'value', {
get() { return c() },
set(_: T) { /* readonly */ },
enumerable: true,
configurable: false,
})
}
interface WatchOptions { immediate?: boolean, flush?: 'post' | 'sync' }
export function watch<T>(
source: () => T,
cb: (newVal: T, oldVal: T | undefined) => void,
options: WatchOptions = {},
): WatchStopHandle {
let initialized = false
let oldVal: T | undefined
const stop = aEffect(() => {
const newVal = source()
if (!initialized) {
initialized = true
if (options.immediate) {
if (options.flush === 'post')
queueMicrotask(() => cb(newVal, oldVal))
else
cb(newVal, oldVal)
}
}
else if (!Object.is(newVal, oldVal)) {
if (options.flush === 'post')
queueMicrotask(() => cb(newVal, oldVal))
else
cb(newVal, oldVal)
}
oldVal = newVal
})
return stop
}
+551
View File
@@ -0,0 +1,551 @@
import type * as monaco from 'monaco-editor'
import type {
Highlighter as _ShikiHighlighter,
SpecialTheme,
ThemeInput,
} from 'shiki'
import type * as monacoShim from './monaco-shim'
export type ShikiHighlighter = _ShikiHighlighter | any
export type MonacoModule = typeof monacoShim
export type MonacoEditorInstance = monaco.editor.IStandaloneCodeEditor
export type MonacoDiffEditorInstance = monaco.editor.IStandaloneDiffEditor
export type MonacoTheme
= | 'andromeeda'
| 'aurora-x'
| 'ayu-dark'
| 'catppuccin-frappe'
| 'catppuccin-latte'
| 'catppuccin-macchiato'
| 'catppuccin-mocha'
| 'dark-plus'
| 'dracula'
| 'dracula-soft'
| 'everforest-dark'
| 'everforest-light'
| 'github-dark'
| 'github-dark-default'
| 'github-dark-dimmed'
| 'github-dark-high-contrast'
| 'github-light'
| 'github-light-default'
| 'github-light-high-contrast'
| 'gruvbox-dark-hard'
| 'gruvbox-dark-medium'
| 'gruvbox-dark-soft'
| 'gruvbox-light-hard'
| 'gruvbox-light-medium'
| 'gruvbox-light-soft'
| 'houston'
| 'kanagawa-dragon'
| 'kanagawa-lotus'
| 'kanagawa-wave'
| 'laserwave'
| 'light-plus'
| 'material-theme'
| 'material-theme-darker'
| 'material-theme-lighter'
| 'material-theme-ocean'
| 'material-theme-palenight'
| 'min-dark'
| 'min-light'
| 'monokai'
| 'night-owl'
| 'nord'
| 'one-dark-pro'
| 'one-light'
| 'plastic'
| 'poimandres'
| 'red'
| 'rose-pine'
| 'rose-pine-dawn'
| 'rose-pine-moon'
| 'slack-dark'
| 'slack-ochin'
| 'snazzy-light'
| 'solarized-dark'
| 'solarized-light'
| 'synthwave-84'
| 'tokyo-night'
| 'vesper'
| 'vitesse-black'
| 'vitesse-dark'
| 'vitesse-light'
| ThemeInput
| string
| SpecialTheme
export type MonacoLanguage
= | 'abap'
| 'actionscript-3'
| 'ada'
| 'angular-html'
| 'angular-ts'
| 'apache'
| 'apex'
| 'apl'
| 'applescript'
| 'ara'
| 'asciidoc'
| 'asm'
| 'astro'
| 'awk'
| 'ballerina'
| 'bat'
| 'beancount'
| 'berry'
| 'bibtex'
| 'bicep'
| 'blade'
| 'bsl'
| 'c'
| 'cadence'
| 'cairo'
| 'clarity'
| 'clojure'
| 'cmake'
| 'cobol'
| 'codeowners'
| 'codeql'
| 'coffee'
| 'common-lisp'
| 'coq'
| 'cpp'
| 'crystal'
| 'csharp'
| 'css'
| 'csv'
| 'cue'
| 'cypher'
| 'd'
| 'dart'
| 'dax'
| 'desktop'
| 'diff'
| 'docker'
| 'dotenv'
| 'dream-maker'
| 'edge'
| 'elixir'
| 'elm'
| 'emacs-lisp'
| 'erb'
| 'erlang'
| 'fennel'
| 'fish'
| 'fluent'
| 'fortran-fixed-form'
| 'fortran-free-form'
| 'fsharp'
| 'gdresource'
| 'gdscript'
| 'gdshader'
| 'genie'
| 'gherkin'
| 'git-commit'
| 'git-rebase'
| 'gleam'
| 'glimmer-js'
| 'glimmer-ts'
| 'glsl'
| 'gnuplot'
| 'go'
| 'graphql'
| 'groovy'
| 'hack'
| 'haml'
| 'handlebars'
| 'haskell'
| 'haxe'
| 'hcl'
| 'hjson'
| 'hlsl'
| 'html'
| 'html-derivative'
| 'http'
| 'hxml'
| 'hy'
| 'imba'
| 'ini'
| 'java'
| 'javascript'
| 'jinja'
| 'jison'
| 'json'
| 'json5'
| 'jsonc'
| 'jsonl'
| 'jsonnet'
| 'jssm'
| 'jsx'
| 'julia'
| 'kotlin'
| 'kusto'
| 'latex'
| 'lean'
| 'less'
| 'liquid'
| 'llvm'
| 'log'
| 'logo'
| 'lua'
| 'luau'
| 'make'
| 'markdown'
| 'marko'
| 'matlab'
| 'mdc'
| 'mdx'
| 'mermaid'
| 'mipsasm'
| 'mojo'
| 'move'
| 'narrat'
| 'nextflow'
| 'nginx'
| 'nim'
| 'nix'
| 'nushell'
| 'objective-c'
| 'objective-cpp'
| 'ocaml'
| 'pascal'
| 'perl'
| 'php'
| 'plsql'
| 'po'
| 'polar'
| 'postcss'
| 'powerquery'
| 'powershell'
| 'prisma'
| 'prolog'
| 'proto'
| 'pug'
| 'puppet'
| 'purescript'
| 'python'
| 'qml'
| 'qmldir'
| 'qss'
| 'r'
| 'racket'
| 'raku'
| 'razor'
| 'reg'
| 'regexp'
| 'rel'
| 'riscv'
| 'rst'
| 'ruby'
| 'rust'
| 'sas'
| 'sass'
| 'scala'
| 'scheme'
| 'scss'
| 'sdbl'
| 'shaderlab'
| 'shellscript'
| 'shellsession'
| 'smalltalk'
| 'solidity'
| 'soy'
| 'sparql'
| 'splunk'
| 'sql'
| 'ssh-config'
| 'stata'
| 'stylus'
| 'svelte'
| 'swift'
| 'system-verilog'
| 'systemd'
| 'talonscript'
| 'tasl'
| 'tcl'
| 'templ'
| 'terraform'
| 'tex'
| 'toml'
| 'ts-tags'
| 'tsv'
| 'tsx'
| 'turtle'
| 'twig'
| 'typescript'
| 'typespec'
| 'typst'
| 'v'
| 'vala'
| 'vb'
| 'verilog'
| 'vhdl'
| 'viml'
| 'vue'
| 'vue-html'
| 'vyper'
| 'wasm'
| 'wenyan'
| 'wgsl'
| 'wikitext'
| 'wit'
| 'wolfram'
| 'xml'
| 'xsl'
| 'yaml'
| 'zenscript'
| 'zig'
| string
export type DiffHideUnchangedRegions
= | boolean
| NonNullable<
monaco.editor.IDiffEditorConstructionOptions['hideUnchangedRegions']
>
export type DiffLineStyle = 'background' | 'bar'
export type DiffAppearance = 'auto' | 'light' | 'dark'
export type DiffUnchangedRegionStyle
= | 'line-info'
| 'line-info-basic'
| 'metadata'
| 'simple'
export interface DiffModels {
original: monaco.editor.ITextModel | null
modified: monaco.editor.ITextModel | null
}
export interface DiffModelPair {
original: monaco.editor.ITextModel
modified: monaco.editor.ITextModel
}
export interface DiffModelTransitionOptions {
codeLanguage?: MonacoLanguage
preserveViewState?: boolean
preserveModelState?: boolean
}
export interface DiffCodeValue {
original: string
modified: string
}
export type MonacoCodeValue = string | DiffCodeValue | null
export interface MonacoOptions
extends monaco.editor.IStandaloneEditorConstructionOptions,
monaco.editor.IDiffEditorConstructionOptions {
MAX_HEIGHT?: number | string
readOnly?: boolean
themes?: MonacoTheme[]
languages?: MonacoLanguage[]
theme?: string
isCleanOnBeforeCreate?: boolean
/**
* 控制更新时的自动滚动行为:当为 true 时,如果当前接近底部则在新增内容后自动滚动到底部;
* 当为 false 时,将完全禁用自动滚动。
* 默认 true。
*/
autoScrollOnUpdate?: boolean
/**
* 编辑器创建时是否默认启用自动滚动。用户一旦滚离底部将自动暂停,回到底部附近再恢复。
* 默认 true(保持原有行为)。
*/
autoScrollInitial?: boolean
/**
* 触发“接近底部”的绝对像素阈值。如果设置,将与 autoScrollThresholdLines 共同取最大值。
* 默认 32。
*/
autoScrollThresholdPx?: number
/**
* 触发“接近底部”的相对行数阈值(以当前行高计算)。如果设置,将与 autoScrollThresholdPx 共同取最大值。
* 默认 2 行。
*/
autoScrollThresholdLines?: number
/**
* 是否启用 Diff 编辑器 modified 侧的自动滚动逻辑。
* 当为 false 时,updateDiff/appendModified 等不会触发自动滚动。
* 默认 true(与单编辑器体验保持一致)。
*/
diffAutoScroll?: boolean
/**
* Controls Monaco's diff unchanged-region folding behavior.
* - `true`: use stream-monaco defaults (enabled with compact context lines)
* - `false`: disable unchanged-region folding
* - object: forward to Monaco `hideUnchangedRegions`
*
* Default: `true`
*/
diffHideUnchangedRegions?: DiffHideUnchangedRegions
/**
* Controls how changed lines are visually emphasized in the diff editor.
* - `background`: richer filled blocks for added/removed lines
* - `bar`: subtler fill with stronger leading bars, closer to review UIs
*
* Default: `background`
*/
diffLineStyle?: DiffLineStyle
/**
* Controls the overall chrome appearance of the diff editor shell.
* - `auto`: infer light/dark appearance from the active Monaco theme
* - `light`: force light diff chrome
* - `dark`: force dark diff chrome
*
* Token colors still follow the active Monaco/Shiki theme.
*
* Default: `auto`
*/
diffAppearance?: DiffAppearance
/**
* Controls how collapsed unchanged regions are rendered in the diff editor.
* - `line-info`: line-info bars with line-number-width reveal buttons
* - `line-info-basic`: legacy line-info bars with full-width reveal rail
* - `metadata`: unified-diff-style hunk metadata such as `@@ -59,9 +59,11 @@`
* - `simple`: minimal gray placeholder bar without text
*
* Default: `line-info`
*/
diffUnchangedRegionStyle?: DiffUnchangedRegionStyle
/**
* Enable hover actions for each diff hunk split part (upper/lower):
* local `revert` and `stage`.
* Default: `false` (must be explicitly enabled).
*/
diffHunkActionsOnHover?: boolean
/**
* Hide delay (ms) for diff hunk hover action widgets after mouse leaves.
* Default: `160`.
*/
diffHunkHoverHideDelayMs?: number
/**
* Optional interception callback for hunk hover actions.
* Return `false` to prevent the built-in model edit behavior.
*/
onDiffHunkAction?: (
context: DiffHunkActionContext,
) => void | boolean | Promise<void | boolean>
/**
* Debounce time (ms) to coalesce multiple reveal requests into a single
* reveal. Useful for streaming/append scenarios. Default: 75
*/
revealDebounceMs?: number
/**
* How to reveal target line when auto-scrolling.
* - 'bottom' : revealLine (closest to bottom)
* - 'centerIfOutside' : revealLineInCenterIfOutsideViewport (default)
* - 'center' : revealLineInCenter
*/
revealStrategy?: 'bottom' | 'centerIfOutside' | 'center'
/**
* If set to a positive number (ms), append/streaming scenarios will delay a final
* "scroll to bottom" until this idle time has passed since the last append. Useful
* to batch many small appends and then perform one final jump to bottom. Default: undefined (disabled).
*/
revealBatchOnIdleMs?: number
/**
* Time window (ms) used to throttle `updateCode` calls in addition to RAF batching.
* - 0 means only RAF-based coalescing (no extra time throttling).
* - Default (library): 50
*/
updateThrottleMs?: number
/**
* Time window (ms) used to throttle diff streaming updates in addition to RAF batching.
* This affects `appendOriginal`/`appendModified` and the fast-path append branches of `updateDiff`.
*
* Why: Monaco's diff computation is async and cancels/restarts when models change.
* If you apply edits every frame (or per token), the diff may only finish once
* streaming stops, so the highlights appear "at the end".
*
* - 0 means only RAF-based coalescing (more responsive, but can starve diff computation).
* - Default (library): 50
*/
diffUpdateThrottleMs?: number
/**
* When attempting the "minimal edit" algorithm, if prev.length + next.length
* exceeds this number the library will fall back to full `setValue` to avoid
* expensive diff computation on very large documents.
*/
minimalEditMaxChars?: number
/**
* When the relative change ratio (|new-prev|/maxLen) exceeds this value the
* library will fall back to full `setValue` instead of attempting minimal edit.
*/
minimalEditMaxChangeRatio?: number
// 添加在编辑器创建之前的钩子
onBeforeCreate?: (
monaco: MonacoModule,
) => monaco.IDisposable[]
/**
* Optional callback that is invoked after a theme change has been applied.
* This callback will be awaited when possible so callers can track completion
* of theme application. It receives the name of the applied theme.
*/
onThemeChange?: (theme: MonacoTheme) => void | Promise<void>
}
// Convenience enum for consumers who prefer a TypeScript constant instead of strings
export enum RevealStrategy {
Bottom = 'bottom',
CenterIfOutside = 'centerIfOutside',
Center = 'center',
}
export type DiffHunkActionKind = 'revert' | 'stage'
export type DiffHunkSide = 'upper' | 'lower'
export interface DiffHunkActionContext {
action: DiffHunkActionKind
side: DiffHunkSide
lineChange: monaco.editor.ILineChange
originalModel: monaco.editor.ITextModel
modifiedModel: monaco.editor.ITextModel
}
export interface UseMonacoReturn {
createEditor: (
container: HTMLElement,
code: string,
language: string,
) => Promise<monaco.editor.IStandaloneCodeEditor>
createDiffEditor: (
container: HTMLElement,
originalCode: string,
modifiedCode: string,
language: string,
) => Promise<monaco.editor.IStandaloneDiffEditor>
cleanupEditor: () => void
safeClean: () => void
updateCode: (newCode: string, codeLanguage: string) => void
appendCode: (appendText: string, codeLanguage?: string) => void
updateDiff: (
originalCode: string,
modifiedCode: string,
codeLanguage?: string,
) => void
updateOriginal: (newCode: string, codeLanguage?: string) => void
updateModified: (newCode: string, codeLanguage?: string) => void
appendOriginal: (appendText: string, codeLanguage?: string) => void
appendModified: (appendText: string, codeLanguage?: string) => void
setDiffModels: (
models: DiffModelPair,
options?: DiffModelTransitionOptions,
) => Promise<void>
setTheme: (theme: MonacoTheme, force?: boolean) => Promise<void>
refreshDiffPresentation: () => void
setLanguage: (language: MonacoLanguage) => void
getCurrentTheme: () => string
getEditor: () => typeof monaco.editor
getEditorView: () => monaco.editor.IStandaloneCodeEditor | null
getDiffEditorView: () => monaco.editor.IStandaloneDiffEditor | null
getDiffModels: () => DiffModels
getMonacoInstance: () => MonacoModule
setUpdateThrottleMs: (ms: number) => void
getUpdateThrottleMs: () => number
getCode: () => MonacoCodeValue
}
@@ -0,0 +1,17 @@
// shallow array equality (order-sensitive)
export function arraysEqual<T>(
a: readonly T[] | null | undefined,
b: readonly T[] | null | undefined,
) {
if (a === b)
return true
if (!a || !b)
return false
if (a.length !== b.length)
return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i])
return false
}
return true
}
@@ -0,0 +1,105 @@
import { log } from './logger'
export function createHeightManager(container: HTMLElement, computeNext: () => number | null) {
// Debug logging is enabled during development but can be controlled via
let raf: number | null = null
let debounceTimer: number | null = null
let lastApplied = -1
let suppressed = false
// larger hysteresis to reduce perceived jitter during incremental growth
const HYSTERESIS_PX = 12
// debounce delay to coalesce frequent height changes (ms)
const DEBOUNCE_MS = 0
function apply() {
const next = computeNext()
if (next == null) {
return
}
log('heightManager', 'computeNext ->', { next, lastApplied })
// Guard against invalid or non-positive heights which can collapse the
// container. Some downstream callers may compute values incorrectly
// during transient states; ignore those to keep the editor visible.
if (!Number.isFinite(next) || next <= 0) {
log('heightManager', 'invalid next height, ignoring', next)
return
}
const currentHeight
= Number.parseFloat(container.style.height || '')
|| container.getBoundingClientRect?.().height
|| 0
if (
currentHeight > 0
&& Math.abs(next - currentHeight) <= HYSTERESIS_PX
) {
lastApplied = next
return
}
if (lastApplied !== -1 && Math.abs(next - lastApplied) <= HYSTERESIS_PX) {
return
}
if (next === lastApplied) {
return
}
suppressed = true
container.style.height = `${next}px`
lastApplied = next
log('heightManager', 'applied height ->', next)
queueMicrotask(() => {
suppressed = false
})
}
function scheduleApply() {
// Cancel any pending debounce timer and schedule a new one that will
// requestAnimationFrame and run `apply`. This batches updates that occur
// within DEBOUNCE_MS to avoid rapid reflows.
if (debounceTimer != null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
if (DEBOUNCE_MS === 0) {
if (raf != null) {
return
}
raf = requestAnimationFrame(() => {
raf = null
apply()
})
return
}
debounceTimer = (setTimeout(() => {
debounceTimer = null
if (raf != null) {
return
}
raf = requestAnimationFrame(() => {
raf = null
apply()
})
}, DEBOUNCE_MS) as unknown) as number
}
function update() {
scheduleApply()
}
function dispose() {
if (raf != null) {
cancelAnimationFrame(raf)
raf = null
}
if (debounceTimer != null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
function isSuppressed() {
return suppressed
}
function getLastApplied() {
return lastApplied
}
return { update, dispose, isSuppressed, getLastApplied }
}
@@ -0,0 +1,29 @@
let seq = 0
// Centralized debug determination. Priority:
// 1. browser global `window.__STREAM_MONACO_DEBUG__` if defined
// Default: enable debug during local development
export const DEBUG: boolean = (() => {
if (typeof window !== 'undefined' && (window as any).__STREAM_MONACO_DEBUG__ !== undefined)
return Boolean((window as any).__STREAM_MONACO_DEBUG__)
return false
})()
export function log(tag: string, ...args: any[]) {
if (!DEBUG)
return
seq += 1
const id = `#${seq}`
const ts = (typeof performance !== 'undefined' && performance.now)
? (performance.now()).toFixed(1)
: Date.now()
// Use console.warn to comply with ESLint console restrictions.
console.warn(`${id} [${tag}] @${ts}ms`, ...args)
}
export function error(tag: string, ...args: any[]) {
if (!DEBUG)
return
console.error(`[${tag}]`, ...args)
}
@@ -0,0 +1,38 @@
export interface TimeSource {
requestAnimationFrame: (cb: FrameRequestCallback) => number
cancelAnimationFrame: (id: number) => void
}
/**
* Create a per-instance RAF scheduler that coalesces tasks by "kind" within
* the same instance. Instances are isolated so different editors won't
* cancel each other's scheduled work.
*/
export function createRafScheduler(timeSource?: TimeSource) {
const ids: Record<string, number | null> = {}
const ts: TimeSource = timeSource ?? {
requestAnimationFrame: (cb: FrameRequestCallback) => requestAnimationFrame(cb),
cancelAnimationFrame: (id: number) => cancelAnimationFrame(id),
}
function schedule(kind: string, cb: FrameRequestCallback) {
const existing = ids[kind]
if (existing != null) {
ts.cancelAnimationFrame(existing)
}
ids[kind] = ts.requestAnimationFrame((t) => {
ids[kind] = null
cb(t)
})
}
function cancel(kind: string) {
const id = ids[kind]
if (id != null) {
ts.cancelAnimationFrame(id)
ids[kind] = null
}
}
return { schedule, cancel }
}
@@ -0,0 +1,398 @@
import type { SpecialTheme, ThemeInput } from 'shiki'
import { shikiToMonaco } from '@shikijs/monaco'
import { createHighlighter } from 'shiki'
import * as monaco from '../monaco-shim'
import { arraysEqual } from './arraysEqual'
const LEGACY_ONIG_INIT_KEY = '__streamMonacoLegacyOnigurumaInit__'
const LEGACY_ENGINE_KEY = '__streamMonacoLegacyShikiEngine__'
const LEGACY_MONACO_LANGS_INIT_KEY = '__streamMonacoLegacyMonacoLangsInit__'
async function awaitLegacyOnigurumaInitIfPresent() {
try {
const p = (globalThis as any)?.[LEGACY_ONIG_INIT_KEY]
if (p && typeof p.then === 'function')
await p
}
catch {}
}
async function awaitLegacyMonacoLanguageContributionsIfPresent() {
try {
const p = (globalThis as any)?.[LEGACY_MONACO_LANGS_INIT_KEY]
if (p && typeof p.then === 'function')
await p
}
catch {}
}
async function getLegacyShikiEngineIfPresent() {
try {
const p = (globalThis as any)?.[LEGACY_ENGINE_KEY]
if (p && typeof p.then === 'function')
return await p
}
catch {}
return null
}
async function createHighlighterWithLegacyEngineIfNeeded(options: any) {
await awaitLegacyOnigurumaInitIfPresent()
await awaitLegacyMonacoLanguageContributionsIfPresent()
const engine = await getLegacyShikiEngineIfPresent()
if (engine)
return createHighlighter({ ...options, engine })
return createHighlighter(options)
}
let languagesRegistered = false
let currentLanguages: string[] = []
// promise that resolves to a shiki highlighter or null when registration completes
let themeRegisterPromise: Promise<import('../type').ShikiHighlighter | null> | null = null
// Serialize registrations to avoid races where multiple calls patch Monaco's
// global `editor.setTheme` with different highlighters in an interleaved order.
let registrationQueue: Promise<unknown> = Promise.resolve()
function enqueueRegistration<T>(task: () => Promise<T>): Promise<T> {
const next = registrationQueue.then(task, task)
// keep queue alive even if a task rejects
registrationQueue = next.then(() => undefined, () => undefined)
return next
}
export function getThemeRegisterPromise() {
return themeRegisterPromise
}
export function setThemeRegisterPromise(p: Promise<import('../type').ShikiHighlighter | null> | null) {
return themeRegisterPromise = p
}
interface HighlighterEntry {
// promise that resolves to a shiki highlighter
promise: Promise<any>
// set of languages this highlighter was created with
languages: Set<string>
}
const highlighterCache = new Map<string, HighlighterEntry>()
// Monaco integration is global (monaco.editor.setTheme is patched by shikiToMonaco).
// When multiple editors/code blocks initialize concurrently with different theme
// sets, repeatedly calling shikiToMonaco with different highlighters causes the
// last one to win, and themes not present in that last highlighter can throw.
//
// To avoid that, keep a single shared highlighter for Monaco and incrementally
// load themes/languages into it.
let monacoHighlighterPromise: Promise<import('../type').ShikiHighlighter> | null = null
let lastPatchedHighlighter: import('../type').ShikiHighlighter | null = null
let lastPatchedLanguages = new Set<string>()
let lastPatchedThemes = new Set<string>()
const monacoThemeByKey = new Map<string, ThemeInput | string | SpecialTheme>()
const monacoLanguageSet = new Set<string>()
function themeKey(t: ThemeInput | string | SpecialTheme) {
return typeof t === 'string' ? t : (t as any).name ?? JSON.stringify(t)
}
async function ensureMonacoHighlighter(
themes: (ThemeInput | string | SpecialTheme)[],
languages: string[],
) {
// track union first
for (const t of themes)
monacoThemeByKey.set(themeKey(t), t)
for (const l of languages)
monacoLanguageSet.add(l)
// create shared highlighter once
if (!monacoHighlighterPromise) {
const initialThemes = Array.from(monacoThemeByKey.values())
const initialLangs = Array.from(monacoLanguageSet.values())
monacoHighlighterPromise = createHighlighterWithLegacyEngineIfNeeded({ themes: initialThemes, langs: initialLangs })
.then(h => h)
}
const h = await monacoHighlighterPromise
// Incrementally load missing themes/langs when supported by shiki.
// If incremental loading isn't available, fall back to re-creating the shared
// highlighter with the full union (still safe because the union only grows).
const wantsThemes = Array.from(monacoThemeByKey.values())
const wantsLangs = Array.from(monacoLanguageSet.values())
const canLoadTheme = typeof (h as any).loadTheme === 'function'
const canLoadLanguage = typeof (h as any).loadLanguage === 'function'
if (canLoadTheme || canLoadLanguage) {
if (canLoadTheme) {
// loadTheme is idempotent in shiki; call only for newly requested theme keys
for (const t of themes) {
const k = themeKey(t)
// If we've already loaded it, skip.
// We can't reliably introspect loaded themes from shiki, so track keys.
if (!(h as any).__streamMonacoLoadedThemes)
(h as any).__streamMonacoLoadedThemes = new Set<string>()
const loaded: Set<string> = (h as any).__streamMonacoLoadedThemes
if (loaded.has(k))
continue
await (h as any).loadTheme(t)
loaded.add(k)
}
}
if (canLoadLanguage) {
for (const l of languages) {
if (!(h as any).__streamMonacoLoadedLangs)
(h as any).__streamMonacoLoadedLangs = new Set<string>()
const loaded: Set<string> = (h as any).__streamMonacoLoadedLangs
if (loaded.has(l))
continue
await (h as any).loadLanguage(l)
loaded.add(l)
}
}
return h
}
// fallback: recreate shared highlighter with union
const p = createHighlighterWithLegacyEngineIfNeeded({ themes: wantsThemes, langs: wantsLangs })
.then(hh => hh)
monacoHighlighterPromise = p
return p
}
/**
* Clear all cached shiki highlighters.
*
* Useful for long-running apps that dynamically create many theme combinations,
* or in tests to ensure a clean state. Call this when you know the highlighters
* are no longer needed (for example on app shutdown) to free memory.
*/
export function clearHighlighterCache() {
highlighterCache.clear()
monacoHighlighterPromise = null
lastPatchedHighlighter = null
lastPatchedLanguages = new Set<string>()
monacoThemeByKey.clear()
monacoLanguageSet.clear()
themeRegisterPromise = null
languagesRegistered = false
currentLanguages = []
}
/**
* Return number of entries currently in the highlighter cache.
* Helpful for tests and debugging.
*/
export function getHighlighterCacheSize() {
return highlighterCache.size
}
function serializeThemes(themes: (ThemeInput | string | SpecialTheme)[]) {
return JSON.stringify(
themes.map(t => typeof t === 'string' ? t : (t as any).name ?? JSON.stringify(t)).sort(),
)
}
async function getOrCreateHighlighter(
themes: (ThemeInput | string | SpecialTheme)[],
languages: string[],
): Promise<import('../type').ShikiHighlighter> {
const key = serializeThemes(themes)
const requestedSet = new Set(languages)
let existing = highlighterCache.get(key)
if (existing) {
// if existing entry already covers requested languages, reuse
let allIncluded = true
for (const l of requestedSet) {
if (!existing.languages.has(l)) {
allIncluded = false
break
}
}
if (allIncluded) {
return existing.promise
}
// double-check cache in case a concurrent request already replaced/expanded the entry
const prev = existing
const current = highlighterCache.get(key)
if (current && current !== prev) {
// if the current cached entry already covers requested languages, reuse it
let allIncludedCurrent = true
for (const l of requestedSet) {
if (!current.languages.has(l)) {
allIncludedCurrent = false
break
}
}
if (allIncludedCurrent) {
return current.promise
}
// otherwise prefer the most recent cached entry for the union creation
existing = current
}
// otherwise create a new highlighter with the union of languages
const union = new Set<string>([...existing.languages, ...requestedSet])
const langsArray = Array.from(union)
const p = createHighlighterWithLegacyEngineIfNeeded({ themes, langs: langsArray })
const newEntry: HighlighterEntry = { promise: p, languages: union }
highlighterCache.set(key, newEntry)
// if creation fails, try to restore previous entry (prev)
p.catch(() => {
if (highlighterCache.get(key) === newEntry && prev) {
highlighterCache.set(key, prev)
}
})
return p
}
// no cached entry, create and cache
const p = createHighlighterWithLegacyEngineIfNeeded({ themes, langs: Array.from(requestedSet) })
const entry: HighlighterEntry = { promise: p, languages: requestedSet }
highlighterCache.set(key, entry)
p.catch(() => {
if (highlighterCache.get(key) === entry) {
highlighterCache.delete(key)
}
})
return p
}
// Exported for callers that need direct access to the shiki highlighter
export { getOrCreateHighlighter }
/**
* Update the theme used by the shiki highlighter for a given themes+languages
* combination. Useful when Monaco themes are already registered (so switching
* Monaco only requires `monaco.editor.setTheme`) but you also want shiki's
* standalone renderer to use the new theme without recreating everything.
*/
// NOTE: setHighlighterTheme removed — switching Monaco theme via
// `monaco.editor.setTheme(themeName)` is sufficient for editor theme changes.
// If consumers need to directly control a shiki highlighter they can use
// `getOrCreateHighlighter(...)` and call methods on the returned object.
export async function registerMonacoThemes(
themes: (ThemeInput | string | SpecialTheme)[],
languages: string[],
): Promise<import('../type').ShikiHighlighter | null> {
return enqueueRegistration(async () => {
registerMonacoLanguages(languages)
const p = (async () => {
const highlighter = await ensureMonacoHighlighter(themes, languages)
// Patch Monaco when:
// - the shared highlighter instance changes, OR
// - new languages were added (incremental load) and need tokens providers.
//
// `shikiToMonaco()` installs tokens providers per language at call-time.
// When we incrementally load languages into the shared highlighter, we
// must re-run `shikiToMonaco()` so Monaco receives providers for the newly
// loaded languages.
const wantsLangs = Array.from(monacoLanguageSet.values())
const loadedThemeIds = typeof highlighter.getLoadedThemes === 'function'
? highlighter.getLoadedThemes()
: []
const needsThemePatch = loadedThemeIds.some((t: string) => !lastPatchedThemes.has(t))
const needsLanguagePatch = lastPatchedHighlighter !== highlighter
|| wantsLangs.some(l => !lastPatchedLanguages.has(l))
|| needsThemePatch
if (needsLanguagePatch) {
if (lastPatchedHighlighter !== highlighter)
lastPatchedLanguages = new Set<string>()
// In some bundlers (notably Webpack 4), Shiki/TextMate tokenization can
// still throw at runtime (e.g. `null.compileAG`) due to regex engine
// initialization quirks. Monaco reports these as "unexpected errors"
// and they crash the app.
//
// We cannot monkey-patch `import * as monaco` namespace exports (ESM
// namespace objects are immutable), so pass a proxy object into
// `shikiToMonaco()` that wraps `setTokensProvider` instead.
const realLanguages: any = (monaco as any).languages
const realEditor: any = (monaco as any).editor
const setTokensProvider = typeof realLanguages?.setTokensProvider === 'function'
? realLanguages.setTokensProvider.bind(realLanguages)
: null
const getLanguages = typeof realLanguages?.getLanguages === 'function'
? realLanguages.getLanguages.bind(realLanguages)
: null
const monacoProxy: any = {
// Forward everything else (Range, Uri, etc.) via prototype lookup.
__proto__: monaco as any,
editor: realEditor,
languages: {
__proto__: realLanguages,
getLanguages,
setTokensProvider(lang: string, provider: any) {
if (provider && typeof provider.tokenize === 'function') {
const originalTokenize = provider.tokenize.bind(provider)
provider = {
...provider,
tokenize(line: string, state: any) {
try {
return originalTokenize(line, state)
}
catch {
return {
endState: state,
tokens: [{ startIndex: 0, scopes: '' }],
}
}
},
}
}
return setTokensProvider?.(lang, provider)
},
},
}
shikiToMonaco(highlighter, monacoProxy)
lastPatchedHighlighter = highlighter
lastPatchedLanguages = new Set(wantsLangs)
lastPatchedThemes = new Set(loadedThemeIds)
}
// Track last language set for Monaco language registration short-circuit.
currentLanguages = languages.slice()
return highlighter
})()
setThemeRegisterPromise(p)
try {
const res = await p
return res
}
catch (e) {
setThemeRegisterPromise(null)
throw e
}
})
}
function registerMonacoLanguages(languages: string[]) {
if (languagesRegistered && arraysEqual(languages, currentLanguages)) {
return
}
const existing = new Set(monaco.languages.getLanguages().map(l => l.id))
for (const lang of languages) {
if (!existing.has(lang)) {
try {
monaco.languages.register({ id: lang })
}
catch {
// ignore unsupported ids
}
}
}
languagesRegistered = true
currentLanguages = languages
}
@@ -0,0 +1,145 @@
import { log } from './logger'
export function createScrollWatcherForEditor(
ed: any,
opts: {
onPause: () => void
onMaybeResume: () => void
getLast: () => number
setLast: (v: number) => void
},
) {
const initial = ed.getScrollTop?.() ?? 0
opts.setLast(initial)
log('scrollWatcher', 'initial scrollTop=', initial)
let suppressedExternally = false
const THRESHOLD_PX = 6
let domNode: HTMLElement | null = null
let interactionListener: ((e: Event) => void) | null = null
const listener = (e: any) => {
if (suppressedExternally) {
log('scrollWatcher', 'suppressedExternally, ignoring event')
return
}
const currentTop = e && typeof e.scrollTop === 'number' ? e.scrollTop : ed.getScrollTop?.() ?? 0
const delta = currentTop - opts.getLast()
// update last seen position immediately
opts.setLast(currentTop)
// ignore very small scroll deltas (usually from layout/height adjustments)
if (Math.abs(delta) < THRESHOLD_PX) {
log('scrollWatcher', 'small delta ignored', delta)
// If the delta is small, check whether the viewport is effectively
// at the bottom of the scrollable area. In that case we should treat
// this as a potential resume so streaming auto-scroll can re-enable.
try {
const scrollHeight = typeof ed.getScrollHeight === 'function' ? ed.getScrollHeight() : undefined
const li = typeof (ed as any).getLayoutInfo === 'function' ? (ed as any).getLayoutInfo() : undefined
const viewportH = li?.height ?? undefined
if (typeof scrollHeight === 'number' && typeof viewportH === 'number') {
const distance = scrollHeight - (currentTop + viewportH)
if (distance <= Math.max(THRESHOLD_PX, 0)) {
log('scrollWatcher', 'small delta but at bottom, maybe resume', { distance })
opts.onMaybeResume()
}
}
}
catch { }
return
}
log('scrollWatcher', 'delta=', delta, 'currentTop=', currentTop)
if (delta < 0) {
log('scrollWatcher', 'pause detected delta=', delta)
opts.onPause()
return
}
log('scrollWatcher', 'maybe resume delta=', delta)
opts.onMaybeResume()
}
const disp = ed.onDidScrollChange?.(listener) ?? null
// Return an object that provides dispose and suppression control. We keep
// the dispose method for compatibility with monaco.IDisposable.
const api: any = {
dispose() {
try {
if (disp && typeof disp.dispose === 'function')
disp.dispose()
else if (typeof disp === 'function')
disp()
}
catch { }
log('scrollWatcher', 'dispose')
},
setSuppressed(v: boolean) {
const newVal = !!v
if (newVal === suppressedExternally)
return
suppressedExternally = newVal
log('scrollWatcher', 'setSuppressed =>', suppressedExternally)
// When suppression is enabled, attach lightweight user-interaction
// listeners so real user input (wheel/pointer/touch) can immediately
// cancel suppression and mark the watcher as paused. This prevents
// programmatic suppression from blocking the user's attempt to scroll up.
try {
// Try to get the editor DOM node if available
if (!domNode && typeof (ed as any).getDomNode === 'function') {
domNode = (ed as any).getDomNode() as HTMLElement | null
}
if (suppressedExternally && domNode) {
if (!interactionListener) {
interactionListener = () => {
try {
log('scrollWatcher', 'user interaction detected while suppressed, cancelling suppression')
// Treat this as a pause (user took control)
opts.onPause()
// clear suppression so subsequent scroll events are observed
suppressedExternally = false
// update last known position to current scrollTop
const cur = ed.getScrollTop?.() ?? 0
opts.setLast(cur)
// If the user interaction already scrolled to bottom, treat as resume
try {
const scrollHeight = typeof (ed as any).getScrollHeight === 'function' ? (ed as any).getScrollHeight() : undefined
const li = typeof (ed as any).getLayoutInfo === 'function' ? (ed as any).getLayoutInfo() : undefined
const viewportH = li?.height ?? undefined
if (typeof scrollHeight === 'number' && typeof viewportH === 'number') {
const distance = scrollHeight - (cur + viewportH)
if (distance <= Math.max(THRESHOLD_PX, 0)) {
log('scrollWatcher', 'interaction moved to bottom, maybe resume', { distance })
opts.onMaybeResume()
}
}
}
catch { }
// remove listeners
if (domNode && interactionListener) {
domNode.removeEventListener('wheel', interactionListener, { passive: true } as any)
domNode.removeEventListener('pointerdown', interactionListener as any)
domNode.removeEventListener('touchstart', interactionListener as any)
}
interactionListener = null
}
catch { }
}
domNode.addEventListener('wheel', interactionListener, { passive: true } as any)
domNode.addEventListener('pointerdown', interactionListener as any)
domNode.addEventListener('touchstart', interactionListener as any)
}
}
else {
if (domNode && interactionListener) {
domNode.removeEventListener('wheel', interactionListener, { passive: true } as any)
domNode.removeEventListener('pointerdown', interactionListener as any)
domNode.removeEventListener('touchstart', interactionListener as any)
interactionListener = null
}
}
}
catch { }
},
}
return api
}
@@ -0,0 +1,233 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('../src/monaco-shim', () => {
class Range {
constructor(
public lineNumber: number,
public column: number,
public endLineNumber: number,
public endColumn: number,
) {}
}
const editor = {
EditorOption: { lineHeight: 16, readOnly: 0 },
ScrollType: { Smooth: 1 },
setModelLanguage: vi.fn(),
}
return {
default: { editor, Range, languages: { getLanguages: () => [] } },
editor,
Range,
languages: { getLanguages: () => [] },
}
})
import { DiffEditorManager } from '../src/core/DiffEditorManager'
import { EditorManager } from '../src/core/EditorManager'
const EDITOR_RAF_KINDS = [
'maybe-scroll',
'reveal',
'maybe-resume',
'content-size-change',
'sync-last-known',
'immediate-reveal',
'update',
'append',
] as const
const DIFF_RAF_KINDS = [
'sync-diff-presentation',
'capture-diff-unchanged-state',
'restore-diff-unchanged-state',
'patch-diff-unchanged-regions',
'maybe-scroll-diff',
'revealDiff',
'immediate-reveal-diff',
'maybe-resume-diff',
'content-size-change-diff',
'sync-last-known-modified',
'diff',
'appendDiff',
] as const
function createEditorManager() {
return new EditorManager(
{ readOnly: true } as any,
600,
'600px',
true,
true,
32,
2,
75,
)
}
function createDiffEditorManager() {
return new DiffEditorManager(
{ readOnly: true } as any,
600,
'600px',
true,
true,
32,
2,
true,
75,
)
}
function stubRafScheduler(instance: any) {
const cancel = vi.fn()
instance.rafScheduler = { schedule: vi.fn(), cancel }
return cancel
}
describe('EditorManager cleanup semantics', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('cleanup cancels every scheduled RAF kind and pending timers', () => {
const manager = createEditorManager()
const cancel = stubRafScheduler(manager)
const revealDebounceHandle = 101
const revealIdleHandle = 102
const suppressionHandle = 103
const throttleHandle = 104
manager['revealDebounceId'] = revealDebounceHandle
manager['revealIdleTimerId'] = revealIdleHandle
manager['scrollWatcherSuppressionTimer'] = suppressionHandle
manager['updateThrottleTimer'] = throttleHandle
manager['appendBuffer'].push('x')
manager['appendBufferScheduled'] = true
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
manager.cleanup()
for (const kind of EDITOR_RAF_KINDS)
expect(cancel).toHaveBeenCalledWith(kind)
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
expect(clearSpy).toHaveBeenCalledWith(throttleHandle)
expect(manager['revealDebounceId']).toBeNull()
expect(manager['revealIdleTimerId']).toBeNull()
expect(manager['scrollWatcherSuppressionTimer']).toBeNull()
expect(manager['updateThrottleTimer']).toBeNull()
expect(manager['appendBufferScheduled']).toBe(false)
expect(manager['appendBuffer']).toHaveLength(0)
})
it('safeClean mirrors cleanup for RAFs, timers, and append state', () => {
const manager = createEditorManager()
const cancel = stubRafScheduler(manager)
const revealDebounceHandle = 201
const revealIdleHandle = 202
const suppressionHandle = 203
const throttleHandle = 204
manager['revealDebounceId'] = revealDebounceHandle
manager['revealIdleTimerId'] = revealIdleHandle
manager['scrollWatcherSuppressionTimer'] = suppressionHandle
manager['updateThrottleTimer'] = throttleHandle
manager['appendBuffer'].push('x')
manager['appendBufferScheduled'] = true
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
manager.safeClean()
for (const kind of EDITOR_RAF_KINDS)
expect(cancel).toHaveBeenCalledWith(kind)
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
expect(clearSpy).toHaveBeenCalledWith(throttleHandle)
expect(manager['revealDebounceId']).toBeNull()
expect(manager['revealIdleTimerId']).toBeNull()
expect(manager['scrollWatcherSuppressionTimer']).toBeNull()
expect(manager['updateThrottleTimer']).toBeNull()
expect(manager['appendBufferScheduled']).toBe(false)
expect(manager['appendBuffer']).toHaveLength(0)
})
})
describe('DiffEditorManager cleanup semantics', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('cleanup clears diff RAF queues, timers, and append buffers', () => {
const manager = createDiffEditorManager()
const cancel = stubRafScheduler(manager)
const revealDebounceHandle = 301
const revealIdleHandle = 302
const suppressionHandle = 303
const appendThrottleHandle = 304
const hideHandle = 305
manager['revealDebounceIdDiff'] = revealDebounceHandle
manager['revealIdleTimerIdDiff'] = revealIdleHandle
manager['diffScrollWatcherSuppressionTimer'] = suppressionHandle
manager['appendFlushThrottleTimerDiff'] = appendThrottleHandle
manager['diffHunkHideTimer'] = hideHandle
manager['appendBufferOriginalDiff'].push('left')
manager['appendBufferModifiedDiff'].push('right')
manager['appendBufferDiffScheduled'] = true
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
manager.cleanup()
for (const kind of DIFF_RAF_KINDS)
expect(cancel).toHaveBeenCalledWith(kind)
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
expect(clearSpy).toHaveBeenCalledWith(appendThrottleHandle)
expect(clearSpy).toHaveBeenCalledWith(hideHandle)
expect(manager['revealDebounceIdDiff']).toBeNull()
expect(manager['revealIdleTimerIdDiff']).toBeNull()
expect(manager['diffScrollWatcherSuppressionTimer']).toBeNull()
expect(manager['appendFlushThrottleTimerDiff']).toBeNull()
expect(manager['diffHunkHideTimer']).toBeNull()
expect(manager['appendBufferDiffScheduled']).toBe(false)
expect(manager['appendBufferOriginalDiff']).toHaveLength(0)
expect(manager['appendBufferModifiedDiff']).toHaveLength(0)
})
it('safeClean also cancels diff RAFs, timers, and append buffers', () => {
const manager = createDiffEditorManager()
const cancel = stubRafScheduler(manager)
const revealDebounceHandle = 401
const revealIdleHandle = 402
const suppressionHandle = 403
const appendThrottleHandle = 404
const hideHandle = 405
manager['revealDebounceIdDiff'] = revealDebounceHandle
manager['revealIdleTimerIdDiff'] = revealIdleHandle
manager['diffScrollWatcherSuppressionTimer'] = suppressionHandle
manager['appendFlushThrottleTimerDiff'] = appendThrottleHandle
manager['diffHunkHideTimer'] = hideHandle
manager['appendBufferOriginalDiff'].push('left')
manager['appendBufferModifiedDiff'].push('right')
manager['appendBufferDiffScheduled'] = true
const clearSpy = vi.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {})
manager.safeClean()
for (const kind of DIFF_RAF_KINDS)
expect(cancel).toHaveBeenCalledWith(kind)
expect(clearSpy).toHaveBeenCalledWith(revealDebounceHandle)
expect(clearSpy).toHaveBeenCalledWith(revealIdleHandle)
expect(clearSpy).toHaveBeenCalledWith(suppressionHandle)
expect(clearSpy).toHaveBeenCalledWith(appendThrottleHandle)
expect(clearSpy).toHaveBeenCalledWith(hideHandle)
expect(manager['revealDebounceIdDiff']).toBeNull()
expect(manager['revealIdleTimerIdDiff']).toBeNull()
expect(manager['diffScrollWatcherSuppressionTimer']).toBeNull()
expect(manager['appendFlushThrottleTimerDiff']).toBeNull()
expect(manager['diffHunkHideTimer']).toBeNull()
expect(manager['appendBufferDiffScheduled']).toBe(false)
expect(manager['appendBufferOriginalDiff']).toHaveLength(0)
expect(manager['appendBufferModifiedDiff']).toHaveLength(0)
})
})
@@ -0,0 +1,316 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
function deferred<T = unknown>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
async function loadUseMonacoHarness(options: Record<string, any> = {}) {
vi.resetModules()
const registerCalls: Array<{
themes: any
languages: any
deferred: ReturnType<typeof deferred>
}> = []
const editorManagers: any[] = []
const diffManagers: any[] = []
vi.doMock('../src/utils/registerMonacoThemes', () => {
return {
registerMonacoThemes: vi.fn((themes: any, languages: any) => {
const call = {
themes,
languages,
deferred: deferred(),
}
registerCalls.push(call)
return call.deferred.promise
}),
clearHighlighterCache: vi.fn(),
getOrCreateHighlighter: vi.fn(),
}
})
vi.doMock('../src/core/EditorManager', () => {
return {
EditorManager: class {
cleanup = vi.fn()
safeClean = vi.fn()
appendCode = vi.fn((appendText: string) => {
this.code += appendText
})
updateCode = vi.fn((code: string) => {
this.code = code
})
setLanguage = vi.fn()
code = ''
throttleMs = 50
editorView = {
dispose: vi.fn(),
getModel: vi.fn(() => ({
getValue: () => this.code,
getLanguageId: () => 'javascript',
})),
}
createEditor = vi.fn(async (
_container: HTMLElement,
code: string,
_language: string,
_theme: string,
) => {
this.code = code
return this.editorView
})
constructor(...args: any[]) {
this.throttleMs = args[8] ?? 50
editorManagers.push(this)
}
getEditorView() {
return this.editorView
}
getCode() {
return this.code
}
setUpdateThrottleMs(ms: number) {
this.throttleMs = ms
}
getUpdateThrottleMs() {
return this.throttleMs
}
},
}
})
vi.doMock('../src/core/DiffEditorManager', () => {
return {
DiffEditorManager: class {
cleanup = vi.fn()
safeClean = vi.fn()
notifyThemeChange = vi.fn()
updateDiff = vi.fn()
updateOriginal = vi.fn()
updateModified = vi.fn()
appendOriginal = vi.fn()
appendModified = vi.fn()
refreshDiffPresentation = vi.fn()
setLanguage = vi.fn()
diffEditorView = { dispose: vi.fn() }
models = { original: null, modified: null }
createDiffEditor = vi.fn(async () => this.diffEditorView)
constructor() {
diffManagers.push(this)
}
getDiffEditorView() {
return this.diffEditorView
}
getDiffModels() {
return this.models
}
async setDiffModels(models: any) {
this.models = models
}
},
}
})
vi.doMock('../src/monaco-shim', () => {
const setTheme = vi.fn()
const setModelLanguage = vi.fn()
const editor = {
setTheme,
setModelLanguage,
EditorOption: { lineHeight: 16 },
ScrollType: { Smooth: 1 },
}
const languages = {
getLanguages: () => [{ id: 'javascript' }],
register: vi.fn(),
}
const Range = class {
constructor(
public startLineNumber: number,
public startColumn: number,
public endLineNumber: number,
public endColumn: number,
) {}
}
return {
default: { editor, languages, Range, ScrollType: { Smooth: 1 } },
editor,
languages,
Range,
ScrollType: { Smooth: 1 },
}
})
const base = await import('../src/index.base')
const monaco = await import('../src/monaco-shim')
const api = base.useMonaco({
themes: ['vitesse-dark', 'vitesse-light'],
languages: ['javascript'],
...options,
})
return {
api,
monaco,
registerCalls,
editorManagers,
diffManagers,
}
}
describe('useMonaco create lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
it('replays the latest queued update after createEditor commits', async () => {
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness()
const container = {} as HTMLElement
const createPromise = api.createEditor(container, 'initial', 'javascript')
expect(registerCalls).toHaveLength(1)
api.updateCode('queued', 'javascript')
registerCalls[0].deferred.resolve(null)
await createPromise
expect(editorManagers).toHaveLength(1)
expect(editorManagers[0].updateCode).toHaveBeenCalledWith('queued', 'javascript')
})
it('rejects superseded createEditor requests and disposes request-local resources', async () => {
const firstDispose = vi.fn()
const secondDispose = vi.fn()
let beforeCreateCount = 0
const { api, registerCalls } = await loadUseMonacoHarness({
onBeforeCreate: () => {
beforeCreateCount += 1
return [{ dispose: beforeCreateCount === 1 ? firstDispose : secondDispose }]
},
})
const firstPromise = api.createEditor({} as HTMLElement, 'first', 'javascript')
expect(registerCalls).toHaveLength(1)
const secondPromise = api.createEditor({} as HTMLElement, 'second', 'javascript')
expect(registerCalls).toHaveLength(2)
expect(firstDispose).toHaveBeenCalledTimes(1)
expect(secondDispose).not.toHaveBeenCalled()
registerCalls[1].deferred.resolve(null)
await secondPromise
registerCalls[0].deferred.resolve(null)
await expect(firstPromise).rejects.toMatchObject({
message: 'Editor creation was superseded',
code: 'STREAM_MONACO_CREATE_SUPERSEDED',
})
expect(secondDispose).not.toHaveBeenCalled()
})
it('cleanupEditor cancels an in-flight create and disposes pending resources', async () => {
const dispose = vi.fn()
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness({
onBeforeCreate: () => [{ dispose }],
})
const createPromise = api.createEditor({} as HTMLElement, 'code', 'javascript')
expect(registerCalls).toHaveLength(1)
api.cleanupEditor()
expect(dispose).toHaveBeenCalledTimes(1)
expect(editorManagers).toHaveLength(0)
registerCalls[0].deferred.resolve(null)
await expect(createPromise).rejects.toMatchObject({
message: 'Editor creation was superseded',
code: 'STREAM_MONACO_CREATE_SUPERSEDED',
})
})
it('stabilizes createEditor on the latest requested theme during registration', async () => {
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness()
const container = {} as HTMLElement
const createPromise = api.createEditor(container, 'code', 'javascript')
expect(registerCalls).toHaveLength(1)
const setThemePromise = api.setTheme('vitesse-light')
expect(registerCalls).toHaveLength(2)
registerCalls[0].deferred.resolve(null)
for (let i = 0; i < 5 && registerCalls.length < 3; i++) {
await Promise.resolve()
}
expect(registerCalls).toHaveLength(3)
registerCalls[1].deferred.resolve(null)
registerCalls[2].deferred.resolve(null)
await Promise.all([createPromise, setThemePromise])
expect(editorManagers).toHaveLength(1)
expect(editorManagers[0].createEditor).toHaveBeenCalledWith(
container,
'code',
'javascript',
'vitesse-light',
)
})
it('safeClean does not cancel an in-flight create and cleanupEditor disposes committed resources', async () => {
const dispose = vi.fn()
const { api, registerCalls, editorManagers } = await loadUseMonacoHarness({
onBeforeCreate: () => [{ dispose }],
})
const createPromise = api.createEditor({} as HTMLElement, 'code', 'javascript')
expect(registerCalls).toHaveLength(1)
api.safeClean()
expect(dispose).not.toHaveBeenCalled()
registerCalls[0].deferred.resolve(null)
await createPromise
expect(editorManagers).toHaveLength(1)
expect(editorManagers[0].safeClean).not.toHaveBeenCalled()
expect(dispose).not.toHaveBeenCalled()
api.safeClean()
expect(editorManagers[0].safeClean).toHaveBeenCalledTimes(1)
expect(dispose).not.toHaveBeenCalled()
api.cleanupEditor()
expect(editorManagers[0].cleanup).toHaveBeenCalledTimes(1)
expect(dispose).toHaveBeenCalledTimes(1)
})
})

Some files were not shown because too many files have changed in this diff Show More