[current_metadata](#current_metadata) from the
// provided `code_chat_for_web` struct and store it as a global
@@ -284,8 +298,10 @@ const _open_lp = async (
// mathematics, you will need to tell MathJax about that so that it
// knows the typeset math that you are removing is no longer on the
// page."
- window.MathJax.typesetClear(codechat_body);
- if (tinymce.activeEditor === null) {
+ window.MathJax?.typesetClear?.(codechat_body);
+ // Note that `==` is intentional: `null` (no editor instance) or
+ // `undefined` (TinyMCE not loaded).
+ if (tinymce_instance() == null) {
// We shouldn't have a diff if the editor hasn't been
// initialized.
assert("Plain" in source);
@@ -305,15 +321,16 @@ const _open_lp = async (
// this is how to create a TinyMCE event handler.
setup: (editor: Editor) => {
editor.on("Dirty", () => {
- if (!ignoreDirty) {
- is_dirty = true;
+ if (!ignoreTinyMceDirty) {
+ set_is_dirty(true);
+ startAutoUpdateTimer();
}
- startAutoUpdateTimer();
});
editor.on("input", () => {
- ignoreDirty = true;
- is_dirty = true;
+ ignoreTinyMceDirty = true;
+ set_is_dirty(true);
+ startAutoUpdateTimer();
});
// Send updates on cursor movement.
@@ -329,18 +346,18 @@ const _open_lp = async (
);
},
});
- tinymce.activeEditor!.focus();
+ tinymce_instance()!.focus();
} else {
// Save the cursor location before the update, then restore it
// afterwards, if TinyMCE has focus.
- const sel = tinymce.activeEditor!.hasFocus()
+ const sel = tinymce_instance()!.hasFocus()
? saveSelection()
: undefined;
doc_content =
"Plain" in source
? source.Plain.doc
: apply_diff_str(doc_content, source.Diff.doc);
- tinymce.activeEditor!.setContent(doc_content);
+ tinymce_instance()!.setContent(doc_content);
if (sel !== undefined) {
restoreSelection(sel);
}
@@ -348,7 +365,7 @@ const _open_lp = async (
await mathJaxTypeset(codechat_body);
scroll_to_line(cursor_position, scroll_line);
} else {
- if (is_dirty && "Diff" in source) {
+ if (get_is_dirty() && "Diff" in source) {
// Send an `OutOfSync` response, so that the IDE will send the
// full text to overwrite these changes with.
webSocketComm().send_result(
@@ -376,7 +393,7 @@ const _open_lp = async (
// contents have been overwritten by contents from the IDE. By the same
// reasoning, restart the auto update timer.
clearAutoUpdateTimer();
- is_dirty = false;
+ set_is_dirty(false);
// If tests should be run, then the
// [following global variable](CodeChatEditor-test.mts#CodeChatEditor_test)
@@ -432,9 +449,10 @@ const save_lp = async (
// To save a document only, simply get the HTML from the only
// Tiny MCE div. Update the `doc_contents` to stay in sync with
// the Server.
- doc_content = tinymce.activeEditor!.save({ format: "raw" });
- // The `save()` flushes any duplicate `Dirty` events. After this, following `Dirty` events are genuine.
- ignoreDirty = false;
+ doc_content = tinymce_instance()!.save({ format: "raw" });
+ // The `save()` flushes any duplicate `Dirty` events. After
+ // this, following `Dirty` events are genuine.
+ ignoreTinyMceDirty = false;
(
code_mirror_diffable as {
Plain: CodeMirror;
@@ -520,10 +538,12 @@ export const restoreSelection = ({
// Copy the selection over to TinyMCE by indexing the selection path to find
// the selected node.
if (selection_path.length && typeof selection_offset === "number") {
- let selection_node = tinymce.activeEditor!.getContentAreaContainer();
- while (selection_path.length) {
+ let selection_node = tinymce_instance()!.getContentAreaContainer();
+ // Avoid mutating `selection_path` by making a copy of it.
+ const selection_path_copy = [...selection_path];
+ while (selection_path_copy.length) {
const new_selection_node = selection_node.childNodes[
- selection_path.shift()!
+ selection_path_copy.shift()!
] as HTMLElement;
// If we get lost during the descent, then stop just before that.
if (new_selection_node === undefined) {
@@ -539,7 +559,7 @@ export const restoreSelection = ({
selection_node.nodeValue?.length ?? 0,
);
// Use that to set the selection.
- tinymce.activeEditor!.selection.setCursorLocation(
+ tinymce_instance()!.selection.setCursorLocation(
selection_node,
final_selection_offset,
);
@@ -666,12 +686,19 @@ const on_click = (event: MouseEvent) => {
// Save the current document, then navigate to the provided URL, which must be a
// reference to another CodeChat Editor document.
const save_then_navigate = (codeChatEditorUrl: URL) => {
- send_update(true).then((_value) => {
+ const navigate = () => {
// Avoid recursion!
window.navigation.removeEventListener("navigate", on_navigate);
parent.window.CodeChatEditorFramework.webSocketComm.current_file(
codeChatEditorUrl,
);
+ };
+ // Navigate after the save completes. If the save fails, still navigate --
+ // otherwise the user is stranded on the current page with only a generic
+ // error toast -- but report the failure so the lost save isn't silent.
+ send_update(true).then(navigate, (reason) => {
+ show_toast(`Error saving before navigation: ${reason}`);
+ navigate();
});
};
@@ -694,11 +721,24 @@ export const on_error = (event: Event) => {
if (event instanceof ErrorEvent) {
err_str = `${event.filename}:${event.lineno}: ${event.message}`;
} else if (event instanceof PromiseRejectionEvent) {
- err_str = `${event.promise} rejected: ${event.reason}`;
+ const reason = event.reason;
+ let userMessage = "An unexpected error occurred. Please try again.";
+ console.log(reason, reason instanceof Error, typeof reason);
+ // A simple `reason instanceof Error` fails here. Better would be
+ // [Error.isError()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/isError),
+ // but this requires es2027.
+ if (typeof reason.message === "string") {
+ // Extracts the text from `reject(new Error('Your text'))`.
+ userMessage = reason.message;
+ } else if (typeof reason === "string") {
+ // Extracts the text from `reject('Your text')`.
+ userMessage = reason;
+ }
+ err_str = `Promise rejected: ${userMessage}`;
} else {
err_str = `Unexpected error ${typeof event}: ${event}`;
}
- show_toast(`Error: ${err_str}`);
+ show_toast(err_str);
console.error(event);
};
diff --git a/client/src/CodeChatEditorFramework.mts b/client/src/CodeChatEditorFramework.mts
index 1c7f4c56..443bd2b0 100644
--- a/client/src/CodeChatEditorFramework.mts
+++ b/client/src/CodeChatEditorFramework.mts
@@ -326,8 +326,7 @@ class WebSocketComm {
};
}
- /*eslint-disable-next-line @typescript-eslint/no-explicit-any */
- send = (data: any) => this.ws.send(data);
+ send = (data: string | BufferSource | Blob) => this.ws.send(data);
/*eslint-disable-next-line @typescript-eslint/no-explicit-any */
close = (...args: any) => this.ws.close(...args);
diff --git a/client/src/CodeMirror-integration.mts b/client/src/CodeMirror-integration.mts
index b1cdc3d8..51e7eb54 100644
--- a/client/src/CodeMirror-integration.mts
+++ b/client/src/CodeMirror-integration.mts
@@ -71,25 +71,9 @@ import {
Annotation,
TransactionSpec,
} from "@codemirror/state";
-import { cpp } from "@codemirror/lang-cpp";
-import { css } from "@codemirror/lang-css";
-import { go } from "@codemirror/lang-go";
-import { html } from "@codemirror/lang-html";
-import { java } from "@codemirror/lang-java";
-import { javascript } from "@codemirror/lang-javascript";
-import { json } from "@codemirror/lang-json";
-import { python } from "@codemirror/lang-python";
-import { rust } from "@codemirror/lang-rust";
-import { sql } from "@codemirror/lang-sql";
-import { yaml } from "@codemirror/lang-yaml";
-import { StreamLanguage } from "@codemirror/language";
-import { shell } from "@codemirror/legacy-modes/mode/shell";
-import { swift } from "@codemirror/legacy-modes/mode/swift";
-import { toml } from "@codemirror/legacy-modes/mode/toml";
-import { verilog } from "@codemirror/legacy-modes/mode/verilog";
-import { vhdl } from "@codemirror/legacy-modes/mode/vhdl";
-import { tinymce, init } from "./tinymce-config.mjs";
-import { Editor, EditorEvent, Events } from "tinymce";
+
+import type { StreamParser } from "@codemirror/language";
+import type { Editor, EditorEvent, Events } from "tinymce";
// ### Local
import {
@@ -97,6 +81,9 @@ import {
startAutoUpdateTimer,
saveSelection,
restoreSelection,
+ tinymce_instance,
+ tinymce,
+ init,
} from "./CodeChatEditor.mjs";
import {
CodeChatForWeb,
@@ -117,7 +104,7 @@ let current_view: EditorView;
let on_dirty_scheduled = false;
// This set when an `input` event occurs, which usually produces a duplicate
// `Dirty` event which should be ignored.
-let ignoreDirty = false;
+let ignoreTinyMceDirty = false;
// Options used when creating a `Decoration`.
const decorationOptions = {
@@ -127,7 +114,8 @@ const decorationOptions = {
declare global {
interface Window {
- // Tye `#types/MathJax` definitions are out of date.
+ // The `@types/MathJax` definitions are out of date and I can't figure
+ // out how to import the v4 Typescript definitions.
/*eslint-disable-next-line @typescript-eslint/no-explicit-any */
MathJax: any;
}
@@ -196,9 +184,7 @@ export const docBlockField = StateField.define`. An + // empty paragraph is created by TinyMCE as `
` element with no attributes
+/// whose only child is a ` \({a}_1, b_{2}\)
- \(a*1, b*2\)
- \([a](b)\)
- \(3 <a> b\)
- \(a \; b\) $${a}_1, b_{2}, a*1, b*2, [a](b), 3 <a> b, a \; b$$ \({a}_1, b_{2}\)
+ \(a*1, b*2\)
+ \([a](b)\)
+ \(3 <a> b\)
+ \(a \; b\) $${a}_1, b_{2}, a*1, b*2, [a](b), 3 <a> b, a \; b$$ \({a}_1, b_{2}\)
- \(a*1, b*2\)
- \([a](b)\)
- \(3 <a> b\)
- \(a \; b\) $${a}_1, b_{2}, a*1, b*2, [a](b), 3 <a> b, a \; b$$ \({a}_1, b_{2}\)
+ \(a*1, b*2\)
+ \([a](b)\)
+ \(3 <a> b\)
+ \(a \; b\) $${a}_1, b_{2}, a*1, b*2, [a](b), 3 <a> b, a \; b$$ 1
` element with no attributes.
+fn is_empty_p_with_br(node: &Rc
` element with no attributes.
+ && node.children.borrow().len() == 1
+ && node.children.borrow().first().is_some_and(|br| {
+ get_node_tag_name(br) == Some("br")
+ && matches!(&br.data, NodeData::Element { attrs, .. } if attrs.borrow().is_empty())
+ })
+}
+
fn get_node_tag_name(node: &RcFatal error
+ # 2
+ #
+ # 4
+ #
+ # 6
+ "
+ )
+ .to_string(),
+ ide_version,
+ )),
+ false,
+ 6.0,
+ )
+ .await;
+
+ // Target the iframe containing the Client.
+ select_codechat_iframe(&driver).await;
+
+ // Focus the doc block. It should produce an update with only cursor/scroll
+ // info (no contents).
+ let mut client_id = INITIAL_CLIENT_MESSAGE_ID;
+ let doc_block = driver.find(By::Css(".CodeChat-doc")).await.unwrap();
+ click_element_top_left(&driver, &doc_block).await.unwrap();
+ 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;
+
+ // Refind it, since it's now switched with a TinyMCE editor.
+ let tinymce_contents = driver.find(By::Id("TinyMCE-inst")).await.unwrap();
+
+ // Move to the next lines.
+ for expected_line in [2, 4, 6] {
+ tinymce_contents.send_keys(Key::Down).await.unwrap();
+
+ 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(expected_line)),
+ 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;
+
+ Ok(())
+}
+
+make_test!(test_8, test_8_core);
+
+// Test that Clients can insert a new paragraph.
+async fn test_8_core(
+ codechat_server: CodeChatEditorServer,
+ driver: WebDriver,
+ test_dir: PathBuf,
+) -> Result<(), WebDriverError> {
+ let path = canonicalize(test_dir.join("test.py")).unwrap();
+ let path_str = path.to_str().unwrap().to_string();
+ let ide_version = 0.0;
+ let mut server_id = perform_loadfile(
+ &codechat_server,
+ &test_dir,
+ "test.py",
+ Some((
+ indoc!(
+ "
+ # 2
+ #
+ # 4
+ "
+ )
+ .to_string(),
+ ide_version,
+ )),
+ false,
+ 6.0,
+ )
+ .await;
+
+ // Target the iframe containing the Client.
+ select_codechat_iframe(&driver).await;
+
+ // Focus the doc block. It should produce an update with only cursor/scroll
+ // info (no contents).
+ let mut client_id = INITIAL_CLIENT_MESSAGE_ID;
+ let doc_block = driver.find(By::Css(".CodeChat-doc")).await.unwrap();
+ click_element_top_left(&driver, &doc_block).await.unwrap();
+
+ 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;
+
+ // Refind it, since it's now switched with a TinyMCE editor.
+ let tinymce_contents = driver.find(By::Id("TinyMCE-inst")).await.unwrap();
+
+ // Move to the beginning of this line. Due to MacOS fun, avoid option+left
+ // arrow. TODO: the cursor movement doesn't seem to change the actual
+ // insertion point. Not sure why.
+ tinymce_contents
+ .send_keys(Key::Left + Key::Left)
+ .await
+ .unwrap();
+
+ // Uncomment for debug.
+ //use std::time::Duration;
+ //use tokio::time::sleep;
+ //sleep(Duration::from_hours(1)).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(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;
+
+ // Start a new paragraph. Wait for a re-translation as the line changes.
+ tinymce_contents.send_keys(Key::Enter).await.unwrap();
+
+ 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: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ }),
+ )
+ .await;
+ let mut version = 0.0;
+ let 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(1)),
+ 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: 10,
+ to: None,
+ insert: "#\n# \u{a0}\n".to_string(),
+ },],
+ doc_blocks: vec![],
+ version,
+ }),
+ version: client_version,
+ }),
+ })
+ }
+ );
+ version = client_version;
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+
+ // There's a re-translation sent to the client, whose response comes back to
+ // the IDE.
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: server_id,
+ message: EditorMessageContents::Result(Ok(ResultOkTypes::Void))
+ }
+ );
+ server_id += MESSAGE_ID_INCREMENT;
+
+ 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;
+
+ // ### Insert a newline between two existing paragraphs
+ //
+ // After the previous edit, the doc block contains three paragraphs. Move up
+ // to the first paragraph (producing a cursor-only update), then start a new
+ // paragraph between the first and second ones. Wait for a re-translation as
+ // the lines change.
+ tinymce_contents.send_keys(Key::Up + Key::Up).await.unwrap();
+ tinymce_contents.send_keys(Key::Enter).await.unwrap();
+
+ // The cursor move produces an optional cursor-only update before the
+ // re-translation arrives.
+ 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: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ }),
+ )
+ .await;
+ let 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(3)),
+ 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: 0,
+ to: None,
+ insert: "# \u{a0}\n#\n".to_string(),
+ },],
+ doc_blocks: vec![],
+ version,
+ }),
+ version: client_version,
+ }),
+ })
+ }
+ );
+ version = client_version;
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+
+ // There's a re-translation sent to the client, whose response comes back to
+ // the IDE.
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: server_id,
+ message: EditorMessageContents::Result(Ok(ResultOkTypes::Void))
+ }
+ );
+ server_id += MESSAGE_ID_INCREMENT;
+
+ 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;
+
+ // ### Insert a newline at the end of the document
+ //
+ // Move to the end of the last paragraph, then start a new paragraph there.
+ // Wait for a re-translation as the lines change.
+ tinymce_contents
+ .send_keys(Key::Down + Key::Down + Key::Down + Key::Down + Key::Down + Key::End)
+ .await
+ .unwrap();
+ tinymce_contents.send_keys(Key::Enter).await.unwrap();
+
+ 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: Some(1.0),
+ is_re_translation: false,
+ contents: None,
+ }),
+ )
+ .await;
+ let 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(1)),
+ 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: 22,
+ to: None,
+ insert: "#\n# \u{a0}\n".to_string(),
+ },],
+ doc_blocks: vec![],
+ version,
+ }),
+ version: client_version,
+ }),
+ })
+ }
+ );
+ codechat_server.send_result(client_id, None).await.unwrap();
+ client_id += MESSAGE_ID_INCREMENT;
+
+ // There's a re-translation sent to the client, whose response comes back to
+ // the IDE.
+ assert_eq!(
+ codechat_server.get_message_timeout(TIMEOUT).await.unwrap(),
+ EditorMessage {
+ id: server_id,
+ message: EditorMessageContents::Result(Ok(ResultOkTypes::Void))
+ }
+ );
+
+ 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;
+
+ assert_no_more_messages(&codechat_server).await;
+
+ Ok(())
+}
diff --git a/server/tests/overall_common/mod.rs b/server/tests/overall_common/mod.rs
index df15255e..4cdafdbe 100644
--- a/server/tests/overall_common/mod.rs
+++ b/server/tests/overall_common/mod.rs
@@ -250,7 +250,7 @@ macro_rules! make_test {
($test_name: ident, $test_core_name: ident) => {
#[tokio::test]
#[tracing::instrument]
- async fn $test_name() -> Result<(), Box