RDP-Verbindungen als Tabs (mstsc/FreeRDP eingebettet); App-Kürzel im Terminal auf Strg+Shift, damit Strg+W/K/T bei nano & Co. ankommen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:20:04 +02:00
co-authored by Claude Opus 5.5
parent b1c6141264
commit aa31e753c2
11 changed files with 830 additions and 60 deletions
+127 -9
View File
@@ -267,7 +267,8 @@ function hostCard(hst) {
function hostMenu(hst) {
return [
{ label: hst.protocol === 'rdp' ? 'RDP-Verbindung starten' : 'Verbinden', icon: 'play', run: () => connectHost(hst) },
{ label: hst.protocol === 'rdp' ? 'Im Tab verbinden' : 'Verbinden', icon: 'play', run: () => connectHost(hst) },
...(hst.protocol === 'rdp' ? [{ label: 'In eigenem Fenster öffnen', icon: 'screen', run: () => launchRdp(hst) }] : []),
...(hst.protocol !== 'rdp' ? [{ label: 'SFTP öffnen', icon: 'folder', run: () => openSftp(hst) }] : []),
'-',
{ label: 'Bearbeiten', icon: 'edit', run: () => editHost(hst) },
@@ -279,10 +280,19 @@ function hostMenu(hst) {
}
function connectHost(hst) {
if (hst.protocol === 'rdp') return launchRdp(hst);
if (hst.protocol === 'rdp') return openRdp(hst);
openTerminal(hst);
}
async function openRdp(hst) {
if (settings().rdpEmbed !== false) {
const s = await api.call('rdp:embedSupported').catch((e) => ({ ok: false, reason: e.message }));
if (s.ok) return new RdpSession(hst);
toast('RDP im Tab nicht möglich (' + s.reason + ') – öffne eigenes Fenster.');
}
launchRdp(hst);
}
async function launchRdp(hst) {
try {
const client = await call('rdp:launch', hst.id || hst);
@@ -676,8 +686,15 @@ async function viewSettings(page) {
});
const rdpCard = h('<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">Prüfe …</div></div>');
const emb = check('RDP-Verbindungen als Tab in MrTerm anzeigen', 'rdpEmbed', st.rdpEmbed !== false);
emb.onchange = async () => {
await save({ rdpEmbed: $('input', emb).checked });
if (api.platform === 'linux') toast('Wird nach einem Neustart von MrTerm wirksam (X11/XWayland-Modus).');
};
rdpCard.append(emb);
if (api.platform === 'linux') rdpCard.append(h('<div class="hint" style="color:var(--faint);margin:-4px 0 12px 26px;font-size:11px">Nutzt xfreerdp3 mit /parent-window. MrTerm läuft dafür unter Wayland über XWayland.</div>'));
if (api.platform !== 'win32') {
const f = field('FreeRDP-Client', 'rdpClientLinux', st.rdpClientLinux, { type: 'select', options: [['auto', 'Automatisch'], ['xfreerdp3', 'xfreerdp3 (X11)'], ['sdl-freerdp3', 'sdl-freerdp3 (Wayland/X11)'], ['wlfreerdp3', 'wlfreerdp3 (Wayland)'], ['xfreerdp', 'xfreerdp (FreeRDP 2)']] });
const f = field('FreeRDP-Client (eigenes Fenster)', 'rdpClientLinux', st.rdpClientLinux, { type: 'select', options: [['auto', 'Automatisch'], ['xfreerdp3', 'xfreerdp3 (X11)'], ['sdl-freerdp3', 'sdl-freerdp3 (Wayland/X11)'], ['wlfreerdp3', 'wlfreerdp3 (Wayland)'], ['xfreerdp', 'xfreerdp (FreeRDP 2)']] });
f.onchange = () => save({ rdpClientLinux: $('select', f).value }).then(render);
rdpCard.append(f);
}
@@ -720,8 +737,8 @@ async function viewSettings(page) {
updCard.append(updFields, chk);
const keysCard = h(`<div class="settings-card"><h3>Tastenkürzel</h3><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 18px;color:var(--muted)">
<span><kbd>Strg</kbd>+<kbd>K</kbd> / <kbd>Strg</kbd>+<kbd>T</kbd></span><span>Quick Connect / Befehlspalette</span>
<span><kbd>Strg</kbd>+<kbd>W</kbd></span><span>Tab schließen</span>
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>K</kbd> / <kbd>T</kbd></span><span>Quick Connect / Befehlspalette (außerhalb des Terminals auch <kbd>Strg</kbd>+<kbd>K</kbd>)</span>
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span><span>Tab schließen</span>
<span><kbd>Strg</kbd>+<kbd>Tab</kbd></span><span>Nächster Tab</span>
<span><kbd>Strg</kbd>+<kbd>1..9</kbd></span><span>Zu Tab wechseln</span>
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>C/V</kbd></span><span>Kopieren / Einfügen im Terminal</span>
@@ -797,7 +814,7 @@ api.on('update:progress', (done, total) => {
// ============================================================ Tabs
function addTab(session) {
S.tabs.push(session);
const tab = h(`<div class="tab" data-tab="${session.id}"><span class="dot wait"></span><span>${esc(session.title)}</span><button class="x" title="Schließen (Strg+W)">${ICONS.close}</button></div>`);
const tab = h(`<div class="tab" data-tab="${session.id}"><span class="dot wait"></span><span>${esc(session.title)}</span><button class="x" title="Schließen (Strg+Shift+W)">${ICONS.close}</button></div>`);
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
@@ -822,6 +839,7 @@ function activateTab(id) {
S.tabs.forEach((t) => {
t.el.classList.toggle('active', t.id === id);
if (t.id === id) requestAnimationFrame(() => t.onShow?.());
else t.onHide?.();
});
$$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
@@ -882,7 +900,9 @@ class TerminalSession {
if (e.ctrlKey && e.shiftKey && e.code === 'KeyC') { if (this.term.hasSelection()) api.call('clipboard:write', this.term.getSelection()); return false; }
if (e.ctrlKey && e.shiftKey && e.code === 'KeyV') { this.paste(); return false; }
if (e.ctrlKey && e.shiftKey && e.code === 'KeyF') { this.toggleFind(true); return false; }
if (e.ctrlKey && !e.shiftKey && ['KeyK', 'KeyT', 'KeyW', 'Tab'].includes(e.code)) return false;
// App-Kürzel im Terminal nur mit Strg+Shift, damit Strg+W/K/T (nano, bash, …) im Terminal ankommen
if (e.ctrlKey && e.shiftKey && ['KeyK', 'KeyT', 'KeyW'].includes(e.code)) return false;
if (e.ctrlKey && e.code === 'Tab') return false;
if (e.ctrlKey && /^Digit[1-9]$/.test(e.code)) return false;
if (e.ctrlKey && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '0')) { this.zoom(e.key); return false; }
return true;
@@ -1001,6 +1021,103 @@ class TerminalSession {
}
function openTerminal(host) { return new TerminalSession(host); }
// ============================================================ RDP-Session (eingebettet im Tab)
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
S.covered = false;
function updateCover() {
const covered = !!document.querySelector('.modal-bg, .ctx');
if (covered === S.covered) return;
S.covered = covered;
const t = S.tabs.find((x) => x.id === S.active && x.kind === 'rdp');
if (t) covered ? t.onHide() : t.onShow();
}
new MutationObserver(updateCover).observe(document.body, { childList: true, subtree: true });
class RdpSession {
constructor(host) {
this.kind = 'rdp';
this.id = uid();
this.host = host;
this.title = host.label || host.address;
this.attached = false;
this.el = h(`<div class="session">
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span></div>
<button class="btn ghost sm" data-a="reconnect" title="Neu verbinden">${ICONS.refresh}Neu verbinden</button>
${api.platform === 'win32' ? `<button class="btn ghost sm" data-a="detach" title="Sitzung als eigenes Fenster weiterführen">${ICONS.screen}Als Fenster lösen</button>` : ''}
</div>
<div class="term-wrap rdp-area"><div class="rdp-hint">RDP-Sitzung ausgeblendet, solange ein Dialog geöffnet ist.</div></div></div>`);
this.area = $('.rdp-area', this.el);
this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'reconnect') this.reconnect();
if (a === 'detach') this.detach();
});
let raf = 0;
this.ro = new ResizeObserver(() => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => this.pushBounds()); });
this.ro.observe(this.area);
this.unsub = api.on('rdp:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
addTab(this);
this.connect();
}
bounds() {
const r = this.area.getBoundingClientRect();
const d = window.devicePixelRatio || 1;
return { x: Math.round(r.left * d), y: Math.round(r.top * d), width: Math.round(r.width * d), height: Math.round(r.height * d) };
}
pushBounds() { if (this.attached && this.area.offsetWidth) api.rdp.bounds(this.id, this.bounds()); }
isVisible() { return S.active === this.id && !S.covered; }
showOverlay(msg, err) {
this.overlay?.remove();
this.overlay = h(`<div class="overlay">${err ? '' : '<div class="spinner"></div>'}<div class="msg ${err ? 'err' : ''}">${esc(msg)}</div>
<div class="row">${err ? `<button class="btn" data-o="window">${ICONS.screen}Im eigenen Fenster</button><button class="btn primary" data-o="retry">${ICONS.refresh}Erneut verbinden</button>` : '<button class="btn ghost" data-o="cancel">Abbrechen</button>'}</div></div>`);
this.overlay.onclick = (e) => {
const o = e.target.closest('[data-o]')?.dataset.o;
if (o === 'retry') this.reconnect();
if (o === 'cancel') closeTab(this.id);
if (o === 'window') { launchRdp(this.host); closeTab(this.id); }
};
this.area.append(this.overlay);
}
async connect() {
setTabState(this, 'wait');
this.showOverlay(`Verbinde mit ${this.host.address} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.`);
await new Promise((r) => requestAnimationFrame(r));
try {
const ok = await api.call('rdp:open', this.id, this.host.id || this.host, this.bounds());
if (!ok && !this.attached && !this.disposed && !this.exited) this.showOverlay('Das RDP-Fenster konnte nicht eingebettet werden.', true);
} catch (e) {
setTabState(this, 'err');
this.showOverlay(e.message, true);
}
}
onEvent(type, payload) {
if (type === 'attached') {
this.attached = true; this.exited = false;
this.overlay?.remove(); this.overlay = null;
setTabState(this, 'on');
if (this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } else api.rdp.hide(this.id);
} else if (type === 'exit') {
this.attached = false; this.exited = true;
setTabState(this, payload?.code ? 'err' : '');
this.showOverlay(`RDP-Sitzung beendet${payload?.code ? ` (Code ${payload.code})` : ''}.${payload?.message ? ' ' + payload.message : ''}`, true);
}
}
reconnect() { api.rdp.close(this.id); this.attached = false; this.exited = false; this.connect(); }
async detach() {
if (!this.attached) return;
if (await api.call('rdp:detach', this.id)) { this.attached = false; closeTab(this.id); toast('Sitzung läuft als eigenes Fenster weiter', 'ok'); }
}
onShow() { if (this.attached && this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } }
onHide() { if (this.attached) api.rdp.hide(this.id); }
dispose() { this.disposed = true; this.unsub(); this.ro.disconnect(); api.rdp.close(this.id); }
}
// ============================================================ SFTP
function viewSftpPicker(page) {
page.append(toolbar('Host für SFTP wählen …'));
@@ -1261,8 +1378,9 @@ if (api.platform === 'darwin') $('.win-ctrls').style.display = 'none';
document.addEventListener('keydown', (e) => {
if (!e.ctrlKey) return;
if (!e.shiftKey && (e.code === 'KeyK' || e.code === 'KeyT')) { e.preventDefault(); openPalette(); }
else if (!e.shiftKey && e.code === 'KeyW') { e.preventDefault(); if (S.active !== 'home') closeTab(S.active); }
const inTerm = !!e.target.closest?.('.xterm');
if ((e.code === 'KeyK' || e.code === 'KeyT') && (e.shiftKey || !inTerm)) { e.preventDefault(); openPalette(); }
else if (e.shiftKey && e.code === 'KeyW') { e.preventDefault(); if (S.active !== 'home') closeTab(S.active); }
else if (e.code === 'Tab') {
e.preventDefault();
const ids = ['home', ...S.tabs.map((t) => t.id)];