working multisession web linux terminal

This commit is contained in:
2026-05-24 07:44:54 +00:00
parent 5b4803bc49
commit 63dbdf7e6a
5 changed files with 138 additions and 117 deletions
+3 -2
View File
@@ -106,11 +106,12 @@ Structured JSON-lines, one entry per login attempt:
| `Alt+T` | New tab | | `Alt+T` | New tab |
| `Alt+W` | Close tab | | `Alt+W` | Close tab |
| `Alt+Shift+←/→` | Previous / next tab | | `Alt+Shift+←/→` | Previous / next tab |
| `Alt+\` | Split pane left/right | | `Alt+H` | Split pane left/right |
| `Alt+-` | Split pane top/bottom | | `Alt+V` | Split pane top/bottom |
| `Alt+X` | Close active pane | | `Alt+X` | Close active pane |
| `Ctrl+Shift+C` | Copy selection | | `Ctrl+Shift+C` | Copy selection |
| `Ctrl+V` | Paste | | `Ctrl+V` | Paste |
| `Ctrl+←/→` | Word backward / forward (shell readline) |
--- ---
+14
View File
@@ -3,6 +3,7 @@ package internals
import ( import (
"crypto/rand" "crypto/rand"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"runtime" "runtime"
"strings" "strings"
@@ -68,6 +69,19 @@ func getOrCreate(id string) *Session {
} }
cmd.Dir = initialCwd cmd.Dir = initialCwd
// Build environment: inherit parent env but force TERM so that bash readline
// correctly decodes modifier+cursor sequences (Shift+Arrow etc.).
// Without this, PTYs started from daemons/services may have TERM unset or
// set to "dumb", causing escape sequences to appear as literal characters.
env := os.Environ()
filtered := make([]string, 0, len(env)+1)
for _, e := range env {
if !strings.HasPrefix(e, "TERM=") {
filtered = append(filtered, e)
}
}
cmd.Env = append(filtered, "TERM=xterm-256color")
ptty, err := pty.Start(cmd) ptty, err := pty.Start(cmd)
if err != nil { if err != nil {
return nil return nil
+22
View File
@@ -149,6 +149,9 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0;
.tb-btn.tb-danger { background: rgba(239,68,68,0.08); color: #f87171; .tb-btn.tb-danger { background: rgba(239,68,68,0.08); color: #f87171;
border: 1px solid rgba(239,68,68,0.15); } border: 1px solid rgba(239,68,68,0.15); }
.tb-btn.tb-danger:hover { background: rgba(239,68,68,0.16); color: #fca5a5; } .tb-btn.tb-danger:hover { background: rgba(239,68,68,0.16); color: #fca5a5; }
.tb-btn.tb-info { background: rgba(59,130,246,0.1); color: #93c5fd;
border: 1px solid rgba(59,130,246,0.2); }
.tb-btn.tb-info:hover { background: rgba(59,130,246,0.18); color: #bfdbfe; }
.tb-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.07); flex-shrink: 0; margin: 0 2px; } .tb-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.07); flex-shrink: 0; margin: 0 2px; }
/* ── Toast ── */ /* ── Toast ── */
@@ -258,6 +261,25 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0;
.m-btn.busy .btn-text { display: none; } .m-btn.busy .btn-text { display: none; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
/* ── Info modal ── */
.info-section-label {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
color: #6ee7b7; font-weight: 700; margin: 14px 0 6px;
}
.info-section-label:first-child { margin-top: 0; }
.info-table {
width: 100%; border-collapse: collapse; font-size: 12px;
}
.info-table tr { border-bottom: 1px solid rgba(255,255,255,0.04); }
.info-table tr:last-child { border-bottom: none; }
.info-table td { padding: 5px 4px; color: #9ca3af; vertical-align: top; }
.info-table td:first-child { white-space: nowrap; padding-right: 16px; color: #e2e8f0; }
kbd {
display: inline-block; padding: 1px 6px;
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12);
border-radius: 4px; font-size: 11px; font-family: inherit; color: #e2e8f0;
}
/* ── Auth modal specifics ── */ /* ── Auth modal specifics ── */
.auth-card { padding: 40px 36px; } .auth-card { padding: 40px 36px; }
.auth-logo { font-size: 22px; font-weight: 700; color: #6ee7b7; .auth-logo { font-size: 22px; font-weight: 700; color: #6ee7b7;
+20 -76
View File
@@ -11,7 +11,6 @@ const TERM_THEME = {
let tabs = []; let tabs = [];
let activeTab = null; let activeTab = null;
let tabCounter = 0; let tabCounter = 0;
let isAuthenticated = AUTHED; // updated to true after successful login
let saveTimer = null; let saveTimer = null;
// ── Helpers ─────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────
@@ -48,7 +47,7 @@ function saveWorkspace() {
} }
async function _doSaveWorkspace() { async function _doSaveWorkspace() {
if (!isAuthenticated || !tabs.length) return; if (!tabs.length) return;
const ws = { const ws = {
id: WORKSPACE_ID, id: WORKSPACE_ID,
tabs: tabs.map(t => ({ tabs: tabs.map(t => ({
@@ -138,12 +137,11 @@ document.addEventListener('keydown', function(e) {
case 'KeyT': e.preventDefault(); newTab(); return; case 'KeyT': e.preventDefault(); newTab(); return;
case 'KeyW': e.preventDefault(); if (activeTab) closeTab(activeTab); return; case 'KeyW': e.preventDefault(); if (activeTab) closeTab(activeTab); return;
case 'KeyX': e.preventDefault(); closePane(); return; case 'KeyX': e.preventDefault(); closePane(); return;
case 'Backslash': e.preventDefault(); splitPane('h'); return; case 'KeyH': e.preventDefault(); splitPane('h'); return; // Alt+H → side-by-side
case 'Minus': e.preventDefault(); splitPane('v'); return; case 'KeyV': e.preventDefault(); splitPane('v'); return; // Alt+V → top/bottom
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); sendToActive('\x1bb'); return;
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); sendToActive('\x1bf'); return; return; // let xterm pass Ctrl+Arrow to shell readline
} }
} else { } else {
if (e.code === 'ArrowLeft') { if (e.code === 'ArrowLeft') {
@@ -161,10 +159,9 @@ document.addEventListener('keydown', function(e) {
} }
} }
// Block ALL Ctrl+key browser shortcuts; xterm still gets the event. // Block browser Ctrl+key defaults (find, save, etc.); xterm still gets event.
if (C && !A && !M) { if (C && !A && !M) {
e.preventDefault(); e.preventDefault();
if (!S && e.code === 'KeyW') sendToActive('\x17');
return; return;
} }
@@ -234,68 +231,12 @@ function bgClose(evt, id) {
} }
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
['upOverlay', 'dlOverlay'].forEach(id => { ['upOverlay', 'dlOverlay', 'infoOverlay'].forEach(id => {
if (!document.getElementById(id).classList.contains('hidden')) closeModal(id); if (!document.getElementById(id).classList.contains('hidden')) closeModal(id);
}); });
} }
}); });
// ── Auth ───────────────────────────────────────────────────────────────
async function doAuth() {
const username = document.getElementById('fUser').value.trim();
const password = document.getElementById('fPass').value;
const btn = document.getElementById('authBtn');
const errDiv = document.getElementById('authErr');
if (!username || !password) { showAuthErr('Please enter username and password'); return; }
btn.disabled = true; btn.classList.add('busy');
errDiv.classList.remove('show');
const form = new URLSearchParams();
form.append('username', username);
form.append('password', password);
try {
const res = await fetch('/auth', { method: 'POST', body: form,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
const data = await res.json();
if (data.ok) {
isAuthenticated = true;
document.getElementById('authOverlay').classList.add('hidden');
// Load or create workspace after login
const ws = await loadWorkspace();
if (ws && ws.tabs && ws.tabs.length) {
restoreWorkspace(ws);
} else {
newTab();
}
const leaf = activeTab && activeTab.activeLeaf;
if (leaf) leaf.term.focus();
} else {
showAuthErr(data.error || 'Authentication failed');
}
} catch (_) {
showAuthErr('Network error — try again');
} finally {
btn.disabled = false; btn.classList.remove('busy');
}
}
function showAuthErr(msg) {
const e = document.getElementById('authErr');
e.textContent = msg; e.classList.add('show');
document.getElementById('fPass').value = '';
document.getElementById('fPass').focus();
}
document.getElementById('fUser').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('fPass').focus();
});
document.getElementById('fPass').addEventListener('keydown', e => {
if (e.key === 'Enter') doAuth();
});
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
// PANE TREE // PANE TREE
// ══════════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════════
@@ -394,15 +335,23 @@ function createLeaf(sessionId) {
term.attachCustomKeyEventHandler(evt => { term.attachCustomKeyEventHandler(evt => {
if (evt.type !== 'keydown') return true; if (evt.type !== 'keydown') return true;
const C = evt.ctrlKey, S = evt.shiftKey, A = evt.altKey; const C = evt.ctrlKey, S = evt.shiftKey, A = evt.altKey;
// Copy selection
if (C && S && evt.code === 'KeyC') { if (C && S && evt.code === 'KeyC') {
const sel = term.getSelection(); const sel = term.getSelection();
if (sel) navigator.clipboard.writeText(sel).then( if (sel) navigator.clipboard.writeText(sel).then(
() => toast('Copied!', 'ok'), () => toast('Copy failed', 'err')); () => toast('Copied!', 'ok'), () => toast('Copy failed', 'err'));
return false; return false;
} }
// Paste
if (C && evt.code === 'KeyV') { pasteToTerminal(); return false; } if (C && evt.code === 'KeyV') { pasteToTerminal(); return false; }
if (C && !S && !A && evt.code === 'KeyW') return false;
if (C && S && evt.code === 'KeyW') return false; // Block app-level Alt shortcuts from reaching the PTY
if (A && !C) {
if (!S && ['KeyT','KeyW','KeyX','KeyH','KeyV'].includes(evt.code)) return false;
if ( S && (evt.code === 'ArrowLeft' || evt.code === 'ArrowRight')) return false;
}
return true; return true;
}); });
@@ -440,7 +389,7 @@ function splitPane(dir) {
applyFlex(splitNode); applyFlex(splitNode);
setupDivider(splitNode); setupDivider(splitNode);
if (isAuthenticated) connectLeaf(newLeaf); connectLeaf(newLeaf);
setActiveLeaf(tab, newLeaf); setActiveLeaf(tab, newLeaf);
refreshActiveHighlight(tab); refreshActiveHighlight(tab);
@@ -592,7 +541,7 @@ function newTab(tabId, sessionId) {
addTabButton(tab); addTabButton(tab);
switchTab(tab); switchTab(tab);
saveWorkspace(); saveWorkspace();
if (isAuthenticated) connectLeaf(rootLeaf); connectLeaf(rootLeaf);
return tab; return tab;
} }
@@ -618,9 +567,7 @@ function restoreTab(tabData) {
tabs.push(tab); tabs.push(tab);
addTabButton(tab); addTabButton(tab);
if (isAuthenticated) {
walkLeaves(root, l => connectLeaf(l)); walkLeaves(root, l => connectLeaf(l));
}
return tab; return tab;
} }
@@ -762,16 +709,13 @@ function modalFb(id, msg, type) {
} }
// ── Bootstrap ────────────────────────────────────────────────────────── // ── Bootstrap ──────────────────────────────────────────────────────────
// Terminal page is only served to authenticated users (server redirects
// unauthenticated requests to /login). Load saved workspace or start fresh.
(async function bootstrap() { (async function bootstrap() {
if (isAuthenticated) {
document.getElementById('authOverlay').classList.add('hidden');
const ws = await loadWorkspace(); const ws = await loadWorkspace();
if (ws && ws.tabs && ws.tabs.length) { if (ws && ws.tabs && ws.tabs.length) {
restoreWorkspace(ws); restoreWorkspace(ws);
} else { } else {
newTab(); newTab();
} }
} else {
setTimeout(() => document.getElementById('fUser').focus(), 80);
}
})(); })();
+70 -30
View File
@@ -12,31 +12,6 @@
</head> </head>
<body> <body>
<!-- ── Auth modal ─────────────────────────────────────────────────── -->
<div class="m-overlay" id="authOverlay">
<div class="m-card">
<div class="auth-card">
<div class="auth-logo"><em>&gt;_</em> GoTermix</div>
<div class="auth-sub">Authentication required</div>
<label class="m-label" for="fUser">Username</label>
<input class="m-input" type="text" id="fUser"
autocomplete="username" placeholder="username" spellcheck="false">
<label class="m-label" for="fPass">Password</label>
<input class="m-input" type="password" id="fPass"
autocomplete="current-password" placeholder="password">
<div class="auth-err" id="authErr"></div>
<button class="auth-btn" id="authBtn" onclick="doAuth()">
<div class="auth-spin"></div>
<span class="btn-text">Sign in</span>
</button>
</div>
</div>
</div>
<!-- ── Upload modal ───────────────────────────────────────────────── --> <!-- ── Upload modal ───────────────────────────────────────────────── -->
<div class="m-overlay hidden" id="upOverlay" onclick="bgClose(event,'upOverlay')"> <div class="m-overlay hidden" id="upOverlay" onclick="bgClose(event,'upOverlay')">
<div class="m-card"> <div class="m-card">
@@ -122,16 +97,16 @@
<span class="status-label" id="statusLabel">connecting...</span> <span class="status-label" id="statusLabel">connecting...</span>
</div> </div>
<div class="tb-right"> <div class="tb-right">
<!-- Split left/right (Alt+\) --> <!-- Split left/right (Alt+H) -->
<button class="tb-btn" onclick="splitPane('h')" title="Split left/right (Alt+\)"> <button class="tb-btn" onclick="splitPane('h')" title="Split left/right (Alt+H)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="18" rx="2"/> <rect x="2" y="3" width="20" height="18" rx="2"/>
<line x1="12" y1="3" x2="12" y2="21"/> <line x1="12" y1="3" x2="12" y2="21"/>
</svg> </svg>
Split H Split H
</button> </button>
<!-- Split top/bottom (Alt+-) --> <!-- Split top/bottom (Alt+V) -->
<button class="tb-btn" onclick="splitPane('v')" title="Split top/bottom (Alt+-)"> <button class="tb-btn" onclick="splitPane('v')" title="Split top/bottom (Alt+V)">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="18" rx="2"/> <rect x="2" y="3" width="20" height="18" rx="2"/>
<line x1="2" y1="12" x2="22" y2="12"/> <line x1="2" y1="12" x2="22" y2="12"/>
@@ -155,6 +130,15 @@
</svg> </svg>
Link Link
</button> </button>
<!-- Info / shortcuts -->
<button class="tb-btn tb-info" onclick="openModal('infoOverlay')" title="Keyboard shortcuts &amp; help">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="8" stroke-linecap="round" stroke-width="2.5"/>
<line x1="12" y1="12" x2="12" y2="16"/>
</svg>
Info
</button>
<!-- End Session --> <!-- End Session -->
<button class="tb-btn tb-danger" onclick="endSession()" title="End session — clears saved layout, next open starts fresh"> <button class="tb-btn tb-danger" onclick="endSession()" title="End session — clears saved layout, next open starts fresh">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -183,13 +167,69 @@
</div> </div>
</div> </div>
<!-- ── Info modal ─────────────────────────────────────────────────── -->
<div class="m-overlay hidden" id="infoOverlay" onclick="bgClose(event,'infoOverlay')">
<div class="m-card" style="max-width:520px;">
<div class="m-head">
<span class="m-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="8"/><line x1="12" y1="12" x2="12" y2="16"/>
</svg>
GoTermix — Quick Reference
</span>
<button class="m-x" onclick="closeModal('infoOverlay')">&#215;</button>
</div>
<div class="m-body" style="padding:16px 20px 20px;">
<div class="info-section-label">Tabs</div>
<table class="info-table">
<tr><td><kbd>Alt+T</kbd></td><td>New tab</td></tr>
<tr><td><kbd>Alt+W</kbd></td><td>Close active tab</td></tr>
<tr><td><kbd>Alt+Shift+&larr;/&rarr;</kbd></td><td>Switch to previous / next tab</td></tr>
<tr><td>Right-click tab label</td><td>Rename tab</td></tr>
</table>
<div class="info-section-label">Split panes</div>
<table class="info-table">
<tr><td><kbd>Alt+H</kbd></td><td>Split active pane left / right</td></tr>
<tr><td><kbd>Alt+V</kbd></td><td>Split active pane top / bottom</td></tr>
<tr><td><kbd>Alt+X</kbd></td><td>Close active pane</td></tr>
<tr><td>Drag divider</td><td>Resize panes</td></tr>
<tr><td>Click pane</td><td>Focus pane</td></tr>
</table>
<div class="info-section-label">Copy &amp; paste</div>
<table class="info-table">
<tr><td><kbd>Ctrl+Shift+C</kbd></td><td>Copy selection</td></tr>
<tr><td><kbd>Ctrl+V</kbd></td><td>Paste from clipboard</td></tr>
<tr><td>Mouse drag</td><td>Select text</td></tr>
</table>
<div class="info-section-label">Word navigation (shell readline)</div>
<table class="info-table">
<tr><td><kbd>Ctrl+&larr;</kbd></td><td>Word backward</td></tr>
<tr><td><kbd>Ctrl+&rarr;</kbd></td><td>Word forward</td></tr>
</table>
<div class="info-section-label">Session &amp; files</div>
<table class="info-table">
<tr><td>Link button</td><td>Copy shareable workspace URL — open on any device to resume all tabs &amp; splits</td></tr>
<tr><td>End button</td><td>Clear saved layout; next visit starts fresh</td></tr>
<tr><td>Upload button</td><td>Upload file to server (lands in active shell's cwd by default)</td></tr>
<tr><td>Down button</td><td>Download file from server by path</td></tr>
</table>
</div>
</div>
</div>
<!-- ── Toast ──────────────────────────────────────────────────────── --> <!-- ── Toast ──────────────────────────────────────────────────────── -->
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script> <script>
// Workspace context — injected server-side, consumed by app.js // Workspace context — injected server-side, consumed by app.js
const WORKSPACE_ID = "[[WORKSPACE_ID]]"; const WORKSPACE_ID = "[[WORKSPACE_ID]]";
const AUTHED = [[AUTHED]];
</script> </script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>