diff --git a/src/DodoSSH.Client.Shell/WebAssets/terminal.js b/src/DodoSSH.Client.Shell/WebAssets/terminal.js index 68298ee..b3779f9 100644 --- a/src/DodoSSH.Client.Shell/WebAssets/terminal.js +++ b/src/DodoSSH.Client.Shell/WebAssets/terminal.js @@ -84,14 +84,57 @@ const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus'; const root = document.getElementById('root'); const statusBanner = document.getElementById('status'); -/** @type {Map} */ +/** @type {Map} */ const sessions = new Map(); /** @type {WebSocket | null} */ let socket = null; -function setStatus(text) { - statusBanner.textContent = text ?? ''; +/** Whose pane is showing, or null before there is one — see activate(). */ +let activeSessionId = null; + +/* + ── THE BANNER BELONGS TO ONE PANE AT A TIME ───────────────────────────────────────────────────────── + There is one #status element for the whole page, because there is one page for every terminal: the + panes are stacked in the same box and all but the active one are hidden. What goes in it comes from + two sources that are not the same size, and the difference is the whole of this. + + The socket's troubles are the page's. There is a single socket behind every pane, so "the view is + reconnecting" is true of whatever is on screen and true of the panes behind it. + + A session's last words are not. "The remote closed the session." is a fact about one terminal and says + nothing whatever about the others — so it is held on the session and drawn only while that session's + pane is the one showing. Written straight into the shared element, which is what this used to do, it + outlived the tab it described: switching to a live terminal left the dead one's epitaph sitting under + it, and opening or closing any other tab wiped the message whether or not it belonged to that tab. + + The socket's half wins when both have something to say: a page whose socket is down is not showing + live output on any pane, which makes what became of one session the less urgent of the two. +*/ +let transportStatus = statusBanner.textContent ?? ''; + +function renderStatus() { + const notice = activeSessionId === null ? '' : sessions.get(activeSessionId)?.notice ?? ''; + + statusBanner.textContent = transportStatus || notice; +} + +/** Says something about the socket, which every pane shares. */ +function setTransportStatus(text) { + transportStatus = text ?? ''; + renderStatus(); +} + +/** Records what became of one session, to be drawn only while that session's pane is showing. */ +function setSessionNotice(sessionId, text) { + const session = sessions.get(sessionId); + + if (!session) { + return; + } + + session.notice = text ?? ''; + renderStatus(); } /** Builds a frame: opcode, big-endian session id, then payload. */ @@ -292,7 +335,7 @@ function createSession(sessionId) { term.onResize(() => sendResize(sessionId, term, pane)); - const session = { term, fit, pane }; + const session = { term, fit, pane, notice: '' }; sessions.set(sessionId, session); activate(sessionId); @@ -306,6 +349,11 @@ function activate(sessionId) { session.pane.dataset.active = String(id === sessionId); } + // The banner follows the pane. Whatever this session has to say for itself replaces whatever the + // session that was showing had to say for its own, which is the point of holding it per session. + activeSessionId = sessionId; + renderStatus(); + const active = sessions.get(sessionId); if (active) { active.term.focus(); @@ -373,7 +421,10 @@ function handleFrame(buffer) { session.term.write(REPLAY_BANNER); } - setStatus(''); + // This session's own line, and only this one's: a session that is open has nothing to say about + // how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens + // before any frame can arrive. + setSessionNotice(sessionId, ''); break; } @@ -426,7 +477,14 @@ function handleFrame(buffer) { session.pane.remove(); sessions.delete(sessionId); - setStatus(''); + // The notice went with the session record it was held on, but the page can still be pointing at + // the pane that is now gone. Cleared rather than left dangling, so the banner stops describing a + // closed tab while the host decides which pane to show next. + if (activeSessionId === sessionId) { + activeSessionId = null; + } + + renderStatus(); break; } @@ -503,14 +561,19 @@ function handleFrame(buffer) { const session = sessions.get(sessionId); const reason = new TextDecoder().decode(payload); - if (session) { - // The pane and its scrollback stay. The user was probably reading the last thing the - // remote said, and that is usually why the session ended. - session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`); - session.term.options.cursorBlink = false; + if (!session) { + // No pane, so there is nothing this page can honestly hang the reason on. It used to go into + // the banner anyway, which printed one session's ending underneath whichever pane happened to + // be showing at the time. + break; } - setStatus(reason); + // The pane and its scrollback stay. The user was probably reading the last thing the + // remote said, and that is usually why the session ended. + session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`); + session.term.options.cursorBlink = false; + + setSessionNotice(sessionId, reason); break; } @@ -535,7 +598,7 @@ function scheduleReconnect() { return; } - setStatus('Reconnecting the terminal view…'); + setTransportStatus('Reconnecting the terminal view…'); reconnectTimer = setTimeout(() => { reconnectTimer = null; @@ -555,7 +618,7 @@ function connect() { socket.binaryType = 'arraybuffer'; socket.addEventListener('open', () => { - setStatus(''); + setTransportStatus(''); // Back to the quick attempt for whatever the next failure turns out to be. Kept slow between // attempts within one outage, reset once the outage is actually over.