From f155bd28004e4fd6bfa2634488a2ad9b9006910f Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Mon, 25 May 2026 13:52:16 +0500 Subject: [PATCH 1/9] Add: Client support for a simple debug message type. --- client/src/CodeChatEditor.mts | 10 ++++++++++ client/src/CodeChatEditorFramework.mts | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/client/src/CodeChatEditor.mts b/client/src/CodeChatEditor.mts index f9d2995c..7b2cedc0 100644 --- a/client/src/CodeChatEditor.mts +++ b/client/src/CodeChatEditor.mts @@ -107,6 +107,7 @@ declare global { cursor_position?: CursorPosition, scroll_line?: number, ) => Promise; + do_debug: () => void; send_update: (_only_if_dirty: boolean) => Promise; scroll_to_line: ( cursor_position?: CursorPosition, @@ -197,6 +198,14 @@ const open_lp = async ( // incorrect results. This text is the unmodified content sent from the IDE. let doc_content = ""; +// For debugging, allow the extension or server to run this routine by sending +// the appropriate message. +const do_debug = () => { + if (DEBUG_ENABLED) { + tinymce.activeEditor?.save({ format: "raw" }); + } +}; + // This function is called on page load to "load" a file. Before this point, the // server has already lexed the source file into code and doc blocks; this // function transforms the code and doc blocks into HTML and updates the current @@ -720,6 +729,7 @@ on_dom_content_loaded(async () => { window.addEventListener("error", on_error); window.CodeChatEditor = { + do_debug, open_lp, send_update, scroll_to_line, diff --git a/client/src/CodeChatEditorFramework.mts b/client/src/CodeChatEditorFramework.mts index b25354e5..da36e2bd 100644 --- a/client/src/CodeChatEditorFramework.mts +++ b/client/src/CodeChatEditorFramework.mts @@ -283,6 +283,12 @@ class WebSocketComm { } case "Result": { + // If the result has the magic ID, then call a debug + // routine. + if (id === 1e6 && DEBUG_ENABLED) { + root_iframe!.contentWindow?.CodeChatEditor?.do_debug(); + break; + } // Cancel the timer for this message and remove it from // `pending_messages`. const pending_message = this.pending_messages[id]; From 407707b4d635afd1b17675fd4d987569f01118fd Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Mon, 25 May 2026 13:53:11 +0500 Subject: [PATCH 2/9] Fix: console_log now reports correct line number. Correct typo. --- client/src/CodeChatEditor.mts | 12 ++++++------ client/src/CodeChatEditorFramework.mts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/CodeChatEditor.mts b/client/src/CodeChatEditor.mts index 7b2cedc0..5760df99 100644 --- a/client/src/CodeChatEditor.mts +++ b/client/src/CodeChatEditor.mts @@ -692,12 +692,12 @@ const scroll_to_line = ( } }; -/*eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export const console_log = (...args: any) => { - if (DEBUG_ENABLED) { - console.log(...args); - } -}; +// If debug is enabled, show the line number of the caller, not the current line +// number, in the log output. +export const console_log = DEBUG_ENABLED + ? console.log.bind(console) + : /*eslint-disable-next-line @typescript-eslint/no-explicit-any */ + (..._args: any) => undefined; // A global error handler: this is called on any uncaught exception. export const on_error = (event: Event) => { diff --git a/client/src/CodeChatEditorFramework.mts b/client/src/CodeChatEditorFramework.mts index da36e2bd..8eac4f3c 100644 --- a/client/src/CodeChatEditorFramework.mts +++ b/client/src/CodeChatEditorFramework.mts @@ -413,7 +413,7 @@ class WebSocketComm { Result: result === undefined ? { Ok: "Void" } : { Err: result }, }; console_log( - `CodeChat Client: sending result id = ${id}, message = ${format_struct(message)}`, + `CodeChat Editor Client: sending result id = ${id}, message = ${format_struct(message)}`, ); // We can't simply call `send_message` because that function expects a // result message back from the server. From c25f620693e6afd28527b850884573f19561b40d Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Mon, 25 May 2026 14:26:29 +0500 Subject: [PATCH 3/9] Fix: Avoid unnecessary saves when a doc block is focused. Don't allow edits of a doc block before it's replaced by the TinyMCE editor. --- client/src/CodeMirror-integration.mts | 30 ++++-- server/tests/overall_1.rs | 136 +++++++++++++++----------- server/tests/overall_2.rs | 104 +++++++++++++++----- server/tests/overall_common/mod.rs | 43 -------- 4 files changed, 179 insertions(+), 134 deletions(-) diff --git a/client/src/CodeMirror-integration.mts b/client/src/CodeMirror-integration.mts index ef7215e7..429cdf90 100644 --- a/client/src/CodeMirror-integration.mts +++ b/client/src/CodeMirror-integration.mts @@ -473,11 +473,13 @@ class DocBlockWidget extends WidgetType { wrap.innerHTML = // This doc block's indent. TODO: allow paste, but must only allow // pasting whitespace. - `
${sanitize_html(this.indent)}
` + - // The contents of this doc block. - `
` + + // The contents of this doc block. Make it focusable by assigning a + // tab stop, but not editable (until it's replaced by the TinyMCE + // editor). + `
` + this.contents + "
"; // TODO: this is an async call. However, CodeMirror doesn't provide @@ -548,8 +550,8 @@ class DocBlockWidget extends WidgetType { codechat_body.insertBefore(tinymce_div, null); // Make TinyMCE invisible, since it's placed below the body of the // page. - tinymce_div.classList.add(CODECHAT_DOC_HIDDEN); - tinymce.activeEditor?.resetContent(); + tinymce.get(0)!.dom.addClass(tinymce_div, CODECHAT_DOC_HIDDEN); + tinymce.get(0)!.resetContent(); } } } @@ -833,9 +835,9 @@ export const DocBlockPlugin = ViewPlugin.fromClass( // If the contents aren't editable, then the div // won't receive a `focusin` message (it instead // goes to a CodeMirror layer). - old_contents_div.contentEditable = "true"; + old_contents_div.tabIndex = 0; old_contents_div.innerHTML = - tinymce.activeEditor!.save({ format: "raw" }); + tinymce.activeEditor!.save(); tinymce_div.parentNode!.insertBefore( old_contents_div, null, @@ -848,11 +850,21 @@ export const DocBlockPlugin = ViewPlugin.fromClass( // div it will replace. target.insertBefore(tinymce_div, null); - tinymce.activeEditor!.setContent( + // Calling `setContent()` instead produces spurious + // `Dirty` events, observed after receiving a + // re-translation. In addition, `resetContent()` clears + // the undo history, which is appropriate given that + // edits to the previous doc block no longer apply here. + // TODO: Eventually, we need a way to chain TinyMCE's + // undo history with CodeMirror's undo history. + tinymce.activeEditor!.resetContent( contents_div.innerHTML, ); contents_div.remove(); - tinymce_div.classList.remove(CODECHAT_DOC_HIDDEN); + tinymce.activeEditor!.dom.removeClass( + tinymce_div, + CODECHAT_DOC_HIDDEN, + ); // The new div is now a TinyMCE editor. Retypeset this. await mathJaxTypeset(tinymce_div); diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index 23e64d10..a6b7f051 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -786,6 +786,25 @@ async fn test_client_updates_core( // Select the doc block and add to the line, causing a word wrap. let contents_css = ".CodeChat-CodeMirror .CodeChat-doc-contents"; + let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap(); + doc_block_contents.click().await.unwrap(); + let mut client_id = INITIAL_CLIENT_MESSAGE_ID; + assert_eq!( + codechat_server.get_message_timeout(TIMEOUT).await.unwrap(), + EditorMessage { + id: client_id, + message: EditorMessageContents::Update(UpdateMessageContents { + file_path: path_str.clone(), + cursor_position: Some(CursorPosition::Line(1)), + scroll_position: Some(1.0), + is_re_translation: false, + contents: None, + }) + } + ); + codechat_server.send_result(client_id, None).await.unwrap(); + client_id += MESSAGE_ID_INCREMENT; + let doc_block_contents = driver.find(By::Css(contents_css)).await.unwrap(); doc_block_contents .send_keys("" + Key::End + " testing") @@ -794,7 +813,6 @@ async fn test_client_updates_core( // Get the next message, which could be a cursor update followed by a text // update, or just the text update. - let mut client_id = INITIAL_CLIENT_MESSAGE_ID; let mut msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); if let EditorMessageContents::Update(ref update) = msg.message && update.contents.is_none() @@ -942,63 +960,71 @@ async fn test_client_updates_core( } ); codechat_server.send_result(client_id, None).await.unwrap(); - //client_id += MESSAGE_ID_INCREMENT; + client_id += MESSAGE_ID_INCREMENT; - /*x TODO broken by OutOfSync due to unnecessary save after re-translate. - // Send the original text back, to ensure the re-translation correctly updated the Client. - ide_version = 1.0; - let ide_id = codechat_server - .send_message_update_plain( - path_str.clone(), - Some((orig_text, ide_version)), - Some(1), - None, - ) - .await - .unwrap(); - assert_eq!( - codechat_server.get_message_timeout(TIMEOUT).await.unwrap(), - EditorMessage { - id: ide_id, - message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)) - } - ); - // Trigger a client edit to send the Client contents back. - let code_line = driver.find(By::Css(code_line_css)).await.unwrap(); - code_line.send_keys(" ").await.unwrap(); + // Send the original text back, to ensure the re-translation correctly updated the Client. + let ide_version = 1.0; + let ide_id = codechat_server + .send_message_update_plain( + path_str.clone(), + Some((orig_text, ide_version)), + Some(1), + None, + ) + .await + .unwrap(); + assert_eq!( + codechat_server.get_message_timeout(TIMEOUT).await.unwrap(), + EditorMessage { + id: ide_id, + message: EditorMessageContents::Result(Ok(ResultOkTypes::Void)) + } + ); + // Trigger a client edit to send the Client contents back. + let code_line = driver.find(By::Css(code_line_css)).await.unwrap(); + code_line.send_keys(" ").await.unwrap(); - let msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); - let new_client_version = get_version(&msg); - assert_eq!( - msg, - EditorMessage { - id: client_id, - message: EditorMessageContents::Update(UpdateMessageContents { - file_path: path_str.clone(), - cursor_position: Some(CursorPosition::Line(2)), - scroll_position: Some(1.0), - is_re_translation: false, - contents: Some(CodeChatForWeb { - metadata: SourceFileMetadata { - mode: "python".to_string(), - }, - source: CodeMirrorDiffable::Diff(CodeMirrorDiff { - doc: vec![StringDiff { - from: 79, - to: Some(90), - insert: "def foo(): \n".to_string() - }], - doc_blocks: vec![], - version: ide_version, - }), - version: new_client_version, + let msg = optional_message( + &codechat_server, + &mut client_id, + EditorMessageContents::Update(UpdateMessageContents { + file_path: path_str.clone(), + cursor_position: Some(CursorPosition::Line(1)), + scroll_position: None, + is_re_translation: false, + contents: None, + }), + ) + .await; + let new_client_version = get_version(&msg); + assert_eq!( + msg, + EditorMessage { + id: client_id, + message: EditorMessageContents::Update(UpdateMessageContents { + file_path: path_str.clone(), + cursor_position: Some(CursorPosition::Line(2)), + scroll_position: Some(1.0), + is_re_translation: false, + contents: Some(CodeChatForWeb { + metadata: SourceFileMetadata { + mode: "python".to_string(), + }, + source: CodeMirrorDiffable::Diff(CodeMirrorDiff { + doc: vec![StringDiff { + from: 79, + to: Some(90), + insert: "def foo(): \n".to_string() + }], + doc_blocks: vec![], + version: ide_version, }), - }) - } - ); - codechat_server.send_result(client_id, None).await.unwrap(); - */ - + version: new_client_version, + }), + }) + } + ); + codechat_server.send_result(client_id, None).await.unwrap(); assert_no_more_messages(&codechat_server).await; Ok(()) diff --git a/server/tests/overall_2.rs b/server/tests/overall_2.rs index 6901dd33..c139cc77 100644 --- a/server/tests/overall_2.rs +++ b/server/tests/overall_2.rs @@ -33,12 +33,12 @@ use std::{error::Error, path::PathBuf}; use dunce::canonicalize; use indoc::indoc; use pretty_assertions::assert_eq; -use thirtyfour::{By, WebDriver, error::WebDriverError}; +use thirtyfour::{By, Key, WebDriver, error::WebDriverError}; // ### Local use crate::overall_common::{ - TIMEOUT, assert_no_more_messages, get_empty_client_update, get_version, optional_message, - perform_loadfile, select_codechat_iframe, + TIMEOUT, assert_no_more_messages, get_version, optional_message, perform_loadfile, + select_codechat_iframe, }; use code_chat_editor::{ ide::CodeChatEditorServer, @@ -111,29 +111,38 @@ async fn test_4_core( client_id += MESSAGE_ID_INCREMENT; doc_blocks[1].click().await.unwrap(); - let mut client_version = 0.0; - get_empty_client_update( - &codechat_server, - &path_str, - &mut client_id, - &mut client_version, - "python", - Some(CursorPosition::Line(3)), - Some(1.0), - ) - .await; + assert_eq!( + codechat_server.get_message_timeout(TIMEOUT).await.unwrap(), + EditorMessage { + id: client_id, + message: EditorMessageContents::Update(UpdateMessageContents { + file_path: path_str.clone(), + cursor_position: Some(CursorPosition::Line(3)), + scroll_position: Some(1.0), + is_re_translation: false, + contents: None, + }) + } + ); + codechat_server.send_result(client_id, None).await.unwrap(); + client_id += MESSAGE_ID_INCREMENT; doc_blocks[2].click().await.unwrap(); - get_empty_client_update( - &codechat_server, - &path_str, - &mut client_id, - &mut client_version, - "python", - Some(CursorPosition::Line(5)), - Some(1.0), - ) - .await; + assert_eq!( + codechat_server.get_message_timeout(TIMEOUT).await.unwrap(), + EditorMessage { + id: client_id, + message: EditorMessageContents::Update(UpdateMessageContents { + file_path: path_str.clone(), + cursor_position: Some(CursorPosition::Line(5)), + scroll_position: Some(1.0), + is_re_translation: false, + contents: None, + }) + } + ); + codechat_server.send_result(client_id, None).await.unwrap(); + //client_id += MESSAGE_ID_INCREMENT; assert_no_more_messages(&codechat_server).await; @@ -362,17 +371,58 @@ async fn test_6_core( // Check the content. let body_css = "#CodeChat-body .CodeChat-doc-contents"; let body_content = driver.find(By::Css(body_css)).await.unwrap(); + body_content.click().await.unwrap(); + let body_content = driver.find(By::Css(body_css)).await.unwrap(); + let mut client_id = INITIAL_CLIENT_MESSAGE_ID; + // Sometimes, the cursor starts at the beginning of the doc block before it + // moves to the end. + let msg = optional_message( + &codechat_server, + &mut client_id, + EditorMessageContents::Update(UpdateMessageContents { + file_path: path_str.clone(), + cursor_position: Some(CursorPosition::Line(1)), + scroll_position: None, + is_re_translation: false, + contents: None, + }), + ) + .await; + assert_eq!( + msg, + EditorMessage { + id: client_id, + message: EditorMessageContents::Update(UpdateMessageContents { + file_path: path_str.clone(), + cursor_position: Some(CursorPosition::Line(3)), + scroll_position: None, + is_re_translation: false, + contents: None, + }) + } + ); + codechat_server.send_result(client_id, None).await.unwrap(); + client_id += MESSAGE_ID_INCREMENT; - // Perform edits. + // Perform edits at beginning of document. + body_content + .send_keys( + if cfg!(target_os = "macos") { + Key::Command + } else { + Key::Control + } + Key::Home, + ) + .await + .unwrap(); body_content.send_keys("a").await.unwrap(); - let mut client_id = INITIAL_CLIENT_MESSAGE_ID; // Sometimes, a cursor update gets sent before the edit. let msg = optional_message( &codechat_server, &mut client_id, EditorMessageContents::Update(UpdateMessageContents { file_path: path_str.clone(), - cursor_position: Some(CursorPosition::Line(1)), + cursor_position: Some(CursorPosition::Line(3)), scroll_position: None, is_re_translation: false, contents: None, diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs index 150eaef7..df15255e 100644 --- a/server/tests/overall_common/mod.rs +++ b/server/tests/overall_common/mod.rs @@ -63,7 +63,6 @@ use tracing_subscriber::EnvFilter; // ### Local use code_chat_editor::{ ide::CodeChatEditorServer, - processing::{CodeChatForWeb, CodeMirrorDiff, CodeMirrorDiffable, SourceFileMetadata}, webserver::{ CursorPosition, EditorMessage, EditorMessageContents, MESSAGE_ID_INCREMENT, ResultOkTypes, UpdateMessageContents, set_root_path, @@ -422,48 +421,6 @@ pub async fn select_codechat_iframe(driver_ref: &WebDriver) -> WebElement { codechat_iframe } -// Used in one of the common tests, but not in the other...so we get a clippy -// lint. -#[allow(dead_code)] -pub async fn get_empty_client_update( - codechat_server: &CodeChatEditorServer, - path_str: &str, - client_id: &mut f64, - client_version: &mut f64, - mode: &str, - cursor_position: Option, - scroll_position: Option, -) { - let msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); - let version = *client_version; - *client_version = get_version(&msg); - assert_eq!( - msg, - EditorMessage { - id: *client_id, - message: EditorMessageContents::Update(UpdateMessageContents { - file_path: path_str.to_owned(), - cursor_position, - scroll_position, - is_re_translation: false, - contents: Some(CodeChatForWeb { - metadata: SourceFileMetadata { - mode: mode.to_string() - }, - source: CodeMirrorDiffable::Diff(CodeMirrorDiff { - doc: vec![], - doc_blocks: vec![], - version, - }), - version: *client_version - }), - }) - } - ); - codechat_server.send_result(*client_id, None).await.unwrap(); - *client_id += MESSAGE_ID_INCREMENT; -} - pub async fn assert_no_more_messages(codechat_server: &CodeChatEditorServer) { if let Some(msg) = codechat_server .get_message_timeout(Duration::from_millis(500)) From dbc3c8a172346d3820c09b9a70f5f13ad7fe9924 Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Thu, 4 Jun 2026 19:57:49 +0500 Subject: [PATCH 4/9] Fix: No dirty on document undo. --- client/src/CodeChatEditor.mts | 29 +++++----- client/src/CodeMirror-integration.mts | 81 ++++++++++++++++++++++++--- server/tests/overall_1.rs | 18 ------ 3 files changed, 89 insertions(+), 39 deletions(-) diff --git a/client/src/CodeChatEditor.mts b/client/src/CodeChatEditor.mts index 5760df99..ca08033e 100644 --- a/client/src/CodeChatEditor.mts +++ b/client/src/CodeChatEditor.mts @@ -307,21 +307,22 @@ const _open_lp = async ( // [handling editor events](https://www.tiny.cloud/docs/tinymce/6/events/#handling-editor-events), // this is how to create a TinyMCE event handler. setup: (editor: Editor) => { - editor.on( - "dirty", - ( - event: EditorEvent< - Events.EditorEventMap["dirty"] - >, - ) => { - // Sometimes, `tinymce.activeEditor` is null - // (perhaps when it's not focused). Use the - // `event` data instead. - event.target.setDirty(false); + let inputTimer: undefined | NodeJS.Timeout = undefined; + editor.on("dirty", () => { + if (inputTimer === undefined) { is_dirty = true; - startAutoUpdateTimer(); - }, - ); + } + startAutoUpdateTimer(); + }); + + editor.on("input", () => { + inputTimer = setTimeout( + () => (inputTimer = undefined), + 0, + ); + is_dirty = true; + }); + // Send updates on cursor movement. editor.on( "SelectionChange", diff --git a/client/src/CodeMirror-integration.mts b/client/src/CodeMirror-integration.mts index 429cdf90..d4c19dc3 100644 --- a/client/src/CodeMirror-integration.mts +++ b/client/src/CodeMirror-integration.mts @@ -1102,26 +1102,93 @@ export const CodeMirror_load = async ( await init({ selector: "#TinyMCE-inst", setup: (editor: Editor) => { + let inputTimer: undefined | NodeJS.Timeout = undefined; // See the // [docs](https://www.tiny.cloud/docs/tinymce/latest/events/#editor-core-events). - // This is triggered on edits (just as the `input` event), but - // also when applying formatting changes, inserting images, etc. - // that the above callback misses. + // After much experimentation, using both an `input` event + // (which suppresses the redundant `Dirty` event which follows + // it) combined with a `Dirty` event (which catches GUI + // interactions, undo, etc. which doesn't produce an `input` + // event). Just using `Dirty` produces one failing case: insert + // a character (dirty event), delete the character (no dirty + // event), left arrow (delayed dirty event from backspace). + // + // Here's a demonstration of the bug and its fix: + // + // ``` + // + // + // + // + // TinyMCE Dirty Event Test + // + // + //

TinyMCE Dirty Event Test

+ // + // + // + // + // + // + // ``` + + // ``` + // ``` editor.on( "Dirty", (event: EditorEvent) => { // Sometimes, `tinymce.activeEditor` is null (perhaps // when it's not focused). Use the `event` data instead. - event.target.setDirty(false); // Get the div TinyMCE stores edits in. - const target_or_false = event.target.bodyElement; - if (target_or_false == null) { + const target = event.target.bodyElement; + if (target == null) { return; } - setTimeout(() => on_dirty(target_or_false)); + if (inputTimer === undefined) { + on_dirty(target); + } }, ); + editor.on("input", (event: InputEvent) => { + const target = event.target as HTMLElement; + if (target == null) { + return; + } + inputTimer = setTimeout(() => (inputTimer = undefined), 0); + on_dirty(target); + }); + // Send updates on cursor movement. editor.on( "SelectionChange", diff --git a/server/tests/overall_1.rs b/server/tests/overall_1.rs index a6b7f051..9695679f 100644 --- a/server/tests/overall_1.rs +++ b/server/tests/overall_1.rs @@ -453,24 +453,6 @@ async fn test_server_core( codechat_server.send_result(client_id, None).await.unwrap(); client_id += MESSAGE_ID_INCREMENT; - // Get the resulting cursor position update after the edit. - let msg = codechat_server.get_message_timeout(TIMEOUT).await.unwrap(); - assert_eq!( - msg, - EditorMessage { - id: client_id, - message: EditorMessageContents::Update(UpdateMessageContents { - file_path: md_path_str.clone(), - cursor_position: Some(CursorPosition::Line(1)), - scroll_position: None, - is_re_translation: false, - contents: None, - }) - } - ); - codechat_server.send_result(client_id, None).await.unwrap(); - client_id += MESSAGE_ID_INCREMENT; - // Perform an IDE edit. version = 5.0; let ide_id = codechat_server From bdf07a4c29e112d95aaaa2ee461d4d271db4614c Mon Sep 17 00:00:00 2001 From: "Bryan A. Jones" Date: Mon, 1 Jun 2026 14:37:56 +0500 Subject: [PATCH 5/9] Fix: remove debug print. --- server/src/translation.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/src/translation.rs b/server/src/translation.rs index 892d2a4a..c467b044 100644 --- a/server/src/translation.rs +++ b/server/src/translation.rs @@ -1355,9 +1355,6 @@ fn compare_html( // paragraph

`, which minifies to `

Previous // paragraph

`. Fix up this difference. let raw_html = raw_html.replace("