- Fix asyncio.get_event_loop() → get_running_loop() in PTY reader - Add error logging for PTY spawn failures - Add POST /api/session/state endpoint for sendBeacon (beforeunload) - Use navigator.sendBeacon for reliable state save on page close - Improve frontend error reporting when terminal creation fails Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
366 lines
12 KiB
JavaScript
366 lines
12 KiB
JavaScript
/* Atlus — Terminal app (xterm.js + persistent PTY sessions) */
|
|
(function () {
|
|
'use strict';
|
|
|
|
let sessions = []; // {terminalId, ws, term, fitAddon, title}
|
|
let activeTerminalId = null;
|
|
let container = null;
|
|
let termContainer = null;
|
|
let tabsContainer = null;
|
|
let kbdVisible = true;
|
|
let resizeObserver = null;
|
|
|
|
// ---- Create or attach to a terminal ----
|
|
|
|
async function createNewTerminal() {
|
|
// Ask backend to create a persistent terminal
|
|
try {
|
|
const res = await Atlus.apiFetch('/api/session/terminals', {
|
|
method: 'POST',
|
|
body: { cols: 120, rows: 30 },
|
|
});
|
|
if (!res || !res.ok) {
|
|
const err = res ? await res.text().catch(() => 'unknown') : 'no response';
|
|
console.error('Failed to create terminal:', err);
|
|
return null;
|
|
}
|
|
const data = await res.json();
|
|
return attachToTerminal(data.terminal_id, data.title);
|
|
} catch (e) {
|
|
console.error('Failed to create terminal:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function attachToTerminal(terminalId, title) {
|
|
// Don't attach twice
|
|
const existing = sessions.find(s => s.terminalId === terminalId);
|
|
if (existing) {
|
|
switchSession(terminalId);
|
|
return existing;
|
|
}
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 14,
|
|
fontFamily: "'IBM Plex Mono', monospace",
|
|
theme: {
|
|
background: '#111318',
|
|
foreground: '#c8ccd8',
|
|
cursor: '#6ea6f0',
|
|
selectionBackground: 'rgba(110, 166, 240, 0.3)',
|
|
black: '#0d0f14',
|
|
red: '#e05a4a',
|
|
green: '#3ab86a',
|
|
yellow: '#e09a2a',
|
|
blue: '#6ea6f0',
|
|
magenta: '#c678dd',
|
|
cyan: '#56b6c2',
|
|
white: '#c8ccd8',
|
|
brightBlack: '#4a5068',
|
|
brightRed: '#e05a4a',
|
|
brightGreen: '#3ab86a',
|
|
brightYellow: '#e09a2a',
|
|
brightBlue: '#6ea6f0',
|
|
brightMagenta: '#c678dd',
|
|
brightCyan: '#56b6c2',
|
|
brightWhite: '#ffffff',
|
|
},
|
|
allowProposedApi: true,
|
|
});
|
|
|
|
const fitAddon = new FitAddon.FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
|
|
// Connect WebSocket to the persistent terminal
|
|
const wsUrl = Atlus.wsUrl(`/api/terminal/ws?terminal_id=${terminalId}`);
|
|
const ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
setTimeout(() => {
|
|
fitAddon.fit();
|
|
ws.send(JSON.stringify({
|
|
type: 'resize',
|
|
cols: term.cols,
|
|
rows: term.rows,
|
|
}));
|
|
}, 100);
|
|
};
|
|
|
|
ws.onmessage = (e) => {
|
|
const msg = JSON.parse(e.data);
|
|
if (msg.type === 'output') {
|
|
term.write(msg.data);
|
|
} else if (msg.type === 'scrollback') {
|
|
// Replay scrollback from server
|
|
term.write(msg.data);
|
|
} else if (msg.type === 'error') {
|
|
term.write(`\r\n\x1b[31m[Error: ${msg.data}]\x1b[0m\r\n`);
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
term.write('\r\n\x1b[33m[Disconnected — refresh to reconnect]\x1b[0m\r\n');
|
|
};
|
|
|
|
// Send input to PTY
|
|
term.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'input', data }));
|
|
}
|
|
});
|
|
|
|
// Handle resize
|
|
term.onResize(({ cols, rows }) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
}
|
|
});
|
|
|
|
const session = { terminalId, ws, term, fitAddon, title: title || 'Shell' };
|
|
sessions.push(session);
|
|
|
|
// Connect on-screen keyboard
|
|
if (Atlus.keyboard) {
|
|
Atlus.keyboard.setTerminal({
|
|
input: (data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'input', data }));
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
function switchSession(terminalId) {
|
|
activeTerminalId = terminalId;
|
|
renderTabs();
|
|
renderTerminal();
|
|
if (Atlus.saveDesktopState) Atlus.saveDesktopState();
|
|
}
|
|
|
|
async function closeSession(terminalId) {
|
|
const idx = sessions.findIndex(s => s.terminalId === terminalId);
|
|
if (idx === -1) return;
|
|
|
|
const session = sessions[idx];
|
|
|
|
// Close WebSocket (doesn't kill PTY)
|
|
if (session.ws.readyState === WebSocket.OPEN) {
|
|
session.ws.close();
|
|
}
|
|
session.term.dispose();
|
|
sessions.splice(idx, 1);
|
|
|
|
// Tell server to kill the PTY
|
|
try {
|
|
await Atlus.apiFetch(`/api/session/terminals/${terminalId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
} catch (e) { /* best effort */ }
|
|
|
|
if (sessions.length === 0) {
|
|
Atlus.closeApp('terminal');
|
|
return;
|
|
}
|
|
|
|
if (activeTerminalId === terminalId) {
|
|
activeTerminalId = sessions[sessions.length - 1].terminalId;
|
|
}
|
|
renderTabs();
|
|
renderTerminal();
|
|
if (Atlus.saveDesktopState) Atlus.saveDesktopState();
|
|
}
|
|
|
|
function renderTabs() {
|
|
if (!tabsContainer) return;
|
|
tabsContainer.innerHTML = '';
|
|
sessions.forEach((s, i) => {
|
|
const tab = document.createElement('button');
|
|
tab.className = 'terminal-tab' + (s.terminalId === activeTerminalId ? ' active' : '');
|
|
tab.innerHTML = `<span>${s.title || 'Shell ' + (i + 1)}</span>`;
|
|
if (sessions.length > 1) {
|
|
tab.innerHTML += `<span class="terminal-tab-close">×</span>`;
|
|
}
|
|
tab.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('terminal-tab-close')) {
|
|
closeSession(s.terminalId);
|
|
} else {
|
|
switchSession(s.terminalId);
|
|
}
|
|
});
|
|
tabsContainer.appendChild(tab);
|
|
});
|
|
}
|
|
|
|
function renderTerminal() {
|
|
if (!termContainer) return;
|
|
termContainer.innerHTML = '';
|
|
|
|
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
|
if (!session) return;
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'terminal-container';
|
|
termContainer.appendChild(wrap);
|
|
session.term.open(wrap);
|
|
|
|
requestAnimationFrame(() => {
|
|
session.fitAddon.fit();
|
|
});
|
|
|
|
// Update keyboard terminal ref
|
|
if (Atlus.keyboard) {
|
|
Atlus.keyboard.setTerminal({
|
|
input: (data) => {
|
|
if (session.ws.readyState === WebSocket.OPEN) {
|
|
session.ws.send(JSON.stringify({ type: 'input', data }));
|
|
}
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---- Session restore logic ----
|
|
|
|
async function initTerminals() {
|
|
const restored = Atlus._restoredTerminals || [];
|
|
const tabInfo = Atlus._restoredTerminalTabs || [];
|
|
|
|
if (restored.length > 0) {
|
|
// Re-attach to existing server-side terminals
|
|
for (const t of restored) {
|
|
// Find title from tab info if available
|
|
const info = tabInfo.find(ti => ti.terminal_id === t.terminal_id);
|
|
const title = (info && info.title) || t.title || 'Shell';
|
|
attachToTerminal(t.terminal_id, title);
|
|
}
|
|
if (sessions.length > 0) {
|
|
activeTerminalId = sessions[0].terminalId;
|
|
}
|
|
}
|
|
|
|
// If no terminals were restored, create a fresh one
|
|
if (sessions.length === 0) {
|
|
const s = await createNewTerminal();
|
|
if (s) activeTerminalId = s.terminalId;
|
|
}
|
|
|
|
// Clear restored data so re-opening terminal app doesn't double-attach
|
|
delete Atlus._restoredTerminals;
|
|
delete Atlus._restoredTerminalTabs;
|
|
|
|
renderTabs();
|
|
renderTerminal();
|
|
}
|
|
|
|
// ---- App registration ----
|
|
|
|
Atlus.registerApp('terminal', {
|
|
title: 'Terminal',
|
|
|
|
init(el) {
|
|
container = el;
|
|
container.classList.add('app-terminal');
|
|
|
|
// Toolbar
|
|
const toolbar = document.createElement('div');
|
|
toolbar.className = 'terminal-toolbar';
|
|
|
|
tabsContainer = document.createElement('div');
|
|
tabsContainer.className = 'terminal-tabs';
|
|
toolbar.appendChild(tabsContainer);
|
|
|
|
const newTabBtn = document.createElement('button');
|
|
newTabBtn.className = 'terminal-new-tab';
|
|
newTabBtn.textContent = '+';
|
|
newTabBtn.title = 'New tab';
|
|
newTabBtn.addEventListener('click', async () => {
|
|
const s = await createNewTerminal();
|
|
if (s) switchSession(s.terminalId);
|
|
});
|
|
toolbar.appendChild(newTabBtn);
|
|
|
|
const kbdBtn = document.createElement('button');
|
|
kbdBtn.className = 'terminal-kbd-toggle' + (kbdVisible ? ' active' : '');
|
|
kbdBtn.textContent = '⌨';
|
|
kbdBtn.title = 'Toggle keyboard';
|
|
kbdBtn.addEventListener('click', () => {
|
|
kbdVisible = !kbdVisible;
|
|
kbdBtn.classList.toggle('active', kbdVisible);
|
|
if (Atlus.keyboard) Atlus.keyboard.toggle();
|
|
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
|
if (session) {
|
|
requestAnimationFrame(() => session.fitAddon.fit());
|
|
}
|
|
});
|
|
toolbar.appendChild(kbdBtn);
|
|
|
|
container.appendChild(toolbar);
|
|
|
|
// Terminal area
|
|
termContainer = document.createElement('div');
|
|
termContainer.style.flex = '1';
|
|
termContainer.style.overflow = 'hidden';
|
|
termContainer.style.display = 'flex';
|
|
termContainer.style.flexDirection = 'column';
|
|
container.appendChild(termContainer);
|
|
|
|
// On-screen keyboard
|
|
if (Atlus.keyboard) {
|
|
const kbdEl = Atlus.keyboard.create();
|
|
if (!kbdVisible) kbdEl.classList.add('hidden');
|
|
container.appendChild(kbdEl);
|
|
}
|
|
|
|
// Initialize — restore or create terminals
|
|
initTerminals();
|
|
|
|
// Resize observer
|
|
resizeObserver = new ResizeObserver(() => {
|
|
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
|
if (session) {
|
|
try { session.fitAddon.fit(); } catch (e) {}
|
|
}
|
|
});
|
|
resizeObserver.observe(termContainer);
|
|
},
|
|
|
|
destroy() {
|
|
if (resizeObserver) {
|
|
resizeObserver.disconnect();
|
|
resizeObserver = null;
|
|
}
|
|
// Close WebSockets but do NOT kill PTYs — they persist on server
|
|
sessions.forEach(s => {
|
|
if (s.ws.readyState === WebSocket.OPEN) s.ws.close();
|
|
s.term.dispose();
|
|
});
|
|
sessions = [];
|
|
activeTerminalId = null;
|
|
container = null;
|
|
termContainer = null;
|
|
tabsContainer = null;
|
|
},
|
|
|
|
onFocus() {
|
|
const session = sessions.find(s => s.terminalId === activeTerminalId);
|
|
if (session) {
|
|
requestAnimationFrame(() => {
|
|
session.fitAddon.fit();
|
|
session.term.focus();
|
|
});
|
|
}
|
|
},
|
|
|
|
/** Public API: get terminal IDs for session state saving */
|
|
getTerminalIds() {
|
|
return sessions.map(s => ({
|
|
terminal_id: s.terminalId,
|
|
title: s.title,
|
|
}));
|
|
},
|
|
});
|
|
})();
|