Skip to content

Commit 65c35e2

Browse files
Merge pull request #166 from TryCli-Studio/dev
fix(terminal): sync resize events and suppress echo artifacts
2 parents d16aedf + 455dc93 commit 65c35e2

2 files changed

Lines changed: 67 additions & 60 deletions

File tree

client/src/components/terminal.rs

Lines changed: 33 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use crate::api::ws_base;
22
use leptos::*;
33
use wasm_bindgen::prelude::*;
4-
use wasm_bindgen::JsCast;
5-
use web_sys::{ErrorEvent, MessageEvent, WebSocket};
4+
use wasm_bindgen::JsCast; // Required for unchecked_into
5+
use web_sys::{MessageEvent, WebSocket}; // Removed unused ErrorEvent
66
use std::rc::Rc;
77
use std::cell::Cell;
88

@@ -31,6 +31,10 @@ extern "C" {
3131
fn on_data(this: &Terminal, callback: &Closure<dyn FnMut(String)>);
3232
#[wasm_bindgen(method, js_name = loadAddon)]
3333
fn load_addon(this: &Terminal, addon: &XtermFitAddon);
34+
#[wasm_bindgen(method, getter)]
35+
fn cols(this: &Terminal) -> u16;
36+
#[wasm_bindgen(method, getter)]
37+
fn rows(this: &Terminal) -> u16;
3438
}
3539

3640
#[component]
@@ -41,83 +45,68 @@ pub fn TerminalView(container_id: String) -> impl IntoView {
4145
create_effect(move |_| {
4246
if let Some(div) = terminal_div_ref.get() {
4347
let term = Terminal::new();
44-
4548
let fit_addon = XtermFitAddon::new();
4649
term.load_addon(&fit_addon);
4750
term.open(&div);
48-
4951
fit_addon.fit();
50-
let fit_addon_clone = fit_addon.clone().unchecked_into::<XtermFitAddon>();
51-
let on_resize = Closure::<dyn FnMut()>::new(move || {
52-
fit_addon_clone.fit();
53-
});
54-
window().set_onresize(Some(on_resize.as_ref().unchecked_ref()));
55-
on_resize.forget();
56-
on_cleanup(move || {
57-
window().set_onresize(None);
58-
});
52+
5953
term.write(&format!("Connecting to session {}...\r\n", id_for_effect));
6054

61-
let term_clone: Terminal = term.clone().unchecked_into();
6255
let ws_url = format!("{}/ws/{}", ws_base(), id_for_effect);
56+
57+
// 1. CAST: Ensure clones are treated as Terminal type, not generic JsValue
58+
let term_clone = term.clone().unchecked_into::<Terminal>();
59+
let term_resize = term.clone().unchecked_into::<Terminal>();
6360
let first_message = Rc::new(Cell::new(true));
6461

65-
// FIX: Removed unwrap() on WebSocket::new
6662
match WebSocket::new(&ws_url) {
6763
Ok(ws) => {
68-
let ws_cleanup = ws.clone();
64+
let ws_resize = ws.clone();
65+
let fit_addon_resize = fit_addon.clone().unchecked_into::<XtermFitAddon>();
66+
67+
let on_resize = Closure::<dyn FnMut()>::new(move || {
68+
fit_addon_resize.fit();
69+
// Now methods like cols() work because term_resize is typed
70+
let cols = term_resize.cols();
71+
let rows = term_resize.rows();
72+
let _ = ws_resize.send_with_str(&format!("RESIZE:{}:{}", cols, rows));
73+
});
74+
75+
window().set_onresize(Some(on_resize.as_ref().unchecked_ref()));
76+
on_resize.forget();
6977

78+
let ws_cleanup = ws.clone();
7079
on_cleanup(move || {
7180
let _ = ws_cleanup.close();
81+
window().set_onresize(None);
7282
});
83+
7384
let onmessage = Closure::<dyn FnMut(MessageEvent)>::new(move |e: MessageEvent| {
7485
if let Ok(txt) = e.data().dyn_into::<js_sys::JsString>() {
7586
let text = String::from(txt);
76-
77-
// If this is the first data packet, clear the screen
7887
if first_message.get() {
79-
// \x1b[2J = Clear entire screen
80-
// \x1b[H = Move cursor to home (top-left)
8188
term_clone.write("\x1b[2J\x1b[H");
8289
first_message.set(false);
8390
}
84-
8591
term_clone.write(&text);
8692
}
8793
});
8894
ws.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
8995
onmessage.forget();
9096

91-
let ws_clone = ws.clone();
92-
let on_data_callback =
93-
Closure::<dyn FnMut(String)>::new(move |data: String| {
94-
let _ = ws_clone.send_with_str(&data);
95-
});
97+
let ws_send = ws.clone();
98+
let on_data_callback = Closure::<dyn FnMut(String)>::new(move |data: String| {
99+
let _ = ws_send.send_with_str(&data);
100+
});
96101
term.on_data(&on_data_callback);
97102
on_data_callback.forget();
98-
99-
let term_err = term.clone().unchecked_into::<Terminal>();
100-
let onerror = Closure::<dyn FnMut(ErrorEvent)>::new(move |_| {
101-
term_err.write("\r\n\x1b[31m[!] Connection Error.\x1b[0m\r\n");
102-
});
103-
ws.set_onerror(Some(onerror.as_ref().unchecked_ref()));
104-
onerror.forget();
105-
106-
let term_close = term.clone().unchecked_into::<Terminal>();
107-
let onclose = Closure::<dyn FnMut()>::new(move || {
108-
term_close.write("\r\n\x1b[33m[!] Connection Closed.\x1b[0m\r\n");
109-
});
110-
ws.set_onclose(Some(onclose.as_ref().unchecked_ref()));
111-
onclose.forget();
112103
}
113104
Err(_) => {
114-
term.write(
115-
"\r\n\x1b[31m[!] Failed to initialize WebSocket connection.\x1b[0m\r\n",
116-
);
105+
term.write("\r\n\x1b[31m[!] WebSocket Error.\x1b[0m\r\n");
117106
}
118107
}
119108
}
120109
});
121110

122111
view! { <div _ref=terminal_div_ref class="terminal" style="height: 100%; width: 100%; padding: 8px"></div> }
123-
}
112+
}

