diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d52a3417..341e0897 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -# This file was autogenerated by dist: https://opensource.axo.dev/cargo-dist/ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist # # Copyright 2022-2024, axodotdev # SPDX-License-Identifier: MIT or Apache-2.0 @@ -63,7 +63,7 @@ jobs: # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.28.1-prerelease.2/cargo-dist-installer.sh | sh" + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.28.2/cargo-dist-installer.sh | sh" - name: Cache dist uses: actions/upload-artifact@v4 with: diff --git a/client/src/CodeChatEditor.mts b/client/src/CodeChatEditor.mts index 76882cb4..1d28d65c 100644 --- a/client/src/CodeChatEditor.mts +++ b/client/src/CodeChatEditor.mts @@ -113,7 +113,7 @@ declare global { interface Window { CodeChatEditor: { // Called by the Client Framework. - open_lp: (all_source: CodeChatForWeb) => Promise; + open_lp: (code_chat_for_web: CodeChatForWeb) => Promise; on_save: (_only_if_dirty: boolean) => Promise; allow_navigation: boolean; }; @@ -211,8 +211,8 @@ const is_doc_only = () => { }; // Wait for the DOM to load before opening the file. -const open_lp = async (all_source: CodeChatForWeb) => - on_dom_content_loaded(() => _open_lp(all_source)); +const open_lp = async (code_chat_for_web: CodeChatForWeb) => + on_dom_content_loaded(() => _open_lp(code_chat_for_web)); // Store the HTML sent for CodeChat Editor documents. We can't simply use TinyMCE's [getContent](https://www.tiny.cloud/docs/tinymce/latest/apis/tinymce.editor/#getContent), since this modifies the content based on cleanup rules before returning it -- which causes applying diffs to this unexpectedly modified content to produce incorrect results. This text is the unmodified content sent from the IDE. let doc_content = ""; @@ -224,7 +224,7 @@ let doc_content = ""; const _open_lp = async ( // A data structure provided by the server, containing the source and // associated metadata. See[`AllSource`](#AllSource). - all_source: CodeChatForWeb, + code_chat_for_web: CodeChatForWeb, ) => { // Use[URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) // to parse out the search parameters of this window's URL. @@ -239,9 +239,9 @@ const _open_lp = async ( const editorMode = EditorMode[urlParams.get("mode") ?? "edit"]; // Get thecurrent_metadata from - // the provided`all_source` struct and store it as a global variable. - current_metadata = all_source["metadata"]; - const source = all_source["source"]; + // the provided `code_chat_for_web` struct and store it as a global variable. + current_metadata = code_chat_for_web["metadata"]; + const source = code_chat_for_web["source"]; const codechat_body = document.getElementById( "CodeChat-body", ) as HTMLDivElement; diff --git a/client/src/CodeMirror-integration.mts b/client/src/CodeMirror-integration.mts index df6ff376..2c35b7d7 100644 --- a/client/src/CodeMirror-integration.mts +++ b/client/src/CodeMirror-integration.mts @@ -66,7 +66,6 @@ import { StateEffect, EditorSelection, Transaction, - TransactionSpec, Annotation, } from "@codemirror/state"; import { cpp } from "@codemirror/lang-cpp"; @@ -1027,7 +1026,7 @@ export const CodeMirror_load = async ( } }; -// Appply a `StringDiff` to the before string to produce the after string. +// Apply a `StringDiff` to the before string to produce the after string. export const apply_diff_str = (before: string, diffs: StringDiff[]) => { // Walk from the last diff to the first. JavaScript doesn't have reverse // iteration AFAIK. diff --git a/docs/changelog.md b/docs/changelog.md index 476b624d..c802e2ce 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -20,7 +20,7 @@ Changelog ========= * [Github master](https://github.com/bjones1/CodeChat_Editor): - * No changes. + * Better support for opening a page in a web browser. * v0.1.21, 2025-Jul-18: * Allow specifying the host address the server binds to. * Send server logs to the console by default. diff --git a/extensions/VSCode/src/extension.ts b/extensions/VSCode/src/extension.ts index f00d3165..9ca0ff5a 100644 --- a/extensions/VSCode/src/extension.ts +++ b/extensions/VSCode/src/extension.ts @@ -30,7 +30,7 @@ import process from "node:process"; // ### Third-party packages import escape from "escape-html"; -import vscode, { commands, ViewColumn } from "vscode"; +import vscode, { commands, Position, Range } from "vscode"; import { WebSocket } from "ws"; // ### Local packages @@ -40,9 +40,7 @@ import { MessageResult, UpdateMessageContents, } from "../../../client/src/shared_types.mjs"; -// -// None. -// + // Globals // ------- enum CodeChatEditorClientLocation { @@ -123,12 +121,6 @@ export const activate = (context: vscode.ExtensionContext) => { // `\_. context.subscriptions.push( vscode.workspace.onDidChangeTextDocument((event) => { - // If this change was produced by applying an - // `Update` from the Client, ignore it. Do this first, in case the update causes no changes to the content, since we still need to set `ignore_text_document_change` to `false`. - if (ignore_text_document_change) { - ignore_text_document_change = false; - return; - } // VSCode sends empty change events -- ignore these. if (event.contentChanges.length === 0) { return; @@ -373,15 +365,14 @@ export const activate = (context: vscode.ExtensionContext) => { const source = current_update.contents.source; // Is this plain text, or a diff? + // This will produce a change event, which + // we'll ignore. + ignore_text_document_change = true; + // Use a workspace edit, since calls to + // `TextEditor.edit` must be made to the + // active editor only. + const wse = new vscode.WorkspaceEdit(); if ("Plain" in source) { - const new_contents = source.Plain.doc; - // This will produce a change event, which - // we'll ignore. - ignore_text_document_change = true; - // Use a workspace edit, since calls to - // `TextEditor.edit` must be made to the - // active editor only. - const wse = new vscode.WorkspaceEdit(); wse.replace( doc.uri, doc.validateRange( @@ -392,12 +383,32 @@ export const activate = (context: vscode.ExtensionContext) => { 0, ), ), - new_contents, + source.Plain.doc, ); - vscode.workspace.applyEdit(wse); + } else { + assert("Diff" in source); + const diffs = source.Diff.doc; + for (const diff of diffs) { + // Convert from character offsets from the beginning of the document to a `Position` + // (line, then offset on that line) needed by VSCode. + const from = doc.positionAt(diff.from); + if (diff.to === undefined) { + // This is an insert. + wse.insert(doc.uri, from, diff.insert); + } else { + // This is a replace or delete. + const to = doc.positionAt(diff.to); + wse.replace( + doc.uri, + new Range(from, to), + diff.insert, + ); + } + } } + vscode.workspace.applyEdit(wse).then(() => ignore_text_document_change = false); } else { - // TODO handle diffs. + // TODO: handle cursor/scroll position updates. assert(false); } send_result(id); diff --git a/server/Cargo.toml b/server/Cargo.toml index cec09c55..443c408d 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -50,8 +50,10 @@ lexer_explain = [] # ------------ [dependencies] actix-files = "0.6" +actix-http = "3.9.0" actix-rt = "2.9.0" actix-web = "4" +actix-web-httpauth = "0.8.2" actix-ws = "0.3.0" bytes = { version = "1", features = ["serde"] } chrono = "0.4" @@ -68,7 +70,6 @@ mime_guess = "2.0.5" minreq = "2.12.0" normalize-line-endings = "0.3.0" notify-debouncer-full = "0.5" -open = "5.3.0" path-slash = "0.2.1" pest = "2.7.14" pest_derive = "2.7.14" @@ -86,6 +87,7 @@ tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } ts-rs = { version = "11.0.1", features = ["serde-compat", "import-esm"] } url = "2.5.2" urlencoding = "2" +webbrowser = "1.0.5" # [Windows-only # dependencies](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#platform-specific-dependencies). diff --git a/server/dist-workspace.toml b/server/dist-workspace.toml index 3dd9c63a..cb33a669 100644 --- a/server/dist-workspace.toml +++ b/server/dist-workspace.toml @@ -25,7 +25,7 @@ members = ["cargo:."] # Config for 'dist' [dist] # The preferred dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.28.1-prerelease.2" +cargo-dist-version = "0.28.2" # Extra static files to include in each App (path relative to this Cargo.toml's dir) include = ["log4rs.yml", "hashLocations.json", "../client/static"] # The installers to generate for each app diff --git a/server/src/main.rs b/server/src/main.rs index 4565b0ba..e96a2438 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -36,7 +36,7 @@ use clap::{Parser, Subcommand}; use log::LevelFilter; // ### Local -use code_chat_editor::webserver::{self, GetServerUrlError, path_to_url}; +use code_chat_editor::webserver::{self, Credentials, GetServerUrlError, path_to_url}; // Data structures // --------------- @@ -78,6 +78,10 @@ enum Commands { /// Control logging verbosity. #[arg(short, long)] log: Option, + + /// Define the username:password used to limit access to the server. By default, access is unlimited. + #[arg(short, long, value_parser = parse_credentials)] + credentials: Option, }, /// Start the webserver in a child process then exit. Start { @@ -96,7 +100,7 @@ enum Commands { impl Cli { fn run(self, addr: &SocketAddr) -> Result<(), Box> { match &self.command { - Commands::Serve { log } => { + Commands::Serve { log, credentials } => { #[cfg(debug_assertions)] if let Some(TestMode::Sleep) = self.test_mode { // For testing, don't start the server at all. @@ -104,7 +108,7 @@ impl Cli { return Ok(()); } webserver::configure_logger(log.unwrap_or(LevelFilter::Info))?; - webserver::main(addr).unwrap(); + webserver::main(addr, credentials.clone()).unwrap(); } Commands::Start { open } => { // Poll the server to ensure it starts. @@ -134,7 +138,7 @@ impl Cli { let open_path = fs::canonicalize(open_path)?; let open_path = path_to_url(&format!("{address}/fw/fsb"), None, &open_path); - open::that_detached(&open_path)?; + webbrowser::open(&open_path)?; } return Ok(()); @@ -309,6 +313,21 @@ fn port_in_range(s: &str) -> Result { } } +fn parse_credentials(s: &str) -> Result { + let split_: Vec<_> = s.split(":").collect(); + if split_.len() != 2 { + Err(format!( + "Unable to parse credentials as username:password; found {} colon-separated string(s)", + split_.len() + )) + } else { + Ok(Credentials { + username: split_[0].to_string(), + password: split_[1].to_string(), + }) + } +} + fn fix_addr(addr: &SocketAddr) -> SocketAddr { if addr.ip() == IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)) { let mut addr = *addr; diff --git a/server/src/webserver.rs b/server/src/webserver.rs index ba30f1ca..6ed99d8b 100644 --- a/server/src/webserver.rs +++ b/server/src/webserver.rs @@ -44,8 +44,9 @@ use actix_web::{ error::Error, get, http::header::{ContentType, DispositionType}, - web, + middleware, web, }; +use actix_web_httpauth::{extractors::basic::BasicAuth, middleware::HttpAuthentication}; use actix_ws::AggregatedMessage; use bytes::Bytes; use dunce::simplified; @@ -284,23 +285,31 @@ struct UpdateMessageContents { /// Define the [state](https://actix.rs/docs/application/#state) available to /// all endpoints. pub struct AppState { - // Provide methods to control the server. + /// Provide methods to control the server. server_handle: Mutex>, - // The number of the next connection ID to assign. + /// The number of the next connection ID to assign. connection_id: Mutex, - // The port this server listens on. + /// The port this server listens on. port: u16, - // For each connection ID, store a queue tx for the HTTP server to send - // requests to the processing task for that ID. + /// For each connection ID, store a queue tx for the HTTP server to send + /// requests to the processing task for that ID. processing_task_queue_tx: Arc>>>, - // For each (connection ID, requested URL) store channel to send the - // matching response to the HTTP task. + /// For each (connection ID, requested URL) store channel to send the + /// matching response to the HTTP task. filewatcher_client_queues: Arc>>, - // For each connection ID, store the queues for the VSCode IDE. + /// For each connection ID, store the queues for the VSCode IDE. vscode_ide_queues: Arc>>, vscode_client_queues: Arc>>, - // Connection IDs that are currently in use. + /// Connection IDs that are currently in use. vscode_connection_id: Arc>>, + /// The auth credentials if authentication is used. + credentials: Option, +} + +#[derive(Clone)] +pub struct Credentials { + pub username: String, + pub password: String, } // Macros @@ -691,7 +700,7 @@ async fn make_simple_http_response( SimpleHttpResponse, // If this file is currently being edited, this is the body of an `Update` // message to send. - Option, + Option, ) { // Convert the provided URL back into a file name. let file_path = &http_request.file_path; @@ -755,7 +764,7 @@ async fn file_to_response( // If the response is a Client, also return the appropriate `Update` data to // populate the Client with the parsed `file_contents`. In all other cases, // return None. - Option, + Option, ) { // Use a lossy conversion, since this is UI display, not filesystem access. let file_path = &http_request.file_path; @@ -981,12 +990,12 @@ async fn file_to_response( )), // If this file is editable and is the main file, send an `Update`. The // `simple_http_response` contains the Client. - Some(EditorMessageContents::Update(UpdateMessageContents { + Some(UpdateMessageContents { file_path: file_path.to_string(), contents: Some(codechat_for_web), cursor_position: None, scroll_position: None, - })), + }), ) } @@ -1333,32 +1342,69 @@ async fn client_websocket( // Webserver core // -------------- #[actix_web::main] -pub async fn main(addr: &SocketAddr) -> std::io::Result<()> { - run_server(addr).await +pub async fn main(addr: &SocketAddr, credentials: Option) -> std::io::Result<()> { + run_server(addr, credentials).await } -pub async fn run_server(addr: &SocketAddr) -> std::io::Result<()> { +pub async fn run_server( + addr: &SocketAddr, + credentials: Option, +) -> std::io::Result<()> { // Connect to the Capture Database //let _event_capture = EventCapture::new("config.json").await?; // Pre-load the bundled files before starting the webserver. let _ = &*BUNDLED_FILES_MAP; - let app_data = make_app_data(addr.port()); + let app_data = make_app_data(addr.port(), credentials); let app_data_server = app_data.clone(); - let server = - match HttpServer::new(move || configure_app(App::new(), &app_data_server)).bind(addr) { - Ok(server) => server.run(), - Err(err) => { - error!("Unable to bind to {addr} - {err}"); - return Err(err); - } - }; + let server = match HttpServer::new(move || { + let auth = HttpAuthentication::with_fn(basic_validator); + configure_app( + App::new().wrap(middleware::Condition::new( + app_data_server.credentials.is_some(), + auth, + )), + &app_data_server, + ) + }) + .bind(addr) + { + Ok(server) => server.run(), + Err(err) => { + error!("Unable to bind to {addr} - {err}"); + return Err(err); + } + }; // Store the server handle in the global state. *(app_data.server_handle.lock().unwrap()) = Some(server.handle()); // Start the server. server.await } +// Use HTTP basic authentication (if provided) to mediate access. +async fn basic_validator( + req: ServiceRequest, + credentials: BasicAuth, +) -> Result { + // Get the provided credentials. + let expected_credentials = &req + .app_data::>() + .unwrap() + .credentials + .as_ref() + .unwrap(); + if credentials.user_id() == expected_credentials.username + && credentials.password() == Some(&expected_credentials.password) + { + Ok(req) + } else { + Err(( + actix_web::error::ErrorUnauthorized("Incorrect username or password."), + req, + )) + } +} + pub fn configure_logger(level: LevelFilter) -> Result<(), Box> { #[cfg(not(debug_assertions))] let l4rs = ROOT_PATH.clone(); @@ -1383,7 +1429,7 @@ pub fn configure_logger(level: LevelFilter) -> Result<(), Box web::Data { +fn make_app_data(port: u16, credentials: Option) -> web::Data { web::Data::new(AppState { server_handle: Mutex::new(None), connection_id: Mutex::new(0), @@ -1393,6 +1439,7 @@ fn make_app_data(port: u16) -> web::Data { vscode_ide_queues: Arc::new(Mutex::new(HashMap::new())), vscode_client_queues: Arc::new(Mutex::new(HashMap::new())), vscode_connection_id: Arc::new(Mutex::new(HashSet::new())), + credentials, }) } diff --git a/server/src/webserver/filewatcher.rs b/server/src/webserver/filewatcher.rs index 352d0a5e..fc148a14 100644 --- a/server/src/webserver/filewatcher.rs +++ b/server/src/webserver/filewatcher.rs @@ -537,7 +537,10 @@ async fn processing_task(file_path: &Path, app_state: web::Data, conne let (simple_http_response, option_update) = make_simple_http_response(&http_request, cfp, false).await; if let Some(update) = option_update { // Send the update to the client. - queue_send!(to_websocket_tx.send(EditorMessage { id, message: update })); + queue_send!(to_websocket_tx.send(EditorMessage { + id, + message: EditorMessageContents::Update(update) + })); id += 1.0; } oneshot_send!(http_request.response_queue.send(simple_http_response)); @@ -754,7 +757,7 @@ mod tests { WebsocketQueues, impl Service, Error = actix_web::Error> + use<>, ) { - let app_data = make_app_data(IP_PORT); + let app_data = make_app_data(IP_PORT, None); let app = test::init_service(configure_app(App::new(), &app_data)).await; // Load in a test source file to create a websocket. diff --git a/server/src/webserver/vscode.rs b/server/src/webserver/vscode.rs index 67f343fd..da0aa7a4 100644 --- a/server/src/webserver/vscode.rs +++ b/server/src/webserver/vscode.rs @@ -35,8 +35,9 @@ use actix_web::{ get, web, }; use indoc::formatdoc; +use lazy_static::lazy_static; use log::{debug, error, warn}; -use open; +use regex::Regex; use tokio::{fs::File, select, sync::mpsc}; // ### Local @@ -47,9 +48,9 @@ use super::{ use crate::{ oneshot_send, processing::{ - CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, CodeMirrorDocBlockVec, - SourceFileMetadata, TranslationResultsString, codechat_for_web_to_source, - diff_code_mirror_doc_blocks, diff_str, source_to_codechat_for_web_string, + CodeChatForWeb, CodeMirror, CodeMirrorDiff, CodeMirrorDiffable, SourceFileMetadata, + TranslationResultsString, codechat_for_web_to_source, diff_code_mirror_doc_blocks, + diff_str, source_to_codechat_for_web_string, }, queue_send, webserver::{ @@ -66,8 +67,45 @@ const VSCODE_PATH_PREFIX: &[&str] = &["vsc", "fs"]; // The max length of a message to show in the console. const MAX_MESSAGE_LENGTH: usize = 300; +lazy_static! { + /// A regex to determine the type of the first EOL. See 'PROCESSINGS1. + pub static ref EOL_FINDER: Regex = Regex::new("[^\r\n]*(\r?\n)").unwrap(); + +} + +// Data structures +// --------------- +#[derive(Clone, Debug, PartialEq)] +pub enum EolType { + Lf, + Crlf, +} + // Code // ---- +pub fn find_eol_type(s: &str) -> EolType { + match EOL_FINDER.captures(s) { + // Assume a line type for strings with no newlines. + None => { + if cfg!(windows) { + EolType::Crlf + } else { + EolType::Lf + } + } + Some(captures) => match captures.get(1) { + None => panic!("No capture group!"), + Some(match_) => { + if match_.as_str() == "\n" { + EolType::Lf + } else { + EolType::Crlf + } + } + }, + } +} + // // This is the processing task for the Visual Studio Code IDE. It handles all // the core logic to moving data between the IDE and the client. @@ -158,7 +196,7 @@ pub async fn vscode_ide_websocket( .insert(connection_id_str.clone()); // Clone variables owned by the processing task. - let connection_id_task = connection_id_str.clone(); + let connection_id_task = connection_id_str; let app_state_task = app_state.clone(); // Start the processing task. @@ -267,7 +305,7 @@ pub async fn vscode_ide_websocket( } else { // Open the Client in an external browser. if let Err(err) = - open::that_detached(format!("{address}/vsc/cf/{connection_id_task}")) + webbrowser::open(&format!("{address}/vsc/cf/{connection_id_task}")) { let msg = format!("Unable to open web browser: {err}"); error!("{msg}"); @@ -306,9 +344,14 @@ pub async fn vscode_ide_websocket( // All further messages are handled in the main loop. let mut id: f64 = INITIAL_MESSAGE_ID + MESSAGE_ID_INCREMENT; - let mut _source_code = String::new(); + let mut source_code = String::new(); let mut code_mirror_doc = String::new(); - let mut code_mirror_doc_blocks: CodeMirrorDocBlockVec = Vec::new(); + // The initial state will be overwritten by the first `Update` or `LoadFile`, so this + // value doesn't matter. + let mut eol = EolType::Lf; + // Some means this contains valid HTML; None means don't use it (since it would have + // contained Markdown). + let mut code_mirror_doc_blocks = Some(Vec::new()); // To send a diff from Server to Client or vice versa, we need to // ensure they are in sync: // @@ -330,7 +373,7 @@ pub async fn vscode_ide_websocket( // blocks, but Markdown. When Turndown is moved from JavaScript to // Rust, this can be changed, since both sides will have HTML in the // doc blocks. - let mut sync_id = SyncState::OutOfSync; + let mut sync_state = SyncState::OutOfSync; loop { select! { // Look for messages from the IDE. @@ -375,16 +418,8 @@ pub async fn vscode_ide_websocket( // If this was confirmation from the IDE // that it received the latest update, then // mark the IDE as synced. - if sync_id == SyncState::Pending(ide_message.id) { - // TODO: currently, the Client sends doc - // blocks as Markdown, not HTML. This means - // sync won't work, since the IDE sends doc - // blocks as HTML. Eventually, move the - // Markdown conversion from the Client - // (implemented in JavaScript) to the - // Server (implemented in Rust). After - // this, we can use the sync results. - //sync_id = SyncState::InSync; + if sync_state == SyncState::Pending(ide_message.id) { + sync_state = SyncState::InSync; } queue_send!(to_client_tx.send(ide_message)); continue; @@ -397,9 +432,9 @@ pub async fn vscode_ide_websocket( }; // Take ownership of the result after sending it - // above (which required owernship). + // above (which requires ownership). let EditorMessageContents::Result(result) = ide_message.message else { - error!("{}", "Not an update."); + error!("{}", "Not a result."); break; }; // Get the file contents from a `LoadFile` @@ -422,8 +457,11 @@ pub async fn vscode_ide_websocket( let use_pdf_js = http_request.file_path.extension() == Some(OsStr::new("pdf")); let (simple_http_response, option_update) = match file_contents_option { Some(file_contents) => { + // If there are Windows newlines, replace with Unix; this is reversed when the file is sent back to the IDE. + eol = find_eol_type(&file_contents); + let file_contents = file_contents.replace("\r\n", "\n"); let ret = file_to_response(&http_request, ¤t_file, Some(&file_contents), use_pdf_js).await; - _source_code = file_contents; + source_code = file_contents; ret }, None => { @@ -433,13 +471,7 @@ pub async fn vscode_ide_websocket( } }; if let Some(update) = option_update { - // Record the CodeMirror contents before - // sending. - let EditorMessageContents::Update(ref update_message_contents) = update else { - error!("Not an update!"); - break; - }; - let Some(ref tmp) = update_message_contents.contents else { + let Some(ref tmp) = update.contents else { error!("None."); break; }; @@ -447,12 +479,17 @@ pub async fn vscode_ide_websocket( error!("Not plain!"); break; }; - // TODO: this is expensive -- fix! + // We must clone here, since the original is + // placed in the TX queue. code_mirror_doc = plain.doc.clone(); - code_mirror_doc_blocks = plain.doc_blocks.clone(); + code_mirror_doc_blocks = Some(plain.doc_blocks.clone()); + sync_state = SyncState::Pending(id); debug!("Sending Update to Client, id = {id}."); - queue_send!(to_client_tx.send(EditorMessage { id, message: update })); + queue_send!(to_client_tx.send(EditorMessage { + id, + message: EditorMessageContents::Update(update) + })); id += MESSAGE_ID_INCREMENT; } debug!("Sending HTTP response."); @@ -471,35 +508,44 @@ pub async fn vscode_ide_websocket( match contents.source { CodeMirrorDiffable::Diff(_diff) => Err("TODO: support for updates with diffable sources.".to_string()), CodeMirrorDiffable::Plain(code_mirror) => { + // If there are Windows newlines, replace with Unix; this is reversed when the file is sent back to the IDE. + eol = find_eol_type(&code_mirror.doc); + let doc_normalized_eols = code_mirror.doc.replace("\r\n", "\n"); // Translate the file. let (translation_results_string, _path_to_toc) = - source_to_codechat_for_web_string(&code_mirror.doc, ¤t_file, false); + source_to_codechat_for_web_string(&doc_normalized_eols, ¤t_file, false); match translation_results_string { TranslationResultsString::CodeChat(ccfw) => { // Send the new translated contents. debug!("Sending translated contents to Client."); - // TODO: this is an expensive clone! Try to - // find a way around this. - let CodeMirrorDiffable::Plain(ccfw_source_plain) = ccfw.clone().source else { + let CodeMirrorDiffable::Plain(ref ccfw_source_plain) = ccfw.source else { error!("{}", "Unexpected diff value."); break; }; // Send a diff if possible (only when the // Client's contents are synced with the // IDE). - let contents = Some(if sync_id == SyncState::InSync { - let doc_diff = diff_str(&code_mirror_doc, &ccfw_source_plain.doc); - let code_mirror_diff = diff_code_mirror_doc_blocks(&code_mirror_doc_blocks, &ccfw_source_plain.doc_blocks); - CodeChatForWeb { - metadata: ccfw.metadata, - source: CodeMirrorDiffable::Diff(CodeMirrorDiff { - doc: doc_diff, - doc_blocks: code_mirror_diff - }) + let contents = Some( + if let Some(cmdb) = code_mirror_doc_blocks && + sync_state == SyncState::InSync { + let doc_diff = diff_str(&code_mirror_doc, &ccfw_source_plain.doc); + let code_mirror_diff = diff_code_mirror_doc_blocks(&cmdb, &ccfw_source_plain.doc_blocks); + CodeChatForWeb { + // Clone needed here, so we can copy it later. + metadata: ccfw.metadata.clone(), + source: CodeMirrorDiffable::Diff(CodeMirrorDiff { + doc: doc_diff, + doc_blocks: code_mirror_diff + }) + } + } else { + // We must make a clone to put in the TX + // queue; this allows us to keep the + // original below to use with the next + // diff. + ccfw.clone() } - } else { - ccfw - }); + ); queue_send!(to_client_tx.send(EditorMessage { id: ide_message.id, message: EditorMessageContents::Update(UpdateMessageContents { @@ -510,13 +556,18 @@ pub async fn vscode_ide_websocket( }), })); // Update to the latest code after - // computing diffs. - _source_code = code_mirror.doc; + // computing diffs. To avoid ownership + // problems, re-define `ccfw_source_plain`. + let CodeMirrorDiffable::Plain(ccfw_source_plain) = ccfw.source else { + error!("{}", "Unexpected diff value."); + break; + }; + source_code = code_mirror.doc; code_mirror_doc = ccfw_source_plain.doc; - code_mirror_doc_blocks = ccfw_source_plain.doc_blocks; + code_mirror_doc_blocks = Some(ccfw_source_plain.doc_blocks); // Mark the Client as unsynced until this // is acknowledged. - sync_id = SyncState::Pending(ide_message.id); + sync_state = SyncState::Pending(ide_message.id); Ok(ResultOkTypes::Void) } // TODO @@ -533,10 +584,10 @@ pub async fn vscode_ide_websocket( metadata: SourceFileMetadata { // Since this is raw data, `mode` doesn't // matter. - mode: "".to_string() + mode: "".to_string(), }, source: CodeMirrorDiffable::Plain(CodeMirror { - doc: code_mirror.doc.clone(), + doc: code_mirror.doc, doc_blocks: vec![] }) }), @@ -580,7 +631,7 @@ pub async fn vscode_ide_websocket( current_file = file_path.into(); // Since this is a new file, mark it as // unsynced. - sync_id = SyncState::OutOfSync; + sync_state = SyncState::OutOfSync; } Err(err) => { let msg = format!( @@ -630,8 +681,8 @@ pub async fn vscode_ide_websocket( // If this result confirms that the Client // received the most recent IDE update, then // mark the documents as synced. - if sync_id == SyncState::Pending(client_message.id) { - sync_id = SyncState::InSync; + if sync_state == SyncState::Pending(client_message.id) { + sync_state = SyncState::InSync; } queue_send!(to_ide_tx.send(client_message)) }, @@ -641,7 +692,7 @@ pub async fn vscode_ide_websocket( // This doesn't work in Codespaces. TODO: send // this back to the VSCode window, then call // `vscode.env.openExternal(vscode.Uri.parse(url))`. - if let Err(err) = open::that_detached(&url) { + if let Err(err) = webbrowser::open(&url) { let msg = format!("Unable to open web browser to URL {url}: {err}"); error!("{msg}"); send_response(&to_client_tx, client_message.id, Err(msg)).await; @@ -668,24 +719,40 @@ pub async fn vscode_ide_websocket( Some(cfw) => match codechat_for_web_to_source( &cfw) { - Ok(result) => { - // TODO: this clone is expensive. Look for - // a way to avoid this. - _source_code = result.clone(); + Ok(mut result) => { + if eol == EolType::Crlf { + // Before sending back to the IDE, fix EOLs for Windows. + result = result.replace("\n", "\r\n"); + } + let ccfw = if sync_state == SyncState::InSync { + Some(CodeChatForWeb { + metadata: cfw.metadata, + source: CodeMirrorDiffable::Diff(CodeMirrorDiff { + doc: diff_str(&source_code, &result), + doc_blocks: vec![], + }), + }) + } else { + Some(CodeChatForWeb { + metadata: cfw.metadata, + source: CodeMirrorDiffable::Plain(CodeMirror { + // We must clone here, so that it can be placed in the TX queue. + doc: result.clone(), + doc_blocks: vec![], + }), + }) + }; + source_code = result; let CodeMirrorDiffable::Plain(cmd) = cfw.source else { // TODO: support diffable! error!("No diff!"); break; }; code_mirror_doc = cmd.doc; - code_mirror_doc_blocks = cmd.doc_blocks; - Some(CodeChatForWeb { - metadata: cfw.metadata, - source: CodeMirrorDiffable::Plain(CodeMirror { - doc: result, - doc_blocks: vec![], - }), - }) + // TODO: instead of `cmd.doc_blocks`, use `None` to indicate that + // the doc blocks contain Markdown instead of HTML. + code_mirror_doc_blocks = None; + ccfw }, Err(message) => { let msg = format!( @@ -708,7 +775,7 @@ pub async fn vscode_ide_websocket( })); // Mark the IDE contents as out of sync // until this message is received. - sync_id = SyncState::Pending(client_message.id); + sync_state = SyncState::Pending(client_message.id); } } }, @@ -738,7 +805,7 @@ pub async fn vscode_ide_websocket( current_file = file_path; // Mark the IDE as out of sync, since this // is a new file. - sync_id = SyncState::OutOfSync; + sync_state = SyncState::OutOfSync; Ok(()) } } diff --git a/server/src/webserver/vscode/tests.rs b/server/src/webserver/vscode/tests.rs index f84b68af..d7fc02c1 100644 --- a/server/src/webserver/vscode/tests.rs +++ b/server/src/webserver/vscode/tests.rs @@ -55,7 +55,10 @@ use crate::{ CodeMirrorDocBlockTransaction, SourceFileMetadata, StringDiff, }, test_utils::{_prep_test_dir, check_logger_errors, configure_testing_logger}, - webserver::{ResultOkTypes, UpdateMessageContents, drop_leading_slash}, + webserver::{ + ResultOkTypes, UpdateMessageContents, drop_leading_slash, + vscode::{EolType, find_eol_type}, + }, }; // Globals @@ -63,7 +66,7 @@ use crate::{ lazy_static! { // Run a single webserver for all tests. static ref WEBSERVER_HANDLE: JoinHandle> = - actix_rt::spawn(async move { run_server(&SocketAddr::new("127.0.0.1".parse().unwrap(), IP_PORT)).await }); + actix_rt::spawn(async move { run_server(&SocketAddr::new("127.0.0.1".parse().unwrap(), IP_PORT), None).await }); } // Send a message via a websocket. @@ -1247,3 +1250,17 @@ async fn test_vscode_ide_websocket9() { // Report any errors produced when removing the temporary directory. temp_dir.close().unwrap(); } + +#[test] +fn test_find_eoltypes() { + assert_eq!( + find_eol_type(""), + if cfg!(windows) { + EolType::Crlf + } else { + EolType::Lf + } + ); + assert_eq!(find_eol_type("Testing\nOne, two, three"), EolType::Lf); + assert_eq!(find_eol_type("Testing\r\nOne, two, three"), EolType::Crlf); +}