updated added tabs and closing tabs
Prepare Build and Testing executable binary / Prepare-Build-Testing-With-Make-Without-sqlite3 (push) Successful in 5s
Prepare Build and Testing executable binary / Prepare-Build-Testing-With-CMake-Without-sqlite3 (push) Successful in 9s
Prepare Build and Testing executable binary / Prepare-Build-Testing-With-Meson-Without-sqlite3 (push) Successful in 7s
Prepare Build and Testing executable binary / Prepare-Build-Testing-With-Make-Without-sqlite3 (push) Successful in 5s
Prepare Build and Testing executable binary / Prepare-Build-Testing-With-CMake-Without-sqlite3 (push) Successful in 9s
Prepare Build and Testing executable binary / Prepare-Build-Testing-With-Meson-Without-sqlite3 (push) Successful in 7s
This commit is contained in:
+79
-10
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
#define HTTP_PORT "8888"
|
#define HTTP_PORT "8888"
|
||||||
#define STATIC_DIR "./static/"
|
#define STATIC_DIR "./static/"
|
||||||
|
#define INPUT_BUFFER_SIZE 256
|
||||||
|
|
||||||
static const char *s_http_addr = "http://0.0.0.0:8888";
|
static const char *s_http_addr = "http://0.0.0.0:8888";
|
||||||
static struct mg_mgr mgr;
|
static struct mg_mgr mgr;
|
||||||
@@ -18,6 +19,9 @@ static struct mg_mgr mgr;
|
|||||||
struct client_data {
|
struct client_data {
|
||||||
struct terminal_session term;
|
struct terminal_session term;
|
||||||
bool terminal_ready;
|
bool terminal_ready;
|
||||||
|
bool closing; // Флаг для закрытия соединения
|
||||||
|
char input_buffer[INPUT_BUFFER_SIZE];
|
||||||
|
size_t input_buffer_len;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для логирования запросов
|
// Функция для логирования запросов
|
||||||
@@ -78,6 +82,65 @@ static void restart_terminal_session(struct mg_connection *c,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция для проверки команды exit в буфере
|
||||||
|
static bool check_for_exit_command(struct mg_connection *c,
|
||||||
|
struct client_data *cd) {
|
||||||
|
for (size_t i = 0; i < cd->input_buffer_len; i++) {
|
||||||
|
if (cd->input_buffer[i] == 0x04) {
|
||||||
|
printf("Ctrl+D pressed\n");
|
||||||
|
write_to_terminal(&cd->term, &cd->input_buffer[i], 1);
|
||||||
|
usleep(100);
|
||||||
|
mg_ws_send(c, "CLOSE_TAB", 9, WEBSOCKET_OP_TEXT);
|
||||||
|
cd->closing = true;
|
||||||
|
cd->input_buffer_len = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ищем символ новой строки в буфере
|
||||||
|
for (size_t i = 0; i < cd->input_buffer_len; i++) {
|
||||||
|
if (cd->input_buffer[i] == '\n' || cd->input_buffer[i] == '\r') {
|
||||||
|
// Нашли конец строки, проверяем команду
|
||||||
|
cd->input_buffer[i] = '\0'; // Завершаем строку
|
||||||
|
printf("Command: %s\n", cd->input_buffer);
|
||||||
|
if (cd->input_buffer[i - 1] == 'x') {
|
||||||
|
printf("Ctrl+D pressed\n");
|
||||||
|
write_to_terminal(&cd->term, &cd->input_buffer[i], 1);
|
||||||
|
usleep(100);
|
||||||
|
mg_ws_send(c, "CLOSE_TAB", 9, WEBSOCKET_OP_TEXT);
|
||||||
|
cd->closing = true;
|
||||||
|
cd->input_buffer_len = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Проверяем, является ли команда "exit"
|
||||||
|
if (strcmp(cd->input_buffer, "exit") == 0) {
|
||||||
|
printf("Exit command detected, closing terminal session\n");
|
||||||
|
|
||||||
|
// Ждем немного чтобы команда выполнилась
|
||||||
|
usleep(100);
|
||||||
|
|
||||||
|
// Отправляем клиенту сообщение о закрытии вкладки
|
||||||
|
mg_ws_send(c, "CLOSE_TAB", 9, WEBSOCKET_OP_TEXT);
|
||||||
|
|
||||||
|
// Помечаем соединение для закрытия
|
||||||
|
cd->closing = true;
|
||||||
|
|
||||||
|
// Сбрасываем буфер
|
||||||
|
cd->input_buffer_len = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сдвигаем буфер, удаляя обработанную строку
|
||||||
|
size_t remaining = cd->input_buffer_len - i - 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
memmove(cd->input_buffer, cd->input_buffer + i + 1, remaining);
|
||||||
|
}
|
||||||
|
cd->input_buffer_len = remaining;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Обработчик WebSocket сообщений
|
// Обработчик WebSocket сообщений
|
||||||
static void handle_websocket_message(struct mg_connection *c,
|
static void handle_websocket_message(struct mg_connection *c,
|
||||||
struct mg_ws_message *wm,
|
struct mg_ws_message *wm,
|
||||||
@@ -104,14 +167,19 @@ static void handle_websocket_message(struct mg_connection *c,
|
|||||||
cJSON_Delete(json);
|
cJSON_Delete(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, не команда ли это exit
|
// Добавляем данные в буфер ввода
|
||||||
if (wm->data.len >= 4 && strncmp(wm->data.buf, "exit", 4) == 0) {
|
size_t bytes_to_copy = wm->data.len;
|
||||||
printf("Exit command detected, restarting terminal session\n");
|
if (cd->input_buffer_len + bytes_to_copy >= INPUT_BUFFER_SIZE) {
|
||||||
write_to_terminal(&cd->term, wm->data.buf, wm->data.len);
|
// Буфер переполнен, сбрасываем его
|
||||||
// Даем команде выполниться, затем перезапускаем
|
cd->input_buffer_len = 0;
|
||||||
usleep(100);
|
}
|
||||||
restart_terminal_session(c, cd);
|
|
||||||
return;
|
memcpy(cd->input_buffer + cd->input_buffer_len, wm->data.buf, bytes_to_copy);
|
||||||
|
cd->input_buffer_len += bytes_to_copy;
|
||||||
|
|
||||||
|
printf("Input received: %s\n", wm->data.buf);
|
||||||
|
if (check_for_exit_command(c, cd)) {
|
||||||
|
return; // Вкладка будет закрыта, выходим
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обычный ввод от пользователя
|
// Обычный ввод от пользователя
|
||||||
@@ -157,6 +225,7 @@ static void fn(struct mg_connection *c, int ev, void *ev_data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cd->terminal_ready = true;
|
cd->terminal_ready = true;
|
||||||
|
cd->closing = false;
|
||||||
c->fn_data = cd;
|
c->fn_data = cd;
|
||||||
|
|
||||||
// Upgrade to WebSocket
|
// Upgrade to WebSocket
|
||||||
@@ -198,7 +267,7 @@ static void fn(struct mg_connection *c, int ev, void *ev_data) {
|
|||||||
printf("WebSocket connection opened\n");
|
printf("WebSocket connection opened\n");
|
||||||
struct client_data *cd = (struct client_data *)c->fn_data;
|
struct client_data *cd = (struct client_data *)c->fn_data;
|
||||||
if (cd) {
|
if (cd) {
|
||||||
mg_ws_send(c, "Terminal ready - type 'exit' to restart session\r\n", 49,
|
mg_ws_send(c, "Terminal ready - type 'exit' to close tab\r\n", 44,
|
||||||
WEBSOCKET_OP_TEXT);
|
WEBSOCKET_OP_TEXT);
|
||||||
}
|
}
|
||||||
} else if (ev == MG_EV_WS_MSG) {
|
} else if (ev == MG_EV_WS_MSG) {
|
||||||
@@ -223,7 +292,7 @@ static void fn(struct mg_connection *c, int ev, void *ev_data) {
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
struct client_data *cd = (struct client_data *)conn->fn_data;
|
struct client_data *cd = (struct client_data *)conn->fn_data;
|
||||||
if (cd && cd->terminal_ready) {
|
if (cd && cd->terminal_ready && !cd->closing) {
|
||||||
// Проверяем, не завершился ли процесс
|
// Проверяем, не завершился ли процесс
|
||||||
if (!is_process_alive(cd->term.child_pid)) {
|
if (!is_process_alive(cd->term.child_pid)) {
|
||||||
printf("Terminal process died, restarting...\n");
|
printf("Terminal process died, restarting...\n");
|
||||||
|
|||||||
+619
-100
@@ -1,194 +1,713 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Web Terminal</title>
|
<title>Web Terminal with Tabs</title>
|
||||||
<link rel="stylesheet" href="xterm.css" />
|
<link rel="stylesheet" href="xterm.css" />
|
||||||
<style>
|
<style>
|
||||||
body {
|
/* Стили остаются без изменений */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
sans-serif;
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
color: #fff;
|
color: #ffffff;
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
|
||||||
|
|
||||||
#terminal-container {
|
|
||||||
flex: 1;
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-top: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.header {
|
||||||
padding: 10px;
|
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;
|
border-radius: 3px;
|
||||||
font-weight: bold;
|
cursor: pointer;
|
||||||
margin-bottom: 10px;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connected {
|
.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;
|
background-color: #2d5a2d;
|
||||||
}
|
}
|
||||||
.disconnected {
|
|
||||||
|
.status-disconnected {
|
||||||
background-color: #5a2d2d;
|
background-color: #5a2d2d;
|
||||||
}
|
}
|
||||||
.connecting {
|
|
||||||
|
.status-connecting {
|
||||||
background-color: #5a5a2d;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="header">
|
||||||
<h1>Web Terminal</h1>
|
<h1>Web Terminal</h1>
|
||||||
<div class="status connecting" id="status">Connecting...</div>
|
<div class="connection-status status-connecting" id="global-status">
|
||||||
<div id="terminal-container"></div>
|
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.js"></script>
|
||||||
<script src="xterm-addon-fit.js"></script>
|
<script src="xterm-addon-fit.js"></script>
|
||||||
<script>
|
<script>
|
||||||
class WebTerminal {
|
class TabManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.terminal = new Terminal({
|
this.tabs = new Map(); // Map: tabId -> { terminal, ws, container, tabElement }
|
||||||
cursorBlink: true,
|
this.activeTabId = null;
|
||||||
theme: {
|
this.nextTabId = 1;
|
||||||
background: "#000000",
|
|
||||||
foreground: "#ffffff",
|
|
||||||
cursor: "#ffffff",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fitAddon = new FitAddon.FitAddon();
|
this.tabsList = document.getElementById("tabs-list");
|
||||||
this.terminal.loadAddon(this.fitAddon);
|
this.terminalsContainer = document.getElementById(
|
||||||
|
"terminals-container",
|
||||||
this.ws = null;
|
);
|
||||||
this.reconnectAttempts = 0;
|
this.emptyState = document.getElementById("empty-state");
|
||||||
this.maxReconnectAttempts = 5;
|
this.addTabBtn = document.getElementById("add-tab");
|
||||||
this.reconnectDelay = 3000;
|
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();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const terminalContainer =
|
this.addTabBtn.addEventListener("click", () =>
|
||||||
document.getElementById("terminal-container");
|
this.createTab(),
|
||||||
this.terminal.open(terminalContainer);
|
);
|
||||||
this.fitAddon.fit();
|
this.firstTerminalBtn.addEventListener("click", () =>
|
||||||
|
this.createTab(),
|
||||||
|
);
|
||||||
|
|
||||||
this.terminal.writeln("Welcome to Web Terminal");
|
// Create first tab automatically
|
||||||
this.terminal.writeln("Connecting to server...");
|
setTimeout(() => {
|
||||||
|
this.createTab();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
this.connect();
|
createTab() {
|
||||||
|
const tabId = `tab-${this.nextTabId++}`;
|
||||||
|
const tabTitle = `Terminal ${this.nextTabId - 1}`;
|
||||||
|
|
||||||
// Handle terminal input
|
// Create tab element
|
||||||
this.terminal.onData((data) => {
|
const tabElement = document.createElement("div");
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
tabElement.className = "tab";
|
||||||
this.ws.send(data);
|
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
|
// Handle resize
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
this.fitAddon.fit();
|
if (this.activeTabId === tabId) {
|
||||||
this.sendResize();
|
fitAddon.fit();
|
||||||
});
|
this.sendResize(tabId);
|
||||||
|
|
||||||
// Handle page unload
|
|
||||||
window.addEventListener("beforeunload", () => {
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.close();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return tabId;
|
||||||
}
|
}
|
||||||
|
|
||||||
connect() {
|
createWebSocket(tabId, terminal, tabData) {
|
||||||
this.updateStatus("Connecting...", "connecting");
|
|
||||||
|
|
||||||
const protocol =
|
const protocol =
|
||||||
window.location.protocol === "https:" ? "wss:" : "ws:";
|
window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||||
|
|
||||||
this.ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
tabData.ws = ws;
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log("WebSocket connected");
|
console.log(`WebSocket connected for tab ${tabId}`);
|
||||||
this.reconnectAttempts = 0;
|
tabData.connected = true;
|
||||||
this.updateStatus("Connected", "connected");
|
tabData.connecting = false;
|
||||||
this.terminal.clear();
|
this.updateTabStatus(tabId, true);
|
||||||
this.sendResize();
|
this.updateGlobalStatus();
|
||||||
|
this.sendResize(tabId);
|
||||||
|
|
||||||
|
// Update server status if this is the active tab
|
||||||
|
if (this.activeTabId === tabId) {
|
||||||
|
this.updateServerStatus(
|
||||||
|
"Connected",
|
||||||
|
"status-connected",
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
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") {
|
if (typeof event.data === "string") {
|
||||||
this.terminal.write(event.data);
|
terminal.write(event.data);
|
||||||
} else {
|
} else {
|
||||||
// Handle binary data
|
// Handle binary data
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const data = new Uint8Array(reader.result);
|
const data = new Uint8Array(reader.result);
|
||||||
this.terminal.write(data);
|
terminal.write(data);
|
||||||
};
|
};
|
||||||
reader.readAsArrayBuffer(event.data);
|
reader.readAsArrayBuffer(event.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
console.log(
|
console.log(
|
||||||
"WebSocket disconnected:",
|
`WebSocket disconnected for tab ${tabId}:`,
|
||||||
event.code,
|
event.code,
|
||||||
event.reason,
|
event.reason,
|
||||||
);
|
);
|
||||||
this.updateStatus("Disconnected", "disconnected");
|
tabData.connected = false;
|
||||||
|
tabData.connecting = false;
|
||||||
|
this.updateTabStatus(tabId, false);
|
||||||
|
this.updateGlobalStatus();
|
||||||
|
|
||||||
if (
|
if (this.activeTabId === tabId) {
|
||||||
this.reconnectAttempts < this.maxReconnectAttempts
|
this.updateServerStatus(
|
||||||
) {
|
"Disconnected",
|
||||||
this.reconnectAttempts++;
|
"status-disconnected",
|
||||||
const delay =
|
|
||||||
this.reconnectDelay * this.reconnectAttempts;
|
|
||||||
this.terminal.write(
|
|
||||||
`\r\nConnection lost. Reconnecting in ${delay / 1000}s...\r\n`,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-reconnect after delay, только если это не было закрытие по exit
|
||||||
|
if (event.code !== 1000) {
|
||||||
|
// 1000 - нормальное закрытие
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.connect();
|
if (
|
||||||
}, delay);
|
this.tabs.has(tabId) &&
|
||||||
} else {
|
!tabData.connected
|
||||||
this.terminal.write(
|
) {
|
||||||
"\r\nFailed to reconnect. Please refresh the page.\r\n",
|
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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.ws.onerror = (error) => {
|
|
||||||
console.error("WebSocket error:", error);
|
|
||||||
this.updateStatus("Connection Error", "disconnected");
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sendResize() {
|
switchToTab(tabId) {
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
if (!this.tabs.has(tabId)) return;
|
||||||
const dimensions = this.terminal;
|
|
||||||
|
// 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 = {
|
const resizeMsg = {
|
||||||
cols: dimensions.cols,
|
cols: dimensions.cols,
|
||||||
rows: dimensions.rows,
|
rows: dimensions.rows,
|
||||||
};
|
};
|
||||||
this.ws.send(JSON.stringify(resizeMsg));
|
tabData.ws.send(JSON.stringify(resizeMsg));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStatus(text, className) {
|
updateTabStatus(tabId, connected) {
|
||||||
const status = document.getElementById("status");
|
const tabData = this.tabs.get(tabId);
|
||||||
status.textContent = text;
|
if (tabData) {
|
||||||
status.className = `status ${className}`;
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize terminal when page loads
|
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", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
new WebTerminal();
|
new TabManager();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user