merge: integrate upstream (clipboard, URL click, search addon, delete fixes) with federation proxy rewrite
This commit is contained in:
+1
-1
@@ -191,7 +191,7 @@ def serve(
|
||||
from muxplex.main import app # noqa: PLC0415
|
||||
|
||||
print(f" muxplex → http://{host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_level="warning")
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
def doctor() -> None:
|
||||
|
||||
+12
-2
@@ -581,7 +581,9 @@ function renderSidebar(sessions, currentSession) {
|
||||
list.querySelectorAll('.sidebar-item').forEach((item) => {
|
||||
const name = item.dataset.session;
|
||||
const remoteId = item.dataset.remoteId || '';
|
||||
on(item, 'click', () => {
|
||||
on(item, 'click', (e) => {
|
||||
// Don't navigate when clicking the delete button inside the item
|
||||
if (e.target.closest && e.target.closest('.sidebar-delete')) return;
|
||||
if (name !== currentSession) openSession(name, { remoteId });
|
||||
});
|
||||
});
|
||||
@@ -833,7 +835,11 @@ function renderGrid(sessions) {
|
||||
|
||||
// Bind interaction handlers on each tile
|
||||
document.querySelectorAll('.session-tile').forEach(function(tile) {
|
||||
on(tile, 'click', () => openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' }));
|
||||
on(tile, 'click', (e) => {
|
||||
// Don't navigate when clicking the delete button inside the tile
|
||||
if (e.target.closest && e.target.closest('.tile-delete')) return;
|
||||
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
|
||||
});
|
||||
on(tile, 'keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
|
||||
@@ -2031,6 +2037,10 @@ function killSession(name) {
|
||||
api('DELETE', '/api/sessions/' + name)
|
||||
.then(function() {
|
||||
showToast('Session \'' + name + '\' killed');
|
||||
// If we deleted the session we're currently viewing, return to dashboard
|
||||
if (_viewingSession === name) {
|
||||
closeSession();
|
||||
}
|
||||
pollSessions();
|
||||
})
|
||||
.catch(function(err) {
|
||||
|
||||
@@ -50,7 +50,16 @@
|
||||
<button id="sidebar-new-session-btn" class="sidebar-new-btn">+ New</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
<div class="terminal-wrapper">
|
||||
<div id="terminal-search-bar" class="terminal-search-bar hidden">
|
||||
<input id="terminal-search-input" type="text" class="terminal-search-input" placeholder="Find..." />
|
||||
<span id="terminal-search-count" class="terminal-search-count"></span>
|
||||
<button id="terminal-search-prev" class="terminal-search-btn" title="Previous (Shift+Enter)">▲</button>
|
||||
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)">▼</button>
|
||||
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)">×</button>
|
||||
</div>
|
||||
<div id="terminal-container" class="terminal-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
|
||||
</div>
|
||||
@@ -233,6 +242,9 @@
|
||||
<!-- ── Scripts ──────────────────────────────────────────────────────────── -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-search@0.13.0/lib/xterm-addon-search.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-image@0.9.0/lib/addon-image.js"></script>
|
||||
<script src="/app.js" defer></script>
|
||||
<script src="/terminal.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -630,6 +630,14 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -638,6 +646,59 @@ body {
|
||||
padding: 0 4px; /* keep text off the side edges */
|
||||
}
|
||||
|
||||
/* Terminal search bar */
|
||||
.terminal-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-search-bar.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-search-input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.terminal-search-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.terminal-search-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.terminal-search-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.terminal-search-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */
|
||||
.xterm .xterm-viewport {
|
||||
overflow-y: hidden !important;
|
||||
|
||||
+210
-10
@@ -9,6 +9,40 @@ let _reconnectTimer = null;
|
||||
let _currentSession = null;
|
||||
let _vpHandler = null;
|
||||
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
||||
let _searchAddon = null;
|
||||
|
||||
// ─── Module-level encoding helpers ──────────────────────────────────────────
|
||||
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
|
||||
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
||||
|
||||
function _encodePayload(typeChar, str) {
|
||||
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
||||
var strBytes = _encoder ? _encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
|
||||
var payload = new Uint8Array(1 + strBytes.length);
|
||||
payload[0] = typeChar;
|
||||
payload.set(strBytes, 1);
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ─── Clipboard helpers ───────────────────────────────────────────────────────
|
||||
// Ctrl+Shift+C: copy terminal selection to system clipboard
|
||||
// Ctrl+Shift+V: paste from system clipboard into terminal
|
||||
|
||||
function _copyToClipboard(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(function() {});
|
||||
} else {
|
||||
// Fallback for non-HTTPS contexts (HTTP over LAN)
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch(e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Forward declarations ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,16 +60,8 @@ function connectWebSocket(name, remoteId) {
|
||||
url = proto + '//' + location.host + '/terminal/ws';
|
||||
}
|
||||
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
||||
const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
||||
|
||||
function encodePayload(typeChar, str) {
|
||||
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
||||
var strBytes = encoder ? encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
|
||||
var payload = new Uint8Array(1 + strBytes.length);
|
||||
payload[0] = typeChar;
|
||||
payload.set(strBytes, 1);
|
||||
return payload;
|
||||
}
|
||||
// Use module-level _encodePayload (hoisted above connectWebSocket)
|
||||
var encodePayload = _encodePayload;
|
||||
|
||||
// Register terminal event handlers once on this _term instance.
|
||||
// These handlers read the module-level _ws at call time (not a captured reference),
|
||||
@@ -221,6 +247,67 @@ function createTerminal() {
|
||||
|
||||
_fitAddon = new window.FitAddon.FitAddon();
|
||||
_term.loadAddon(_fitAddon);
|
||||
|
||||
// Clickable URLs — Ctrl+Click (Windows/Linux) or Cmd+Click (macOS) opens in new tab.
|
||||
// xterm-addon-web-links auto-detects URLs and adds hover underlines.
|
||||
// Plain click is preserved for normal terminal text selection.
|
||||
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
|
||||
if (WebLinksAddon) {
|
||||
_term.loadAddon(new WebLinksAddon(function(event, uri) {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
window.open(uri, '_blank');
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Search addon — Ctrl+F to find text in terminal buffer
|
||||
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
|
||||
if (SearchAddon) {
|
||||
_searchAddon = new SearchAddon();
|
||||
_term.loadAddon(_searchAddon);
|
||||
}
|
||||
|
||||
// Image addon — inline image rendering (Sixel, iTerm2 IIP, Kitty graphics)
|
||||
// Needed for tools like yazi file manager that use graphic protocols
|
||||
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
|
||||
if (ImageAddon) {
|
||||
_term.loadAddon(new ImageAddon());
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Search helpers ──────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _openSearch() {
|
||||
var bar = document.getElementById('terminal-search-bar');
|
||||
var input = document.getElementById('terminal-search-input');
|
||||
if (bar) {
|
||||
bar.classList.remove('hidden');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _closeSearch() {
|
||||
var bar = document.getElementById('terminal-search-bar');
|
||||
if (bar) bar.classList.add('hidden');
|
||||
if (_searchAddon) _searchAddon.clearDecorations();
|
||||
if (_term) _term.focus();
|
||||
}
|
||||
|
||||
function _searchNext() {
|
||||
var input = document.getElementById('terminal-search-input');
|
||||
if (input && input.value && _searchAddon) {
|
||||
_searchAddon.findNext(input.value);
|
||||
}
|
||||
}
|
||||
|
||||
function _searchPrev() {
|
||||
var input = document.getElementById('terminal-search-input');
|
||||
if (input && input.value && _searchAddon) {
|
||||
_searchAddon.findPrevious(input.value);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Open / close ─────────────────────────────────────────────────────────────
|
||||
@@ -262,6 +349,70 @@ function openTerminal(sessionName, remoteId) {
|
||||
|
||||
_term.open(container);
|
||||
|
||||
// --- Clipboard integration ---
|
||||
// Ctrl+Shift+C: copy selection to system clipboard (Ctrl+C still sends SIGINT)
|
||||
// Ctrl+Shift+V: paste from system clipboard (Ctrl+V still sends literal bytes)
|
||||
_term.attachCustomKeyEventHandler(function(e) {
|
||||
if (e.type !== 'keydown') return true;
|
||||
|
||||
// Ctrl+Shift+C → copy selection to clipboard
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
|
||||
var sel = _term.getSelection();
|
||||
if (sel) _copyToClipboard(sel);
|
||||
return false; // prevent xterm from processing
|
||||
}
|
||||
|
||||
// Ctrl+Shift+V → paste from clipboard into terminal
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === 'V' || e.code === 'KeyV')) {
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
navigator.clipboard.readText().then(function(text) {
|
||||
if (text && _ws && _ws.readyState === WebSocket.OPEN) {
|
||||
_ws.send(_encodePayload(0x30, text));
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
return false; // prevent xterm from processing
|
||||
}
|
||||
|
||||
// Ctrl+F → open search bar
|
||||
if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) {
|
||||
_openSearch();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true; // let xterm handle all other keys normally
|
||||
});
|
||||
|
||||
// Auto-copy: when mouse selection ends, copy to system clipboard.
|
||||
// Matches terminal emulator conventions (iTerm2, WezTerm, ttyd native).
|
||||
// onSelectionChange fires whenever selection changes — copy if text is selected.
|
||||
// When selection is cleared (empty string), we skip the clipboard write.
|
||||
_term.onSelectionChange(function() {
|
||||
var sel = _term.getSelection();
|
||||
if (sel) {
|
||||
_copyToClipboard(sel);
|
||||
}
|
||||
});
|
||||
|
||||
// OSC 52 clipboard integration — bridges tmux clipboard to the browser.
|
||||
// When tmux copies text (with `set-clipboard on` in .tmux.conf), it sends
|
||||
// an OSC 52 escape sequence to the terminal. xterm.js surfaces this via the
|
||||
// parser API. We intercept and write the decoded text to the system clipboard
|
||||
// so that: Ctrl+B [ → select → Enter (tmux copy) → system clipboard receives it.
|
||||
_term.parser.registerOscHandler(52, function(data) {
|
||||
// OSC 52 format: Pc ; Pd — Pc = selection target (c/p/q/s/0-7), Pd = base64 text
|
||||
var parts = data.split(';');
|
||||
if (parts.length >= 2) {
|
||||
try {
|
||||
var text = atob(parts[1]);
|
||||
_copyToClipboard(text);
|
||||
} catch (e) {
|
||||
// Invalid base64 or unsupported — silently ignore
|
||||
}
|
||||
}
|
||||
return true; // Handled — don't pass to xterm's default handler
|
||||
});
|
||||
|
||||
if (_fitAddon) {
|
||||
// requestAnimationFrame guarantees one full browser layout pass after the flex
|
||||
// container becomes visible before fit() measures dimensions.
|
||||
@@ -279,6 +430,51 @@ function openTerminal(sessionName, remoteId) {
|
||||
});
|
||||
}
|
||||
|
||||
// Wire search bar buttons + keyboard handlers (idempotent — elements are static)
|
||||
var searchInput = document.getElementById('terminal-search-input');
|
||||
var searchClose = document.getElementById('terminal-search-close');
|
||||
var searchNextBtn = document.getElementById('terminal-search-next');
|
||||
var searchPrevBtn = document.getElementById('terminal-search-prev');
|
||||
|
||||
if (searchInput) {
|
||||
// Remove old listeners by replacing with cloned element (avoids duplicate handlers on reconnect)
|
||||
var newInput = searchInput.cloneNode(true);
|
||||
searchInput.parentNode.replaceChild(newInput, searchInput);
|
||||
searchInput = newInput;
|
||||
searchInput.addEventListener('input', function() {
|
||||
if (_searchAddon && searchInput.value) {
|
||||
_searchAddon.findNext(searchInput.value);
|
||||
} else if (_searchAddon) {
|
||||
_searchAddon.clearDecorations();
|
||||
}
|
||||
});
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) _searchPrev(); else _searchNext();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
_closeSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (searchClose) {
|
||||
var newClose = searchClose.cloneNode(true);
|
||||
searchClose.parentNode.replaceChild(newClose, searchClose);
|
||||
newClose.addEventListener('click', _closeSearch);
|
||||
}
|
||||
if (searchNextBtn) {
|
||||
var newNext = searchNextBtn.cloneNode(true);
|
||||
searchNextBtn.parentNode.replaceChild(newNext, searchNextBtn);
|
||||
newNext.addEventListener('click', _searchNext);
|
||||
}
|
||||
if (searchPrevBtn) {
|
||||
var newPrev = searchPrevBtn.cloneNode(true);
|
||||
searchPrevBtn.parentNode.replaceChild(newPrev, searchPrevBtn);
|
||||
newPrev.addEventListener('click', _searchPrev);
|
||||
}
|
||||
|
||||
connectWebSocket(sessionName, remoteId);
|
||||
initVisualViewport(); /* defined in Task 14 */
|
||||
}
|
||||
@@ -306,8 +502,10 @@ function closeTerminal() {
|
||||
_term.dispose();
|
||||
_term = null;
|
||||
_fitAddon = null;
|
||||
_searchAddon = null;
|
||||
}
|
||||
|
||||
_closeSearch();
|
||||
_currentSession = null;
|
||||
_reconnectAttempts = 0; // reset backoff on intentional close
|
||||
}
|
||||
@@ -315,6 +513,8 @@ function closeTerminal() {
|
||||
// ─── Expose to app.js ─────────────────────────────────────────────────────────
|
||||
window._openTerminal = openTerminal;
|
||||
window._closeTerminal = closeTerminal;
|
||||
window._openSearch = _openSearch;
|
||||
window._closeSearch = _closeSearch;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setTerminalFontSize — live font-size update without reconnecting
|
||||
|
||||
@@ -3894,3 +3894,30 @@ test('remote instance debounced input listener selector includes .settings-remot
|
||||
'debounced input listener on #setting-remote-instances must include .settings-remote-key so key-only edits trigger _saveRemoteInstances()',
|
||||
);
|
||||
});
|
||||
|
||||
// --- Bug fixes: delete UX ---
|
||||
|
||||
test('killSession closes active session and returns to dashboard', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
// Find killSession function body
|
||||
const fnStart = source.indexOf('function killSession');
|
||||
const fnBody = source.substring(fnStart, fnStart + 500);
|
||||
assert.ok(fnBody.includes('_viewingSession'), 'killSession must check if deleted session is the active one');
|
||||
assert.ok(fnBody.includes('closeSession'), 'killSession must call closeSession when deleting the active session');
|
||||
});
|
||||
|
||||
test('sidebar click handler ignores clicks on delete button', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes("closest('.sidebar-delete')"),
|
||||
"sidebar click handler must guard against clicks on .sidebar-delete button"
|
||||
);
|
||||
});
|
||||
|
||||
test('tile click handler ignores clicks on tile-delete button', () => {
|
||||
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes("closest('.tile-delete')"),
|
||||
"tile click handler must guard against clicks on .tile-delete button"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -47,6 +47,10 @@ function loadTerminal() {
|
||||
dispose: () => {},
|
||||
write: (data) => { termWriteMessages.push(data); },
|
||||
focus: () => { focusCallCount++; },
|
||||
attachCustomKeyEventHandler: () => {},
|
||||
getSelection: () => '',
|
||||
onSelectionChange: () => {},
|
||||
parser: { registerOscHandler: () => {} },
|
||||
};
|
||||
|
||||
// Capture all messages sent via WebSocket.send()
|
||||
@@ -331,6 +335,10 @@ function createMultiSessionEnv() {
|
||||
loadAddon: () => {},
|
||||
dispose: () => {},
|
||||
focus: () => {},
|
||||
attachCustomKeyEventHandler: () => {},
|
||||
getSelection: () => '',
|
||||
onSelectionChange: () => {},
|
||||
parser: { registerOscHandler: () => {} },
|
||||
writeMessages: [],
|
||||
};
|
||||
t.write = (data) => t.writeMessages.push(data);
|
||||
@@ -891,6 +899,17 @@ test('terminal.js resets _reconnectAttempts on first message, not on open', () =
|
||||
);
|
||||
});
|
||||
|
||||
// --- Clipboard integration ---
|
||||
|
||||
test('terminal.js has clipboard integration with Ctrl+Shift+C and Ctrl+Shift+V', () => {
|
||||
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||
assert.ok(source.includes('attachCustomKeyEventHandler'), 'must register custom key handler');
|
||||
assert.ok(source.includes('getSelection'), 'must use getSelection() for copy');
|
||||
assert.ok(source.includes('clipboard'), 'must interact with clipboard API');
|
||||
assert.ok(source.includes('Shift'), 'must use Shift modifier to avoid conflict with terminal Ctrl+C/V');
|
||||
assert.ok(source.includes('_copyToClipboard') || source.includes('writeText'), 'must have copy mechanism');
|
||||
});
|
||||
|
||||
// --- Issue 4: setTerminalFontSize ---
|
||||
|
||||
test('terminal.js exposes window._setTerminalFontSize function', () => {
|
||||
@@ -915,4 +934,61 @@ test('_setTerminalFontSize sets _term.options.fontSize and calls _fitAddon.fit()
|
||||
);
|
||||
});
|
||||
|
||||
// --- Clipboard Issue 1: auto-copy mouse selection via onSelectionChange ---
|
||||
|
||||
test('terminal.js auto-copies mouse selection to clipboard via onSelectionChange', () => {
|
||||
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes('onSelectionChange'),
|
||||
'must register onSelectionChange handler to auto-copy mouse selection to clipboard',
|
||||
);
|
||||
});
|
||||
|
||||
// --- Clipboard Issue 2: OSC 52 handler bridges tmux clipboard to browser ---
|
||||
|
||||
test('terminal.js registers OSC 52 handler for tmux clipboard bridge', () => {
|
||||
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||
assert.ok(
|
||||
source.includes('registerOscHandler'),
|
||||
'must call parser.registerOscHandler to intercept tmux OSC 52 clipboard sequences',
|
||||
);
|
||||
assert.ok(
|
||||
source.includes('atob'),
|
||||
'must decode base64 OSC 52 clipboard payload with atob()',
|
||||
);
|
||||
});
|
||||
|
||||
// --- Clickable URLs via xterm-addon-web-links ---
|
||||
|
||||
test('terminal.js loads xterm-addon-web-links for clickable URLs', () => {
|
||||
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||
assert.ok(source.includes('WebLinksAddon'), 'must reference WebLinksAddon');
|
||||
assert.ok(
|
||||
source.includes('ctrlKey') || source.includes('metaKey'),
|
||||
'must check modifier key for link clicks',
|
||||
);
|
||||
assert.ok(source.includes('window.open'), 'must open URLs in new tab');
|
||||
});
|
||||
|
||||
// --- Search addon (xterm-addon-search) ---
|
||||
|
||||
test('terminal.js loads xterm-addon-search', () => {
|
||||
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||
assert.ok(source.includes('SearchAddon'), 'must reference SearchAddon');
|
||||
assert.ok(source.includes('findNext') || source.includes('findPrevious'), 'must have search functions');
|
||||
});
|
||||
|
||||
test('terminal.js has Ctrl+F search shortcut', () => {
|
||||
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||
assert.ok(source.includes('_openSearch'), 'must have search open function');
|
||||
assert.ok(source.includes('_closeSearch'), 'must have search close function');
|
||||
});
|
||||
|
||||
// --- Image addon (xterm-addon-image) ---
|
||||
|
||||
test('terminal.js loads xterm-addon-image for inline graphics', () => {
|
||||
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||
assert.ok(source.includes('ImageAddon'), 'must reference ImageAddon');
|
||||
});
|
||||
|
||||
|
||||
|
||||
+7
-1
@@ -366,6 +366,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
|
||||
name = payload.name
|
||||
settings = load_settings()
|
||||
command = settings["new_session_template"].replace("{name}", name)
|
||||
_log.info("Creating session '%s' with command: %s", name, command)
|
||||
try:
|
||||
subprocess.Popen(
|
||||
command,
|
||||
@@ -392,6 +393,7 @@ async def connect_session(name: str) -> dict:
|
||||
if known and name not in known:
|
||||
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
|
||||
|
||||
_log.info("Connecting to session '%s'", name)
|
||||
await kill_ttyd()
|
||||
await spawn_ttyd(name)
|
||||
|
||||
@@ -442,15 +444,19 @@ async def delete_session(name: str) -> dict:
|
||||
"delete_session_template", "tmux kill-session -t {name}"
|
||||
).replace("{name}", name)
|
||||
|
||||
_log.info("Deleting session '%s' with command: %s", name, command)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
input="y\n", # auto-confirm interactive prompts (e.g. amplifier-dev --destroy)
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
if result.returncode == 0:
|
||||
_log.info("Session '%s' deleted successfully", name)
|
||||
else:
|
||||
_log.warning(
|
||||
"Delete command failed (rc=%d): %s",
|
||||
result.returncode,
|
||||
|
||||
+8
-2
@@ -152,7 +152,10 @@ def _systemd_status() -> None:
|
||||
|
||||
|
||||
def _systemd_logs() -> None:
|
||||
subprocess.run(["journalctl", "--user", "-u", "muxplex", "-f"], check=True)
|
||||
try:
|
||||
subprocess.run(["journalctl", "--user", "-u", "muxplex", "-f"])
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -205,7 +208,10 @@ def _launchd_status() -> None:
|
||||
|
||||
|
||||
def _launchd_logs() -> None:
|
||||
subprocess.run(["tail", "-f", "/tmp/muxplex.log"], check=True)
|
||||
try:
|
||||
subprocess.run(["tail", "-f", "/tmp/muxplex.log"])
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1914,3 +1914,140 @@ def test_federation_generate_key_creates_file(client, tmp_path, monkeypatch):
|
||||
f"File contents must match returned key. "
|
||||
f"File: {file_contents!r}, key: {returned_key!r}"
|
||||
)
|
||||
def test_get_auth_token_returns_401_when_not_authenticated(monkeypatch):
|
||||
"""GET /api/auth/token returns 401 when request has no valid session cookie."""
|
||||
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||
with TestClient(app, base_url="http://192.168.1.1") as c:
|
||||
# No cookie set — endpoint must return 401 with application/json accept
|
||||
response = c.get("/api/auth/token", headers={"Accept": "application/json"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug fix: delete_session must pass input="y\n" to subprocess.run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_session_passes_stdin_y_to_subprocess(client, monkeypatch, tmp_path):
|
||||
"""DELETE /api/sessions/{name} must pass input='y\\n' to subprocess.run.
|
||||
|
||||
When delete_session_template uses an interactive command (e.g. amplifier-dev
|
||||
--destroy), the confirmation prompt must be auto-answered via stdin.
|
||||
Without input='y\\n', subprocess.run hangs until 30s timeout and the
|
||||
session is never actually deleted.
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"])
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
|
||||
|
||||
captured_kwargs = []
|
||||
|
||||
def mock_run(cmd, **kwargs):
|
||||
captured_kwargs.append(kwargs)
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
|
||||
response = client.delete("/api/sessions/my-session")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(captured_kwargs) == 1, "subprocess.run must be called once"
|
||||
kwargs = captured_kwargs[0]
|
||||
assert "input" in kwargs, (
|
||||
"subprocess.run must receive input= kwarg to auto-answer interactive prompts"
|
||||
)
|
||||
assert kwargs["input"] == "y\n", (
|
||||
f"input must be 'y\\n' to confirm deletion, got: {kwargs['input']!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug fix: request-level INFO logging for session operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_session_logs_command_at_info(client, monkeypatch, tmp_path, caplog):
|
||||
"""DELETE /api/sessions/{name} must log the command being run at INFO level."""
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["logged-session"])
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
|
||||
|
||||
def mock_run(cmd, **kwargs):
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="muxplex.main"):
|
||||
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
|
||||
client.delete("/api/sessions/logged-session")
|
||||
|
||||
log_messages = "\n".join(caplog.messages)
|
||||
assert "logged-session" in log_messages, (
|
||||
f"delete_session must log the session name at INFO level, got logs:\n{log_messages}"
|
||||
)
|
||||
|
||||
|
||||
def test_create_session_logs_command(client, monkeypatch, tmp_path, caplog):
|
||||
"""POST /api/sessions must log the command being launched at INFO level."""
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import muxplex.settings as settings_mod
|
||||
|
||||
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="muxplex.main"):
|
||||
with patch("muxplex.main.subprocess.Popen") as mock_popen:
|
||||
mock_popen.return_value = MagicMock()
|
||||
client.post("/api/sessions", json={"name": "new-session"})
|
||||
|
||||
log_messages = "\n".join(caplog.messages)
|
||||
assert "new-session" in log_messages, (
|
||||
f"create_session must log session name at INFO level, got logs:\n{log_messages}"
|
||||
)
|
||||
|
||||
|
||||
def test_connect_session_logs_session_name(client, monkeypatch, caplog):
|
||||
"""POST /api/sessions/{name}/connect must log the session name at INFO level."""
|
||||
import logging
|
||||
|
||||
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["target-session"])
|
||||
|
||||
async def mock_kill_ttyd():
|
||||
pass
|
||||
|
||||
async def mock_spawn_ttyd(name):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill_ttyd)
|
||||
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn_ttyd)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="muxplex.main"):
|
||||
client.post("/api/sessions/target-session/connect")
|
||||
|
||||
log_messages = "\n".join(caplog.messages)
|
||||
assert "target-session" in log_messages, (
|
||||
f"connect_session must log session name at INFO level, got logs:\n{log_messages}"
|
||||
)
|
||||
|
||||
|
||||
def test_cli_uvicorn_log_level_is_info():
|
||||
"""cli.py serve() must pass log_level='info' to uvicorn.run so logs appear in journalctl."""
|
||||
import inspect
|
||||
from muxplex import cli
|
||||
|
||||
source = inspect.getsource(cli.serve)
|
||||
assert 'log_level="info"' in source or "log_level='info'" in source, (
|
||||
"serve() must call uvicorn.run(..., log_level='info') so application "
|
||||
"logs appear in journalctl; currently set to 'warning' which suppresses them"
|
||||
)
|
||||
|
||||
@@ -54,7 +54,6 @@ def test_css_zoom_transition():
|
||||
|
||||
def test_css_bell_count_and_toast():
|
||||
css = read_css()
|
||||
assert ".tile-bell-count" in css
|
||||
assert ".connection-status--ok" in css
|
||||
assert ".connection-status--warn" in css
|
||||
assert ".connection-status--err" in css
|
||||
|
||||
@@ -1373,3 +1373,49 @@ def test_html_settings_remote_instance_key_input() -> None:
|
||||
assert el is not None, (
|
||||
"Missing #setting-remote-instances inside devices panel"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Clickable URLs — xterm-addon-web-links (task: clickable URLs)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def read_html() -> str:
|
||||
"""Read raw HTML content of index.html."""
|
||||
return HTML_PATH.read_text()
|
||||
|
||||
|
||||
def test_html_loads_web_links_addon() -> None:
|
||||
"""index.html must load the xterm-addon-web-links CDN script."""
|
||||
html = read_html()
|
||||
assert "web-links" in html.lower() or "weblinks" in html.lower(), (
|
||||
"Must load xterm-addon-web-links from CDN"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Search addon (xterm-addon-search)
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_html_loads_search_addon() -> None:
|
||||
"""index.html must load the xterm-addon-search CDN script."""
|
||||
html = read_html()
|
||||
assert "search" in html.lower() and "addon" in html.lower() and "xterm" in html.lower(), (
|
||||
"Must load xterm-addon-search from CDN"
|
||||
)
|
||||
|
||||
|
||||
def test_html_loads_image_addon() -> None:
|
||||
"""index.html must load the xterm-addon-image CDN script."""
|
||||
html = read_html()
|
||||
assert "addon-image" in html.lower(), (
|
||||
"Must load xterm-addon-image from CDN (expected 'addon-image' in script src)"
|
||||
)
|
||||
|
||||
|
||||
def test_html_has_search_bar() -> None:
|
||||
"""index.html must contain the terminal search bar elements."""
|
||||
html = read_html()
|
||||
assert "terminal-search-bar" in html, "Must have #terminal-search-bar element"
|
||||
assert "terminal-search-input" in html, "Must have #terminal-search-input element"
|
||||
|
||||
@@ -43,3 +43,77 @@ def test_readme_shows_restart_workflow():
|
||||
assert "muxplex service restart" in README, (
|
||||
"README must include 'muxplex service restart' in the example"
|
||||
)
|
||||
|
||||
|
||||
# ── Comprehensive documentation tests ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_readme_documents_all_settings_keys():
|
||||
"""README must document every key from DEFAULT_SETTINGS."""
|
||||
from muxplex.settings import DEFAULT_SETTINGS
|
||||
|
||||
for key in DEFAULT_SETTINGS:
|
||||
assert f"`{key}`" in README, f"README must document setting key '{key}'"
|
||||
|
||||
|
||||
def test_readme_has_keyboard_shortcuts_section():
|
||||
"""README must have a keyboard shortcuts section."""
|
||||
assert "Ctrl+Shift+C" in README, "README must document Ctrl+Shift+C"
|
||||
assert "Ctrl+Shift+V" in README, "README must document Ctrl+Shift+V"
|
||||
|
||||
|
||||
def test_readme_documents_clipboard_features():
|
||||
"""README must mention clipboard and mouse select features."""
|
||||
assert "clipboard" in README.lower(), "README must mention clipboard"
|
||||
assert "mouse" in README.lower() or "select" in README.lower(), (
|
||||
"README must mention mouse selection"
|
||||
)
|
||||
|
||||
|
||||
def test_readme_documents_all_cli_commands():
|
||||
"""README must list all top-level CLI commands."""
|
||||
commands = [
|
||||
"muxplex doctor",
|
||||
"muxplex upgrade",
|
||||
"muxplex show-password",
|
||||
"muxplex reset-secret",
|
||||
"muxplex config",
|
||||
]
|
||||
for cmd in commands:
|
||||
assert cmd in README, f"README must mention CLI command '{cmd}'"
|
||||
|
||||
|
||||
def test_readme_has_platform_support_section():
|
||||
"""README must document platform support."""
|
||||
assert "systemd" in README, "README must mention systemd"
|
||||
assert "launchd" in README, "README must mention launchd"
|
||||
|
||||
|
||||
def test_readme_documents_auth_modes():
|
||||
"""README must document both PAM and password auth."""
|
||||
assert "PAM" in README, "README must mention PAM auth"
|
||||
assert "password" in README.lower(), "README must mention password auth"
|
||||
assert "localhost" in README.lower() or "127.0.0.1" in README, (
|
||||
"README must mention localhost bypass"
|
||||
)
|
||||
|
||||
|
||||
def test_readme_documents_view_modes():
|
||||
"""README must document Auto and Fit view modes."""
|
||||
readme_lower = README.lower()
|
||||
assert "view mode" in readme_lower or "view modes" in readme_lower, (
|
||||
"README must mention view modes"
|
||||
)
|
||||
|
||||
|
||||
def test_readme_documents_ansi_color_previews():
|
||||
"""README must document ANSI color previews in tiles."""
|
||||
assert "ANSI" in README, "README must mention ANSI color previews"
|
||||
|
||||
|
||||
def test_readme_documents_hover_preview():
|
||||
"""README must document hover preview feature."""
|
||||
readme_lower = README.lower()
|
||||
assert "hover" in readme_lower and "preview" in readme_lower, (
|
||||
"README must mention hover preview"
|
||||
)
|
||||
|
||||
@@ -522,3 +522,32 @@ def test_prompt_host_missing_host_key_no_keyerror(monkeypatch):
|
||||
|
||||
# Must not raise KeyError
|
||||
svc._prompt_host_if_localhost()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug fix: Ctrl+C handling in logs functions (clean exit on KeyboardInterrupt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_systemd_logs_handles_keyboard_interrupt(monkeypatch):
|
||||
"""service logs must exit cleanly on Ctrl+C."""
|
||||
import muxplex.service as svc
|
||||
|
||||
def mock_run(*args, **kwargs):
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
# Should not raise
|
||||
svc._systemd_logs()
|
||||
|
||||
|
||||
def test_launchd_logs_handles_keyboard_interrupt(monkeypatch):
|
||||
"""service logs must exit cleanly on Ctrl+C on macOS."""
|
||||
import muxplex.service as svc
|
||||
|
||||
def mock_run(*args, **kwargs):
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
# Should not raise
|
||||
svc._launchd_logs()
|
||||
|
||||
Reference in New Issue
Block a user