feat: implement multi-session support with sidebar management and dynamic terminal switching

This commit is contained in:
roy01
2026-06-04 11:55:46 +08:00
parent b99af5f734
commit 7991f1fcba
4 changed files with 842 additions and 280 deletions
+395 -126
View File
@@ -14,7 +14,7 @@ const paritySelect = document.getElementById('parity');
// Controls & Console
const scrollModeSelect = document.getElementById('scroll-mode');
const displayFormatSelect = document.getElementById('display-format');
const terminalOutput = document.getElementById('terminal-output');
const terminalContainer = document.getElementById('terminal-container');
// Sending Inputs
const sendInput = document.getElementById('send-input');
@@ -22,19 +22,99 @@ const sendFormatSelect = document.getElementById('send-format');
const sendEolSelect = document.getElementById('send-eol');
const sendBtn = document.getElementById('send-btn');
const clearBtn = document.getElementById('clear-btn');
const addSessionBtn = document.getElementById('add-session-btn');
const sessionListContainer = document.getElementById('session-list');
// App Serial State Variables
let port = null;
let reader = null;
let writer = null;
let keepReading = false;
let receivedLogBuffer = []; // Accumulates all received characters/data for download log
let isTerminalEmpty = true;
// PortSession Class definition
class PortSession {
constructor(id, name) {
this.id = id;
this.name = name;
// Web Serial State
this.port = null;
this.reader = null;
this.writer = null;
this.keepReading = false;
this.receivedLogBuffer = [];
this.isTerminalEmpty = true;
this.deviceInfo = '';
// Settings state
this.baudRate = '115200';
this.dataBits = '8';
this.stopBits = '1';
this.parity = 'none';
// Console controls
this.scrollMode = 'auto';
this.displayFormat = 'auto';
this.sendFormat = 'ascii';
this.sendEol = 'crlf';
this.sendText = '';
// Dedicated Terminal Element
this.terminalOutputElement = document.createElement('div');
this.terminalOutputElement.id = `terminal-output-${this.id}`;
this.terminalOutputElement.className = 'terminal-output empty';
this.terminalOutputElement.tabIndex = 0;
this.terminalOutputElement.textContent = 'No data received yet...';
}
}
// Global Application State
let sessions = [];
let activeSession = null;
// Initialize theme and browser support on load
document.addEventListener('DOMContentLoaded', () => {
initTheme();
checkWebSerialSupport();
// Wire up main event listeners
addSessionBtn.addEventListener('click', addSession);
baudRateSelect.addEventListener('change', () => {
if (activeSession) {
activeSession.baudRate = baudRateSelect.value;
renderSessionsList();
}
});
dataBitsSelect.addEventListener('change', () => {
if (activeSession) activeSession.dataBits = dataBitsSelect.value;
});
stopBitsSelect.addEventListener('change', () => {
if (activeSession) activeSession.stopBits = stopBitsSelect.value;
});
paritySelect.addEventListener('change', () => {
if (activeSession) activeSession.parity = paritySelect.value;
});
scrollModeSelect.addEventListener('change', () => {
if (activeSession) activeSession.scrollMode = scrollModeSelect.value;
});
displayFormatSelect.addEventListener('change', () => {
if (activeSession) activeSession.displayFormat = displayFormatSelect.value;
});
sendFormatSelect.addEventListener('change', () => {
if (activeSession) activeSession.sendFormat = sendFormatSelect.value;
});
sendEolSelect.addEventListener('change', () => {
if (activeSession) activeSession.sendEol = sendEolSelect.value;
});
sendInput.addEventListener('input', () => {
if (activeSession) activeSession.sendText = sendInput.value;
});
// Initialize with a default session
addSession();
});
// Toast notification helper
@@ -78,20 +158,27 @@ function checkWebSerialSupport() {
connectBtn.disabled = true;
connectBtn.style.opacity = '0.5';
connectBtn.style.cursor = 'not-allowed';
addSessionBtn.disabled = true;
addSessionBtn.style.opacity = '0.5';
addSessionBtn.style.cursor = 'not-allowed';
showToast('Web Serial API is not supported in this browser.', 'error');
}
}
function updateConnectionUI(connected, errorMsg = '') {
if (connected) {
if (!activeSession) return;
if (connected && activeSession.port) {
connectBtn.className = 'connect-btn connected';
connectBtn.title = 'Disconnect from serial port';
statusBanner.className = 'status-banner connected';
const info = port.getInfo();
const portLabel = info.usbVendorId ? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})` : 'Serial Device';
const info = activeSession.port.getInfo();
const portLabel = info.usbVendorId
? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})`
: 'Serial Device';
statusText.innerHTML = `Connected to <strong>${portLabel}</strong> at ${baudRateSelect.value} bps.`;
statusText.innerHTML = `Connected to <strong>${portLabel}</strong> at ${activeSession.baudRate} bps.`;
} else {
connectBtn.className = 'connect-btn disconnected';
connectBtn.title = 'Connect to serial port';
@@ -107,92 +194,287 @@ function updateConnectionUI(connected, errorMsg = '') {
}
// -------------------------------------------------------------
// 3. Serial Port Connection & Reader Loop
// 3. Serial Session Management
// -------------------------------------------------------------
async function connectSerial() {
function addSession() {
const id = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
const name = `Session ${sessions.length + 1}`;
const newSession = new PortSession(id, name);
sessions.push(newSession);
terminalContainer.appendChild(newSession.terminalOutputElement);
selectSession(newSession);
showToast(`${name} created.`, 'success');
}
function selectSession(session) {
if (activeSession) {
activeSession.sendText = sendInput.value;
}
activeSession = session;
renderSessionsList();
// Show only active session's terminal DOM element
const terminals = terminalContainer.querySelectorAll('.terminal-output');
terminals.forEach(el => {
if (el.id === activeSession.terminalOutputElement.id) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
});
// Sync dropdown UI values
baudRateSelect.value = activeSession.baudRate;
dataBitsSelect.value = activeSession.dataBits;
stopBitsSelect.value = activeSession.stopBits;
paritySelect.value = activeSession.parity;
scrollModeSelect.value = activeSession.scrollMode;
displayFormatSelect.value = activeSession.displayFormat;
sendFormatSelect.value = activeSession.sendFormat;
sendEolSelect.value = activeSession.sendEol;
sendInput.value = activeSession.sendText;
downloadBtn.disabled = activeSession.isTerminalEmpty;
// Sync status banner and buttons
const isConnected = activeSession.port !== null && activeSession.port.readable;
updateConnectionUI(isConnected);
}
async function deleteSession(session, event) {
if (event) {
event.stopPropagation();
}
if (sessions.length <= 1) {
showToast('Cannot delete the last remaining session.', 'error');
return;
}
// Disconnect if connected
if (session.port) {
await disconnectSession(session);
}
// Remove terminal from DOM
if (session.terminalOutputElement && session.terminalOutputElement.parentNode) {
session.terminalOutputElement.parentNode.removeChild(session.terminalOutputElement);
}
const index = sessions.indexOf(session);
if (index > -1) {
sessions.splice(index, 1);
}
if (activeSession === session) {
const nextActive = sessions[Math.max(0, index - 1)];
selectSession(nextActive);
} else {
renderSessionsList();
}
showToast(`${session.name} deleted.`, 'success');
}
function renderSessionsList() {
sessionListContainer.innerHTML = '';
sessions.forEach(session => {
const item = document.createElement('div');
item.className = `session-item ${session === activeSession ? 'active' : ''}`;
item.addEventListener('click', () => selectSession(session));
const leftGroup = document.createElement('div');
leftGroup.className = 'session-item-left';
// Status dot
const dot = document.createElement('span');
const isConnected = session.port !== null && session.port.readable;
dot.className = `status-dot ${isConnected ? 'connected' : 'disconnected'}`;
leftGroup.appendChild(dot);
// Session labels
const info = document.createElement('div');
info.className = 'session-info';
const nameEl = document.createElement('span');
nameEl.className = 'session-name';
nameEl.textContent = session.name;
// Double click to rename
nameEl.addEventListener('dblclick', (e) => {
e.stopPropagation();
const input = document.createElement('input');
input.type = 'text';
input.value = session.name;
input.className = 'session-rename-input';
const saveRename = () => {
const val = input.value.trim();
if (val) {
session.name = val;
}
renderSessionsList();
};
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
saveRename();
} else if (evt.key === 'Escape') {
renderSessionsList();
}
});
input.addEventListener('blur', saveRename);
info.innerHTML = '';
info.appendChild(input);
input.focus();
input.select();
});
const detailEl = document.createElement('span');
detailEl.className = 'session-detail';
if (isConnected) {
detailEl.textContent = `${session.baudRate} bps | ${session.deviceInfo || 'Connected'}`;
} else {
detailEl.textContent = `${session.baudRate} bps | Disconnected`;
}
info.appendChild(nameEl);
info.appendChild(detailEl);
leftGroup.appendChild(info);
item.appendChild(leftGroup);
// Close / Delete Session button
const deleteBtn = document.createElement('button');
deleteBtn.className = 'session-delete-btn';
deleteBtn.title = 'Delete Session';
deleteBtn.innerHTML = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
`;
deleteBtn.addEventListener('click', (e) => deleteSession(session, e));
item.appendChild(deleteBtn);
sessionListContainer.appendChild(item);
});
}
// -------------------------------------------------------------
// 4. Serial Port Connection & Reader Loop
// -------------------------------------------------------------
async function connectSession(session) {
try {
// Request a port from the user
port = await navigator.serial.requestPort();
session.port = await navigator.serial.requestPort();
const options = {
baudRate: parseInt(baudRateSelect.value, 10),
dataBits: parseInt(dataBitsSelect.value, 10),
stopBits: parseInt(stopBitsSelect.value, 10),
parity: paritySelect.value
baudRate: parseInt(session.baudRate, 10),
dataBits: parseInt(session.dataBits, 10),
stopBits: parseInt(session.stopBits, 10),
parity: session.parity
};
// Open connection
await port.open(options);
await session.port.open(options);
updateConnectionUI(true);
keepReading = true;
startReadingLoop();
const info = session.port.getInfo();
if (info.usbVendorId) {
session.deviceInfo = `USB VID:0x${info.usbVendorId.toString(16).toUpperCase()}`;
} else {
session.deviceInfo = 'Serial Device';
}
if (session === activeSession) {
updateConnectionUI(true);
}
renderSessionsList();
session.keepReading = true;
startSessionReadingLoop(session);
showToast(`${session.name} connected.`, 'success');
} catch (err) {
console.error('Connection failed:', err);
updateConnectionUI(false, `Connection failed: ${err.message}`);
showToast('Failed to open port.', 'error');
session.port = null;
if (session === activeSession) {
updateConnectionUI(false, `Connection failed: ${err.message}`);
}
showToast(`Failed to open port for ${session.name}.`, 'error');
}
}
async function disconnectSerial() {
keepReading = false;
async function disconnectSession(session) {
session.keepReading = false;
if (reader) {
if (session.reader) {
try {
await reader.cancel();
await session.reader.cancel();
} catch (e) {
console.error('Error cancelling reader:', e);
}
}
// Wait a short moment for loops to clear
setTimeout(async () => {
if (port) {
try {
await port.close();
} catch (e) {
console.error('Error closing port:', e);
return new Promise((resolve) => {
setTimeout(async () => {
if (session.port) {
try {
await session.port.close();
} catch (e) {
console.error('Error closing port:', e);
}
session.port = null;
}
port = null;
}
updateConnectionUI(false);
showToast('Disconnected from port.', 'success');
}, 100);
session.deviceInfo = '';
if (session === activeSession) {
updateConnectionUI(false);
}
renderSessionsList();
showToast(`${session.name} disconnected.`, 'success');
resolve();
}, 100);
});
}
// Background read loop
async function startReadingLoop() {
async function startSessionReadingLoop(session) {
const decoder = new TextDecoder();
while (port && port.readable && keepReading) {
while (session.port && session.port.readable && session.keepReading) {
try {
reader = port.readable.getReader();
session.reader = session.port.readable.getReader();
while (keepReading) {
const { value, done } = await reader.read();
while (session.keepReading) {
const { value, done } = await session.reader.read();
if (done) break;
if (value) {
handleIncomingData(value, decoder);
handleIncomingSessionData(session, value, decoder);
}
}
} catch (err) {
console.error('Read error:', err);
updateConnectionUI(false, `Port disconnected: ${err.message}`);
showToast('Serial device disconnected.', 'error');
if (session === activeSession) {
updateConnectionUI(false, `Port disconnected: ${err.message}`);
}
showToast(`${session.name} disconnected unexpectedly.`, 'error');
session.port = null;
renderSessionsList();
break;
} finally {
if (reader) {
reader.releaseLock();
reader = null;
if (session.reader) {
session.reader.releaseLock();
session.reader = null;
}
}
}
}
// Handle incoming bytes
function handleIncomingData(uint8Array, decoder) {
const format = displayFormatSelect.value;
function handleIncomingSessionData(session, uint8Array, decoder) {
const format = session.displayFormat;
let formattedText = '';
if (format === 'hex') {
@@ -200,35 +482,32 @@ function handleIncomingData(uint8Array, decoder) {
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
.join(' ') + ' ';
} else {
// text or auto mode
formattedText = decoder.decode(uint8Array);
}
// Save to download log buffer
// Always store string representation
receivedLogBuffer.push(formattedText);
// Display in terminal UI
appendTerminalText(formattedText, 'received');
session.receivedLogBuffer.push(formattedText);
appendSessionTerminalText(session, formattedText, 'received');
}
// -------------------------------------------------------------
// 4. Writing Data (Transmission)
// 5. Writing Data (Transmission)
// -------------------------------------------------------------
async function sendData() {
if (!port || !port.writable) {
showToast('Cannot send. Serial port is not connected.', 'error');
async function sendSessionData() {
if (!activeSession) return;
const session = activeSession;
if (!session.port || !session.port.writable) {
showToast(`Cannot send. ${session.name} is not connected.`, 'error');
return;
}
const rawInput = sendInput.value;
if (!rawInput) return;
const format = sendFormatSelect.value;
const eol = sendEolSelect.value;
const format = session.sendFormat;
const eol = session.sendEol;
let bytesToSend;
// Determine EOL bytes/string
let eolText = '';
let eolBytes = new Uint8Array(0);
if (eol === 'lf') {
@@ -257,10 +536,9 @@ async function sendData() {
}
try {
writer = port.writable.getWriter();
await writer.write(bytesToSend);
session.writer = session.port.writable.getWriter();
await session.writer.write(bytesToSend);
// Log transmission visually on the terminal
let displaySent = '';
if (format === 'hex') {
displaySent = Array.from(bytesToSend)
@@ -273,23 +551,22 @@ async function sendData() {
else if (eol === 'crlf') displaySent += '\\r\\n';
}
appendTerminalText(`\n[Sent] ${displaySent}\n`, 'sent');
sendInput.value = ''; // clear input field
appendSessionTerminalText(session, `\n[Sent] ${displaySent}\n`, 'sent');
sendInput.value = '';
session.sendText = '';
} catch (err) {
console.error('Send error:', err);
showToast(`Failed to send: ${err.message}`, 'error');
} finally {
if (writer) {
writer.releaseLock();
writer = null;
if (session.writer) {
session.writer.releaseLock();
session.writer = null;
}
}
}
// Hex Parsing Utility
function parseHexString(hexString) {
// Remove all white spaces
const clean = hexString.replace(/\s+/g, '');
if (!/^[0-9a-fA-F]*$/.test(clean)) {
@@ -308,45 +585,44 @@ function parseHexString(hexString) {
}
// -------------------------------------------------------------
// 5. Terminal Console Operations (Append, Clear, Scroll)
// 6. Terminal Console Operations
// -------------------------------------------------------------
function appendTerminalText(text, className = '') {
if (isTerminalEmpty) {
terminalOutput.textContent = '';
terminalOutput.classList.remove('empty');
isTerminalEmpty = false;
downloadBtn.disabled = false;
function appendSessionTerminalText(session, text, className = '') {
if (session.isTerminalEmpty) {
session.terminalOutputElement.textContent = '';
session.terminalOutputElement.classList.remove('empty');
session.isTerminalEmpty = false;
if (session === activeSession) {
downloadBtn.disabled = false;
}
}
// Create text container
const span = document.createElement('span');
if (className) {
span.className = `terminal-line ${className}`;
}
span.textContent = text;
terminalOutput.appendChild(span);
session.terminalOutputElement.appendChild(span);
// Auto-scroll handler
if (scrollModeSelect.value === 'auto') {
terminalOutput.scrollTop = terminalOutput.scrollHeight;
if (session.scrollMode === 'auto') {
session.terminalOutputElement.scrollTop = session.terminalOutputElement.scrollHeight;
}
}
// Clear terminal function
function clearTerminal() {
terminalOutput.innerHTML = 'No data received yet...';
terminalOutput.classList.add('empty');
isTerminalEmpty = true;
receivedLogBuffer = [];
if (!activeSession) return;
activeSession.terminalOutputElement.innerHTML = 'No data received yet...';
activeSession.terminalOutputElement.classList.add('empty');
activeSession.isTerminalEmpty = true;
activeSession.receivedLogBuffer = [];
downloadBtn.disabled = true;
showToast('Terminal cleared.', 'success');
}
// Download received console logs
function downloadLog() {
if (receivedLogBuffer.length === 0) return;
if (!activeSession || activeSession.receivedLogBuffer.length === 0) return;
const content = receivedLogBuffer.join('');
const content = activeSession.receivedLogBuffer.join('');
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
@@ -354,7 +630,7 @@ function downloadLog() {
const a = document.createElement('a');
a.href = url;
a.download = `uart_log_${dateStr}.txt`;
a.download = `uart_log_${activeSession.name.replace(/\s+/g, '_')}_${dateStr}.txt`;
document.body.appendChild(a);
a.click();
@@ -367,52 +643,45 @@ function downloadLog() {
}
// -------------------------------------------------------------
// 6. Event Listeners & Keyboard Shortcuts
// 7. Event Listeners & Keyboard Shortcuts
// -------------------------------------------------------------
connectBtn.addEventListener('click', () => {
if (port) {
disconnectSerial();
} else {
connectSerial();
if (activeSession) {
if (activeSession.port) {
disconnectSession(activeSession);
} else {
connectSession(activeSession);
}
}
});
sendBtn.addEventListener('click', sendData);
sendBtn.addEventListener('click', sendSessionData);
clearBtn.addEventListener('click', clearTerminal);
downloadBtn.addEventListener('click', downloadLog);
// Input Keyboard Shortcuts
sendInput.addEventListener('keydown', (e) => {
// Ctrl + Enter or Enter (standard text input submit) to send
if (e.key === 'Enter') {
e.preventDefault();
sendData();
sendSessionData();
}
// Ctrl + Tab to insert actual tab characters
if (e.key === 'Tab' && e.ctrlKey) {
e.preventDefault();
const start = sendInput.selectionStart;
const end = sendInput.selectionEnd;
// Set input value: [text before tab] + \t + [text after tab]
sendInput.value = sendInput.value.substring(0, start) + '\t' + sendInput.value.substring(end);
// Put caret in right position
sendInput.selectionStart = sendInput.selectionEnd = start + 1;
if (activeSession) activeSession.sendText = sendInput.value;
}
});
// Auto-detect browser USB connect/disconnect events
// Auto-detect browser USB disconnect events globally
if ('serial' in navigator) {
navigator.serial.addEventListener('connect', (event) => {
showToast('Serial device connected to port.', 'success');
});
navigator.serial.addEventListener('disconnect', (event) => {
if (port && event.target === port) {
disconnectSerial();
updateConnectionUI(false, 'Selected serial device was disconnected.');
showToast('Device connection lost.', 'error');
navigator.serial.addEventListener('disconnect', async (event) => {
const disconnectedSession = sessions.find(s => s.port === event.target);
if (disconnectedSession) {
await disconnectSession(disconnectedSession);
showToast(`${disconnectedSession.name} device disconnected.`, 'error');
}
});
}