From 1ee53a1aacbb1d15be5cc8d77be5d68b89e0c6ec Mon Sep 17 00:00:00 2001 From: Pearce Kieser <5055971+Pearcekieser@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:52:51 +0000 Subject: [PATCH] fix(pierre): recover failed highlight worker pools --- website/src/i18n/locales/bn.json | 6 + website/src/i18n/locales/de.json | 6 + website/src/i18n/locales/en-XA.json | 6 + website/src/i18n/locales/en.json | 6 + website/src/i18n/locales/es.json | 6 + website/src/i18n/locales/fr.json | 6 + website/src/i18n/locales/hi.json | 6 + website/src/i18n/locales/it.json | 6 + website/src/i18n/locales/ja.json | 6 + website/src/i18n/locales/ko.json | 6 + website/src/i18n/locales/pt.json | 6 + website/src/i18n/locales/ru.json | 6 + website/src/i18n/locales/zh-CN.json | 6 + website/src/pierre/PierreEditorImpl.tsx | 244 +++++++-- website/src/pierre/PierreImpl.tsx | 393 +++++++++++---- website/src/pierre/PlainCodeFallback.tsx | 75 ++- website/src/pierre/config.ts | 17 +- website/src/pierre/index.tsx | 80 ++- website/src/pierre/workerHealth.ts | 61 --- website/src/pierre/workerPoolLifecycle.ts | 159 ++++++ website/src/test/PierreEditorImpl.test.tsx | 222 ++++++++- .../src/test/PierreImpl.workerPool.test.tsx | 466 ++++++++++++++---- website/src/test/pierre.poolRebind.test.tsx | 73 +++ .../test/pierre.warmSwap.slowChunk.test.tsx | 72 +++ website/src/test/pierre.warmSwap.test.tsx | 6 + website/src/test/pierre.workerHealth.test.tsx | 62 --- .../src/test/pierre.workerProtocol.test.ts | 114 +++++ website/src/test/workerPoolLifecycle.test.tsx | 178 +++++++ 28 files changed, 1892 insertions(+), 408 deletions(-) delete mode 100644 website/src/pierre/workerHealth.ts create mode 100644 website/src/pierre/workerPoolLifecycle.ts create mode 100644 website/src/test/pierre.poolRebind.test.tsx create mode 100644 website/src/test/pierre.warmSwap.slowChunk.test.tsx delete mode 100644 website/src/test/pierre.workerHealth.test.tsx create mode 100644 website/src/test/pierre.workerProtocol.test.ts create mode 100644 website/src/test/workerPoolLifecycle.test.tsx diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 69986031b12..3f99c22a110 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -6984,6 +6984,12 @@ "subtitle": "{{productName}} যে পাঁচটি বেনামী ফিল্ড পাঠায়, এবং কীভাবে তা বন্ধ করবেন।", "title": "গোপনীয়তা" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "হাইলাইটিং পুনরায় চালু হচ্ছে। সম্পাদনা করা যাবে।", + "highlighting_starting_editing_available": "সিনট্যাক্স হাইলাইটিং শুরু হচ্ছে। সম্পাদনা করা যাবে।", + "highlighting_unavailable_content_readable_reload": "পুনরায় লোড না করা পর্যন্ত সিনট্যাক্স হাইলাইটিং অনুপলব্ধ। বিষয়বস্তু পড়া যাবে।", + "highlighting_unavailable_reload": "সিনট্যাক্স হাইলাইটিং অনুপলব্ধ। আপনার খসড়া সংরক্ষণ বা অনুলিপি করুন, তারপর সিনট্যাক্স হাইলাইটিং পুনরুদ্ধার করতে পুনরায় লোড করুন; সম্পাদনা করা যাবে।" + }, "projectPicker": { "back": "পেছনে", "browse": "ব্রাউজ করুন", diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index 14321401b68..8c3bacac647 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -6984,6 +6984,12 @@ "subtitle": "Die fünf anonymen Felder, die {{productName}} sendet, und wie du sie abschaltest.", "title": "Datenschutz" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "Die Hervorhebung wird neu gestartet. Bearbeiten bleibt möglich.", + "highlighting_starting_editing_available": "Die Syntaxhervorhebung wird gestartet. Bearbeiten bleibt möglich.", + "highlighting_unavailable_content_readable_reload": "Die Syntaxhervorhebung ist bis zum Neuladen nicht verfügbar. Der Inhalt bleibt lesbar.", + "highlighting_unavailable_reload": "Die Syntaxhervorhebung ist nicht verfügbar. Speichern oder kopieren Sie Ihren Entwurf und laden Sie dann neu, um sie wiederherzustellen; Bearbeiten bleibt möglich." + }, "projectPicker": { "back": "Zurück", "browse": "Durchsuchen", diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index 20d44e33f38..135e4a6b5af 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -6462,6 +6462,12 @@ "tech_non_technical": "[Ñøţ ţèçĥñìçàĺ ············]", "tech_somewhat": "[Şøɱèẁĥàţ ············]" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "[Şýñţàẋ ĥìğĥĺìğĥţìñğ ìş ŕèşţàŕţìñğ. Èðìţìñğ ŕèɱàìñş àṽàìĺàƀĺè. ·····················]", + "highlighting_starting_editing_available": "[Şýñţàẋ ĥìğĥĺìğĥţìñğ ìş şţàŕţìñğ. Èðìţìñğ ŕèɱàìñş àṽàìĺàƀĺè. ·····················]", + "highlighting_unavailable_content_readable_reload": "[Şýñţàẋ ĥìğĥĺìğĥţìñğ ìş ùñàṽàìĺàƀĺè ùñţìĺ ýøù ŕèĺøàð. Çøñţèñţ ŕèɱàìñş ŕèàðàƀĺè. ·······················]", + "highlighting_unavailable_reload": "[Şýñţàẋ ĥìğĥĺìğĥţìñğ ìş ùñàṽàìĺàƀĺè. Şàṽè øŕ çøþý ýøùŕ ðŕàƒţ, ţĥèñ ŕèĺøàð ţø ŕèşţøŕè şýñţàẋ ĥìğĥĺìğĥţìñğ; èðìţìñğ ŕèɱàìñş àṽàìĺàƀĺè. ·······································]" + }, "projectPicker": { "back": "[Ɓàçķ ······]", "browse": "[Ɓŕøẁşè ·········]", diff --git a/website/src/i18n/locales/en.json b/website/src/i18n/locales/en.json index a9fb703eb37..565e8ab9836 100644 --- a/website/src/i18n/locales/en.json +++ b/website/src/i18n/locales/en.json @@ -4803,6 +4803,12 @@ "tell_kiro_about_you": "Tell Kiro about you", "your_role": "Your role" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "Syntax highlighting is restarting. Editing remains available.", + "highlighting_starting_editing_available": "Syntax highlighting is starting. Editing remains available.", + "highlighting_unavailable_content_readable_reload": "Syntax highlighting is unavailable until you reload. Content remains readable.", + "highlighting_unavailable_reload": "Syntax highlighting is unavailable. Save or copy your draft, then reload to restore syntax highlighting; editing remains available." + }, "projectPicker": { "back": "Back", "browse": "Browse", diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index af9168df53f..d8278605785 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -7088,6 +7088,12 @@ "subtitle": "Los cinco campos anónimos que envía {{productName}} y cómo desactivarlos.", "title": "Privacidad" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "El resaltado se está reiniciando. Puedes seguir editando.", + "highlighting_starting_editing_available": "El resaltado de sintaxis se está iniciando. Puedes seguir editando.", + "highlighting_unavailable_content_readable_reload": "El resaltado de sintaxis no estará disponible hasta que recargues. El contenido seguirá siendo legible.", + "highlighting_unavailable_reload": "El resaltado de sintaxis no está disponible. Guarda o copia tu borrador y luego recarga para restaurarlo; puedes seguir editando." + }, "projectPicker": { "back": "Atrás", "browse": "Examinar", diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index 2e05b40e72d..572424683ea 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -7088,6 +7088,12 @@ "subtitle": "Les cinq champs anonymes que {{productName}} envoie, et comment les désactiver.", "title": "Confidentialité" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "La coloration redémarre. La modification reste possible.", + "highlighting_starting_editing_available": "La coloration syntaxique démarre. La modification reste possible.", + "highlighting_unavailable_content_readable_reload": "La coloration syntaxique est indisponible jusqu’au rechargement. Le contenu reste lisible.", + "highlighting_unavailable_reload": "La coloration syntaxique est indisponible. Enregistrer ou copier le brouillon, puis recharger la page pour la restaurer ; la modification reste possible." + }, "projectPicker": { "back": "Retour", "browse": "Parcourir", diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index 79cfa7e249e..256f885ecc5 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -6984,6 +6984,12 @@ "subtitle": "{{productName}} जो पाँच अनाम फ़ील्ड भेजता है, और उन्हें कैसे बंद करना है।", "title": "गोपनीयता" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "हाइलाइटिंग फिर से शुरू हो रही है। संपादन जारी रखा जा सकता है।", + "highlighting_starting_editing_available": "सिंटैक्स हाइलाइटिंग शुरू हो रही है। संपादन जारी रखा जा सकता है।", + "highlighting_unavailable_content_readable_reload": "पुनः लोड करने तक सिंटैक्स हाइलाइटिंग उपलब्ध नहीं है। सामग्री पढ़ी जा सकती है।", + "highlighting_unavailable_reload": "सिंटैक्स हाइलाइटिंग उपलब्ध नहीं है। अपना ड्राफ़्ट सहेजें या कॉपी करें, फिर इसे बहाल करने के लिए पुनः लोड करें; संपादन जारी रखा जा सकता है।" + }, "projectPicker": { "back": "वापस", "browse": "ब्राउज़ करें", diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index e0d687895d8..9af6d8259b3 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -7088,6 +7088,12 @@ "subtitle": "I cinque campi anonimi che {{productName}} invia e come disattivarli.", "title": "Privacy" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "L'evidenziazione si sta riavviando. Puoi continuare a modificare.", + "highlighting_starting_editing_available": "L'evidenziazione della sintassi si sta avviando. Puoi continuare a modificare.", + "highlighting_unavailable_content_readable_reload": "L'evidenziazione della sintassi non sarà disponibile fino al ricaricamento. Il contenuto resta leggibile.", + "highlighting_unavailable_reload": "L'evidenziazione della sintassi non è disponibile. Salva o copia la bozza, quindi ricarica per ripristinarla; puoi continuare a modificare." + }, "projectPicker": { "back": "Indietro", "browse": "Sfoglia", diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index ac1c325eb04..12620a40448 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -6880,6 +6880,12 @@ "subtitle": "{{productName}}が送信する5つの匿名フィールドと、それをオフにする方法。", "title": "プライバシー" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "ハイライトを再起動しています。編集は続けられます。", + "highlighting_starting_editing_available": "構文ハイライトを開始しています。編集は続けられます。", + "highlighting_unavailable_content_readable_reload": "再読み込みするまで構文ハイライトを利用できません。内容は引き続き読めます。", + "highlighting_unavailable_reload": "構文ハイライトを利用できません。下書きを保存またはコピーしてから、構文ハイライトを復元するために再読み込みしてください。編集は続けられます。" + }, "projectPicker": { "back": "戻る", "browse": "参照", diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index 84218ac694d..e666ed587a5 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -6880,6 +6880,12 @@ "subtitle": "{{productName}}이(가) 전송하는 익명 필드 5개와 이를 끄는 방법입니다.", "title": "개인 정보" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "강조 표시를 다시 시작하는 중입니다. 편집은 계속할 수 있습니다.", + "highlighting_starting_editing_available": "구문 강조 표시를 시작하는 중입니다. 편집은 계속할 수 있습니다.", + "highlighting_unavailable_content_readable_reload": "다시 로드할 때까지 구문 강조 표시를 사용할 수 없습니다. 콘텐츠는 계속 읽을 수 있습니다.", + "highlighting_unavailable_reload": "구문 강조 표시를 사용할 수 없습니다. 초안을 저장하거나 복사한 다음, 구문 강조 표시를 복원하려면 다시 로드하세요. 편집은 계속할 수 있습니다." + }, "projectPicker": { "back": "뒤로", "browse": "찾아보기", diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index 7e1a8d8e368..6bc7be12712 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -7088,6 +7088,12 @@ "subtitle": "Os cinco campos anônimos que o {{productName}} envia e como desativá-los.", "title": "Privacidade" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "O realce está reiniciando. Você pode continuar editando.", + "highlighting_starting_editing_available": "O realce de sintaxe está iniciando. Você pode continuar editando.", + "highlighting_unavailable_content_readable_reload": "O realce de sintaxe ficará indisponível até você recarregar. O conteúdo continua legível.", + "highlighting_unavailable_reload": "O realce de sintaxe está indisponível. Salve ou copie seu rascunho e recarregue para restaurá-lo; você pode continuar editando." + }, "projectPicker": { "back": "Voltar", "browse": "Procurar", diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index 55722273018..e2af1de2918 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -7192,6 +7192,12 @@ "subtitle": "Пять анонимных полей, которые отправляет {{productName}}, и как их отключить.", "title": "Конфиденциальность" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "Подсветка перезапускается. Редактирование остаётся доступным.", + "highlighting_starting_editing_available": "Подсветка синтаксиса запускается. Редактирование остаётся доступным.", + "highlighting_unavailable_content_readable_reload": "Подсветка синтаксиса недоступна до перезагрузки. Содержимое остаётся читаемым.", + "highlighting_unavailable_reload": "Подсветка синтаксиса недоступна. Сохраните или скопируйте черновик, затем перезагрузите страницу, чтобы восстановить её; редактирование остаётся доступным." + }, "projectPicker": { "back": "Назад", "browse": "Обзор", diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index ae2bad90b89..7c6ad34b3c2 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -6880,6 +6880,12 @@ "subtitle": "{{productName}} 发送的五个匿名字段,以及如何关闭。", "title": "隐私" }, + "pierreEditorImpl": { + "highlighting_restarting_editing_available": "正在重新启动高亮显示。仍可继续编辑。", + "highlighting_starting_editing_available": "正在启动语法高亮。仍可继续编辑。", + "highlighting_unavailable_content_readable_reload": "重新加载前语法高亮不可用。内容仍可阅读。", + "highlighting_unavailable_reload": "语法高亮不可用。请保存或复制草稿,然后重新加载以恢复语法高亮;仍可继续编辑。" + }, "projectPicker": { "back": "返回", "browse": "浏览", diff --git a/website/src/pierre/PierreEditorImpl.tsx b/website/src/pierre/PierreEditorImpl.tsx index 9447c35d1d8..b0790eaefe3 100644 --- a/website/src/pierre/PierreEditorImpl.tsx +++ b/website/src/pierre/PierreEditorImpl.tsx @@ -3,11 +3,13 @@ * editing surface for every code-editing view. Lives beside `PierreImpl` in * the same lazy chunk; reach it through `../pierre` only. */ -import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef } from 'react' +import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { BaseCodeOptions, FileContents } from '@pierre/diffs' import { EditProvider, File, MultiFileDiff, Virtualizer } from '@pierre/diffs/react' import { Editor, type EditorOptions } from '@pierre/diffs/edit' import { useIsDark } from '../hooks/useIsDark' +import ErrorNotice from '../components/ErrorNotice' +import { i18nT } from '../i18n/t' import { PIERRE_EDIT_CARET_ALIGN_CSS, PIERRE_VIRTUALIZER_CONFIG, @@ -15,7 +17,7 @@ import { pierreFileOptions, pierreThemeType, } from './config' -import { contentCacheKey, PierreShell } from './PierreImpl' +import { activeWorkerPool, contentCacheKey, PierreShell, usePierreWorkerPool, useRegisterEditorSurface } from './PierreImpl' import { isPierreFilePairWithinBudget } from './renderBudget' export interface EditorMarker { @@ -58,6 +60,27 @@ function revealSpan(editor: Editor, line: number, endLine?: number): }]) } +function offsetToEditorPosition(contents: string, offset: number): { line: number; character: number } { + const before = contents.slice(0, Math.max(0, Math.min(offset, contents.length))) + const lastNewline = before.lastIndexOf('\n') + return { + line: lastNewline < 0 ? 0 : before.split('\n').length - 1, + character: lastNewline < 0 ? before.length : before.length - lastNewline - 1, + } +} + +function applyMarkers(editor: Editor, markers: EditorMarker[] | undefined): void { + if (markers == null) return + editor.setMarkers( + markers.map(marker => ({ + severity: marker.severity, + message: marker.message, + start: { line: marker.line - 1, character: 0 }, + end: { line: marker.line - 1, character: Number.MAX_SAFE_INTEGER }, + })), + ) +} + export const PierreEditorImpl = forwardRef(function PierreEditorImpl({ file, options, onChange, onSave, markers, onCursorChange, diffBase, diffSplit, diffExpandUnchanged, className }, ref) { const dark = useIsDark() + const poolState = usePierreWorkerPool() + useRegisterEditorSurface() + const activePool = activeWorkerPool(poolState) + const pierreActive = activePool !== undefined const resolved = useMemo( () => pierreFileOptions({ themeType: pierreThemeType(dark), ...options }), [dark, options], @@ -92,6 +119,56 @@ export const PierreEditorImpl = forwardRef(null) + const fallbackRef = useRef(null) + const restoreFocusRef = useRef(false) + const fallbackSelectionRef = useRef<{ start: number; end: number; direction: 'forward' | 'backward' | 'none' } | null>(null) + const propContentsRef = useRef(file.contents) + const latestContentsRef = useRef(file.contents) + const remountDraftRef = useRef(null) + const previousPierreActiveRef = useRef(pierreActive) + const [fallbackDraft, setFallbackDraft] = useState(file.contents) + const propChanged = propContentsRef.current !== file.contents + if (propChanged) { + propContentsRef.current = file.contents + latestContentsRef.current = file.contents + remountDraftRef.current = null + if (fallbackDraft !== file.contents) setFallbackDraft(file.contents) + } + const previousPierreActive = previousPierreActiveRef.current + const enteringFallback = previousPierreActive && !pierreActive + const leavingFallback = !previousPierreActive && pierreActive + if (enteringFallback && containerRef.current?.contains(document.activeElement)) { + restoreFocusRef.current = true + } + const focusedFallback = fallbackRef.current + if (leavingFallback && focusedFallback !== null && focusedFallback === document.activeElement) { + restoreFocusRef.current = true + fallbackSelectionRef.current = { + start: focusedFallback.selectionStart, + end: focusedFallback.selectionEnd, + direction: focusedFallback.selectionDirection ?? 'none', + } + } + previousPierreActiveRef.current = pierreActive + if (enteringFallback) { + remountDraftRef.current = latestContentsRef.current + if (fallbackDraft !== latestContentsRef.current) setFallbackDraft(latestContentsRef.current) + } + const recoveryContents = enteringFallback ? latestContentsRef.current : fallbackDraft + // The remount seed is frozen for the life of a Pierre generation: the buffer + // is the source of truth while an edit session is active, and a `file` prop + // that changed on every keystroke would clear Pierre's dirty render cache + // mid-edit. Later edits live in `latestContentsRef`, which the next + // recovery snapshot reads, so nothing typed after a remount is lost. + const editorContents = remountDraftRef.current ?? file.contents + const editorFile = useMemo( + () => (file.contents === editorContents + ? file + : { ...file, contents: editorContents, cacheKey: contentCacheKey(file.name, editorContents) }), + [file, editorContents], + ) const baseFile = useMemo( () => (diffBase == null ? null @@ -99,17 +176,30 @@ export const PierreEditorImpl = forwardRef | null>(null) /** A jump requested before Pierre bound its editor, replayed on attach. */ const pendingJumpRef = useRef<{ line: number; endLine?: number } | null>(null) - const containerRef = useRef(null) const onChangeRef = useRef(onChange) onChangeRef.current = onChange const onSaveRef = useRef(onSave) onSaveRef.current = onSave const onCursorRef = useRef(onCursorChange) onCursorRef.current = onCursorChange + const generationRef = useRef(poolState.generation) + generationRef.current = poolState.generation + const markersRef = useRef(markers) + markersRef.current = markers + const markerApplicationRef = useRef<{ generation: number; markers: EditorMarker[] | undefined } | null>(null) + + useLayoutEffect(() => { + if (pierreActive) return + editorRef.current = null + if (restoreFocusRef.current) { + fallbackRef.current?.focus() + restoreFocusRef.current = false + } + }, [pierreActive, poolState.generation]) const reportCursor = () => { const sel = editorRef.current?.getState()?.selections?.[0] @@ -122,13 +212,38 @@ export const PierreEditorImpl = forwardRef ({ onAttach(editor) { editorRef.current = editor + const generation = generationRef.current + const currentMarkers = markersRef.current + const applied = markerApplicationRef.current + if ( + currentMarkers != null + && currentMarkers.length > 0 + && !(applied?.generation === generation && applied.markers === currentMarkers) + ) { + applyMarkers(editor, currentMarkers) + markerApplicationRef.current = { generation, markers: currentMarkers } + } + const fallbackSelection = fallbackSelectionRef.current + if (fallbackSelection !== null) { + fallbackSelectionRef.current = null + editor.setSelections([{ + start: offsetToEditorPosition(latestContentsRef.current, fallbackSelection.start), + end: offsetToEditorPosition(latestContentsRef.current, fallbackSelection.end), + direction: fallbackSelection.direction, + }]) + } const pending = pendingJumpRef.current if (pending !== null) { pendingJumpRef.current = null revealSpan(editor, pending.line, pending.endLine) } + if (restoreFocusRef.current) { + restoreFocusRef.current = false + editor.focus() + } }, onChange(changed) { + latestContentsRef.current = changed.contents onChangeRef.current(changed.contents) reportCursor() }, @@ -138,16 +253,12 @@ export const PierreEditorImpl = forwardRef { const editor = editorRef.current - if (!editor || markers == null) return - editor.setMarkers( - markers.map(m => ({ - severity: m.severity, - message: m.message, - start: { line: m.line - 1, character: 0 }, - end: { line: m.line - 1, character: Number.MAX_SAFE_INTEGER }, - })), - ) - }, [markers]) + if (!editor) return + const applied = markerApplicationRef.current + if (applied?.generation === poolState.generation && applied.markers === markers) return + applyMarkers(editor, markers) + markerApplicationRef.current = { generation: poolState.generation, markers } + }, [markers, poolState.generation]) useImperativeHandle(ref, () => ({ jumpToLine: (line: number, endLine?: number) => { @@ -191,32 +302,87 @@ export const PierreEditorImpl = forwardRef - - - - {renderLiveDiff ? ( - // Live-diff edit session: Pierre diffs the buffer against the - // baseline as you type. Inputs outside the renderer-thread budget - // keep the same editor behavior but omit live diff decoration. - // Keyed so flipping modes rebuilds the edit session rather than - // rebinding one editor across surface kinds. - + + + {renderLiveDiff ? ( + // Live-diff edit session: Pierre diffs the buffer against the + // baseline as you type. Inputs outside the renderer-thread budget + // keep the same editor behavior but omit live diff decoration. + // Keyed so flipping modes rebuilds the edit session rather than + // rebinding one editor across surface kinds. + + ) : ( + + )} + + + + ) : ( +
+