experimental: pop-out container terminals into dedicated windows
Release rolling / release (push) Successful in 7m56s

Pin PTY sessions to the peer they were opened on so shells survive
active-node switches until the window is closed. Electron host owns
the connection; pop-out windows relay I/O via IPC (BroadcastChannel
fallback). Explicit terminal sessionIds no longer wipe sibling PTYs
on the same container.
This commit is contained in:
Raven Scott
2026-07-29 06:34:13 -04:00
parent dcb5b3db4f
commit 26529e92c6
14 changed files with 1710 additions and 4 deletions
+163
View File
@@ -14,6 +14,13 @@ import {
} from './client/peerActive.js';
import { startTerminal, appendTerminalOutput } from './libs/terminal.js';
import { startDockerTerminal, cleanUpDockerTerminal } from './libs/dockerTerminal.js';
import {
openPopoutTerminal,
handlePopoutTerminalOutput,
findPopoutForContainer,
focusPopoutTerminal,
closeAllPopoutTerminals,
} from './libs/popoutTerminals.js';
import {
fetchTemplates,
displayTemplateList,
@@ -7572,6 +7579,9 @@ function cleanupDetailsTerminal() {
detailsTerminalPendingKill = target;
void killDetailsTerminalRemote(target);
}
// Banner is only meaningful while the Terminal tab is active; drop it on leave
// so a later open starts clean (pop-out itself is unaffected).
hideDetailsTerminalPopoutBanner();
return Promise.resolve();
}
@@ -7624,6 +7634,16 @@ async function initDetailsTerminalNow(containerId, desireGen) {
return;
}
// If this container already has a pop-out on the active peer, keep the PTY
// in the external window instead of starting a second session.
const activePeerId = manager.active?.id || null;
const existingPopout = findPopoutForContainer(containerId, activePeerId);
if (existingPopout) {
showDetailsTerminalPopoutBanner(existingPopout);
return;
}
hideDetailsTerminalPopoutBanner();
// Replace any leftover local session without invalidating this desire token
const prev = takeDetailsTerminalSessionForReplace();
if (prev) void killDetailsTerminalRemote(prev);
@@ -7909,8 +7929,108 @@ function applyDetailsTerminalFont(size) {
updateDetailsTerminalFontSizeDisplay();
}
/**
* Banner when the shell for this container lives in a pop-out window.
* @param {{ windowId: string, peerLabel?: string, title?: string }|null} popout
*/
function showDetailsTerminalPopoutBanner(popout) {
const terminalContainer = document.getElementById('container-terminal-xterm');
if (!terminalContainer) return;
// Tear down any local xterm so we do not fight the pop-out session
const prev = takeDetailsTerminalSessionForReplace();
if (prev) void killDetailsTerminalRemote(prev);
terminalContainer.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'terminal-popout-banner';
wrap.id = 'details-terminal-popout-banner';
wrap.innerHTML = `
<div class="terminal-popout-banner-inner">
<i class="fas fa-external-link-alt" aria-hidden="true"></i>
<div>
<div class="terminal-popout-banner-title">Terminal is open in a separate window</div>
<div class="terminal-popout-banner-sub">Session stays alive when you switch servers until you close the window.</div>
</div>
<div class="terminal-popout-banner-actions">
<button type="button" class="btn btn-sm btn-primary" id="details-terminal-focus-popout">
Focus window
</button>
</div>
</div>`;
terminalContainer.appendChild(wrap);
document.getElementById('details-terminal-focus-popout')?.addEventListener('click', () => {
if (popout?.windowId) focusPopoutTerminal(popout.windowId);
});
}
function hideDetailsTerminalPopoutBanner() {
document.getElementById('details-terminal-popout-banner')?.remove();
}
/**
* Pop the details-tab (or current container) shell into a dedicated window.
* Pinned to the peer that is active when opened; survives active-node switches.
* @returns {Promise<void>}
*/
async function popOutDetailsTerminal() {
const containerId =
detailsTerminalSession?.containerId ||
currentContainerDetails?.Id ||
detailsTerminalDesiredId;
if (!containerId) {
showAlert('warning', 'No container selected for terminal');
return;
}
if (!manager.active?.connected) {
showAlert('error', 'Not connected');
return;
}
const peerId = manager.active.id;
const existing = findPopoutForContainer(containerId, peerId);
if (existing) {
focusPopoutTerminal(existing.windowId);
showDetailsTerminalPopoutBanner(existing);
return;
}
const name =
currentContainerDetails?.Names?.[0] ||
currentContainerDetails?.Name ||
containerId.slice(0, 12);
const theme = detailsTerminalTheme || 'dark';
const fontSize = detailsTerminalFontSize || 14;
// Release the in-pane PTY first so the pop-out owns the shell cleanly
if (typeof cleanupDetailsTerminal === 'function') {
cleanupDetailsTerminal();
}
try {
const opened = await openPopoutTerminal({
containerId,
containerName: String(name).replace(/^\//, ''),
peerId,
theme,
fontSize,
});
showDetailsTerminalPopoutBanner(opened);
showAlert('success', 'Terminal opened in a new window');
} catch (err) {
showAlert('error', err?.message || 'Failed to pop out terminal');
// Restore in-pane terminal if still on the tab
if (
isContainerDetailsTabActive('terminal-tab', 'terminal-pane') &&
currentContainerDetails?.Id &&
containerIdsMatch(currentContainerDetails.Id, containerId)
) {
void initDetailsTerminal(containerId);
}
}
}
window.initDetailsTerminal = initDetailsTerminal;
window.cleanupDetailsTerminal = cleanupDetailsTerminal;
window.popOutDetailsTerminal = popOutDetailsTerminal;
window.handleDetailsTerminalOutput = (data, containerId, encoding, sessionId) => {
if (
!detailsTerminalSession ||
@@ -8309,6 +8429,36 @@ document.addEventListener('DOMContentLoaded', () => {
const terminalCopyBtn = document.getElementById('terminal-copy-btn');
const terminalClearBtn = document.getElementById('terminal-clear-btn');
const terminalThemeSelect = document.getElementById('terminal-theme-select');
const terminalPopoutBtn = document.getElementById('terminal-popout-btn');
if (terminalPopoutBtn) {
terminalPopoutBtn.addEventListener('click', () => {
void popOutDetailsTerminal();
});
}
// Refresh details banner when pop-outs open/close
window.addEventListener('peardock-popout-terminal', (ev) => {
const detail = ev?.detail;
const containerId = currentContainerDetails?.Id;
if (!containerId || !isContainerDetailsTabActive('terminal-tab', 'terminal-pane')) return;
if (detail?.reason === 'closed') {
hideDetailsTerminalPopoutBanner();
// Restore in-pane terminal if still viewing this container
if (
detail?.session?.containerId &&
containerIdsMatch(detail.session.containerId, containerId)
) {
void initDetailsTerminal(containerId);
}
return;
}
if (detail?.reason === 'opened' && detail?.session) {
if (containerIdsMatch(detail.session.containerId, containerId)) {
showDetailsTerminalPopoutBanner(detail.session);
}
}
});
if (terminalFontDecreaseBtn) {
terminalFontDecreaseBtn.addEventListener('click', () => {
@@ -9632,6 +9782,17 @@ function handleRpcMessage(response, conn) {
case 'terminalOutput':
case 'terminalErrorOutput':
// Pop-out windows are pinned to a peer and must receive I/O even when
// that peer is not the UI-active node.
handlePopoutTerminalOutput(
{
data: response.data,
containerId: response.containerId,
sessionId: response.sessionId,
encoding: response.encoding,
},
peer
);
appendTerminalOutput(response.data, response.containerId, response.encoding);
if (window.handleDetailsTerminalOutput) {
window.handleDetailsTerminalOutput(
@@ -14346,10 +14507,12 @@ window.startTerminal = startTerminal;
// Close live sockets on quit — do NOT forget peers (that was wiping peers.json)
window.addEventListener('beforeunload', () => {
void closeAllPopoutTerminals().catch(() => {});
manager.disconnectAll({ forget: false }).catch(() => {});
});
// Pear / Electron may fire pagehide when the window is destroyed
window.addEventListener('pagehide', () => {
void closeAllPopoutTerminals().catch(() => {});
manager.disconnectAll({ forget: false }).catch(() => {});
});
+53
View File
@@ -556,6 +556,59 @@ export class ConnectionManager extends EventEmitter {
}
}
/**
* Live connection for a peer id (12-char prefix or longer hex prefix match).
* @param {string|null|undefined} id
* @returns {PearDockConnection|null}
*/
getConnection(id) {
if (!id) return null
const direct = this.connections.get(id)
if (direct) return direct
const needle = String(id)
for (const [cid, conn] of this.connections) {
if (
cid === needle ||
cid.startsWith(needle) ||
needle.startsWith(cid) ||
conn.publicKeyHex === needle ||
(conn.publicKeyHex && needle.startsWith(conn.publicKeyHex.slice(0, 12)))
) {
return conn
}
}
return null
}
/**
* RPC on a specific peer (not necessarily the UI-active connection).
* Used by pop-out terminals that must outlive active-node switches.
* @param {string} peerId
* @param {string} method
* @param {object} [args]
* @param {{ timeout?: number }} [opts]
*/
async requestOn(peerId, method, args = {}, opts = {}) {
const conn = this.getConnection(peerId)
if (!conn?.connected) {
throw new Error(peerId ? `Peer ${peerId} not connected` : 'No connection')
}
const timeout = resolveRequestTimeout(method, opts)
return conn.request(method, args, { ...opts, timeout })
}
/**
* Fire-and-forget on a specific peer connection.
* @param {string} peerId
* @param {string} method
* @param {object} [args]
*/
eventOn(peerId, method, args = {}) {
const conn = this.getConnection(peerId)
if (!conn?.connected) return
conn.event(method, args)
}
/**
* RPC on the active connection.
* @param {string} method
+1
View File
@@ -129,6 +129,7 @@ Sidebar `data-view="…"` sections, including:
| `libs/addContainer.js` | Add container blank create form |
| `libs/registryManager.js` | Registry view: vault, browser, pull/push helpers |
| `libs/terminal.js` etc. | xterm integration |
| `libs/popoutTerminals.js` | Pop-out shell windows (Electron); PTY pinned to a peer, survives active-node switch until closed |
| `app.js` | Large shell glue: tables, container details, image-update column, list filtering |
### Settings: hide containers by label
+1
View File
@@ -17,6 +17,7 @@ What PearDock can do today, mapped to code and protocol surfaces.
| Image update indicators | shipped | on | `services/image-updates.js`, containers UI |
| Logs follow | shipped | on | `handlers/logs.js` |
| Interactive terminals | shipped | on | `handlers/terminal.js` |
| Pop-out terminal windows | shipped | on | `libs/popoutTerminals.js`, Electron `terminal-window.html` (pinned peer; lives past active-node switch) |
| Multi-shell probe | shipped | on | terminal handler + tests |
| Stats + history | shipped | on | `services/stats*.js` |
| Docker events push | shipped | on | `services/events.js` |
+220
View File
@@ -254,6 +254,9 @@ function defaultWebPreferences() {
/** @type {Map<string, import('electron').BrowserWindow>} */
const tunnelWindows = new Map()
/** @type {Map<string, import('electron').BrowserWindow>} */
const terminalWindows = new Map()
function isLocalTunnelUrl(href) {
try {
const u = new URL(String(href || ''))
@@ -590,6 +593,223 @@ ipcMain.handle('peardock:tunnel-nav', (evt, action) => {
}
})
// ---------------------------------------------------------------------------
// Pop-out container terminals
// Host (main UI renderer) owns the peer PTY; this process relays I/O + chrome.
// ---------------------------------------------------------------------------
/**
* @param {{
* windowId: string,
* title?: string,
* peerLabel?: string,
* containerName?: string,
* containerId?: string,
* theme?: string,
* fontSize?: number,
* }} opts
* @param {import('electron').WebContents} hostWc
*/
function createTerminalWindow(opts, hostWc) {
const windowId = String(opts?.windowId || '').trim()
if (!windowId) {
console.warn('[peardock] open-terminal-window: missing windowId')
return null
}
if (!staticPort) {
console.warn('[peardock] static server not ready; cannot open terminal window')
return null
}
const existing = terminalWindows.get(windowId)
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore()
existing.focus()
return existing
}
const icon = resolveAppIcon()
const titleHint = String(opts.title || opts.containerName || 'Terminal').trim()
const win = new BrowserWindow({
width: 960,
height: 640,
minWidth: 480,
minHeight: 280,
backgroundColor: pkg.pear?.gui?.backgroundColor || '#0f1117',
title: titleHint ? `${titleHint} — peardock` : 'Terminal — peardock',
...(icon ? { icon } : {}),
autoHideMenuBar: process.platform !== 'darwin',
...windowChromeOpts(),
webPreferences: defaultWebPreferences(),
show: false,
})
win.__pdTerminal = {
windowId,
hostWc,
title: titleHint,
}
terminalWindows.set(windowId, win)
// If the host UI is destroyed (main window closed/reloaded), drop pop-outs
// so we do not leave orphan xterm windows with a dead PTY relay.
const onHostDestroyed = () => {
if (!win.isDestroyed()) {
try {
win.close()
} catch {
// ignore
}
}
}
try {
hostWc?.once?.('destroyed', onHostDestroyed)
} catch {
// ignore
}
win.on('closed', () => {
if (terminalWindows.get(windowId) === win) terminalWindows.delete(windowId)
try {
hostWc?.removeListener?.('destroyed', onHostDestroyed)
} catch {
// ignore
}
// Notify host so it can kill the remote PTY
try {
if (hostWc && !hostWc.isDestroyed()) {
hostWc.send('peardock:terminal-window-closed', { windowId })
}
} catch {
// host already gone
}
})
win.once('ready-to-show', () => win.show())
const shell = new URL(`http://127.0.0.1:${staticPort}/terminal-window.html`)
shell.searchParams.set('windowId', windowId)
shell.searchParams.set('mode', 'electron')
if (titleHint) shell.searchParams.set('title', titleHint)
if (opts.peerLabel) shell.searchParams.set('peerLabel', String(opts.peerLabel))
if (opts.containerName) shell.searchParams.set('containerName', String(opts.containerName))
if (opts.containerId) shell.searchParams.set('containerId', String(opts.containerId))
if (opts.theme) shell.searchParams.set('theme', String(opts.theme))
if (opts.fontSize) shell.searchParams.set('fontSize', String(opts.fontSize))
win.loadURL(shell.href).catch((err) => {
console.error('[peardock] failed to load terminal window:', err)
})
return win
}
function terminalHostFromWindow(win) {
return win?.__pdTerminal?.hostWc || null
}
function terminalWindowFromEvent(evt) {
try {
return BrowserWindow.fromWebContents(evt.sender)
} catch {
return null
}
}
ipcMain.handle('peardock:open-terminal-window', (evt, payload) => {
try {
const win = createTerminalWindow(payload || {}, evt.sender)
return Boolean(win)
} catch (err) {
console.error('[peardock] open-terminal-window failed:', err)
return false
}
})
ipcMain.handle('peardock:focus-terminal-window', (_evt, payload) => {
const windowId = payload?.windowId
const win = windowId ? terminalWindows.get(windowId) : null
if (!win || win.isDestroyed()) return false
if (win.isMinimized()) win.restore()
win.focus()
return true
})
ipcMain.handle('peardock:close-terminal-window', (_evt, payload) => {
const windowId = payload?.windowId
const win = windowId ? terminalWindows.get(windowId) : null
if (!win || win.isDestroyed()) return false
win.close()
return true
})
/** Terminal window → host */
ipcMain.on('peardock:terminal-window-ready', (evt, payload) => {
const win = terminalWindowFromEvent(evt)
const host = terminalHostFromWindow(win)
if (!host || host.isDestroyed()) return
try {
host.send('peardock:terminal-window-ready', payload)
} catch {
// ignore
}
})
ipcMain.on('peardock:terminal-input', (evt, payload) => {
const win = terminalWindowFromEvent(evt)
const host = terminalHostFromWindow(win)
if (!host || host.isDestroyed()) return
try {
host.send('peardock:terminal-input', payload)
} catch {
// ignore
}
})
ipcMain.on('peardock:terminal-resize', (evt, payload) => {
const win = terminalWindowFromEvent(evt)
const host = terminalHostFromWindow(win)
if (!host || host.isDestroyed()) return
try {
host.send('peardock:terminal-resize', payload)
} catch {
// ignore
}
})
ipcMain.on('peardock:terminal-window-action', (evt, payload) => {
const win = terminalWindowFromEvent(evt)
const host = terminalHostFromWindow(win)
if (!host || host.isDestroyed()) return
try {
host.send('peardock:terminal-window-action', payload)
} catch {
// ignore
}
})
/** Host → terminal window */
function relayHostToTerminal(channel) {
ipcMain.on(channel, (evt, payload) => {
const windowId = payload?.windowId
const win = windowId ? terminalWindows.get(windowId) : null
if (!win || win.isDestroyed()) return
// Only the host that opened the window may relay
const host = terminalHostFromWindow(win)
if (host && evt.sender.id !== host.id) return
try {
win.webContents.send(channel, payload)
} catch {
// ignore
}
})
}
relayHostToTerminal('peardock:terminal-output')
relayHostToTerminal('peardock:terminal-status')
relayHostToTerminal('peardock:terminal-control')
const teardownFns = []
ipcMain.on('peardock:teardown-register', () => {
+297
View File
@@ -0,0 +1,297 @@
/**
* Pop-out terminal window client.
* Host (main peardock UI) owns the peer PTY; this page only renders xterm + relays I/O.
*
* Modes:
* - electron: ipcRenderer via nodeIntegration
* - broadcast: BroadcastChannel (window.open fallback)
*/
;(function () {
'use strict'
const params = new URLSearchParams(window.location.search || '')
const windowId = params.get('windowId') || ''
const mode = params.get('mode') || 'electron'
const channelName = params.get('channel') || ''
const title = params.get('title') || 'Terminal'
const peerLabel = params.get('peerLabel') || ''
const containerName = params.get('containerName') || ''
const themeKey = params.get('theme') || 'dark'
const fontSize = Math.max(8, Math.min(28, Number(params.get('fontSize')) || 14))
const elTitle = document.getElementById('term-title')
const elSubtitle = document.getElementById('term-subtitle')
const elStatus = document.getElementById('term-status')
const elStatusText = document.getElementById('term-status-text')
const elTitlebar = document.getElementById('titlebar-label')
const host = document.getElementById('term-host')
if (elTitle) elTitle.textContent = title
if (elTitlebar) elTitlebar.textContent = title
if (elSubtitle) {
const parts = []
if (containerName) parts.push(containerName)
if (peerLabel) parts.push(peerLabel)
elSubtitle.textContent = parts.length ? parts.join(' · ') : 'Pop-out terminal'
}
document.title = `${title} — peardock`
const THEMES = {
dark: {
background: '#0b0f14',
foreground: '#e6edf3',
cursor: '#34d399',
cursorAccent: '#0b0f14',
selectionBackground: 'rgba(52, 211, 153, 0.35)',
selectionForeground: '#f4f7fb',
},
light: {
background: '#ffffff',
foreground: '#0f172a',
cursor: '#0f766e',
cursorAccent: '#ffffff',
selectionBackground: 'rgba(15, 118, 110, 0.22)',
selectionForeground: '#0f172a',
},
'solarized-dark': {
background: '#002b36',
foreground: '#839496',
cursor: '#93a1a1',
selectionBackground: '#073642',
},
'solarized-light': {
background: '#fdf6e3',
foreground: '#657b83',
cursor: '#586e75',
selectionBackground: '#eee8d5',
},
monokai: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f0',
selectionBackground: '#49483e',
},
}
const palette = THEMES[themeKey] || THEMES.dark
if (host) host.style.background = palette.background || '#0b0f14'
if (themeKey === 'light' || themeKey === 'solarized-light') {
try {
document.documentElement.dataset.theme = 'light'
} catch {
// ignore
}
}
function setStatus(connected, message) {
if (elStatus) {
elStatus.classList.toggle('is-live', Boolean(connected))
elStatus.classList.toggle('is-dead', connected === false)
}
if (elStatusText) elStatusText.textContent = message || (connected ? 'Live' : 'Disconnected')
}
function decodePayload(data, encoding) {
if (data == null) return ''
if (encoding === 'utf8' || encoding === 'text' || !encoding) {
return typeof data === 'string' ? data : String(data)
}
if (encoding === 'base64') {
try {
const bin = atob(String(data))
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return new TextDecoder('utf-8', { fatal: false }).decode(bytes)
} catch {
try {
return atob(String(data))
} catch {
return ''
}
}
}
return String(data)
}
/** @type {any} */
let ipc = null
/** @type {BroadcastChannel|null} */
let channel = null
if (mode === 'electron') {
try {
// eslint-disable-next-line global-require
ipc = require('electron').ipcRenderer
} catch {
ipc = null
}
} else if (mode === 'broadcast' && channelName && typeof BroadcastChannel !== 'undefined') {
channel = new BroadcastChannel(channelName)
}
function send(type, payload) {
const body = { windowId, ...(payload || {}) }
if (ipc) {
try {
ipc.send(type, body)
} catch {
// ignore
}
return
}
if (channel) {
try {
channel.postMessage({ type, ...body })
} catch {
// ignore
}
}
}
function on(type, handler) {
if (ipc) {
ipc.on(type, (_evt, payload) => handler(payload || {}))
return
}
if (channel) {
channel.addEventListener('message', (ev) => {
const msg = ev?.data
if (!msg || msg.type !== type) return
if (msg.windowId && msg.windowId !== windowId) return
handler(msg)
})
}
}
if (typeof window.Terminal !== 'function') {
setStatus(false, 'xterm failed to load')
return
}
const FitAddonCtor =
(window.FitAddon && window.FitAddon.FitAddon) ||
(typeof window.FitAddon === 'function' ? window.FitAddon : null)
if (!FitAddonCtor) {
setStatus(false, 'FitAddon failed to load')
return
}
const term = new window.Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontSize,
fontFamily:
'"JetBrains Mono", Menlo, Monaco, "Cascadia Code", "Courier New", monospace',
lineHeight: 1.2,
scrollback: 10000,
allowProposedApi: true,
convertEol: false,
macOptionIsMeta: true,
theme: palette,
})
const fitAddon = new FitAddonCtor()
term.loadAddon(fitAddon)
term.open(host)
function fitAndResize() {
try {
fitAddon.fit()
} catch {
// ignore
}
const cols = term.cols || 80
const rows = term.rows || 24
send('peardock:terminal-resize', { cols, rows })
return { cols, rows }
}
// Coalesce input (mirrors main app termInput coalescer, simplified)
let inputBuf = ''
let inputTimer = null
function flushInput() {
if (inputTimer) {
clearTimeout(inputTimer)
inputTimer = null
}
if (!inputBuf) return
const data = inputBuf
inputBuf = ''
send('peardock:terminal-input', { data, encoding: 'utf8' })
}
term.onData((data) => {
inputBuf += data
if (data.includes('\r') || data.includes('\n') || inputBuf.length >= 4096) {
flushInput()
return
}
if (!inputTimer) {
inputTimer = setTimeout(flushInput, 8)
}
})
on('peardock:terminal-output', (payload) => {
const text = decodePayload(payload.data, payload.encoding || 'base64')
if (text) term.write(text)
})
on('peardock:terminal-status', (payload) => {
setStatus(payload.connected !== false, payload.message || '')
if (payload.connected === false && payload.message) {
term.writeln(`\r\n\x1b[31m[peardock] ${payload.message}\x1b[0m`)
}
})
on('peardock:terminal-control', (payload) => {
if (payload.action === 'clear') term.clear()
})
document.getElementById('btn-copy')?.addEventListener('click', () => {
const sel = term.getSelection?.()
if (sel) {
navigator.clipboard?.writeText(sel).catch(() => {})
}
})
document.getElementById('btn-clear')?.addEventListener('click', () => {
term.clear()
})
document.getElementById('btn-close')?.addEventListener('click', () => {
flushInput()
send('peardock:terminal-window-action', { action: 'kill' })
// Electron: host closes us; broadcast: close self after notify
setTimeout(() => {
try {
window.close()
} catch {
// ignore
}
}, 50)
})
window.addEventListener('resize', () => {
fitAndResize()
})
window.addEventListener('beforeunload', () => {
flushInput()
send('peardock:terminal-window-closed', {})
try {
channel?.close?.()
} catch {
// ignore
}
})
// Layout settle then announce ready
requestAnimationFrame(() => {
const dims = fitAndResize()
setStatus(null, 'Connecting…')
send('peardock:terminal-window-ready', {
cols: dims.cols,
rows: dims.rows,
})
term.focus()
setTimeout(() => fitAndResize(), 80)
})
})()
+6
View File
@@ -1941,6 +1941,9 @@
<option value="solarized-light">Solarized Light</option>
<option value="monokai">Monokai</option>
</select>
<button class="terminal-control-btn" id="terminal-popout-btn" title="Pop out into a new window (stays alive when switching servers)">
<i class="fas fa-external-link-alt"></i> Pop out
</button>
<button class="terminal-control-btn" id="terminal-settings-btn" title="Terminal settings">
<i class="fas fa-cog"></i>
</button>
@@ -4175,6 +4178,9 @@ services:
<option value="solarized-light">Solarized Light</option>
<option value="monokai">Monokai</option>
</select>
<button class="terminal-control-btn" id="modal-terminal-popout-btn" title="Pop out into a new window (stays alive when switching servers)">
<i class="fas fa-external-link-alt"></i> Pop out
</button>
</div>
</div>
</div>
+550
View File
@@ -0,0 +1,550 @@
/**
* Pop-out container terminals (Electron windows, with window.open fallback).
*
* Sessions are owned by the main renderer and pinned to a specific peer
* connection (not manager.active). Switching the active fleet node does not
* kill them — only closing the window (or peer disconnect / explicit kill).
*/
import { manager, Methods } from '../client/manager.js'
import { decodePayload } from './xtermUtils.js'
/** @typedef {{
* windowId: string,
* peerId: string,
* containerId: string,
* sessionId: string,
* title: string,
* containerName?: string,
* peerLabel?: string,
* theme?: string,
* fontSize?: number,
* mode: 'electron' | 'broadcast',
* channel?: BroadcastChannel,
* child?: Window | null,
* cols: number,
* rows: number,
* ready: boolean,
* }} PopoutSession */
/** @type {Map<string, PopoutSession>} */
const sessions = new Map()
/** @type {((session: PopoutSession, reason: string) => void) | null} */
let onChangeHook = null
let ipcBound = false
let disconnectBound = false
let seq = 0
function nextWindowId() {
seq += 1
return `term-${Date.now().toString(36)}-${seq.toString(36)}`
}
function getIpc() {
try {
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
const { ipcRenderer } = require('electron')
return ipcRenderer || null
} catch {
return null
}
}
/**
* @param {(session: PopoutSession, reason: string) => void} [fn]
*/
export function setPopoutTerminalChangeHook(fn) {
onChangeHook = typeof fn === 'function' ? fn : null
}
function emitChange(session, reason) {
try {
onChangeHook?.(session, reason)
} catch {
// ignore
}
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('peardock-popout-terminal', {
detail: { reason, session: publicSession(session) },
})
)
}
} catch {
// ignore
}
}
/**
* @param {PopoutSession} s
*/
function publicSession(s) {
return {
windowId: s.windowId,
peerId: s.peerId,
containerId: s.containerId,
sessionId: s.sessionId,
title: s.title,
containerName: s.containerName,
peerLabel: s.peerLabel,
}
}
/**
* @returns {PopoutSession[]}
*/
export function listPopoutTerminals() {
return [...sessions.values()].map(publicSession)
}
/**
* @param {string} containerId
* @param {string} [peerId]
* @returns {PopoutSession|null}
*/
export function findPopoutForContainer(containerId, peerId) {
if (!containerId) return null
const needle = String(containerId)
for (const s of sessions.values()) {
if (peerId && s.peerId !== peerId && !idsMatch(s.peerId, peerId)) continue
if (idsMatch(s.containerId, needle)) return publicSession(s)
}
return null
}
/**
* @param {string} a
* @param {string} b
*/
function idsMatch(a, b) {
if (!a || !b) return false
const x = String(a)
const y = String(b)
if (x === y) return true
if (x.length >= 12 && y.length >= 12) {
return x.startsWith(y.slice(0, 12)) || y.startsWith(x.slice(0, 12))
}
return x.startsWith(y) || y.startsWith(x)
}
function ensureIpcBridge() {
if (ipcBound) return
const ipc = getIpc()
if (!ipc) return
ipcBound = true
ipc.on('peardock:terminal-window-ready', (_evt, payload) => {
const windowId = payload?.windowId
const s = sessions.get(windowId)
if (!s) return
s.ready = true
const cols = Number(payload?.cols) || s.cols || 80
const rows = Number(payload?.rows) || s.rows || 24
s.cols = cols
s.rows = rows
void ensureRemotePty(s, cols, rows)
})
ipc.on('peardock:terminal-input', (_evt, payload) => {
const s = sessions.get(payload?.windowId)
if (!s) return
const data = payload?.data
if (data == null || data === '') return
manager.eventOn(s.peerId, Methods.terminalInput, {
containerId: s.containerId,
sessionId: s.sessionId,
data,
encoding: payload?.encoding || 'utf8',
})
})
ipc.on('peardock:terminal-resize', (_evt, payload) => {
const s = sessions.get(payload?.windowId)
if (!s) return
const cols = Number(payload?.cols)
const rows = Number(payload?.rows)
if (!cols || !rows) return
s.cols = cols
s.rows = rows
manager.eventOn(s.peerId, Methods.terminalResize, {
containerId: s.containerId,
sessionId: s.sessionId,
cols,
rows,
})
})
ipc.on('peardock:terminal-window-closed', (_evt, payload) => {
const windowId = payload?.windowId
if (!windowId || !sessions.has(windowId)) return
void closePopoutTerminal(windowId, { remoteKill: true, skipWindowClose: true })
})
ipc.on('peardock:terminal-window-action', (_evt, payload) => {
const s = sessions.get(payload?.windowId)
if (!s) return
const action = String(payload?.action || '')
if (action === 'kill') {
void closePopoutTerminal(s.windowId, { remoteKill: true })
} else if (action === 'clear') {
sendToWindow(s, 'peardock:terminal-control', { action: 'clear' })
}
})
}
function ensureDisconnectHook() {
if (disconnectBound) return
disconnectBound = true
manager.on('disconnect', (conn) => {
if (!conn?.id) return
for (const s of [...sessions.values()]) {
if (!idsMatch(s.peerId, conn.id)) continue
sendToWindow(s, 'peardock:terminal-status', {
connected: false,
message: 'Peer disconnected — terminal session ended',
})
void closePopoutTerminal(s.windowId, { remoteKill: false, skipWindowClose: false })
}
})
}
/**
* @param {PopoutSession} s
* @param {string} channel
* @param {object} payload
*/
function sendToWindow(s, channel, payload) {
if (s.mode === 'electron') {
const ipc = getIpc()
if (!ipc) return
try {
ipc.send(channel, { windowId: s.windowId, ...payload })
} catch {
// ignore
}
return
}
if (s.mode === 'broadcast' && s.channel) {
try {
s.channel.postMessage({ type: channel, windowId: s.windowId, ...payload })
} catch {
// ignore
}
}
}
/**
* @param {PopoutSession} s
* @param {number} cols
* @param {number} rows
*/
async function ensureRemotePty(s, cols, rows) {
const conn = manager.getConnection(s.peerId)
if (!conn?.connected) {
sendToWindow(s, 'peardock:terminal-status', {
connected: false,
message: 'Peer not connected',
})
return
}
try {
await manager.requestOn(s.peerId, Methods.startTerminal, {
containerId: s.containerId,
sessionId: s.sessionId,
cols: cols || 80,
rows: rows || 24,
tty: true,
})
manager.eventOn(s.peerId, Methods.terminalResize, {
containerId: s.containerId,
sessionId: s.sessionId,
cols: cols || 80,
rows: rows || 24,
})
sendToWindow(s, 'peardock:terminal-status', {
connected: true,
message: 'Connected',
peerId: s.peerId,
peerLabel: s.peerLabel,
containerId: s.containerId,
sessionId: s.sessionId,
})
} catch (err) {
sendToWindow(s, 'peardock:terminal-status', {
connected: false,
message: err?.message || String(err),
})
}
}
/**
* Open a pop-out terminal for a container on a pinned peer.
* @param {{
* containerId: string,
* containerName?: string,
* peerId?: string,
* title?: string,
* theme?: string,
* fontSize?: number,
* }} opts
* @returns {Promise<{ windowId: string, sessionId: string, peerId: string }>}
*/
export async function openPopoutTerminal(opts = {}) {
const containerId = String(opts.containerId || '').trim()
if (!containerId) throw new Error('containerId required')
const peer =
manager.getConnection(opts.peerId) ||
manager.active ||
null
if (!peer?.connected) {
throw new Error('No connected peer for pop-out terminal')
}
const peerId = peer.id
const existing = findPopoutForContainer(containerId, peerId)
if (existing) {
focusPopoutTerminal(existing.windowId)
return {
windowId: existing.windowId,
sessionId: existing.sessionId,
peerId: existing.peerId,
}
}
ensureIpcBridge()
ensureDisconnectHook()
const windowId = nextWindowId()
const sessionId = `popout-${String(containerId).slice(0, 12)}-${windowId}`
const peerLabel =
opts.peerLabel ||
manager.resolvePeerLabel(peer) ||
`Peer ${String(peerId).slice(0, 6)}`
const containerName = opts.containerName || containerId.slice(0, 12)
const title =
opts.title ||
`${containerName} @ ${peerLabel}`
/** @type {PopoutSession} */
const session = {
windowId,
peerId,
containerId,
sessionId,
title,
containerName,
peerLabel,
theme: opts.theme || 'dark',
fontSize: opts.fontSize || 14,
mode: 'electron',
cols: 80,
rows: 24,
ready: false,
}
const ipc = getIpc()
if (ipc) {
const ok = await ipc.invoke('peardock:open-terminal-window', {
windowId,
title,
peerLabel,
containerName,
containerId,
theme: session.theme,
fontSize: session.fontSize,
})
if (!ok) {
throw new Error('Failed to open terminal window')
}
session.mode = 'electron'
} else {
// Fallback: BroadcastChannel + window.open (Pear / browser-like shells)
if (typeof BroadcastChannel === 'undefined' || typeof window === 'undefined') {
throw new Error('Pop-out terminals require Electron or BroadcastChannel support')
}
const channelName = `peardock-term-${windowId}`
const channel = new BroadcastChannel(channelName)
session.mode = 'broadcast'
session.channel = channel
channel.onmessage = (ev) => {
const msg = ev?.data
if (!msg || msg.windowId !== windowId) return
if (msg.type === 'peardock:terminal-window-ready') {
session.ready = true
session.cols = Number(msg.cols) || 80
session.rows = Number(msg.rows) || 24
void ensureRemotePty(session, session.cols, session.rows)
} else if (msg.type === 'peardock:terminal-input') {
manager.eventOn(session.peerId, Methods.terminalInput, {
containerId: session.containerId,
sessionId: session.sessionId,
data: msg.data,
encoding: msg.encoding || 'utf8',
})
} else if (msg.type === 'peardock:terminal-resize') {
const cols = Number(msg.cols)
const rows = Number(msg.rows)
if (!cols || !rows) return
session.cols = cols
session.rows = rows
manager.eventOn(session.peerId, Methods.terminalResize, {
containerId: session.containerId,
sessionId: session.sessionId,
cols,
rows,
})
} else if (msg.type === 'peardock:terminal-window-closed') {
void closePopoutTerminal(windowId, { remoteKill: true, skipWindowClose: true })
}
}
const url = new URL('terminal-window.html', window.location.href)
url.searchParams.set('windowId', windowId)
url.searchParams.set('channel', channelName)
url.searchParams.set('title', title)
url.searchParams.set('theme', session.theme)
url.searchParams.set('fontSize', String(session.fontSize))
url.searchParams.set('peerLabel', peerLabel)
url.searchParams.set('containerName', containerName)
url.searchParams.set('mode', 'broadcast')
const child = window.open(url.href, `peardock-term-${windowId}`, 'width=960,height=640')
session.child = child
if (!child) {
channel.close()
throw new Error('Pop-up blocked — allow pop-ups for pop-out terminals')
}
}
sessions.set(windowId, session)
emitChange(session, 'opened')
return { windowId, sessionId, peerId }
}
/**
* @param {string} windowId
*/
export function focusPopoutTerminal(windowId) {
const s = sessions.get(windowId)
if (!s) return false
if (s.mode === 'electron') {
const ipc = getIpc()
try {
ipc?.invoke?.('peardock:focus-terminal-window', { windowId })
return true
} catch {
return false
}
}
try {
s.child?.focus?.()
return true
} catch {
return false
}
}
/**
* Route a terminalOutput push to any matching pop-out session.
* @param {{ data: string, containerId?: string, sessionId?: string, encoding?: string }} msg
* @param {{ id?: string }|null} [conn]
* @returns {boolean} true if delivered to a pop-out
*/
export function handlePopoutTerminalOutput(msg, conn) {
if (!msg || sessions.size === 0) return false
let delivered = false
for (const s of sessions.values()) {
if (conn?.id && !idsMatch(s.peerId, conn.id)) continue
// Prefer exact sessionId match when present
if (msg.sessionId) {
if (msg.sessionId !== s.sessionId) continue
} else if (msg.containerId && !idsMatch(s.containerId, msg.containerId)) {
continue
}
sendToWindow(s, 'peardock:terminal-output', {
data: msg.data,
encoding: msg.encoding || 'base64',
})
delivered = true
}
return delivered
}
/**
* @param {string} windowId
* @param {{ remoteKill?: boolean, skipWindowClose?: boolean }} [opts]
*/
export async function closePopoutTerminal(windowId, opts = {}) {
const s = sessions.get(windowId)
if (!s) return
sessions.delete(windowId)
if (opts.remoteKill !== false) {
try {
if (manager.getConnection(s.peerId)?.connected) {
await manager.requestOn(
s.peerId,
Methods.killTerminal,
{ sessionId: s.sessionId, containerId: s.containerId },
{ timeout: 5000 }
)
}
} catch {
// already gone
}
}
if (!opts.skipWindowClose) {
if (s.mode === 'electron') {
try {
getIpc()?.invoke?.('peardock:close-terminal-window', { windowId })
} catch {
// ignore
}
} else {
try {
s.child?.close?.()
} catch {
// ignore
}
try {
s.channel?.close?.()
} catch {
// ignore
}
}
} else if (s.mode === 'broadcast') {
try {
s.channel?.close?.()
} catch {
// ignore
}
}
emitChange(s, 'closed')
}
/**
* Close every pop-out (app quit / disconnect-all).
*/
export async function closeAllPopoutTerminals() {
const ids = [...sessions.keys()]
await Promise.all(ids.map((id) => closePopoutTerminal(id, { remoteKill: true })))
}
/**
* Decode helper re-export for tests / callers.
*/
export { decodePayload }
if (typeof window !== 'undefined') {
window.__peardockPopoutTerminals = {
open: openPopoutTerminal,
close: closePopoutTerminal,
closeAll: closeAllPopoutTerminals,
list: listPopoutTerminals,
find: findPopoutForContainer,
focus: focusPopoutTerminal,
}
}
+52
View File
@@ -13,6 +13,11 @@ import {
applyXtermPalette,
} from './xtermUtils.js'
import { createInputCoalescer } from './termInput.js'
import {
openPopoutTerminal,
findPopoutForContainer,
focusPopoutTerminal,
} from './popoutTerminals.js'
const Terminal = getTerminalCtor()
const FitAddon = getFitAddonCtor()
@@ -349,6 +354,49 @@ function applyFontSizeAll(size) {
updateModalTerminalFontSizeDisplay()
}
/**
* Pop the active modal terminal into a dedicated window on the current peer.
* @returns {Promise<void>}
*/
async function popOutModalTerminal() {
const containerId = activeContainerId
if (!containerId) return
if (!manager.active?.connected) {
console.error('[ERROR] No active peer connection for pop-out.')
return
}
const peerId = manager.active.id
const existing = findPopoutForContainer(containerId, peerId)
if (existing) {
focusPopoutTerminal(existing.windowId)
return
}
const session = terminalSessions[containerId]
const name = session?.name || containerId.slice(0, 12)
// Close local modal session UI; remote kill then fresh PTY in pop-out
if (window.sendCommand) {
try {
window.sendCommand('killTerminal', { containerId })
} catch {
// ignore
}
}
cleanUpTerminal(containerId)
if (terminalModal) terminalModal.style.display = 'none'
activeContainerId = null
try {
await openPopoutTerminal({
containerId,
containerName: String(name).replace(/^\//, ''),
peerId,
theme: modalTerminalTheme,
fontSize: modalTerminalFontSize,
})
} catch (err) {
console.error('[ERROR] Pop-out terminal failed:', err?.message || err)
}
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('modal-terminal-font-decrease')?.addEventListener('click', () => {
if (modalTerminalFontSize > 8) applyFontSizeAll(modalTerminalFontSize - 1)
@@ -370,6 +418,9 @@ document.addEventListener('DOMContentLoaded', () => {
const session = terminalSessions[activeContainerId]
if (session?.xterm) session.xterm.clear()
})
document.getElementById('modal-terminal-popout-btn')?.addEventListener('click', () => {
void popOutModalTerminal()
})
const themeSelect = document.getElementById('modal-terminal-theme-select')
// Prefer resolved settings default (auto → light/dark from UI)
try {
@@ -425,4 +476,5 @@ export {
killActiveTerminal,
cleanUpTerminal,
cleanUpAllTerminals,
popOutModalTerminal,
}
+13 -4
View File
@@ -412,13 +412,22 @@ export function registerTerminalHandlers(session) {
const sessions = getSessions(session)
const sessionId = resolveSessionId(args)
// Always clear any prior PTY for this id/container before opening a new one.
// Prevents races where a late killTerminal and a re-entry startTerminal overlap,
// and avoids "stuck" sessions when the client re-enters the Terminal tab.
// Replace any prior PTY for this exact session id (re-entry / restart).
// Explicit unique sessionIds (details-*, popout-*) may coexist on the same
// container so pop-out windows stay alive while the in-app tab reopens.
if (sessions.has(sessionId)) {
endOne(sessions, sessionId)
}
cleanupTerminalsForContainer(session, containerId)
// Legacy clients key the session by containerId only — clear leftovers for
// that container so re-open is clean. Explicit multi-session ids skip this.
const explicitSession =
args.sessionId != null &&
String(args.sessionId) !== '' &&
String(args.sessionId) !== String(containerId) &&
String(args.sessionId) !== 'default'
if (!explicitSession) {
cleanupTerminalsForContainer(session, containerId)
}
const useTty = args.tty !== false
const shellCandidates = buildShellCandidates(args)
+213
View File
@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Terminal — peardock</title>
<meta name="theme-color" content="#0a0c10">
<link rel="icon" href="assets/favicons/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicons/favicon-32x32.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/css/xterm.css">
<link rel="stylesheet" href="ui/modern.css">
<link rel="stylesheet" href="ui/theme-light.css">
<style>
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: var(--bg-primary, #0a0c10);
color: var(--text-primary, #f4f7fb);
font-family: var(--font-sans, 'Inter', system-ui, sans-serif);
-webkit-font-smoothing: antialiased;
}
body.pd-term-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.pd-term-main {
flex: 1 1 auto;
display: flex;
flex-direction: column;
margin-top: var(--titlebar-h, 42px);
height: calc(100vh - var(--titlebar-h, 42px));
min-height: 0;
}
.pd-term-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
padding: 6px 12px;
background: var(--bg-secondary, #0f131a);
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
-webkit-app-region: no-drag;
}
.pd-term-meta {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1 1 auto;
}
.pd-term-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary, #e2e8f0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pd-term-subtitle {
font-size: 11px;
color: var(--text-secondary, #94a3b8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pd-term-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-secondary, #94a3b8);
flex: 0 0 auto;
}
.pd-term-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #64748b;
}
.pd-term-status.is-live .pd-term-status-dot {
background: #34d399;
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.2);
}
.pd-term-status.is-dead .pd-term-status-dot {
background: #f87171;
}
.pd-term-actions {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.pd-term-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
height: 28px;
margin: 0;
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #94a3b8);
font-size: 12px;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.pd-term-btn:hover {
background: var(--bg-hover, rgba(148, 163, 184, 0.16));
color: var(--text-primary, #e2e8f0);
}
.pd-term-btn--danger:hover {
background: rgba(248, 113, 113, 0.18);
color: #fca5a5;
}
.pd-term-host {
flex: 1 1 auto;
min-height: 0;
width: 100%;
padding: 4px 6px 6px;
box-sizing: border-box;
background: #0b0f14;
}
.pd-term-host .xterm {
height: 100%;
}
.pd-term-host .xterm-viewport {
overflow-y: auto !important;
}
/* Titlebar drag region already in modern.css; keep controls clickable */
#titlebar {
-webkit-app-region: drag;
}
#titlebar pear-ctrl,
#titlebar .pd-term-titlebar-label {
-webkit-app-region: no-drag;
}
.pd-term-titlebar-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #94a3b8);
margin-left: 8px;
}
.pd-term-titlebar-label i {
color: var(--accent, #2dd4bf);
}
</style>
</head>
<body class="pd-term-shell">
<div id="titlebar" role="banner">
<pear-ctrl></pear-ctrl>
<div class="pd-term-titlebar-label">
<i class="fas fa-terminal" aria-hidden="true"></i>
<span id="titlebar-label">Terminal</span>
</div>
</div>
<div class="pd-term-main">
<div class="pd-term-toolbar">
<div class="pd-term-meta">
<div class="pd-term-title" id="term-title">Terminal</div>
<div class="pd-term-subtitle" id="term-subtitle">Connecting…</div>
</div>
<div class="pd-term-status" id="term-status" title="Connection status">
<span class="pd-term-status-dot" aria-hidden="true"></span>
<span id="term-status-text">Starting</span>
</div>
<div class="pd-term-actions">
<button type="button" class="pd-term-btn" id="btn-copy" title="Copy selection">Copy</button>
<button type="button" class="pd-term-btn" id="btn-clear" title="Clear buffer">Clear</button>
<button type="button" class="pd-term-btn pd-term-btn--danger" id="btn-close" title="Close terminal">Close</button>
</div>
</div>
<div class="pd-term-host" id="term-host"></div>
</div>
<!-- Font Awesome (icons in titlebar) — match main app CDN if available -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm-addon-fit.js"></script>
<script src="electron/terminal-window-boot.js"></script>
</body>
</html>
+74
View File
@@ -0,0 +1,74 @@
/**
* Pop-out terminal session registry (no Electron / Docker required).
*/
import test from 'brittle'
import {
listPopoutTerminals,
findPopoutForContainer,
handlePopoutTerminalOutput,
closeAllPopoutTerminals,
} from '../libs/popoutTerminals.js'
import { ConnectionManager } from '../client/manager.js'
test('listPopoutTerminals starts empty', (t) => {
t.alike(listPopoutTerminals(), [])
t.is(findPopoutForContainer('abc'), null)
})
test('handlePopoutTerminalOutput is a no-op with no sessions', (t) => {
t.is(
handlePopoutTerminalOutput(
{ data: 'aGVsbG8=', containerId: 'c1', sessionId: 's1', encoding: 'base64' },
{ id: 'peer1' }
),
false
)
})
test('ConnectionManager getConnection / requestOn / eventOn', async (t) => {
const m = new ConnectionManager()
t.is(m.getConnection(null), null)
t.is(m.getConnection('deadbeef'), null)
// Fake a connected peer handle
const fake = {
id: 'abcdef123456',
publicKeyHex: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
connected: true,
requestCalls: [],
eventCalls: [],
async request(method, args) {
this.requestCalls.push({ method, args })
return { ok: true, method, args }
},
event(method, args) {
this.eventCalls.push({ method, args })
},
}
m.connections.set(fake.id, fake)
m.active = fake
t.is(m.getConnection(fake.id), fake)
t.is(m.getConnection(fake.id.slice(0, 6)), fake)
t.is(m.getConnection(fake.publicKeyHex), fake)
const res = await m.requestOn(fake.id, 'startTerminal', { containerId: 'c1' })
t.is(res.ok, true)
t.is(fake.requestCalls.length, 1)
t.is(fake.requestCalls[0].method, 'startTerminal')
m.eventOn(fake.id, 'terminalInput', { data: 'x' })
t.is(fake.eventCalls.length, 1)
t.is(fake.eventCalls[0].method, 'terminalInput')
// eventOn missing peer is silent
m.eventOn('nope', 'terminalInput', {})
t.is(fake.eventCalls.length, 1)
await t.exception(() => m.requestOn('missing', 'ping', {}))
})
test('closeAllPopoutTerminals is safe when empty', async (t) => {
await closeAllPopoutTerminals()
t.alike(listPopoutTerminals(), [])
})
+18
View File
@@ -148,6 +148,24 @@ test('details terminal host fills pane for FitAddon (no height:auto collapse)',
t.ok(!wrapperBlock.slice(0, 400).includes('height: auto !important'))
})
test('pop-out terminal controls and shell page exist', (t) => {
const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8')
t.ok(html.includes('id="terminal-popout-btn"'))
t.ok(html.includes('id="modal-terminal-popout-btn"'))
const termWin = fs.readFileSync(path.join(root, 'terminal-window.html'), 'utf8')
t.ok(termWin.includes('pd-term-shell'))
t.ok(termWin.includes('electron/terminal-window-boot.js'))
const boot = fs.readFileSync(path.join(root, 'electron/terminal-window-boot.js'), 'utf8')
t.ok(boot.includes('peardock:terminal-window-ready'))
t.ok(boot.includes('peardock:terminal-input'))
const main = fs.readFileSync(path.join(root, 'electron/main.cjs'), 'utf8')
t.ok(main.includes('peardock:open-terminal-window'))
t.ok(main.includes('createTerminalWindow'))
const pop = fs.readFileSync(path.join(root, 'libs/popoutTerminals.js'), 'utf8')
t.ok(pop.includes('openPopoutTerminal'))
t.ok(pop.includes('requestOn'))
})
test('track-g-ux.js exports shortcuts and go-map', (t) => {
const src = fs.readFileSync(path.join(root, 'ui/track-g-ux.js'), 'utf8')
t.ok(src.includes('openShortcutsModal'))
+49
View File
@@ -1273,6 +1273,55 @@ kbd {
border: none;
}
/* Pop-out terminal placeholder in details tab */
.terminal-popout-banner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 160px;
padding: 1.5rem;
box-sizing: border-box;
background: var(--bg-secondary, #0f131a);
color: var(--text-primary, #e2e8f0);
}
.terminal-popout-banner-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
max-width: 36rem;
padding: 1.25rem 1.5rem;
border-radius: 12px;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
background: var(--bg-primary, #0a0c10);
}
.terminal-popout-banner-inner > i {
font-size: 1.35rem;
color: var(--accent, #2dd4bf);
flex: 0 0 auto;
}
.terminal-popout-banner-title {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.2rem;
}
.terminal-popout-banner-sub {
font-size: 0.8rem;
color: var(--text-secondary, #94a3b8);
line-height: 1.35;
}
.terminal-popout-banner-actions {
flex: 0 0 auto;
margin-left: auto;
}
#container-terminal-xterm {
flex: 1 1 auto;
min-height: 0;