experimental: pop-out container terminals into dedicated windows
Release rolling / release (push) Successful in 7m56s
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:
@@ -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(() => {});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})()
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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(), [])
|
||||
})
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user