initial commit
This commit is contained in:
commit
5af31644a3
11 files changed
+17526
No files matched your search
@@ -0,0 +1,6 @@
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
strip = true
|
||||
@@ -0,0 +1,46 @@
|
||||
on: [push, pull_request]
|
||||
|
||||
name: Continuous integration
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- run: cargo check
|
||||
|
||||
fmt:
|
||||
name: Rustfmt
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- run: rustup component add rustfmt
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
# clippy:
|
||||
# name: Clippy
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
# - run: rustup component add clippy
|
||||
# - run: cargo clippy -- -D warnings
|
||||
|
||||
build:
|
||||
name: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- run: cargo build
|
||||
|
||||
build-release:
|
||||
name: build-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- run: cargo build --release
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+1125
File diff suppressed because it is too large.
Load diff
+18
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "rwterm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
tower-http = { version = "0.5", features = ["fs"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
libc = "0.2"
|
||||
nix = { version = "0.29", features = ["process", "signal", "term", "fs"] }
|
||||
futures = "0.3"
|
||||
tokio-util = "0.7"
|
||||
anyhow = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
mod terminal;
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
State,
|
||||
},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use futures::{sink::SinkExt, stream::StreamExt};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
// use tokio::time::Duration;
|
||||
use tower_http::services::ServeDir;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use terminal::TerminalSession;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResizeMessage {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
}
|
||||
|
||||
type Clients = Arc<RwLock<HashMap<String, ClientHandle>>>;
|
||||
|
||||
struct ClientHandle;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let static_dir = "./static";
|
||||
if !Path::new(static_dir).exists() {
|
||||
eprintln!(
|
||||
"Static directory '{}' not found! Creating it...",
|
||||
static_dir
|
||||
);
|
||||
std::fs::create_dir_all(static_dir)?;
|
||||
}
|
||||
|
||||
let clients: Clients = Arc::new(RwLock::new(HashMap::new()));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/ws", get(websocket_handler))
|
||||
.fallback_service(ServeDir::new(static_dir))
|
||||
.with_state(clients);
|
||||
|
||||
let addr: SocketAddr = "0.0.0.0:8888".parse()?;
|
||||
info!("Starting web terminal server on {}", addr);
|
||||
info!("Static files directory: {}", static_dir);
|
||||
info!("Server is ready. Open http://localhost:8888 in your browser");
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn index() -> impl IntoResponse {
|
||||
let index_path = "./static/index.html";
|
||||
if Path::new(index_path).exists() {
|
||||
match tokio::fs::read_to_string(index_path).await {
|
||||
Ok(content) => axum::response::Html(content),
|
||||
Err(_) => axum::response::Html(fallback_html()),
|
||||
}
|
||||
} else {
|
||||
axum::response::Html(fallback_html())
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_html() -> String {
|
||||
r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Web Terminal</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; background: #000; height: 100vh; overflow: hidden; }
|
||||
#terminal { width: 100%; height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<script>
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
theme: { background: '#000000' }
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
fitAddon.fit();
|
||||
|
||||
const ws = new WebSocket(`ws://${window.location.host}/ws`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
ws.send(JSON.stringify({ cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const data = e.data instanceof ArrayBuffer ?
|
||||
new Uint8Array(e.data) : e.data;
|
||||
term.write(typeof data === 'string' ? data : new TextDecoder().decode(data));
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('WebSocket error:', e);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket closed');
|
||||
term.writeln('\r\n*** Connection closed ***');
|
||||
};
|
||||
|
||||
term.onData(data => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ cols, rows }));
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => fitAddon.fit());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"#.to_string()
|
||||
}
|
||||
|
||||
async fn websocket_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(clients): State<Clients>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, clients))
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, clients: Clients) {
|
||||
let addr = format!(
|
||||
"client_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis()
|
||||
);
|
||||
|
||||
info!("WebSocket connection opened: {}", addr);
|
||||
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
|
||||
// Создаем терминальную сессию
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
||||
let session = match TerminalSession::new() {
|
||||
Ok(mut s) => {
|
||||
if let Err(e) = s.start(&shell) {
|
||||
error!("Failed to start terminal: {}", e);
|
||||
let _ = sender
|
||||
.send(Message::Text("*** Failed to start terminal ***\r\n".into()))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create terminal session: {}", e);
|
||||
let _ = sender
|
||||
.send(Message::Text(
|
||||
"*** Failed to create terminal ***\r\n".into(),
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
info!("Terminal session started successfully");
|
||||
|
||||
// Отправляем приветственное сообщение
|
||||
let welcome = "Terminal ready - type 'exit' to close\r\n";
|
||||
if let Err(e) = sender.send(Message::Text(welcome.into())).await {
|
||||
error!("Failed to send welcome: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем клиента
|
||||
{
|
||||
let mut clients_guard = clients.write().await;
|
||||
clients_guard.insert(addr.clone(), ClientHandle);
|
||||
}
|
||||
|
||||
// Запускаем задачу чтения из терминала
|
||||
let session_arc = Arc::new(std::sync::Mutex::new(session));
|
||||
let session_read = session_arc.clone();
|
||||
let addr_read = addr.clone();
|
||||
|
||||
let read_handle = tokio::task::spawn_blocking(move || {
|
||||
let mut buffer = [0u8; 4096];
|
||||
let mut consecutive_empty = 0;
|
||||
|
||||
loop {
|
||||
let session = session_read.lock().unwrap();
|
||||
|
||||
match session.read(&mut buffer) {
|
||||
Ok(result) => {
|
||||
match result {
|
||||
Some(n) => {
|
||||
if n > 0 {
|
||||
consecutive_empty = 0;
|
||||
let data = buffer[..n].to_vec();
|
||||
drop(session);
|
||||
|
||||
if tx.send(data).is_err() {
|
||||
warn!("Channel closed, stopping read loop");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// n == 0, EOF
|
||||
drop(session);
|
||||
consecutive_empty += 1;
|
||||
|
||||
if consecutive_empty > 100 {
|
||||
let session = session_read.lock().unwrap();
|
||||
if !session.is_process_alive() {
|
||||
warn!("Terminal process died, stopping read loop");
|
||||
break;
|
||||
}
|
||||
consecutive_empty = 0;
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
drop(session);
|
||||
consecutive_empty += 1;
|
||||
|
||||
if consecutive_empty > 100 {
|
||||
let session = session_read.lock().unwrap();
|
||||
if !session.is_process_alive() {
|
||||
warn!("Terminal process died, stopping read loop");
|
||||
break;
|
||||
}
|
||||
consecutive_empty = 0;
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Read error: {}", e);
|
||||
drop(session);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("Read loop ended for {}", addr_read);
|
||||
});
|
||||
|
||||
// Основной цикл обработки WebSocket
|
||||
let session_write = session_arc.clone();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Данные от терминала -> WebSocket
|
||||
maybe_data = rx.recv() => {
|
||||
match maybe_data {
|
||||
Some(data) => {
|
||||
if let Err(e) = sender.send(Message::Binary(data.into())).await {
|
||||
error!("Failed to send to WebSocket: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
info!("Terminal channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Данные от клиента -> терминал
|
||||
maybe_msg = receiver.next() => {
|
||||
match maybe_msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let session = session_write.lock().unwrap();
|
||||
|
||||
// Проверяем resize
|
||||
if let Ok(resize) = serde_json::from_str::<ResizeMessage>(&text) {
|
||||
info!("Resizing terminal to {}x{}", resize.rows, resize.cols);
|
||||
let _ = session.resize(resize.rows, resize.cols);
|
||||
} else {
|
||||
// Обычный ввод
|
||||
if let Err(e) = session.write(text.as_bytes()) {
|
||||
error!("Write error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Binary(data))) => {
|
||||
let session = session_write.lock().unwrap();
|
||||
if let Err(e) = session.write(&data) {
|
||||
error!("Write error: {}", e);
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) => {
|
||||
info!("Close frame received");
|
||||
break;
|
||||
}
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(e)) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
info!("WebSocket closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Очистка
|
||||
info!("Closing connection: {}", addr);
|
||||
{
|
||||
let mut clients_guard = clients.write().await;
|
||||
clients_guard.remove(&addr);
|
||||
}
|
||||
|
||||
read_handle.abort();
|
||||
drop(session_arc);
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
use libc::c_int;
|
||||
use nix::errno::Errno;
|
||||
use nix::fcntl::{fcntl, FcntlArg, OFlag};
|
||||
use nix::sys::signal::{kill, Signal};
|
||||
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
|
||||
use nix::unistd::{dup2, fork, setsid, ForkResult, Pid};
|
||||
use std::os::fd::RawFd;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::exit;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Winsize {
|
||||
ws_row: u16,
|
||||
ws_col: u16,
|
||||
ws_xpixel: u16,
|
||||
ws_ypixel: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TerminalSession {
|
||||
master_pty: RawFd,
|
||||
child_pid: Option<Pid>,
|
||||
active: AtomicBool,
|
||||
// Флаг что процесс завершился (чтобы не вызывать waitpid многократно)
|
||||
process_exited: AtomicBool,
|
||||
}
|
||||
|
||||
impl TerminalSession {
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
let mut master: c_int = -1;
|
||||
let mut slave: c_int = -1;
|
||||
|
||||
let res = unsafe {
|
||||
libc::openpty(
|
||||
&mut master,
|
||||
&mut slave,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
|
||||
if res != 0 {
|
||||
anyhow::bail!("openpty failed: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
// Закрываем slave в родителе сразу
|
||||
unsafe {
|
||||
libc::close(slave);
|
||||
}
|
||||
|
||||
Ok(TerminalSession {
|
||||
master_pty: master,
|
||||
child_pid: None,
|
||||
active: AtomicBool::new(false),
|
||||
process_exited: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn start(&mut self, shell: &str) -> anyhow::Result<()> {
|
||||
match unsafe { fork() }? {
|
||||
ForkResult::Child => {
|
||||
// Закрываем master в дочернем процессе
|
||||
let pts_str = unsafe {
|
||||
let pts = libc::ptsname(self.master_pty);
|
||||
if pts.is_null() {
|
||||
eprintln!("ptsname failed");
|
||||
exit(1);
|
||||
}
|
||||
std::ffi::CStr::from_ptr(pts).to_string_lossy().into_owned()
|
||||
};
|
||||
let slave_fd = unsafe { libc::open(pts_str.as_ptr() as *const i8, libc::O_RDWR) };
|
||||
|
||||
if slave_fd < 0 {
|
||||
eprintln!("Failed to open slave pty");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
setsid()?;
|
||||
|
||||
unsafe {
|
||||
libc::ioctl(slave_fd, libc::TIOCSCTTY, 0);
|
||||
}
|
||||
|
||||
dup2(slave_fd, libc::STDIN_FILENO)?;
|
||||
dup2(slave_fd, libc::STDOUT_FILENO)?;
|
||||
dup2(slave_fd, libc::STDERR_FILENO)?;
|
||||
|
||||
if slave_fd > libc::STDERR_FILENO {
|
||||
unsafe {
|
||||
libc::close(slave_fd);
|
||||
}
|
||||
}
|
||||
|
||||
std::env::set_var("TERM", "xterm-256color");
|
||||
std::env::set_var("COLORTERM", "truecolor");
|
||||
|
||||
let err = std::process::Command::new(shell).exec();
|
||||
eprintln!("Failed to exec shell: {}", err);
|
||||
exit(1);
|
||||
}
|
||||
ForkResult::Parent { child } => {
|
||||
// Устанавливаем неблокирующий режим
|
||||
let flags = OFlag::from_bits_truncate(fcntl(self.master_pty, FcntlArg::F_GETFL)?);
|
||||
fcntl(
|
||||
self.master_pty,
|
||||
FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK),
|
||||
)?;
|
||||
|
||||
self.child_pid = Some(child);
|
||||
self.active.store(true, Ordering::SeqCst);
|
||||
self.process_exited.store(false, Ordering::SeqCst);
|
||||
|
||||
tracing::info!("Created terminal session with PID {}", child);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read(&self, buf: &mut [u8]) -> anyhow::Result<Option<usize>> {
|
||||
if !self.active.load(Ordering::SeqCst) {
|
||||
anyhow::bail!("Session not active");
|
||||
}
|
||||
|
||||
// Проверяем процесс только если еще не знаем что он завершился
|
||||
if !self.process_exited.load(Ordering::SeqCst) {
|
||||
if let Some(pid) = self.child_pid {
|
||||
match waitpid(pid, Some(WaitPidFlag::WNOHANG)) {
|
||||
Ok(WaitStatus::StillAlive) => {}
|
||||
Ok(_) => {
|
||||
self.process_exited.store(true, Ordering::SeqCst);
|
||||
return Ok(None); // Процесс завершился
|
||||
}
|
||||
Err(Errno::ECHILD) => {
|
||||
self.process_exited.store(true, Ordering::SeqCst);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("waitpid error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let res = unsafe { libc::read(self.master_pty, buf.as_mut_ptr() as *mut _, buf.len()) };
|
||||
|
||||
if res < 0 {
|
||||
let err = Errno::last();
|
||||
if err == Errno::EAGAIN || err == Errno::EWOULDBLOCK {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("read error: {}", err))
|
||||
}
|
||||
} else if res == 0 {
|
||||
// EOF - процесс likely завершился
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(res as usize))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&self, data: &[u8]) -> anyhow::Result<usize> {
|
||||
if !self.active.load(Ordering::SeqCst) {
|
||||
anyhow::bail!("Session not active");
|
||||
}
|
||||
|
||||
let res = unsafe { libc::write(self.master_pty, data.as_ptr() as *const _, data.len()) };
|
||||
|
||||
if res < 0 {
|
||||
let err = Errno::last();
|
||||
if err == Errno::EAGAIN || err == Errno::EWOULDBLOCK {
|
||||
Ok(0)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("write error: {}", err))
|
||||
}
|
||||
} else {
|
||||
Ok(res as usize)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&self, rows: u16, cols: u16) -> anyhow::Result<()> {
|
||||
if !self.active.load(Ordering::SeqCst) {
|
||||
anyhow::bail!("Session not active");
|
||||
}
|
||||
|
||||
let ws = Winsize {
|
||||
ws_row: rows,
|
||||
ws_col: cols,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
|
||||
let res = unsafe { libc::ioctl(self.master_pty, libc::TIOCSWINSZ, &ws) };
|
||||
|
||||
if res < 0 {
|
||||
Err(anyhow::anyhow!(
|
||||
"ioctl TIOCSWINSZ failed: {}",
|
||||
Errno::last()
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_process_alive(&self) -> bool {
|
||||
if self.process_exited.load(Ordering::SeqCst) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(pid) = self.child_pid else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match waitpid(pid, Some(WaitPidFlag::WNOHANG)) {
|
||||
Ok(WaitStatus::StillAlive) => true,
|
||||
Ok(_) => {
|
||||
self.process_exited.store(true, Ordering::SeqCst);
|
||||
false
|
||||
}
|
||||
Err(Errno::ECHILD) => {
|
||||
self.process_exited.store(true, Ordering::SeqCst);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("waitpid error: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroy(&mut self) {
|
||||
if !self.active.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::info!("Destroying terminal session");
|
||||
|
||||
if let Some(pid) = self.child_pid {
|
||||
if !self.process_exited.load(Ordering::SeqCst) {
|
||||
let _ = kill(pid, Signal::SIGKILL);
|
||||
let _ = waitpid(pid, None);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
libc::close(self.master_pty);
|
||||
}
|
||||
|
||||
self.active.store(false, Ordering::SeqCst);
|
||||
self.child_pid = None;
|
||||
self.master_pty = -1;
|
||||
|
||||
tracing::info!("Terminal session destroyed");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalSession {
|
||||
fn drop(&mut self) {
|
||||
self.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Web Terminal with Tabs</title>
|
||||
<link rel="stylesheet" href="xterm.css" />
|
||||
<style>
|
||||
/* Стили остаются без изменений */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
sans-serif;
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #2d2d2d;
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #444;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #444;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
flex-shrink: 0;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.tabs-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background-color: #2d2d2d;
|
||||
color: #cccccc;
|
||||
padding: 10px 20px;
|
||||
border: 1px solid #444;
|
||||
border-bottom: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
position: relative;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
border-top: 2px solid #007acc;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
margin-left: 8px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background-color: #444;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #cccccc;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
margin-left: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.add-tab:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.terminals-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.terminal-wrapper.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background-color: #007acc;
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin-bottom: 10px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background-color: #007acc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #005a9e;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.status-connected {
|
||||
background-color: #2d5a2d;
|
||||
}
|
||||
|
||||
.status-disconnected {
|
||||
background-color: #5a2d2d;
|
||||
}
|
||||
|
||||
.status-connecting {
|
||||
background-color: #5a5a2d;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.tabs-list::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.tabs-list::-webkit-scrollbar-track {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
.tabs-list::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tabs-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Web Terminal</h1>
|
||||
<div class="connection-status status-connecting" id="global-status">
|
||||
Initializing...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs-container">
|
||||
<div class="tabs-list" id="tabs-list">
|
||||
<!-- Tabs will be dynamically added here -->
|
||||
</div>
|
||||
<button class="add-tab" id="add-tab" title="New Terminal">+</button>
|
||||
</div>
|
||||
|
||||
<div class="terminals-container" id="terminals-container">
|
||||
<div class="empty-state" id="empty-state">
|
||||
<h2>No terminals open</h2>
|
||||
<p>Create a new terminal tab to get started</p>
|
||||
<button class="btn" id="first-terminal-btn">
|
||||
New Terminal
|
||||
</button>
|
||||
</div>
|
||||
<!-- Terminal wrappers will be dynamically added here -->
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<span id="active-terminal-info">No active terminal</span>
|
||||
<span
|
||||
class="connection-status status-disconnected"
|
||||
id="server-status"
|
||||
>Disconnected</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<script src="xterm.js"></script>
|
||||
<script src="xterm-addon-fit.js"></script>
|
||||
<script>
|
||||
class TabManager {
|
||||
constructor() {
|
||||
this.tabs = new Map(); // Map: tabId -> { terminal, ws, container, tabElement }
|
||||
this.activeTabId = null;
|
||||
this.nextTabId = 1;
|
||||
|
||||
this.tabsList = document.getElementById("tabs-list");
|
||||
this.terminalsContainer = document.getElementById(
|
||||
"terminals-container",
|
||||
);
|
||||
this.emptyState = document.getElementById("empty-state");
|
||||
this.addTabBtn = document.getElementById("add-tab");
|
||||
this.firstTerminalBtn =
|
||||
document.getElementById("first-terminal-btn");
|
||||
this.activeTerminalInfo = document.getElementById(
|
||||
"active-terminal-info",
|
||||
);
|
||||
this.serverStatus =
|
||||
document.getElementById("server-status");
|
||||
this.globalStatus =
|
||||
document.getElementById("global-status");
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.addTabBtn.addEventListener("click", () =>
|
||||
this.createTab(),
|
||||
);
|
||||
this.firstTerminalBtn.addEventListener("click", () =>
|
||||
this.createTab(),
|
||||
);
|
||||
|
||||
// Create first tab automatically
|
||||
setTimeout(() => {
|
||||
this.createTab();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
createTab() {
|
||||
const tabId = `tab-${this.nextTabId++}`;
|
||||
const tabTitle = `Terminal ${this.nextTabId - 1}`;
|
||||
|
||||
// Create tab element
|
||||
const tabElement = document.createElement("div");
|
||||
tabElement.className = "tab";
|
||||
tabElement.dataset.tabId = tabId;
|
||||
|
||||
const titleSpan = document.createElement("span");
|
||||
titleSpan.className = "tab-title";
|
||||
titleSpan.textContent = `○ ${tabTitle}`;
|
||||
|
||||
const closeBtn = document.createElement("span");
|
||||
closeBtn.className = "tab-close";
|
||||
closeBtn.innerHTML = "×";
|
||||
closeBtn.title = "Close terminal";
|
||||
|
||||
tabElement.appendChild(titleSpan);
|
||||
tabElement.appendChild(closeBtn);
|
||||
|
||||
// Create terminal wrapper
|
||||
const terminalWrapper = document.createElement("div");
|
||||
terminalWrapper.className = "terminal-wrapper";
|
||||
terminalWrapper.id = `terminal-${tabId}`;
|
||||
terminalWrapper.dataset.tabId = tabId;
|
||||
|
||||
const terminalElement = document.createElement("div");
|
||||
terminalElement.className = "terminal";
|
||||
|
||||
terminalWrapper.appendChild(terminalElement);
|
||||
|
||||
// Add to DOM
|
||||
this.tabsList.appendChild(tabElement);
|
||||
this.terminalsContainer.appendChild(terminalWrapper);
|
||||
|
||||
// Hide empty state
|
||||
this.emptyState.style.display = "none";
|
||||
|
||||
// Initialize terminal
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: "#000000",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#ffffff",
|
||||
selection: "#ffffff40",
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
terminal.open(terminalElement);
|
||||
fitAddon.fit();
|
||||
|
||||
// Store tab data with initial state
|
||||
const tabData = {
|
||||
terminal,
|
||||
fitAddon,
|
||||
ws: null,
|
||||
container: terminalWrapper,
|
||||
tabElement,
|
||||
title: tabTitle,
|
||||
connected: false,
|
||||
connecting: true,
|
||||
};
|
||||
|
||||
this.tabs.set(tabId, tabData);
|
||||
|
||||
// Update global status immediately
|
||||
this.updateGlobalStatus();
|
||||
|
||||
// Create WebSocket connection
|
||||
this.createWebSocket(tabId, terminal, tabData);
|
||||
|
||||
// Set up event listeners
|
||||
tabElement.addEventListener("click", (e) => {
|
||||
if (e.target !== closeBtn) {
|
||||
this.switchToTab(tabId);
|
||||
}
|
||||
});
|
||||
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeTab(tabId);
|
||||
});
|
||||
|
||||
terminal.onData((data) => {
|
||||
if (
|
||||
tabData.ws &&
|
||||
tabData.ws.readyState === WebSocket.OPEN
|
||||
) {
|
||||
tabData.ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Switch to new tab
|
||||
this.switchToTab(tabId);
|
||||
|
||||
// Handle resize
|
||||
window.addEventListener("resize", () => {
|
||||
if (this.activeTabId === tabId) {
|
||||
fitAddon.fit();
|
||||
this.sendResize(tabId);
|
||||
}
|
||||
});
|
||||
|
||||
return tabId;
|
||||
}
|
||||
|
||||
createWebSocket(tabId, terminal, tabData) {
|
||||
const protocol =
|
||||
window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
tabData.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`WebSocket connected for tab ${tabId}`);
|
||||
tabData.connected = true;
|
||||
tabData.connecting = false;
|
||||
this.updateTabStatus(tabId, true);
|
||||
this.updateGlobalStatus();
|
||||
this.sendResize(tabId);
|
||||
|
||||
// Update server status if this is the active tab
|
||||
if (this.activeTabId === tabId) {
|
||||
this.updateServerStatus(
|
||||
"Connected",
|
||||
"status-connected",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Проверяем специальное сообщение о закрытии вкладки
|
||||
if (event.data === "CLOSE_TAB") {
|
||||
console.log(
|
||||
`Received CLOSE_TAB message for tab ${tabId}`,
|
||||
);
|
||||
this.closeTab(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event.data === "string") {
|
||||
terminal.write(event.data);
|
||||
} else {
|
||||
// Handle binary data
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const data = new Uint8Array(reader.result);
|
||||
terminal.write(data);
|
||||
};
|
||||
reader.readAsArrayBuffer(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log(
|
||||
`WebSocket disconnected for tab ${tabId}:`,
|
||||
event.code,
|
||||
event.reason,
|
||||
);
|
||||
tabData.connected = false;
|
||||
tabData.connecting = false;
|
||||
this.updateTabStatus(tabId, false);
|
||||
this.updateGlobalStatus();
|
||||
|
||||
if (this.activeTabId === tabId) {
|
||||
this.updateServerStatus(
|
||||
"Disconnected",
|
||||
"status-disconnected",
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-reconnect after delay, только если это не было закрытие по exit
|
||||
if (event.code !== 1000) {
|
||||
// 1000 - нормальное закрытие
|
||||
setTimeout(() => {
|
||||
if (
|
||||
this.tabs.has(tabId) &&
|
||||
!tabData.connected
|
||||
) {
|
||||
console.log(
|
||||
`Attempting to reconnect tab ${tabId}`,
|
||||
);
|
||||
tabData.connecting = true;
|
||||
this.updateTabStatus(tabId, false);
|
||||
this.updateGlobalStatus();
|
||||
this.createWebSocket(
|
||||
tabId,
|
||||
terminal,
|
||||
tabData,
|
||||
);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error(
|
||||
`WebSocket error for tab ${tabId}:`,
|
||||
error,
|
||||
);
|
||||
tabData.connected = false;
|
||||
tabData.connecting = false;
|
||||
this.updateTabStatus(tabId, false);
|
||||
this.updateGlobalStatus();
|
||||
|
||||
if (this.activeTabId === tabId) {
|
||||
this.updateServerStatus(
|
||||
"Connection Error",
|
||||
"status-disconnected",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
switchToTab(tabId) {
|
||||
if (!this.tabs.has(tabId)) return;
|
||||
|
||||
// Update active states
|
||||
this.tabs.forEach((data, id) => {
|
||||
const isActive = id === tabId;
|
||||
data.tabElement.classList.toggle("active", isActive);
|
||||
data.container.classList.toggle("active", isActive);
|
||||
});
|
||||
|
||||
this.activeTabId = tabId;
|
||||
|
||||
// Fit terminal to container
|
||||
const tabData = this.tabs.get(tabId);
|
||||
setTimeout(() => {
|
||||
tabData.fitAddon.fit();
|
||||
this.sendResize(tabId);
|
||||
}, 10);
|
||||
|
||||
// Update status bar
|
||||
this.updateActiveTerminalInfo(tabId);
|
||||
|
||||
// Update server status based on tab connection state
|
||||
if (tabData.connected) {
|
||||
this.updateServerStatus(
|
||||
"Connected",
|
||||
"status-connected",
|
||||
);
|
||||
} else if (tabData.connecting) {
|
||||
this.updateServerStatus(
|
||||
"Connecting...",
|
||||
"status-connecting",
|
||||
);
|
||||
} else {
|
||||
this.updateServerStatus(
|
||||
"Disconnected",
|
||||
"status-disconnected",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
closeTab(tabId) {
|
||||
if (!this.tabs.has(tabId)) return;
|
||||
|
||||
const tabData = this.tabs.get(tabId);
|
||||
|
||||
// Close WebSocket
|
||||
if (tabData.ws) {
|
||||
tabData.ws.close(1000, "Tab closed"); // 1000 - нормальное закрытие
|
||||
}
|
||||
|
||||
// Destroy terminal
|
||||
tabData.terminal.dispose();
|
||||
|
||||
// Remove from DOM
|
||||
tabData.tabElement.remove();
|
||||
tabData.container.remove();
|
||||
|
||||
// Remove from tabs map
|
||||
this.tabs.delete(tabId);
|
||||
|
||||
// If this was the active tab, switch to another
|
||||
if (this.activeTabId === tabId) {
|
||||
if (this.tabs.size > 0) {
|
||||
// Switch to the last tab in the list
|
||||
const lastTabId = Array.from(this.tabs.keys())[
|
||||
this.tabs.size - 1
|
||||
];
|
||||
this.switchToTab(lastTabId);
|
||||
} else {
|
||||
this.activeTabId = null;
|
||||
this.emptyState.style.display = "flex";
|
||||
this.updateActiveTerminalInfo(null);
|
||||
this.updateServerStatus(
|
||||
"Disconnected",
|
||||
"status-disconnected",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update global status
|
||||
this.updateGlobalStatus();
|
||||
}
|
||||
|
||||
sendResize(tabId) {
|
||||
const tabData = this.tabs.get(tabId);
|
||||
if (
|
||||
tabData &&
|
||||
tabData.ws &&
|
||||
tabData.ws.readyState === WebSocket.OPEN
|
||||
) {
|
||||
const dimensions = tabData.terminal;
|
||||
const resizeMsg = {
|
||||
cols: dimensions.cols,
|
||||
rows: dimensions.rows,
|
||||
};
|
||||
tabData.ws.send(JSON.stringify(resizeMsg));
|
||||
}
|
||||
}
|
||||
|
||||
updateTabStatus(tabId, connected) {
|
||||
const tabData = this.tabs.get(tabId);
|
||||
if (tabData) {
|
||||
const statusIndicator = connected
|
||||
? "●"
|
||||
: tabData.connecting
|
||||
? "⟳"
|
||||
: "○";
|
||||
const originalTitle = tabData.title.replace(
|
||||
/[●○⟳] /,
|
||||
"",
|
||||
);
|
||||
tabData.tabElement.querySelector(
|
||||
".tab-title",
|
||||
).textContent = `${statusIndicator} ${originalTitle}`;
|
||||
|
||||
if (this.activeTabId === tabId) {
|
||||
if (connected) {
|
||||
this.updateServerStatus(
|
||||
"Connected",
|
||||
"status-connected",
|
||||
);
|
||||
} else if (tabData.connecting) {
|
||||
this.updateServerStatus(
|
||||
"Connecting...",
|
||||
"status-connecting",
|
||||
);
|
||||
} else {
|
||||
this.updateServerStatus(
|
||||
"Disconnected",
|
||||
"status-disconnected",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveTerminalInfo(tabId) {
|
||||
if (!tabId) {
|
||||
this.activeTerminalInfo.textContent =
|
||||
"No active terminal";
|
||||
return;
|
||||
}
|
||||
|
||||
const tabData = this.tabs.get(tabId);
|
||||
if (tabData) {
|
||||
const originalTitle = tabData.title.replace(
|
||||
/[●○⟳] /,
|
||||
"",
|
||||
);
|
||||
this.activeTerminalInfo.textContent = `Active: ${originalTitle}`;
|
||||
}
|
||||
}
|
||||
|
||||
updateServerStatus(text, className) {
|
||||
this.serverStatus.textContent = text;
|
||||
this.serverStatus.className = `connection-status ${className}`;
|
||||
}
|
||||
|
||||
updateGlobalStatus() {
|
||||
const totalTabs = this.tabs.size;
|
||||
|
||||
if (totalTabs === 0) {
|
||||
this.globalStatus.textContent = "No terminals";
|
||||
this.globalStatus.className =
|
||||
"connection-status status-disconnected";
|
||||
return;
|
||||
}
|
||||
|
||||
const connectedTabs = Array.from(this.tabs.values()).filter(
|
||||
(tab) => tab.connected,
|
||||
).length;
|
||||
const connectingTabs = Array.from(
|
||||
this.tabs.values(),
|
||||
).filter((tab) => tab.connecting).length;
|
||||
|
||||
if (connectedTabs === totalTabs) {
|
||||
// All tabs connected
|
||||
this.globalStatus.textContent = `${connectedTabs}/${totalTabs} connected`;
|
||||
this.globalStatus.className =
|
||||
"connection-status status-connected";
|
||||
} else if (connectedTabs === 0 && connectingTabs === 0) {
|
||||
// All tabs disconnected
|
||||
this.globalStatus.textContent = `${connectedTabs}/${totalTabs} connected`;
|
||||
this.globalStatus.className =
|
||||
"connection-status status-disconnected";
|
||||
} else {
|
||||
// Some tabs connecting or mixed state
|
||||
this.globalStatus.textContent = `${connectedTabs}/${totalTabs} connected`;
|
||||
this.globalStatus.className =
|
||||
"connection-status status-connecting";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tab manager when page loads
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
new TabManager();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
!(function (e, t) {
|
||||
"object" == typeof exports && "object" == typeof module
|
||||
? (module.exports = t())
|
||||
: "function" == typeof define && define.amd
|
||||
? define([], t)
|
||||
: "object" == typeof exports
|
||||
? (exports.FitAddon = t())
|
||||
: (e.FitAddon = t());
|
||||
})(self, () =>
|
||||
(() => {
|
||||
"use strict";
|
||||
var e = {};
|
||||
return (
|
||||
(() => {
|
||||
var t = e;
|
||||
(Object.defineProperty(t, "__esModule", { value: !0 }),
|
||||
(t.FitAddon = void 0),
|
||||
(t.FitAddon = class {
|
||||
activate(e) {
|
||||
this._terminal = e;
|
||||
}
|
||||
dispose() {}
|
||||
fit() {
|
||||
const e = this.proposeDimensions();
|
||||
if (!e || !this._terminal || isNaN(e.cols) || isNaN(e.rows))
|
||||
return;
|
||||
const t = this._terminal._core;
|
||||
(this._terminal.rows === e.rows &&
|
||||
this._terminal.cols === e.cols) ||
|
||||
(t._renderService.clear(),
|
||||
this._terminal.resize(e.cols, e.rows));
|
||||
}
|
||||
proposeDimensions() {
|
||||
if (!this._terminal) return;
|
||||
if (
|
||||
!this._terminal.element ||
|
||||
!this._terminal.element.parentElement
|
||||
)
|
||||
return;
|
||||
const e = this._terminal._core,
|
||||
t = e._renderService.dimensions;
|
||||
if (0 === t.css.cell.width || 0 === t.css.cell.height) return;
|
||||
const r =
|
||||
0 === this._terminal.options.scrollback
|
||||
? 0
|
||||
: e.viewport.scrollBarWidth,
|
||||
i = window.getComputedStyle(
|
||||
this._terminal.element.parentElement,
|
||||
),
|
||||
o = parseInt(i.getPropertyValue("height")),
|
||||
s = Math.max(0, parseInt(i.getPropertyValue("width"))),
|
||||
n = window.getComputedStyle(this._terminal.element),
|
||||
l =
|
||||
o -
|
||||
(parseInt(n.getPropertyValue("padding-top")) +
|
||||
parseInt(n.getPropertyValue("padding-bottom"))),
|
||||
a =
|
||||
s -
|
||||
(parseInt(n.getPropertyValue("padding-right")) +
|
||||
parseInt(n.getPropertyValue("padding-left"))) -
|
||||
r;
|
||||
return {
|
||||
cols: Math.max(2, Math.floor(a / t.css.cell.width)),
|
||||
rows: Math.max(1, Math.floor(l / t.css.cell.height)),
|
||||
};
|
||||
}
|
||||
}));
|
||||
})(),
|
||||
e
|
||||
);
|
||||
})(),
|
||||
);
|
||||
//# sourceMappingURL=xterm-addon-fit.js.map
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #fff;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.xterm-underline-2 {
|
||||
text-decoration: double underline;
|
||||
}
|
||||
.xterm-underline-3 {
|
||||
text-decoration: wavy underline;
|
||||
}
|
||||
.xterm-underline-4 {
|
||||
text-decoration: dotted underline;
|
||||
}
|
||||
.xterm-underline-5 {
|
||||
text-decoration: dashed underline;
|
||||
}
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 {
|
||||
text-decoration: overline underline;
|
||||
}
|
||||
.xterm-overline.xterm-underline-2 {
|
||||
text-decoration: overline double underline;
|
||||
}
|
||||
.xterm-overline.xterm-underline-3 {
|
||||
text-decoration: overline wavy underline;
|
||||
}
|
||||
.xterm-overline.xterm-underline-4 {
|
||||
text-decoration: overline dotted underline;
|
||||
}
|
||||
.xterm-overline.xterm-underline-5 {
|
||||
text-decoration: overline dashed underline;
|
||||
}
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen
|
||||
.xterm-decoration-container
|
||||
.xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
+14698
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user