initial commit
Continuous integration / Check (push) Successful in 50s
Continuous integration / Rustfmt (push) Successful in 35s
Continuous integration / build (push) Successful in 44s
Continuous integration / build-release (push) Successful in 52s

This commit is contained in:
wt committed 2026-02-05 16:26:56 +07:00
commit 5af31644a3
11 files changed
+17526

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
+46
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/target
Generated
+1125
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
+714
View File
@@ -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>
+73
View File
@@ -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
+231
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff