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

This commit is contained in:
wt
2025-11-18 13:01:34 +07:00
parent 36aa1bea2a
commit ee29bfe525
2 changed files with 698 additions and 110 deletions
+79 -10
View File
@@ -11,6 +11,7 @@
#define HTTP_PORT "8888"
#define STATIC_DIR "./static/"
#define INPUT_BUFFER_SIZE 256
static const char *s_http_addr = "http://0.0.0.0:8888";
static struct mg_mgr mgr;
@@ -18,6 +19,9 @@ static struct mg_mgr mgr;
struct client_data {
struct terminal_session term;
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 сообщений
static void handle_websocket_message(struct mg_connection *c,
struct mg_ws_message *wm,
@@ -104,14 +167,19 @@ static void handle_websocket_message(struct mg_connection *c,
cJSON_Delete(json);
}
// Проверяем, не команда ли это exit
if (wm->data.len >= 4 && strncmp(wm->data.buf, "exit", 4) == 0) {
printf("Exit command detected, restarting terminal session\n");
write_to_terminal(&cd->term, wm->data.buf, wm->data.len);
// Даем команде выполниться, затем перезапускаем
usleep(100);
restart_terminal_session(c, cd);
return;
// Добавляем данные в буфер ввода
size_t bytes_to_copy = wm->data.len;
if (cd->input_buffer_len + bytes_to_copy >= INPUT_BUFFER_SIZE) {
// Буфер переполнен, сбрасываем его
cd->input_buffer_len = 0;
}
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->closing = false;
c->fn_data = cd;
// Upgrade to WebSocket
@@ -198,7 +267,7 @@ static void fn(struct mg_connection *c, int ev, void *ev_data) {
printf("WebSocket connection opened\n");
struct client_data *cd = (struct client_data *)c->fn_data;
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);
}
} else if (ev == MG_EV_WS_MSG) {
@@ -223,7 +292,7 @@ static void fn(struct mg_connection *c, int ev, void *ev_data) {
continue;
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)) {
printf("Terminal process died, restarting...\n");
+619 -100
View File
@@ -1,194 +1,713 @@
<!doctype html>
<html>
<head>
<title>Web Terminal</title>
<title>Web Terminal with Tabs</title>
<link rel="stylesheet" href="xterm.css" />
<style>
body {
/* Стили остаются без изменений */
* {
box-sizing: border-box;
margin: 0;
padding: 20px;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
background-color: #1e1e1e;
color: #fff;
font-family: Arial, sans-serif;
color: #ffffff;
height: 100vh;
display: flex;
flex-direction: column;
}
#terminal-container {
flex: 1;
border: 1px solid #333;
border-radius: 5px;
overflow: hidden;
margin-top: 10px;
}
.status {
padding: 10px;
.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;
font-weight: bold;
margin-bottom: 10px;
cursor: pointer;
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;
}
.disconnected {
.status-disconnected {
background-color: #5a2d2d;
}
.connecting {
.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="status connecting" id="status">Connecting...</div>
<div id="terminal-container"></div>
<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 WebTerminal {
class TabManager {
constructor() {
this.terminal = new Terminal({
cursorBlink: true,
theme: {
background: "#000000",
foreground: "#ffffff",
cursor: "#ffffff",
},
});
this.tabs = new Map(); // Map: tabId -> { terminal, ws, container, tabElement }
this.activeTabId = null;
this.nextTabId = 1;
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 3000;
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() {
const terminalContainer =
document.getElementById("terminal-container");
this.terminal.open(terminalContainer);
this.fitAddon.fit();
this.addTabBtn.addEventListener("click", () =>
this.createTab(),
);
this.firstTerminalBtn.addEventListener("click", () =>
this.createTab(),
);
this.terminal.writeln("Welcome to Web Terminal");
this.terminal.writeln("Connecting to server...");
// Create first tab automatically
setTimeout(() => {
this.createTab();
}, 100);
}
this.connect();
createTab() {
const tabId = `tab-${this.nextTabId++}`;
const tabTitle = `Terminal ${this.nextTabId - 1}`;
// Handle terminal input
this.terminal.onData((data) => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(data);
// 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", () => {
this.fitAddon.fit();
this.sendResize();
});
// Handle page unload
window.addEventListener("beforeunload", () => {
if (this.ws) {
this.ws.close();
if (this.activeTabId === tabId) {
fitAddon.fit();
this.sendResize(tabId);
}
});
return tabId;
}
connect() {
this.updateStatus("Connecting...", "connecting");
createWebSocket(tabId, terminal, tabData) {
const protocol =
window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/ws`;
this.ws = new WebSocket(wsUrl);
const ws = new WebSocket(wsUrl);
tabData.ws = ws;
this.ws.onopen = () => {
console.log("WebSocket connected");
this.reconnectAttempts = 0;
this.updateStatus("Connected", "connected");
this.terminal.clear();
this.sendResize();
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",
);
}
};
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") {
this.terminal.write(event.data);
terminal.write(event.data);
} else {
// Handle binary data
const reader = new FileReader();
reader.onload = () => {
const data = new Uint8Array(reader.result);
this.terminal.write(data);
terminal.write(data);
};
reader.readAsArrayBuffer(event.data);
}
};
this.ws.onclose = (event) => {
ws.onclose = (event) => {
console.log(
"WebSocket disconnected:",
`WebSocket disconnected for tab ${tabId}:`,
event.code,
event.reason,
);
this.updateStatus("Disconnected", "disconnected");
tabData.connected = false;
tabData.connecting = false;
this.updateTabStatus(tabId, false);
this.updateGlobalStatus();
if (
this.reconnectAttempts < this.maxReconnectAttempts
) {
this.reconnectAttempts++;
const delay =
this.reconnectDelay * this.reconnectAttempts;
this.terminal.write(
`\r\nConnection lost. Reconnecting in ${delay / 1000}s...\r\n`,
if (this.activeTabId === tabId) {
this.updateServerStatus(
"Disconnected",
"status-disconnected",
);
}
// Auto-reconnect after delay, только если это не было закрытие по exit
if (event.code !== 1000) {
// 1000 - нормальное закрытие
setTimeout(() => {
this.connect();
}, delay);
} else {
this.terminal.write(
"\r\nFailed to reconnect. Please refresh the page.\r\n",
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",
);
}
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
this.updateStatus("Connection Error", "disconnected");
};
}
sendResize() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const dimensions = this.terminal;
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,
};
this.ws.send(JSON.stringify(resizeMsg));
tabData.ws.send(JSON.stringify(resizeMsg));
}
}
updateStatus(text, className) {
const status = document.getElementById("status");
status.textContent = text;
status.className = `status ${className}`;
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",
);
}
}
}
}
// 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", () => {
new WebTerminal();
new TabManager();
});
</script>
</body>