mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
244 lines
7.9 KiB
JavaScript
244 lines
7.9 KiB
JavaScript
// src/web/public/terminal-split.js
|
|
|
|
/**
|
|
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
|
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
|
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
|
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
|
* feature by nature — see docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md.
|
|
*
|
|
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
|
* @dependency terminal-ui.js (window.CodemanTerminalFont, codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
|
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
|
*/
|
|
|
|
(function (global) {
|
|
class SplitTerminalPane {
|
|
constructor(sessionId, mountEl) {
|
|
this.sessionId = sessionId;
|
|
this.mountEl = mountEl;
|
|
this.terminal = null;
|
|
this.fitAddon = null;
|
|
this.ws = null;
|
|
this._wsReady = false;
|
|
}
|
|
|
|
connect() {
|
|
this.terminal = new Terminal({
|
|
theme: { ...global.codemanCurrentXtermTheme() },
|
|
fontFamily: global.CodemanTerminalFont.resolve(),
|
|
...global.CodemanTerminalFont.resolveWeights({}),
|
|
fontSize: 14,
|
|
lineHeight: 1.2,
|
|
cursorBlink: false,
|
|
cursorStyle: 'block',
|
|
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
|
scrollback: 5000,
|
|
allowTransparency: true,
|
|
allowProposedApi: true,
|
|
});
|
|
|
|
this.fitAddon = new FitAddon.FitAddon();
|
|
this.terminal.loadAddon(this.fitAddon);
|
|
this.terminal.open(this.mountEl);
|
|
this.fitAddon.fit();
|
|
|
|
this.terminal.onData((data) => {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
|
}
|
|
});
|
|
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
|
this.ws = new WebSocket(url);
|
|
|
|
this.ws.onopen = () => {
|
|
this._wsReady = true;
|
|
this._sendResize();
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.t === 'o') {
|
|
this.terminal.write(msg.d);
|
|
} else if (msg.t === 'c') {
|
|
this.terminal.clear();
|
|
}
|
|
} catch {
|
|
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
|
}
|
|
};
|
|
|
|
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
|
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
|
// closed socket per the WebSocket spec (no exception, no log). No
|
|
// reconnect logic here — Pane B is deliberately plainer than the
|
|
// primary pane (see the fileoverview above); a drop just stops
|
|
// resizing until the parent recreates the pane.
|
|
this.ws.onclose = () => {
|
|
this._wsReady = false;
|
|
};
|
|
|
|
this.ws.onerror = () => {
|
|
// onclose fires after onerror — cleanup happens there.
|
|
};
|
|
}
|
|
|
|
fit() {
|
|
if (!this.fitAddon) return;
|
|
this.fitAddon.fit();
|
|
this._sendResize();
|
|
}
|
|
|
|
_sendResize() {
|
|
if (!this._wsReady || !this.fitAddon) return;
|
|
const dims = this.fitAddon.proposeDimensions();
|
|
if (!dims) return;
|
|
const cols = Math.max(dims.cols, 40);
|
|
const rows = Math.max(dims.rows, 10);
|
|
this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' }));
|
|
}
|
|
|
|
destroy() {
|
|
if (this.ws) {
|
|
this.ws.onopen = null;
|
|
this.ws.onmessage = null;
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
if (this.terminal) {
|
|
this.terminal.dispose();
|
|
this.terminal = null;
|
|
}
|
|
this.fitAddon = null;
|
|
}
|
|
}
|
|
|
|
global.SplitTerminalPane = SplitTerminalPane;
|
|
})(window);
|
|
|
|
Object.assign(CodemanApp.prototype, {
|
|
openSplitPicker() {
|
|
if (this._splitPane) {
|
|
this.closeSplitPane();
|
|
return;
|
|
}
|
|
const candidates = window.CodemanSplitPane.buildSplitPickerSessions(
|
|
this.sessions,
|
|
this.sessionOrder,
|
|
this.activeSessionId
|
|
);
|
|
const existing = document.getElementById('splitPickerMenu');
|
|
if (existing) existing.remove();
|
|
|
|
const menu = document.createElement('div');
|
|
menu.id = 'splitPickerMenu';
|
|
menu.className = 'split-picker-menu';
|
|
if (candidates.length === 0) {
|
|
menu.innerHTML = '<div class="split-picker-empty">No other sessions to split with</div>';
|
|
} else {
|
|
menu.innerHTML = candidates
|
|
.map(
|
|
(c) =>
|
|
`<div class="split-picker-item" data-session-id="${escapeHtml(c.id)}" onclick="app.openSplitPane(${escapeHtml(JSON.stringify(c.id))}); document.getElementById('splitPickerMenu')?.remove();">${escapeHtml(c.label)}</div>`
|
|
)
|
|
.join('');
|
|
}
|
|
document.body.appendChild(menu);
|
|
const splitBtn = document.querySelector('.btn-split');
|
|
if (splitBtn) {
|
|
const rect = splitBtn.getBoundingClientRect();
|
|
menu.style.position = 'fixed';
|
|
menu.style.top = `${rect.bottom + 4}px`;
|
|
menu.style.right = `${window.innerWidth - rect.right}px`;
|
|
}
|
|
},
|
|
|
|
openSplitPane(sessionId) {
|
|
if (this._splitPane) this.closeSplitPane();
|
|
|
|
const wrap = document.querySelector('.terminal-wrap');
|
|
const parent = wrap.parentElement;
|
|
|
|
const container = document.createElement('div');
|
|
container.className = 'terminal-split-container';
|
|
|
|
const divider = document.createElement('div');
|
|
divider.className = 'split-divider';
|
|
|
|
const paneB = document.createElement('div');
|
|
paneB.className = 'terminal-pane-b';
|
|
const session = this.sessions.get(sessionId);
|
|
paneB.innerHTML = `
|
|
<div class="terminal-pane-b-header">
|
|
<span>${escapeHtml(session?.name || 'Session')}</span>
|
|
<span class="terminal-pane-b-close" onclick="app.closeSplitPane()">×</span>
|
|
</div>
|
|
<div class="terminal-pane-b-container"></div>
|
|
`;
|
|
|
|
parent.insertBefore(container, wrap);
|
|
container.appendChild(wrap);
|
|
wrap.style.flexBasis = '50%';
|
|
container.appendChild(divider);
|
|
container.appendChild(paneB);
|
|
paneB.style.flexBasis = '50%';
|
|
|
|
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'));
|
|
this._splitPane.connect();
|
|
this._splitSessionId = sessionId;
|
|
|
|
this._installSplitDividerDrag(divider, wrap, paneB);
|
|
},
|
|
|
|
closeSplitPane() {
|
|
if (!this._splitPane) return;
|
|
this._splitPane.destroy();
|
|
this._splitPane = null;
|
|
this._splitSessionId = null;
|
|
|
|
const container = document.querySelector('.terminal-split-container');
|
|
if (!container) return;
|
|
const wrap = container.querySelector('.terminal-wrap');
|
|
const parent = container.parentElement;
|
|
wrap.style.flexBasis = '';
|
|
parent.insertBefore(wrap, container);
|
|
container.remove();
|
|
|
|
if (this.fitAddon) this.fitAddon.fit();
|
|
this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
|
|
},
|
|
|
|
_installSplitDividerDrag(divider, wrap, paneB) {
|
|
let dragging = false;
|
|
|
|
const onMove = (e) => {
|
|
if (!dragging) return;
|
|
const container = divider.parentElement;
|
|
const rect = container.getBoundingClientRect();
|
|
const rawPercent = ((e.clientX - rect.left) / rect.width) * 100;
|
|
const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent);
|
|
wrap.style.flexBasis = `${percent}%`;
|
|
paneB.style.flexBasis = `${100 - percent}%`;
|
|
if (this.fitAddon) this.fitAddon.fit();
|
|
this._splitPane?.fit();
|
|
};
|
|
|
|
const onUp = () => {
|
|
dragging = false;
|
|
divider.classList.remove('dragging');
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
};
|
|
|
|
divider.addEventListener('mousedown', () => {
|
|
dragging = true;
|
|
divider.classList.add('dragging');
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
});
|
|
},
|
|
});
|