server/src/services/websocket.rs

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ use axum::{
66
use bollard::container::{CreateContainerOptions, Config};
77
use bollard::models::{HostConfig, Mount, MountTypeEnum, MountTmpfsOptions};
88
use bollard::image::CreateImageOptions;
9-
use bollard::exec::{CreateExecOptions, StartExecResults};
9+
// 1. IMPORT ADDED HERE: ResizeExecOptions
10+
use bollard::exec::{CreateExecOptions, StartExecResults, ResizeExecOptions};
1011
use futures::{stream::StreamExt, SinkExt};
1112
use std::collections::HashMap;
1213
use tokio::io::AsyncWriteExt;
@@ -418,12 +419,14 @@ async fn run_setup_wizard(mut socket: WebSocket, state: AppState, session_id: St
418419
);
419420

420421
// Chain commands:
421-
// 1. Inject apt config (rate limiting)
422-
// 2. Run the distro-specific install script (e.g. install fish/zsh)
423-
// 3. Clean the terminal after setup completion
424-
// 4. Exec into the final requested shell
422+
// 1. Disable local echo
423+
// 2. Create apt config dir
424+
// 3. Inject apt rate limiting
425+
// 4. Run the distro-specific install script (e.g. install fish/zsh)
426+
// 5. Clean the terminal after setup completion
427+
// 6. Exec into the final requested shell
425428
let auto_type_cmd = format!(
426-
"mkdir -p /etc/apt/apt.conf.d && {} && {} && printf '\\033[2J\\033[3J\\033[H' && exec {}\n",
429+
"stty -echo; mkdir -p /etc/apt/apt.conf.d && {} && {} && printf '\\033[2J\\033[3J\\033[H' && exec {}\n",
427430
inject_limit_cmd,
428431
install_script,
429432
final_shell
@@ -488,35 +491,49 @@ async fn attach_to_container(
488491
// Clone session_id for logging within the task
489492
let session_id_log = session_id.clone();
490493

494+
// 2. FIX: Clone state for the resize task so we don't move the original 'state'
495+
let state_resize = state.clone();
496+
491497
let mut recv_task = tokio::spawn(async move {
492-
// Guardrail: 20 Minute Idle Timeout
493498
const IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 20);
494499

495500
loop {
496-
// We wrap the receiver.next() in a timeout
497501
match tokio::time::timeout(IDLE_TIMEOUT, receiver.next()).await {
498-
// Case A: Received a message within time limit
499502
Ok(Some(Ok(msg))) => {
500503
match msg {
501504
Message::Text(text) => {
502-
if input.write_all(text.as_bytes()).await.is_err() {
503-
break; // Container stdin closed
505+
// --- RESIZE LOGIC START ---
506+
if text.starts_with("RESIZE:") {
507+
let parts: Vec<&str> = text.split(':').collect();
508+
if parts.len() == 3 {
509+
if let (Ok(w), Ok(h)) = (parts[1].parse::<u16>(), parts[2].parse::<u16>()) {
510+
// USE state_resize HERE (Arc is cloned)
511+
let _ = state_resize.docker.resize_exec(&exec.id, ResizeExecOptions {
512+
width: w,
513+
height: h
514+
}).await;
515+
}
516+
}
517+
}
518+
// --- RESIZE LOGIC END ---
519+
else {
520+
if input.write_all(text.as_bytes()).await.is_err() {
521+
break;
522+
}
504523
}
505524
},
506525
Message::Binary(bin) => {
507526
if input.write_all(&bin).await.is_err() {
508527
break;
509528
}
510529
},
511-
Message::Close(_) => break, // Client closed tab
512-
_ => {} // Ignore Pings/Pongs (handled by Axum)
530+
Message::Close(_) => break,
531+
_ => {}
513532
}
514533
},
515-
// Case B: Stream ended normally (client disconnected)
516534
Ok(None) | Ok(Some(Err(_))) => break,
517-
518-
// Case C: IDLE TIMEOUT HIT
519535
Err(_) => {
536+
// Timeout
520537
println!("Session {} timed out due to inactivity (20m). Closing.", session_id_log);
521538
break;
522539
}
@@ -585,6 +602,7 @@ async fn attach_to_container(
585602
}
586603
}
587604

605+
// USE original 'state' here (it was not moved because we only moved 'state_resize' into the closure)
588606
let _ = state.docker.remove_container(&container_name, Some(
589607
bollard::container::RemoveContainerOptions { force: true, ..Default::default() }
590608
)).await;

0 commit comments

Comments
 (0)