fix session with split screens
This commit is contained in:
+2
-1
@@ -2,4 +2,5 @@ test/
|
|||||||
upload/
|
upload/
|
||||||
gotermix
|
gotermix
|
||||||
*.json
|
*.json
|
||||||
*.key
|
*.key
|
||||||
|
test*
|
||||||
+6
-5
@@ -24,11 +24,12 @@ const (
|
|||||||
// custom TLS cert paths. The password is salted + iterated-SHA256 hashed
|
// custom TLS cert paths. The password is salted + iterated-SHA256 hashed
|
||||||
// (never stored plaintext); the whole struct is AES-256-GCM encrypted on disk.
|
// (never stored plaintext); the whole struct is AES-256-GCM encrypted on disk.
|
||||||
type storedCreds struct {
|
type storedCreds struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Salt string `json:"salt"`
|
Salt string `json:"salt"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
CertFile string `json:"cert_file,omitempty"`
|
CertFile string `json:"cert_file,omitempty"`
|
||||||
KeyFile string `json:"key_file,omitempty"`
|
KeyFile string `json:"key_file,omitempty"`
|
||||||
|
Workspaces map[string]*WorkspaceLayout `json:"workspaces,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type client struct {
|
type client struct {
|
||||||
|
|||||||
@@ -57,19 +57,17 @@ func handleStaticJS(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write(data)
|
w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleIndex: clean URL, always creates a fresh session.
|
// handleIndex: always creates a fresh workspace and redirects to its stable URL.
|
||||||
// The session-specific /s/<id> URL is available in the toolbar for sharing.
|
// PTY sessions are started lazily by the frontend via WebSocket connections.
|
||||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := randHex(16)
|
http.Redirect(w, r, "/s/"+randHex(16), http.StatusFound)
|
||||||
getOrCreate(id)
|
|
||||||
serveTerminalPage(w, id, isAuthed(r))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleShell: reconnects to a specific session by ID.
|
// handleShell: serves the terminal page for an existing (or new) workspace ID.
|
||||||
func handleShell(w http.ResponseWriter, r *http.Request) {
|
func handleShell(w http.ResponseWriter, r *http.Request) {
|
||||||
id := strings.TrimPrefix(r.URL.Path, "/s/")
|
id := strings.TrimPrefix(r.URL.Path, "/s/")
|
||||||
if !validID(id) {
|
if !validID(id) {
|
||||||
@@ -79,14 +77,14 @@ func handleShell(w http.ResponseWriter, r *http.Request) {
|
|||||||
serveTerminalPage(w, id, isAuthed(r))
|
serveTerminalPage(w, id, isAuthed(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveTerminalPage(w http.ResponseWriter, id string, authed bool) {
|
func serveTerminalPage(w http.ResponseWriter, workspaceID string, authed bool) {
|
||||||
authedStr := "false"
|
authedStr := "false"
|
||||||
if authed {
|
if authed {
|
||||||
authedStr = "true"
|
authedStr = "true"
|
||||||
}
|
}
|
||||||
html := strings.NewReplacer(
|
html := strings.NewReplacer(
|
||||||
"[[SESSION_ID]]", id,
|
"[[WORKSPACE_ID]]", workspaceID,
|
||||||
"[[AUTHED]]", authedStr,
|
"[[AUTHED]]", authedStr,
|
||||||
).Replace(shellPageHTML)
|
).Replace(shellPageHTML)
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write([]byte(html))
|
w.Write([]byte(html))
|
||||||
|
|||||||
+10
-9
@@ -136,15 +136,16 @@ func Run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/", handleIndex)
|
mux.HandleFunc("/", handleIndex)
|
||||||
mux.HandleFunc("/s/", handleShell)
|
mux.HandleFunc("/s/", handleShell)
|
||||||
mux.HandleFunc("/ws/", handleWS)
|
mux.HandleFunc("/ws/", handleWS)
|
||||||
mux.HandleFunc("/auth", handleAuth)
|
mux.HandleFunc("/auth", handleAuth)
|
||||||
mux.HandleFunc("/upload", handleUpload)
|
mux.HandleFunc("/upload", handleUpload)
|
||||||
mux.HandleFunc("/download", handleDownload)
|
mux.HandleFunc("/download", handleDownload)
|
||||||
mux.HandleFunc("/favicon.svg", handleFavicon)
|
mux.HandleFunc("/favicon.svg", handleFavicon)
|
||||||
mux.HandleFunc("/static/app.css", handleStaticCSS)
|
mux.HandleFunc("/static/app.css", handleStaticCSS)
|
||||||
mux.HandleFunc("/static/app.js", handleStaticJS)
|
mux.HandleFunc("/static/app.js", handleStaticJS)
|
||||||
|
mux.HandleFunc("/api/workspace/", handleWorkspaceAPI)
|
||||||
|
|
||||||
ln, _ := net.Listen("tcp", *addr)
|
ln, _ := net.Listen("tcp", *addr)
|
||||||
fmt.Printf("Go Web Shell https://%s\n", *addr)
|
fmt.Printf("Go Web Shell https://%s\n", *addr)
|
||||||
|
|||||||
+60
-4
@@ -33,6 +33,12 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0;
|
|||||||
border-color: rgba(255,255,255,0.08);
|
border-color: rgba(255,255,255,0.08);
|
||||||
}
|
}
|
||||||
.tab-label { cursor: pointer; }
|
.tab-label { cursor: pointer; }
|
||||||
|
.tab-rename-input {
|
||||||
|
background: #0d0f14; border: 1px solid rgba(110,231,183,0.45);
|
||||||
|
border-radius: 3px; color: #e2e8f0; font-size: 11px;
|
||||||
|
font-family: inherit; padding: 1px 5px; outline: none;
|
||||||
|
width: 90px; min-width: 40px;
|
||||||
|
}
|
||||||
.tab-x {
|
.tab-x {
|
||||||
width: 16px; height: 16px; border: none; border-radius: 3px;
|
width: 16px; height: 16px; border: none; border-radius: 3px;
|
||||||
background: transparent; color: inherit; cursor: pointer;
|
background: transparent; color: inherit; cursor: pointer;
|
||||||
@@ -54,7 +60,53 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0;
|
|||||||
#termContainer {
|
#termContainer {
|
||||||
position: fixed; top: 36px; left: 0; right: 0; bottom: 48px;
|
position: fixed; top: 36px; left: 0; right: 0; bottom: 48px;
|
||||||
}
|
}
|
||||||
.term-pane { width: 100%; height: 100%; }
|
|
||||||
|
/* One per tab — holds the entire pane tree for that tab */
|
||||||
|
.tab-pane { width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
/* Leaf node — wraps an xterm canvas */
|
||||||
|
.term-pane { width: 100%; height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Active pane highlight (shown only when 2+ panes exist in a tab) */
|
||||||
|
.pane-active { box-shadow: inset 0 0 0 1px rgba(110,231,183,0.45); }
|
||||||
|
|
||||||
|
/* ── Split containers ── */
|
||||||
|
/* dir 'h': side-by-side with a vertical divider */
|
||||||
|
.split-h {
|
||||||
|
display: flex; flex-direction: row;
|
||||||
|
width: 100%; height: 100%; overflow: hidden;
|
||||||
|
}
|
||||||
|
/* dir 'v': stacked with a horizontal divider */
|
||||||
|
.split-v {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
width: 100%; height: 100%; overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Draggable dividers ── */
|
||||||
|
.split-div-h {
|
||||||
|
flex: 0 0 4px; height: 100%;
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
cursor: col-resize;
|
||||||
|
transition: background 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.split-div-h::after {
|
||||||
|
content: ''; position: absolute; inset: 0 -2px;
|
||||||
|
/* wider hit target without affecting layout */
|
||||||
|
}
|
||||||
|
.split-div-h:hover { background: rgba(110,231,183,0.35); }
|
||||||
|
|
||||||
|
.split-div-v {
|
||||||
|
flex: 0 0 4px; width: 100%;
|
||||||
|
background: rgba(255,255,255,0.07);
|
||||||
|
cursor: row-resize;
|
||||||
|
transition: background 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.split-div-v::after {
|
||||||
|
content: ''; position: absolute; inset: -2px 0;
|
||||||
|
}
|
||||||
|
.split-div-v:hover { background: rgba(110,231,183,0.35); }
|
||||||
|
|
||||||
/* ── Compact toolbar ── */
|
/* ── Compact toolbar ── */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -91,9 +143,13 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0;
|
|||||||
}
|
}
|
||||||
.tb-btn:hover { background: rgba(255,255,255,0.1); color: #e2e8f0; }
|
.tb-btn:hover { background: rgba(255,255,255,0.1); color: #e2e8f0; }
|
||||||
.tb-btn svg { flex-shrink: 0; }
|
.tb-btn svg { flex-shrink: 0; }
|
||||||
.tb-btn.accent { background: rgba(110,231,183,0.1); color: #6ee7b7;
|
.tb-btn.accent { background: rgba(110,231,183,0.1); color: #6ee7b7;
|
||||||
border: 1px solid rgba(110,231,183,0.15); }
|
border: 1px solid rgba(110,231,183,0.15); }
|
||||||
.tb-btn.accent:hover { background: rgba(110,231,183,0.18); }
|
.tb-btn.accent:hover { background: rgba(110,231,183,0.18); }
|
||||||
|
.tb-btn.tb-danger { background: rgba(239,68,68,0.08); color: #f87171;
|
||||||
|
border: 1px solid rgba(239,68,68,0.15); }
|
||||||
|
.tb-btn.tb-danger:hover { background: rgba(239,68,68,0.16); color: #fca5a5; }
|
||||||
|
.tb-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.07); flex-shrink: 0; margin: 0 2px; }
|
||||||
|
|
||||||
/* ── Toast ── */
|
/* ── Toast ── */
|
||||||
.toast {
|
.toast {
|
||||||
|
|||||||
+518
-188
@@ -1,23 +1,20 @@
|
|||||||
// ── Session context (injected as inline <script> in shell.html) ─────
|
// WORKSPACE_ID and AUTHED injected as inline <script> in shell.html
|
||||||
// SESSION_ID and AUTHED are defined before this file loads.
|
|
||||||
|
|
||||||
// ── Terminal theme ──────────────────────────────────────────────────
|
// ── Theme ─────────────────────────────────────────────────────────────
|
||||||
const TERM_THEME = {
|
const TERM_THEME = {
|
||||||
background: '#0d0f14', foreground: '#e2e8f0',
|
background: '#0d0f14', foreground: '#e2e8f0',
|
||||||
cursor: '#6ee7b7', cursorAccent: '#0d0f14',
|
cursor: '#6ee7b7', cursorAccent: '#0d0f14',
|
||||||
selectionBackground: 'rgba(110,231,183,0.25)',
|
selectionBackground: 'rgba(110,231,183,0.25)',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Global state ─────────────────────────────────────────────────────
|
// ── Global state ──────────────────────────────────────────────────────
|
||||||
let tabs = [];
|
let tabs = [];
|
||||||
let activeTab = null;
|
let activeTab = null;
|
||||||
let tabCounter = 0;
|
let tabCounter = 0;
|
||||||
// Runtime auth flag — AUTHED is the page-load value; this tracks whether
|
let isAuthenticated = AUTHED; // updated to true after successful login
|
||||||
// the user has authenticated in THIS session (including via the auth modal
|
let saveTimer = null;
|
||||||
// after page load, where AUTHED remains false but we are now connected).
|
|
||||||
let isAuthenticated = AUTHED;
|
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
function randHexClient(n) {
|
function randHexClient(n) {
|
||||||
const arr = new Uint8Array(n);
|
const arr = new Uint8Array(n);
|
||||||
crypto.getRandomValues(arr);
|
crypto.getRandomValues(arr);
|
||||||
@@ -25,28 +22,110 @@ function randHexClient(n) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pasteToTerminal() {
|
function pasteToTerminal() {
|
||||||
|
const leaf = activeTab && activeTab.activeLeaf;
|
||||||
navigator.clipboard.readText().then(
|
navigator.clipboard.readText().then(
|
||||||
text => { if (text && activeTab) activeTab.term.paste(text); },
|
text => { if (text && leaf) leaf.term.paste(text); },
|
||||||
() => toast('Clipboard access denied — allow it in the browser address bar', 'err')
|
() => toast('Clipboard access denied — allow it in the browser address bar', 'err')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── URL hash: persist tab layout ──────────────────────────────────────
|
// ── Workspace API ─────────────────────────────────────────────────────
|
||||||
//
|
|
||||||
// Format: /s/<firstTabId>#t=id1,id2,id3
|
async function loadWorkspace() {
|
||||||
// When another browser opens this URL it reads the hash and opens the
|
try {
|
||||||
// same sessions. The server only sees /s/<firstTabId> (hash is client-only).
|
const res = await fetch('/api/workspace/' + WORKSPACE_ID);
|
||||||
function updateURLHash() {
|
if (res.status === 404) return null;
|
||||||
if (tabs.length === 0) return;
|
if (!res.ok) return null;
|
||||||
const ids = tabs.map(t => t.id).join(',');
|
return await res.json();
|
||||||
history.replaceState(null, '', '/s/' + tabs[0].id + '#t=' + ids);
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTabIDsFromHash() {
|
function saveWorkspace() {
|
||||||
const hash = location.hash; // e.g. "#t=aabb...,ccdd..."
|
clearTimeout(saveTimer);
|
||||||
if (!hash.startsWith('#t=')) return null;
|
saveTimer = setTimeout(_doSaveWorkspace, 300);
|
||||||
const ids = hash.slice(3).split(',').filter(id => /^[0-9a-f]{32}$/.test(id));
|
}
|
||||||
return ids.length ? ids : null;
|
|
||||||
|
async function _doSaveWorkspace() {
|
||||||
|
if (!isAuthenticated || !tabs.length) return;
|
||||||
|
const ws = {
|
||||||
|
id: WORKSPACE_ID,
|
||||||
|
tabs: tabs.map(t => ({
|
||||||
|
tab_id: t.tabId,
|
||||||
|
label: t.label,
|
||||||
|
root: serializePane(t.root),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await fetch('/api/workspace/' + WORKSPACE_ID, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(ws),
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endSession() {
|
||||||
|
if (!confirm('End session? Saved layout will be cleared — this link will start a fresh session.')) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/workspace/' + WORKSPACE_ID, { method: 'DELETE' });
|
||||||
|
} catch (_) {}
|
||||||
|
// Close all sockets silently
|
||||||
|
tabs.forEach(t => walkLeaves(t.root, l => {
|
||||||
|
if (l.socket) { l.socket.onclose = null; l.socket.close(); }
|
||||||
|
}));
|
||||||
|
location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLink() {
|
||||||
|
const url = location.protocol + '//' + location.host + '/s/' + WORKSPACE_ID;
|
||||||
|
navigator.clipboard.writeText(url).then(
|
||||||
|
() => toast('Workspace link copied!', 'ok'),
|
||||||
|
() => toast('Copy failed', 'err')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pane serialization ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function serializePane(node) {
|
||||||
|
if (node.type === 'leaf') return { type: 'leaf', id: node.id };
|
||||||
|
return {
|
||||||
|
type: 'split',
|
||||||
|
dir: node.dir,
|
||||||
|
ratio: node.ratio,
|
||||||
|
a: serializePane(node.a),
|
||||||
|
b: serializePane(node.b),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializePane(data) {
|
||||||
|
if (data.type === 'leaf') {
|
||||||
|
return createLeaf(data.id);
|
||||||
|
}
|
||||||
|
// Split node — build children first, then container
|
||||||
|
const a = deserializePane(data.a);
|
||||||
|
const b = deserializePane(data.b);
|
||||||
|
|
||||||
|
const splitEl = document.createElement('div');
|
||||||
|
splitEl.className = data.dir === 'h' ? 'split-h' : 'split-v';
|
||||||
|
|
||||||
|
const divEl = document.createElement('div');
|
||||||
|
divEl.className = data.dir === 'h' ? 'split-div-h' : 'split-div-v';
|
||||||
|
|
||||||
|
const splitNode = {
|
||||||
|
type: 'split', dir: data.dir, ratio: data.ratio,
|
||||||
|
a, b, el: splitEl, divEl, parent: null,
|
||||||
|
};
|
||||||
|
a.parent = splitNode;
|
||||||
|
b.parent = splitNode;
|
||||||
|
|
||||||
|
splitEl.appendChild(a.el);
|
||||||
|
splitEl.appendChild(divEl);
|
||||||
|
splitEl.appendChild(b.el);
|
||||||
|
applyFlex(splitNode);
|
||||||
|
setupDivider(splitNode);
|
||||||
|
return splitNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Global keyboard handler (capture phase) ───────────────────────────
|
// ── Global keyboard handler (capture phase) ───────────────────────────
|
||||||
@@ -54,58 +133,62 @@ document.addEventListener('keydown', function(e) {
|
|||||||
const C = e.ctrlKey, S = e.shiftKey, A = e.altKey, M = e.metaKey;
|
const C = e.ctrlKey, S = e.shiftKey, A = e.altKey, M = e.metaKey;
|
||||||
|
|
||||||
if (A && !C && !M) {
|
if (A && !C && !M) {
|
||||||
// Alt+T → new tab
|
if (!S) {
|
||||||
if (!S && e.code === 'KeyT') { e.preventDefault(); newTab(); return; }
|
switch (e.code) {
|
||||||
// Alt+W → close active tab (last tab stays)
|
case 'KeyT': e.preventDefault(); newTab(); return;
|
||||||
if (!S && e.code === 'KeyW') { e.preventDefault(); if (activeTab) closeTab(activeTab); return; }
|
case 'KeyW': e.preventDefault(); if (activeTab) closeTab(activeTab); return;
|
||||||
// Alt+Shift+← / Alt+Shift+→ → prev / next tab
|
case 'KeyX': e.preventDefault(); closePane(); return;
|
||||||
// (plain Alt+← / Alt+→ is word-nav below; Shift disambiguates)
|
case 'Backslash': e.preventDefault(); splitPane('h'); return;
|
||||||
if (S && e.code === 'ArrowLeft') {
|
case 'Minus': e.preventDefault(); splitPane('v'); return;
|
||||||
e.preventDefault();
|
case 'ArrowLeft':
|
||||||
const idx = tabs.indexOf(activeTab);
|
e.preventDefault(); sendToActive('\x1bb'); return;
|
||||||
if (idx > 0) switchTab(tabs[idx - 1]);
|
case 'ArrowRight':
|
||||||
return;
|
e.preventDefault(); sendToActive('\x1bf'); return;
|
||||||
}
|
}
|
||||||
if (S && e.code === 'ArrowRight') {
|
} else {
|
||||||
e.preventDefault();
|
if (e.code === 'ArrowLeft') {
|
||||||
const idx = tabs.indexOf(activeTab);
|
e.preventDefault();
|
||||||
if (idx < tabs.length - 1) switchTab(tabs[idx + 1]);
|
const idx = tabs.indexOf(activeTab);
|
||||||
return;
|
if (idx > 0) switchTab(tabs[idx - 1]);
|
||||||
}
|
return;
|
||||||
// Alt+← / Alt+→ → readline word-backward / word-forward
|
}
|
||||||
if (!S && (e.code === 'ArrowLeft' || e.code === 'ArrowRight')) {
|
if (e.code === 'ArrowRight') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const t = activeTab;
|
const idx = tabs.indexOf(activeTab);
|
||||||
if (t && t.socket && t.socket.readyState === WebSocket.OPEN)
|
if (idx < tabs.length - 1) switchTab(tabs[idx + 1]);
|
||||||
t.socket.send(e.code === 'ArrowLeft' ? '\x1bb' : '\x1bf');
|
return;
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block all Ctrl+key browser shortcuts; xterm still receives the event.
|
// Block ALL Ctrl+key browser shortcuts; xterm still gets the event.
|
||||||
if (C && !A && !M) {
|
if (C && !A && !M) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!S && e.code === 'KeyW') {
|
if (!S && e.code === 'KeyW') sendToActive('\x17');
|
||||||
const t = activeTab;
|
|
||||||
if (t && t.socket && t.socket.readyState === WebSocket.OPEN) t.socket.send('\x17');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block all function keys
|
// Block function keys
|
||||||
if (!M && /^F\d+$/.test(e.key)) { e.preventDefault(); return; }
|
if (!M && /^F\d+$/.test(e.key)) { e.preventDefault(); return; }
|
||||||
|
|
||||||
}, true); // capture phase
|
}, true);
|
||||||
|
|
||||||
// Chrome processes Ctrl+W before dispatching keydown — last guard.
|
function sendToActive(data) {
|
||||||
|
const leaf = activeTab && activeTab.activeLeaf;
|
||||||
|
if (leaf && leaf.socket && leaf.socket.readyState === WebSocket.OPEN) leaf.socket.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard accidental tab close
|
||||||
window.addEventListener('beforeunload', function(e) {
|
window.addEventListener('beforeunload', function(e) {
|
||||||
if (tabs.some(t => t.socket && t.socket.readyState === WebSocket.OPEN)) {
|
const anyOpen = tabs.some(t => {
|
||||||
e.preventDefault();
|
let f = false;
|
||||||
return e.returnValue = '';
|
walkLeaves(t.root, l => { if (l.socket && l.socket.readyState === WebSocket.OPEN) f = true; });
|
||||||
}
|
return f;
|
||||||
|
});
|
||||||
|
if (anyOpen) { e.preventDefault(); return e.returnValue = ''; }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Toast ─────────────────────────────────────────────────────────────
|
// ── Toast ──────────────────────────────────────────────────────────────
|
||||||
let toastTimer;
|
let toastTimer;
|
||||||
function toast(msg, type) {
|
function toast(msg, type) {
|
||||||
const el = document.getElementById('toast');
|
const el = document.getElementById('toast');
|
||||||
@@ -125,26 +208,17 @@ function setStatus(text, state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateStatus() {
|
function updateStatus() {
|
||||||
if (!activeTab) return;
|
const leaf = activeTab && activeTab.activeLeaf;
|
||||||
const s = activeTab.socket;
|
const wsShort = '/s/' + WORKSPACE_ID.slice(0, 8) + '...';
|
||||||
|
if (!leaf) { setStatus(wsShort, ''); return; }
|
||||||
|
const s = leaf.socket;
|
||||||
if (!s || s.readyState === WebSocket.CONNECTING) setStatus('connecting...', '');
|
if (!s || s.readyState === WebSocket.CONNECTING) setStatus('connecting...', '');
|
||||||
else if (s.readyState === WebSocket.OPEN) setStatus('connected ' + activeTab.id.slice(0, 8) + '...', 'ok');
|
else if (s.readyState === WebSocket.OPEN)
|
||||||
|
setStatus(wsShort + ' pane:' + leaf.id.slice(0, 8), 'ok');
|
||||||
else setStatus('disconnected', 'err');
|
else setStatus('disconnected', 'err');
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyLink encodes all open tab IDs in the URL hash so the recipient
|
// ── Modal helpers ──────────────────────────────────────────────────────
|
||||||
// browser opens the same session layout.
|
|
||||||
function copyLink() {
|
|
||||||
const ids = tabs.map(t => t.id).join(',');
|
|
||||||
const url = location.protocol + '//' + location.host +
|
|
||||||
'/s/' + tabs[0].id + '#t=' + ids;
|
|
||||||
navigator.clipboard.writeText(url).then(
|
|
||||||
() => toast('Session link copied!', 'ok'),
|
|
||||||
() => toast('Copy failed', 'err')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Modal helpers ─────────────────────────────────────────────────────
|
|
||||||
function openModal(id) {
|
function openModal(id) {
|
||||||
document.getElementById(id).classList.remove('hidden');
|
document.getElementById(id).classList.remove('hidden');
|
||||||
const inp = document.querySelector('#' + id + ' .m-input');
|
const inp = document.querySelector('#' + id + ' .m-input');
|
||||||
@@ -152,7 +226,8 @@ function openModal(id) {
|
|||||||
}
|
}
|
||||||
function closeModal(id) {
|
function closeModal(id) {
|
||||||
document.getElementById(id).classList.add('hidden');
|
document.getElementById(id).classList.add('hidden');
|
||||||
if (activeTab) activeTab.term.focus();
|
const leaf = activeTab && activeTab.activeLeaf;
|
||||||
|
if (leaf) leaf.term.focus();
|
||||||
}
|
}
|
||||||
function bgClose(evt, id) {
|
function bgClose(evt, id) {
|
||||||
if (evt.target.id === id) closeModal(id);
|
if (evt.target.id === id) closeModal(id);
|
||||||
@@ -165,7 +240,7 @@ document.addEventListener('keydown', e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────
|
// ── Auth ───────────────────────────────────────────────────────────────
|
||||||
async function doAuth() {
|
async function doAuth() {
|
||||||
const username = document.getElementById('fUser').value.trim();
|
const username = document.getElementById('fUser').value.trim();
|
||||||
const password = document.getElementById('fPass').value;
|
const password = document.getElementById('fPass').value;
|
||||||
@@ -186,10 +261,17 @@ async function doAuth() {
|
|||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
isAuthenticated = true; // runtime flag: future newTab() calls will connect
|
isAuthenticated = true;
|
||||||
document.getElementById('authOverlay').classList.add('hidden');
|
document.getElementById('authOverlay').classList.add('hidden');
|
||||||
tabs.forEach(t => { if (!t.socket) connectTab(t); });
|
// Load or create workspace after login
|
||||||
if (activeTab) activeTab.term.focus();
|
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 {
|
} else {
|
||||||
showAuthErr(data.error || 'Authentication failed');
|
showAuthErr(data.error || 'Authentication failed');
|
||||||
}
|
}
|
||||||
@@ -214,86 +296,341 @@ document.getElementById('fPass').addEventListener('keydown', e => {
|
|||||||
if (e.key === 'Enter') doAuth();
|
if (e.key === 'Enter') doAuth();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Tab management ────────────────────────────────────────────────────
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// PANE TREE
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
//
|
//
|
||||||
// Each tab:
|
// Leaf node: { type:'leaf', id, term, fit, socket, el, parent }
|
||||||
// id 32-char hex session ID (server PTY key)
|
// Split node: { type:'split', dir:'h'|'v', a, b, ratio, el, divEl, parent }
|
||||||
// term xterm.js Terminal
|
|
||||||
// fit FitAddon
|
|
||||||
// pane .term-pane <div>
|
|
||||||
// socket WebSocket /ws/<id>
|
|
||||||
// tabEl .tab-item <div>
|
|
||||||
//
|
//
|
||||||
// The "+" button lives inside #tabList; new tab elements are inserted
|
// dir 'h' → side-by-side (flex-direction:row, vertical divider)
|
||||||
// before it so "+" always appears right after the last tab.
|
// dir 'v' → stacked (flex-direction:column, horizontal divider)
|
||||||
//
|
|
||||||
// Tab IDs are encoded in the URL hash (#t=id1,id2,...) so the link
|
|
||||||
// button produces a URL that reopens the same sessions in another browser.
|
|
||||||
|
|
||||||
function newTab(sessionId) {
|
function walkLeaves(node, fn) {
|
||||||
const id = sessionId || randHexClient(16);
|
if (!node) return;
|
||||||
const tabNewBtn = document.getElementById('tabNew');
|
if (node.type === 'leaf') { fn(node); return; }
|
||||||
tabCounter++;
|
walkLeaves(node.a, fn);
|
||||||
const tabLabel = 'bash ' + tabCounter;
|
walkLeaves(node.b, fn);
|
||||||
|
}
|
||||||
|
|
||||||
// Terminal pane
|
function countLeaves(node) {
|
||||||
const pane = document.createElement('div');
|
if (!node || node.type === 'leaf') return node ? 1 : 0;
|
||||||
pane.className = 'term-pane';
|
return countLeaves(node.a) + countLeaves(node.b);
|
||||||
pane.style.display = 'none';
|
}
|
||||||
document.getElementById('termContainer').appendChild(pane);
|
|
||||||
|
function findFirstLeaf(node) {
|
||||||
|
return node.type === 'leaf' ? node : findFirstLeaf(node.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refitAll(tab) {
|
||||||
|
if (tab) walkLeaves(tab.root, l => { try { l.fit.fit(); } catch(_) {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace oldNode with newNode in the JS tree and DOM.
|
||||||
|
function replaceNode(tab, oldNode, newNode) {
|
||||||
|
const p = oldNode.parent;
|
||||||
|
newNode.parent = p;
|
||||||
|
if (!p) {
|
||||||
|
tab.root = newNode;
|
||||||
|
tab.pane.innerHTML = '';
|
||||||
|
tab.pane.appendChild(newNode.el);
|
||||||
|
} else {
|
||||||
|
if (p.a === oldNode) p.a = newNode; else p.b = newNode;
|
||||||
|
p.el.replaceChild(newNode.el, oldNode.el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFlex(splitNode) {
|
||||||
|
const { a, b, dir, ratio } = splitNode;
|
||||||
|
const cross = dir === 'h' ? 'height' : 'width';
|
||||||
|
a.el.style.flex = `0 0 calc(${ratio * 100}% - 2px)`;
|
||||||
|
a.el.style[cross] = '100%';
|
||||||
|
a.el.style.minWidth = '0';
|
||||||
|
a.el.style.minHeight = '0';
|
||||||
|
a.el.style.overflow = 'hidden';
|
||||||
|
b.el.style.flex = '1 1 0%';
|
||||||
|
b.el.style[cross] = '100%';
|
||||||
|
b.el.style.minWidth = '0';
|
||||||
|
b.el.style.minHeight = '0';
|
||||||
|
b.el.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshActiveHighlight(tab) {
|
||||||
|
const multi = countLeaves(tab.root) > 1;
|
||||||
|
walkLeaves(tab.root, l => {
|
||||||
|
l.el.classList.toggle('pane-active', multi && l === tab.activeLeaf);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveLeaf(tab, leaf) {
|
||||||
|
tab.activeLeaf = leaf;
|
||||||
|
refreshActiveHighlight(tab);
|
||||||
|
try { leaf.fit.fit(); } catch(_) {}
|
||||||
|
leaf.term.focus();
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create leaf ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createLeaf(sessionId) {
|
||||||
|
const id = sessionId || randHexClient(16);
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'term-pane';
|
||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
theme: TERM_THEME,
|
theme: TERM_THEME, cursorBlink: true, fontSize: 14,
|
||||||
cursorBlink: true, fontSize: 14, scrollback: 20000,
|
scrollback: 20000, fontFamily: '"JetBrains Mono","Fira Mono",monospace',
|
||||||
fontFamily: '"JetBrains Mono","Fira Mono",monospace',
|
|
||||||
});
|
});
|
||||||
const fit = new FitAddon.FitAddon();
|
const fit = new FitAddon.FitAddon();
|
||||||
term.loadAddon(fit);
|
term.loadAddon(fit);
|
||||||
term.open(pane);
|
term.open(el);
|
||||||
|
|
||||||
const tab = { id, label: tabLabel, term, fit, pane, socket: null, tabEl: null };
|
const leaf = { type: 'leaf', id, term, fit, socket: null, el, parent: null };
|
||||||
tabs.push(tab);
|
|
||||||
|
el.addEventListener('mousedown', () => {
|
||||||
|
if (activeTab && activeTab.activeLeaf !== leaf) setActiveLeaf(activeTab, leaf);
|
||||||
|
});
|
||||||
|
|
||||||
// Per-terminal key handler
|
|
||||||
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;
|
||||||
|
|
||||||
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('Copied!', 'ok'), () => toast('Copy failed', 'err'));
|
||||||
() => toast('Copy failed', 'err')
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
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 && !A && evt.code === 'KeyW') return false;
|
||||||
if (C && S && evt.code === 'KeyW') return false;
|
if (C && S && evt.code === 'KeyW') return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tab button — inserted BEFORE the "+" button so "+" stays at end
|
return leaf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Split ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function splitPane(dir) {
|
||||||
|
const tab = activeTab;
|
||||||
|
if (!tab || !tab.activeLeaf) return;
|
||||||
|
const leaf = tab.activeLeaf;
|
||||||
|
const newLeaf = createLeaf();
|
||||||
|
|
||||||
|
const splitEl = document.createElement('div');
|
||||||
|
splitEl.className = dir === 'h' ? 'split-h' : 'split-v';
|
||||||
|
|
||||||
|
const divEl = document.createElement('div');
|
||||||
|
divEl.className = dir === 'h' ? 'split-div-h' : 'split-div-v';
|
||||||
|
|
||||||
|
const splitNode = {
|
||||||
|
type: 'split', dir, a: leaf, b: newLeaf,
|
||||||
|
ratio: 0.5, el: splitEl, divEl, parent: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// replaceNode uses leaf.parent (old parent) — set new parents AFTER
|
||||||
|
replaceNode(tab, leaf, splitNode);
|
||||||
|
leaf.parent = splitNode;
|
||||||
|
newLeaf.parent = splitNode;
|
||||||
|
|
||||||
|
splitEl.appendChild(leaf.el);
|
||||||
|
splitEl.appendChild(divEl);
|
||||||
|
splitEl.appendChild(newLeaf.el);
|
||||||
|
|
||||||
|
applyFlex(splitNode);
|
||||||
|
setupDivider(splitNode);
|
||||||
|
|
||||||
|
if (isAuthenticated) connectLeaf(newLeaf);
|
||||||
|
|
||||||
|
setActiveLeaf(tab, newLeaf);
|
||||||
|
refreshActiveHighlight(tab);
|
||||||
|
setTimeout(() => refitAll(tab), 30);
|
||||||
|
saveWorkspace();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDivider(splitNode) {
|
||||||
|
const { divEl, el, dir } = splitNode;
|
||||||
|
divEl.addEventListener('mousedown', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const total = dir === 'h' ? rect.width : rect.height;
|
||||||
|
const origin = dir === 'h' ? rect.left : rect.top;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
const pos = (dir === 'h' ? ev.clientX : ev.clientY) - origin;
|
||||||
|
splitNode.ratio = Math.max(0.1, Math.min(0.9, pos / total));
|
||||||
|
applyFlex(splitNode);
|
||||||
|
refitAll(activeTab);
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
document.body.style.cursor = document.body.style.userSelect = '';
|
||||||
|
saveWorkspace(); // persist ratio after drag
|
||||||
|
}
|
||||||
|
document.body.style.cursor = dir === 'h' ? 'col-resize' : 'row-resize';
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Close pane ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function closePane() {
|
||||||
|
const tab = activeTab;
|
||||||
|
if (!tab || !tab.activeLeaf || !tab.activeLeaf.parent) return;
|
||||||
|
|
||||||
|
const leaf = tab.activeLeaf;
|
||||||
|
const splitNode = leaf.parent;
|
||||||
|
const sibling = splitNode.a === leaf ? splitNode.b : splitNode.a;
|
||||||
|
|
||||||
|
if (leaf.socket) { leaf.socket.onclose = null; leaf.socket.close(); }
|
||||||
|
leaf.term.dispose();
|
||||||
|
|
||||||
|
replaceNode(tab, splitNode, sibling);
|
||||||
|
sibling.el.removeAttribute('style');
|
||||||
|
|
||||||
|
const next = findFirstLeaf(sibling);
|
||||||
|
setActiveLeaf(tab, next);
|
||||||
|
refreshActiveHighlight(tab);
|
||||||
|
refitAll(tab);
|
||||||
|
saveWorkspace();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
// TAB MANAGEMENT
|
||||||
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Build the label span with click + right-click rename handlers.
|
||||||
|
function buildLabelSpan(tab) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'tab-label';
|
||||||
|
span.textContent = tab.label;
|
||||||
|
span.addEventListener('click', () => switchTab(tab));
|
||||||
|
span.addEventListener('contextmenu', e => { e.preventDefault(); startRename(tab); });
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline tab rename: right-click → input → Enter/blur commit, Escape cancel.
|
||||||
|
function startRename(tab) {
|
||||||
|
const labelSpan = tab.tabEl.querySelector('.tab-label');
|
||||||
|
if (!labelSpan) return;
|
||||||
|
const oldLabel = tab.label;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'tab-rename-input';
|
||||||
|
input.value = oldLabel;
|
||||||
|
labelSpan.replaceWith(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
let committed = false;
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
if (committed) return;
|
||||||
|
committed = true;
|
||||||
|
const val = input.value.trim();
|
||||||
|
tab.label = val || oldLabel;
|
||||||
|
input.replaceWith(buildLabelSpan(tab));
|
||||||
|
saveWorkspace();
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
if (committed) return;
|
||||||
|
committed = true;
|
||||||
|
input.replaceWith(buildLabelSpan(tab));
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and insert the tab bar button for a tab.
|
||||||
|
function addTabButton(tab) {
|
||||||
|
const tabNewBtn = document.getElementById('tabNew');
|
||||||
const tabEl = document.createElement('div');
|
const tabEl = document.createElement('div');
|
||||||
tabEl.className = 'tab-item';
|
tabEl.className = 'tab-item';
|
||||||
tabEl.title = 'Session: ' + id;
|
|
||||||
tabEl.innerHTML =
|
const closeBtn = document.createElement('button');
|
||||||
`<span class="tab-label">${tabLabel}</span>` +
|
closeBtn.className = 'tab-x';
|
||||||
`<button class="tab-x" title="Close tab (Alt+W)">×</button>`;
|
closeBtn.title = 'Close tab (Alt+W)';
|
||||||
tabEl.querySelector('.tab-label').addEventListener('click', () => switchTab(tab));
|
closeBtn.innerHTML = '×';
|
||||||
tabEl.querySelector('.tab-x').addEventListener('click', ev => {
|
closeBtn.addEventListener('click', e => { e.stopPropagation(); closeTab(tab); });
|
||||||
ev.stopPropagation(); closeTab(tab);
|
|
||||||
});
|
tabEl.appendChild(buildLabelSpan(tab));
|
||||||
tabNewBtn.parentElement.insertBefore(tabEl, tabNewBtn); // right before "+"
|
tabEl.appendChild(closeBtn);
|
||||||
|
tabNewBtn.parentElement.insertBefore(tabEl, tabNewBtn);
|
||||||
tab.tabEl = tabEl;
|
tab.tabEl = tabEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new tab with fresh PTY leaf.
|
||||||
|
function newTab(tabId, sessionId) {
|
||||||
|
tabId = tabId || randHexClient(16);
|
||||||
|
tabCounter++;
|
||||||
|
const label = 'bash ' + tabCounter;
|
||||||
|
|
||||||
|
const pane = document.createElement('div');
|
||||||
|
pane.className = 'tab-pane';
|
||||||
|
pane.style.display = 'none';
|
||||||
|
document.getElementById('termContainer').appendChild(pane);
|
||||||
|
|
||||||
|
const rootLeaf = createLeaf(sessionId);
|
||||||
|
pane.appendChild(rootLeaf.el);
|
||||||
|
|
||||||
|
const tab = {
|
||||||
|
tabId,
|
||||||
|
label,
|
||||||
|
pane,
|
||||||
|
root: rootLeaf,
|
||||||
|
activeLeaf: rootLeaf,
|
||||||
|
tabEl: null,
|
||||||
|
};
|
||||||
|
tabs.push(tab);
|
||||||
|
addTabButton(tab);
|
||||||
switchTab(tab);
|
switchTab(tab);
|
||||||
updateURLHash();
|
saveWorkspace();
|
||||||
|
if (isAuthenticated) connectLeaf(rootLeaf);
|
||||||
if (isAuthenticated) connectTab(tab); // connect immediately if already authed
|
|
||||||
return tab;
|
return tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore a single tab from workspace data (without calling saveWorkspace).
|
||||||
|
function restoreTab(tabData) {
|
||||||
|
tabCounter++;
|
||||||
|
const pane = document.createElement('div');
|
||||||
|
pane.className = 'tab-pane';
|
||||||
|
pane.style.display = 'none';
|
||||||
|
document.getElementById('termContainer').appendChild(pane);
|
||||||
|
|
||||||
|
const root = deserializePane(tabData.root);
|
||||||
|
pane.appendChild(root.el);
|
||||||
|
|
||||||
|
const tab = {
|
||||||
|
tabId: tabData.tab_id,
|
||||||
|
label: tabData.label || ('bash ' + tabCounter),
|
||||||
|
pane,
|
||||||
|
root,
|
||||||
|
activeLeaf: findFirstLeaf(root),
|
||||||
|
tabEl: null,
|
||||||
|
};
|
||||||
|
tabs.push(tab);
|
||||||
|
addTabButton(tab);
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
walkLeaves(root, l => connectLeaf(l));
|
||||||
|
}
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore all tabs from saved workspace data.
|
||||||
|
function restoreWorkspace(ws) {
|
||||||
|
ws.tabs.forEach(tabData => restoreTab(tabData));
|
||||||
|
if (!tabs.length) { newTab(); return; }
|
||||||
|
switchTab(tabs[0]);
|
||||||
|
}
|
||||||
|
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
if (activeTab) {
|
if (activeTab) {
|
||||||
activeTab.pane.style.display = 'none';
|
activeTab.pane.style.display = 'none';
|
||||||
@@ -302,73 +639,69 @@ function switchTab(tab) {
|
|||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
tab.pane.style.display = 'block';
|
tab.pane.style.display = 'block';
|
||||||
tab.tabEl.classList.add('active');
|
tab.tabEl.classList.add('active');
|
||||||
tab.fit.fit();
|
refitAll(tab);
|
||||||
tab.term.focus();
|
if (tab.activeLeaf) tab.activeLeaf.term.focus();
|
||||||
updateStatus();
|
updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTab(tab) {
|
function closeTab(tab) {
|
||||||
if (tabs.length === 1) return; // never close the last tab
|
if (tabs.length === 1) return;
|
||||||
if (tab.socket) {
|
walkLeaves(tab.root, l => {
|
||||||
tab.socket.onclose = null; // prevent auto-reconnect
|
if (l.socket) { l.socket.onclose = null; l.socket.close(); }
|
||||||
tab.socket.close();
|
l.term.dispose();
|
||||||
}
|
});
|
||||||
tab.term.dispose();
|
|
||||||
tab.pane.remove();
|
tab.pane.remove();
|
||||||
tab.tabEl.remove();
|
tab.tabEl.remove();
|
||||||
|
|
||||||
const idx = tabs.indexOf(tab);
|
const idx = tabs.indexOf(tab);
|
||||||
tabs.splice(idx, 1);
|
tabs.splice(idx, 1);
|
||||||
|
if (activeTab === tab) switchTab(tabs[Math.min(idx, tabs.length - 1)]);
|
||||||
if (activeTab === tab)
|
saveWorkspace();
|
||||||
switchTab(tabs[Math.min(idx, tabs.length - 1)]);
|
|
||||||
|
|
||||||
updateURLHash();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WebSocket (per tab) ───────────────────────────────────────────────
|
// ── WebSocket per leaf ─────────────────────────────────────────────────
|
||||||
function connectTab(tab) {
|
|
||||||
tab.socket = new WebSocket('wss://' + location.host + '/ws/' + tab.id);
|
|
||||||
tab.socket.binaryType = 'arraybuffer';
|
|
||||||
|
|
||||||
tab.socket.onopen = () => {
|
function connectLeaf(leaf) {
|
||||||
tab.fit.fit();
|
leaf.socket = new WebSocket('wss://' + location.host + '/ws/' + leaf.id);
|
||||||
sendResizeFor(tab);
|
leaf.socket.binaryType = 'arraybuffer';
|
||||||
if (activeTab === tab) { updateStatus(); tab.term.focus(); }
|
|
||||||
|
leaf.socket.onopen = () => {
|
||||||
|
leaf.fit.fit();
|
||||||
|
sendResizeFor(leaf);
|
||||||
|
if (activeTab && activeTab.activeLeaf === leaf) { updateStatus(); leaf.term.focus(); }
|
||||||
};
|
};
|
||||||
|
|
||||||
tab.socket.onmessage = e => {
|
leaf.socket.onmessage = e => {
|
||||||
if (typeof e.data === 'string') {
|
if (typeof e.data === 'string') {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(e.data);
|
const msg = JSON.parse(e.data);
|
||||||
if (msg.type === 'session' && activeTab === tab)
|
if (msg.type === 'session' && activeTab && activeTab.activeLeaf === leaf)
|
||||||
setStatus((msg.new ? 'new' : 'resumed') + ' ' + tab.id.slice(0, 8) + '...', 'ok');
|
setStatus((msg.new ? 'new' : 'resumed') + ' pane:' + leaf.id.slice(0, 8), 'ok');
|
||||||
} catch (_) {}
|
} catch(_) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tab.term.write(new Uint8Array(e.data));
|
leaf.term.write(new Uint8Array(e.data));
|
||||||
};
|
};
|
||||||
|
|
||||||
tab.socket.onclose = () => {
|
leaf.socket.onclose = () => {
|
||||||
if (activeTab === tab) setStatus('reconnecting...', 'err');
|
if (activeTab && activeTab.activeLeaf === leaf) setStatus('reconnecting...', 'err');
|
||||||
setTimeout(() => connectTab(tab), 2000);
|
setTimeout(() => connectLeaf(leaf), 2000);
|
||||||
};
|
};
|
||||||
tab.socket.onerror = () => tab.socket.close();
|
leaf.socket.onerror = () => leaf.socket.close();
|
||||||
|
|
||||||
tab.term.onData(d => {
|
leaf.term.onData(d => {
|
||||||
if (tab.socket && tab.socket.readyState === WebSocket.OPEN) tab.socket.send(d);
|
if (leaf.socket && leaf.socket.readyState === WebSocket.OPEN) leaf.socket.send(d);
|
||||||
});
|
});
|
||||||
tab.term.onResize(() => sendResizeFor(tab));
|
leaf.term.onResize(() => sendResizeFor(leaf));
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendResizeFor(tab) {
|
function sendResizeFor(leaf) {
|
||||||
if (tab.socket && tab.socket.readyState === WebSocket.OPEN)
|
if (leaf.socket && leaf.socket.readyState === WebSocket.OPEN)
|
||||||
tab.socket.send(JSON.stringify({ type: 'resize', cols: tab.term.cols, rows: tab.term.rows }));
|
leaf.socket.send(JSON.stringify({ type: 'resize', cols: leaf.term.cols, rows: leaf.term.rows }));
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', () => { if (activeTab) activeTab.fit.fit(); });
|
window.addEventListener('resize', () => { if (activeTab) refitAll(activeTab); });
|
||||||
|
|
||||||
// ── Upload ────────────────────────────────────────────────────────────
|
// ── Upload ─────────────────────────────────────────────────────────────
|
||||||
function onFileChosen() {
|
function onFileChosen() {
|
||||||
const f = document.getElementById('upFile').files[0];
|
const f = document.getElementById('upFile').files[0];
|
||||||
const zone = document.getElementById('fileZone');
|
const zone = document.getElementById('fileZone');
|
||||||
@@ -381,15 +714,17 @@ async function doUpload() {
|
|||||||
const f = document.getElementById('upFile').files[0];
|
const f = document.getElementById('upFile').files[0];
|
||||||
if (!f) { modalFb('upFb', 'Select a file first', 'err'); return; }
|
if (!f) { modalFb('upFb', 'Select a file first', 'err'); return; }
|
||||||
|
|
||||||
|
const leaf = activeTab && activeTab.activeLeaf;
|
||||||
|
if (!leaf) { modalFb('upFb', 'No active terminal', 'err'); return; }
|
||||||
|
|
||||||
const btn = document.getElementById('upBtn');
|
const btn = document.getElementById('upBtn');
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', f);
|
form.append('file', f);
|
||||||
form.append('dest', document.getElementById('upDest').value.trim());
|
form.append('dest', document.getElementById('upDest').value.trim());
|
||||||
form.append('sid', activeTab ? activeTab.id : SESSION_ID);
|
form.append('sid', leaf.id);
|
||||||
|
|
||||||
btn.disabled = true; btn.classList.add('busy');
|
btn.disabled = true; btn.classList.add('busy');
|
||||||
modalFb('upFb', '', '');
|
modalFb('upFb', '', '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/upload', { method: 'POST', body: form });
|
const res = await fetch('/upload', { method: 'POST', body: form });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -408,7 +743,7 @@ async function doUpload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Download ──────────────────────────────────────────────────────────
|
// ── Download ───────────────────────────────────────────────────────────
|
||||||
function doDownload() {
|
function doDownload() {
|
||||||
const p = document.getElementById('dlPath').value.trim();
|
const p = document.getElementById('dlPath').value.trim();
|
||||||
if (!p) { modalFb('dlFb', 'Enter a file path', 'err'); return; }
|
if (!p) { modalFb('dlFb', 'Enter a file path', 'err'); return; }
|
||||||
@@ -426,22 +761,17 @@ function modalFb(id, msg, type) {
|
|||||||
el.className = 'm-fb' + (type ? ' ' + type : '');
|
el.className = 'm-fb' + (type ? ' ' + type : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bootstrap ─────────────────────────────────────────────────────────
|
// ── Bootstrap ──────────────────────────────────────────────────────────
|
||||||
//
|
(async function bootstrap() {
|
||||||
// If the URL hash encodes tab IDs, open all of them (another browser
|
if (isAuthenticated) {
|
||||||
// shared the link). Otherwise open a single tab with the server-provided
|
document.getElementById('authOverlay').classList.add('hidden');
|
||||||
// SESSION_ID.
|
const ws = await loadWorkspace();
|
||||||
(function bootstrap() {
|
if (ws && ws.tabs && ws.tabs.length) {
|
||||||
const hashIds = getTabIDsFromHash();
|
restoreWorkspace(ws);
|
||||||
if (hashIds && hashIds.length > 0) {
|
} else {
|
||||||
// Restore all sessions from the shared link.
|
newTab();
|
||||||
// SESSION_ID might or might not be in the list — use hash as source of truth.
|
}
|
||||||
hashIds.forEach(id => newTab(id));
|
|
||||||
} else {
|
} else {
|
||||||
newTab(SESSION_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
setTimeout(() => document.getElementById('fUser').focus(), 80);
|
setTimeout(() => document.getElementById('fUser').focus(), 80);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -122,14 +122,48 @@
|
|||||||
<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">
|
||||||
<!-- Copy session link -->
|
<!-- Split left/right (Alt+\) -->
|
||||||
<button class="tb-btn accent" id="copyBtn" onclick="copyLink()" title="Copy session link">
|
<button class="tb-btn" onclick="splitPane('h')" title="Split left/right (Alt+\)">
|
||||||
|
<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"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="21"/>
|
||||||
|
</svg>
|
||||||
|
Split H
|
||||||
|
</button>
|
||||||
|
<!-- Split top/bottom (Alt+-) -->
|
||||||
|
<button class="tb-btn" onclick="splitPane('v')" title="Split top/bottom (Alt+-)">
|
||||||
|
<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"/>
|
||||||
|
<line x1="2" y1="12" x2="22" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Split V
|
||||||
|
</button>
|
||||||
|
<!-- Close active pane (Alt+X) -->
|
||||||
|
<button class="tb-btn" onclick="closePane()" title="Close active pane (Alt+X)">
|
||||||
|
<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"/>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
Pane X
|
||||||
|
</button>
|
||||||
|
<div class="tb-sep"></div>
|
||||||
|
<!-- Copy workspace link -->
|
||||||
|
<button class="tb-btn accent" id="copyBtn" onclick="copyLink()" title="Copy workspace link">
|
||||||
<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="9" y="9" width="13" height="13" rx="2"/>
|
<rect x="9" y="9" width="13" height="13" rx="2"/>
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||||
</svg>
|
</svg>
|
||||||
Link
|
Link
|
||||||
</button>
|
</button>
|
||||||
|
<!-- End Session -->
|
||||||
|
<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">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
End
|
||||||
|
</button>
|
||||||
<!-- Upload -->
|
<!-- Upload -->
|
||||||
<button class="tb-btn" onclick="openModal('upOverlay')" title="Upload file">
|
<button class="tb-btn" onclick="openModal('upOverlay')" title="Upload file">
|
||||||
<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">
|
||||||
@@ -153,9 +187,9 @@
|
|||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Session context injected server-side — consumed by app.js
|
// Workspace context — injected server-side, consumed by app.js
|
||||||
const SESSION_ID = "[[SESSION_ID]]";
|
const WORKSPACE_ID = "[[WORKSPACE_ID]]";
|
||||||
const AUTHED = [[AUTHED]];
|
const AUTHED = [[AUTHED]];
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package internals
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// credsMu guards concurrent reads/writes of appCreds.Workspaces from HTTP
|
||||||
|
// handlers. Credential fields (Username/Hash/etc.) are only written at
|
||||||
|
// startup via CLI flags that call os.Exit, so they don't need the lock.
|
||||||
|
var credsMu sync.Mutex
|
||||||
|
|
||||||
|
// WorkspacePaneLayout is a recursive pane tree.
|
||||||
|
// Leaf nodes own a PTY session; split nodes contain two children.
|
||||||
|
type WorkspacePaneLayout struct {
|
||||||
|
Type string `json:"type"` // "leaf" | "split"
|
||||||
|
ID string `json:"id,omitempty"` // PTY session hex ID (leaf only)
|
||||||
|
Dir string `json:"dir,omitempty"` // "h" | "v" (split only)
|
||||||
|
Ratio float64 `json:"ratio,omitempty"` // 0.1–0.9 (split only)
|
||||||
|
A *WorkspacePaneLayout `json:"a,omitempty"`
|
||||||
|
B *WorkspacePaneLayout `json:"b,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkspaceTabLayout stores one tab's label and pane tree.
|
||||||
|
type WorkspaceTabLayout struct {
|
||||||
|
TabID string `json:"tab_id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Root WorkspacePaneLayout `json:"root"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkspaceLayout is the full layout stored per workspace ID in gws-creds.json.
|
||||||
|
type WorkspaceLayout struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Tabs []WorkspaceTabLayout `json:"tabs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWorkspaceAPI dispatches GET / PUT / DELETE for /api/workspace/<id>.
|
||||||
|
// All methods require auth.
|
||||||
|
func handleWorkspaceAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if !isAuthed(r) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, "/api/workspace/")
|
||||||
|
if !validID(id) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"invalid id"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
workspaceGet(w, id)
|
||||||
|
case http.MethodPut:
|
||||||
|
workspacePut(w, r, id)
|
||||||
|
case http.MethodDelete:
|
||||||
|
workspaceDelete(w, id)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
w.Write([]byte(`{"error":"method not allowed"}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceGet(w http.ResponseWriter, id string) {
|
||||||
|
credsMu.Lock()
|
||||||
|
ws := appCreds.Workspaces[id]
|
||||||
|
credsMu.Unlock()
|
||||||
|
if ws == nil {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte(`{"error":"not found"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(ws) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspacePut(w http.ResponseWriter, r *http.Request, id string) {
|
||||||
|
var ws WorkspaceLayout
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&ws); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte(`{"error":"bad json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ws.ID = id
|
||||||
|
|
||||||
|
credsMu.Lock()
|
||||||
|
if appCreds.Workspaces == nil {
|
||||||
|
appCreds.Workspaces = make(map[string]*WorkspaceLayout)
|
||||||
|
}
|
||||||
|
appCreds.Workspaces[id] = &ws
|
||||||
|
err := saveCreds(appCreds)
|
||||||
|
credsMu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`{"error":"save failed"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceDelete(w http.ResponseWriter, id string) {
|
||||||
|
credsMu.Lock()
|
||||||
|
if appCreds.Workspaces != nil {
|
||||||
|
delete(appCreds.Workspaces, id)
|
||||||
|
}
|
||||||
|
err := saveCreds(appCreds)
|
||||||
|
credsMu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`{"error":"save failed"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte(`{"ok":true}`))
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
export CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000
|
|
||||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS=64000 claude --resume dd1f4084-d53b-4d1a-9401-cb605b5c7718
|
|
||||||
Reference in New Issue
Block a user