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');
}
});
}
+61
View File
@@ -0,0 +1,61 @@
# UART Terminal Multi-Port Implementation Plan
The goal is to modify the UART Terminal to support multiple concurrent serial port sessions. We will introduce a sidebar panel on the left to display all sessions (e.g., active/inactive state, baud rate, and custom names) and a main workspace on the right to manage the configuration and terminal log of the currently selected session.
## User Review Required
> [!IMPORTANT]
> - **Default Session**: On app load, a default "Session 1" will be created and selected. This ensures that users who just want to use a single port don't have to perform any additional clicks.
> - **Concurrent Connections**: Each session will maintain its own active connection, serial reader loop, terminal logs, and configurations. This allows the user to have multiple serial devices connected and communicating simultaneously, switching between them at will without losing context or scroll history.
> - **Session Renaming**: Users can double-click a session name in the sidebar to rename it to something descriptive (e.g., "MCU 1", "GPS Module").
## Proposed Changes
### UI / Layout Configuration
#### [MODIFY] [index.html](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.html)
- Wrap the `<main class="app-main">` and a new `<aside class="app-sidebar">` inside a container `<div class="app-layout">`.
- Adjust container structure:
- Increase `.app-container` max-width to `1200px` to comfortably host the sidebar and terminal split.
- Implement dynamic placeholders for the terminal output elements. Instead of a single `#terminal-output` div, we'll make `#terminal-container` host dynamically created terminal-output elements for each session, toggling their visibility based on the active session.
- Add an "Add Session" (`#add-session-btn`) button to the sidebar.
#### [MODIFY] [index.css](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.css)
- Increase `.app-container` `max-width` to `1200px`.
- Add rules for `.app-layout` (CSS Grid or Flexbox layout, split `260px` and `1fr`).
- Add styles for the sidebar (`.app-sidebar`):
- `.sidebar-header` (flex row, title, add-session button).
- `.session-list` (flex column, scrolling).
- `.session-item` (states: hover, active, status-dots, title/subtitle, hover delete button).
- `.session-rename-input` (input box replacing title on double-click).
- Add support for displaying/hiding terminal sessions via class `.hidden { display: none !important; }`.
### State & Logic Configuration
#### [MODIFY] [app.js](file:///home/roy/zData/WTPCode/bodeplot/uart_example/app.js)
- Define a `PortSession` class to encapsulate all states of a single UART connection:
- ID, name, connection state, terminal DOM element, log buffers, input text cache.
- Port options: `baudRate`, `dataBits`, `stopBits`, `parity`.
- Terminal configs: `scrollMode`, `displayFormat`, `sendFormat`, `sendEol`.
- Maintain global variables:
- `sessions` array.
- `activeSession` pointer.
- Refactor connection functions (`connectSerial`, `disconnectSerial`, `startReadingLoop`, `sendData`) to execute against the active session's parameters.
- Re-route UI change listeners (Baud Rate selector, display formats, send input, etc.) to update properties on `activeSession` when changed.
- Build functions to:
- Add a session (`addSession()`).
- Select/Activate a session (`activateSession(session)`).
- Delete/Remove a session (`deleteSession(session)`).
- Sync UI selectors with active session states.
## Verification Plan
### Automated Tests
We will run `npm run dev` or a local static files server to review the layout, and use our understanding of the DOM state to ensure multiple concurrent sessions behave correctly.
### Manual Verification
1. **Multiple Tabs**: Add 3 ports in the sidebar. Verify they are named Session 1, Session 2, Session 3.
2. **Settings Persistence**: Change Baud Rate in Session 1 to `9600` and Session 2 to `115200`. Switch between them and verify that the dropdown value updates to reflect the active session's settings.
3. **Session Renaming**: Double-click "Session 1" name in the sidebar, type "Test Port", and press Enter. Verify the label changes.
4. **Delete Session**: Select a session and click the `x` delete icon. Verify it gets removed. If connected, it should disconnect safely first.
5. **Console logs isolation**: Verify that sending data or logs in one session does not bleed into another session's terminal panel.
+213 -1
View File
@@ -83,7 +83,7 @@ body {
/* App container */
.app-container {
width: 100%;
max-width: 960px;
max-width: 1200px;
padding: 2.5rem 1.5rem;
display: flex;
flex-direction: column;
@@ -684,3 +684,215 @@ body.light-theme .terminal-line.sent {
border-color: var(--accent-danger);
color: var(--accent-danger);
}
/* Sidebar and Split Layout */
.app-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1.5rem;
width: 100%;
align-items: start;
}
@media (max-width: 900px) {
.app-layout {
grid-template-columns: 1fr;
}
}
.app-sidebar {
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 1.25rem;
transition: background-color var(--transition-normal), border-color var(--transition-normal);
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h2 {
font-size: 1.15rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-main);
}
.add-session-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background-color: var(--accent-purple);
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform var(--transition-fast), background-color var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.add-session-btn:hover {
background-color: var(--accent-purple-hover);
transform: scale(1.08);
}
.add-session-btn svg {
width: 16px;
height: 16px;
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 500px;
overflow-y: auto;
padding-right: 4px;
}
/* Custom Scrollbar for Session List */
.session-list::-webkit-scrollbar {
width: 6px;
}
.session-list::-webkit-scrollbar-track {
background: transparent;
}
.session-list::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.session-list::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
.session-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
user-select: none;
position: relative;
}
.session-item:hover {
border-color: var(--border-hover);
background-color: rgba(255, 255, 255, 0.02);
}
body.light-theme .session-item:hover {
background-color: rgba(0, 0, 0, 0.01);
}
.session-item.active {
border-color: var(--border-focus);
background-color: rgba(99, 102, 241, 0.08);
box-shadow: 0 0 0 1px var(--border-focus);
}
body.light-theme .session-item.active {
background-color: rgba(99, 102, 241, 0.05);
}
.session-item-left {
display: flex;
align-items: center;
gap: 0.75rem;
flex-grow: 1;
min-width: 0;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
}
.status-dot.disconnected {
background-color: var(--text-muted);
}
.status-dot.connected {
background-color: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
}
.session-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.session-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-detail {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-delete-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: color var(--transition-fast), background-color var(--transition-fast);
opacity: 0.6;
}
.session-item:hover .session-delete-btn {
opacity: 1;
}
.session-delete-btn:hover {
color: var(--accent-danger);
background-color: rgba(239, 68, 68, 0.1);
}
.session-rename-input {
background-color: var(--bg-panel);
border: 1px solid var(--border-focus);
color: var(--text-main);
font-family: var(--font-ui);
font-size: 0.9rem;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
outline: none;
width: 100%;
}
/* Utilities */
.hidden {
display: none !important;
}
+173 -153
View File
@@ -35,176 +35,196 @@
</button>
</header>
<!-- Main Workspace -->
<main class="app-main">
<!-- Connection Controls & Status -->
<section class="control-row">
<div class="action-buttons">
<button id="connect-btn" class="connect-btn disconnected" title="Connect to serial port">
<!-- Play Icon -->
<svg class="play-icon" viewBox="0 0 24 24" fill="currentColor">
<polygon points="6,4 20,12 6,20"></polygon>
</svg>
<!-- Stop Icon -->
<svg class="stop-icon" viewBox="0 0 24 24" fill="currentColor">
<rect x="5" y="5" width="14" height="14" rx="2"></rect>
</svg>
</button>
<button id="download-btn" class="download-btn" title="Download received log" disabled>
<!-- Download Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
<!-- App Layout Split -->
<div class="app-layout">
<!-- Sidebar Panel -->
<aside class="app-sidebar">
<div class="sidebar-header">
<h2>Sessions</h2>
<button id="add-session-btn" class="add-session-btn" title="Add new session">
<!-- Plus Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
<!-- Connection Status Banner -->
<div id="status-banner" class="status-banner warn">
<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span id="status-text">Not connected. Click the connect button to select a port.</span>
<div id="session-list" class="session-list">
<!-- Session items dynamically injected -->
</div>
</section>
</aside>
<!-- Settings Form Grid -->
<section class="settings-grid">
<div class="settings-group">
<label for="baud-rate">Baud Rate</label>
<div class="select-wrapper">
<select id="baud-rate">
<option value="300">300</option>
<option value="1200">1200</option>
<option value="2400">2400</option>
<option value="4800">4800</option>
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="74880">74880</option>
<option value="115200" selected>115200</option>
<option value="230400">230400</option>
<option value="250000">250000</option>
<option value="500000">500000</option>
<option value="1000000">1000000</option>
<option value="2000000">2000000</option>
</select>
<!-- Main Workspace -->
<main class="app-main">
<!-- Connection Controls & Status -->
<section class="control-row">
<div class="action-buttons">
<button id="connect-btn" class="connect-btn disconnected" title="Connect to serial port">
<!-- Play Icon -->
<svg class="play-icon" viewBox="0 0 24 24" fill="currentColor">
<polygon points="6,4 20,12 6,20"></polygon>
</svg>
<!-- Stop Icon -->
<svg class="stop-icon" viewBox="0 0 24 24" fill="currentColor">
<rect x="5" y="5" width="14" height="14" rx="2"></rect>
</svg>
</button>
<button id="download-btn" class="download-btn" title="Download received log" disabled>
<!-- Download Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
</div>
</div>
<div class="settings-group">
<label for="data-bits">Data Bits</label>
<div class="select-wrapper">
<select id="data-bits">
<option value="8" selected>8</option>
<option value="7">7</option>
<option value="6">6</option>
<option value="5">5</option>
</select>
<!-- Connection Status Banner -->
<div id="status-banner" class="status-banner warn">
<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span id="status-text">Not connected. Click the connect button to select a port.</span>
</div>
</div>
</section>
<div class="settings-group">
<label for="stop-bits">Stop Bits</label>
<div class="select-wrapper">
<select id="stop-bits">
<option value="1" selected>1</option>
<option value="2">2</option>
</select>
</div>
</div>
<div class="settings-group">
<label for="parity">Parity</label>
<div class="select-wrapper">
<select id="parity">
<option value="none" selected>none</option>
<option value="even">even</option>
<option value="odd">odd</option>
</select>
</div>
</div>
</section>
<!-- Received Data Section -->
<section class="received-data-section">
<div class="received-header">
<h2>Received Data</h2>
<div class="received-controls">
<div class="control-select-group">
<label for="scroll-mode">Scroll</label>
<div class="select-wrapper select-wrapper-sm">
<select id="scroll-mode">
<option value="auto" selected>Auto-scroll</option>
<option value="none">No scroll</option>
</select>
</div>
<!-- Settings Form Grid -->
<section class="settings-grid">
<div class="settings-group">
<label for="baud-rate">Baud Rate</label>
<div class="select-wrapper">
<select id="baud-rate">
<option value="300">300</option>
<option value="1200">1200</option>
<option value="2400">2400</option>
<option value="4800">4800</option>
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="74880">74880</option>
<option value="115200" selected>115200</option>
<option value="230400">230400</option>
<option value="250000">250000</option>
<option value="500000">500000</option>
<option value="1000000">1000000</option>
<option value="2000000">2000000</option>
</select>
</div>
<div class="control-select-group">
<label for="display-format">Display Format</label>
<div class="select-wrapper select-wrapper-sm">
<select id="display-format">
<option value="auto" selected>Auto</option>
<option value="text">Text</option>
<option value="hex">Hex</option>
</select>
</div>
<div class="settings-group">
<label for="data-bits">Data Bits</label>
<div class="select-wrapper">
<select id="data-bits">
<option value="8" selected>8</option>
<option value="7">7</option>
<option value="6">6</option>
<option value="5">5</option>
</select>
</div>
</div>
<div class="settings-group">
<label for="stop-bits">Stop Bits</label>
<div class="select-wrapper">
<select id="stop-bits">
<option value="1" selected>1</option>
<option value="2">2</option>
</select>
</div>
</div>
<div class="settings-group">
<label for="parity">Parity</label>
<div class="select-wrapper">
<select id="parity">
<option value="none" selected>none</option>
<option value="even">even</option>
<option value="odd">odd</option>
</select>
</div>
</div>
</section>
<!-- Received Data Section -->
<section class="received-data-section">
<div class="received-header">
<h2>Received Data</h2>
<div class="received-controls">
<div class="control-select-group">
<label for="scroll-mode">Scroll</label>
<div class="select-wrapper select-wrapper-sm">
<select id="scroll-mode">
<option value="auto" selected>Auto-scroll</option>
<option value="none">No scroll</option>
</select>
</div>
</div>
<div class="control-select-group">
<label for="display-format">Display Format</label>
<div class="select-wrapper select-wrapper-sm">
<select id="display-format">
<option value="auto" selected>Auto</option>
<option value="text">Text</option>
<option value="hex">Hex</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Terminal Console -->
<div class="terminal-wrapper">
<div id="terminal-output" class="terminal-output empty" tabindex="0">No data received yet...</div>
</div>
</section>
<!-- Terminal Console -->
<div id="terminal-container" class="terminal-wrapper">
<!-- Terminal elements will be dynamically appended here per session -->
</div>
</section>
<!-- Send Data Input Area -->
<section class="send-data-section">
<div class="send-bar">
<div class="input-container">
<input type="text" id="send-input" placeholder="Enter data to send... (Ctrl+Tab to insert tab)" autocomplete="off">
<!-- Send Data Input Area -->
<section class="send-data-section">
<div class="send-bar">
<div class="input-container">
<input type="text" id="send-input" placeholder="Enter data to send... (Ctrl+Tab to insert tab)" autocomplete="off">
</div>
<div class="select-wrapper select-wrapper-sm send-format-wrapper">
<select id="send-format">
<option value="ascii" selected>ASCII</option>
<option value="hex">Hex</option>
</select>
</div>
<div class="select-wrapper select-wrapper-sm send-eol-wrapper">
<select id="send-eol">
<option value="none">None</option>
<option value="lf">LF (\n)</option>
<option value="cr">CR (\r)</option>
<option value="crlf" selected>CRLF (\r\n)</option>
</select>
</div>
<button id="send-btn" class="send-btn" title="Send Data (Ctrl+Enter)">
<!-- Send / Paper Airplane Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
<button id="clear-btn" class="clear-btn" title="Clear Terminal Console">
<!-- Trash Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
<div class="select-wrapper select-wrapper-sm send-format-wrapper">
<select id="send-format">
<option value="ascii" selected>ASCII</option>
<option value="hex">Hex</option>
</select>
</div>
<div class="select-wrapper select-wrapper-sm send-eol-wrapper">
<select id="send-eol">
<option value="none">None</option>
<option value="lf">LF (\n)</option>
<option value="cr">CR (\r)</option>
<option value="crlf" selected>CRLF (\r\n)</option>
</select>
</div>
<button id="send-btn" class="send-btn" title="Send Data (Ctrl+Enter)">
<!-- Send / Paper Airplane Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
<button id="clear-btn" class="clear-btn" title="Clear Terminal Console">
<!-- Trash Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
</section>
</section>
</main>
</main>
</div>
</div>
<script type="module" src="./app.js"></script>