working multisession web linux terminal
This commit is contained in:
@@ -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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>>_</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 & 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')">×</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+←/→</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 & 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+←</kbd></td><td>Word backward</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl+→</kbd></td><td>Word forward</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="info-section-label">Session & files</div>
|
||||||
|
<table class="info-table">
|
||||||
|
<tr><td>Link button</td><td>Copy shareable workspace URL — open on any device to resume all tabs & 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user