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
715 lines
26 KiB
HTML
715 lines
26 KiB
HTML
<!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>
|