From d87feb63af0da37c359c7933d467021df6c6b8de Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 14 Oct 2026 21:54:00 +0200 Subject: [PATCH] Tabs: reorder by dragging; detach tabs into their own window (drag out of the window or "Open in new window") and move them back (drag into another window's tab bar or "Back to main window"); terminal tabs keep their SSH connection and output, SFTP/Docker/firewall/network reconnect; events go to all windows, password prompts to the window showing the session; closing a detached window ends its sessions Co-Authored-By: Claude Opus 5.5 --- src/i18n.js | 2 + src/main/main.js | 74 +++++++++++++++++++++---- src/preload.js | 6 ++ src/renderer/app.js | 120 ++++++++++++++++++++++++++++++++++++++-- src/renderer/styles.css | 5 ++ 5 files changed, 191 insertions(+), 16 deletions(-) diff --git a/src/i18n.js b/src/i18n.js index b50e4a3..1feb7cb 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -156,6 +156,8 @@ 'sudo password': 'sudo-Passwort', 'sudo password for {user}': 'sudo-Passwort für {user}', 'Wrong password. sudo password for {user}': 'Falsches Passwort. sudo-Passwort für {user}', + 'Open in new window': 'In neuem Fenster öffnen', + 'Back to main window': 'Zurück ins Hauptfenster', // Support-Assistent 'Delete chat': 'Chat löschen', 'Delete chat?': 'Chat löschen?', diff --git a/src/main/main.js b/src/main/main.js index cab4df8..815ba1c 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -43,8 +43,19 @@ process.on('uncaughtException', (e) => { }); process.on('unhandledRejection', (e) => console.error('Unhandled rejection:', e)); +// Abgelöste Tabs laufen in eigenen Fenstern. Ereignisse gehen an alle Fenster (die Oberfläche filtert nach Sitzungs-ID), +// Passwortabfragen nur an das Fenster, das die Sitzung zeigt; Kopplung/Updates nur an das Hauptfenster. +const tabWins = new Set(); +const sessionOwner = new Map(); // Sitzungs-ID -> BrowserWindow (nur für abgelöste Fenster) +const MAIN_ONLY = new Set(['sync:pairPrompt', 'update:available']); +const alive = (w) => w && !w.isDestroyed(); function send(channel, ...args) { - if (win && !win.isDestroyed()) win.webContents.send(channel, ...args); + if (channel === 'secret:request') { + const w = sessionOwner.get(args[0]?.sessionId); + if (alive(w)) return w.webContents.send(channel, ...args); + } + if (alive(win)) win.webContents.send(channel, ...args); + if (!MAIN_ONLY.has(channel)) for (const w of tabWins) if (alive(w)) w.webContents.send(channel, ...args); } async function confirmHostKey(target, fingerprint, known) { @@ -91,12 +102,13 @@ const sync = new SyncService(store, send, (req) => new Promise((resolve) => { ipcMain.on('sync:pairReply', (_e, reqId, ok) => { const r = pairReplies.get(reqId); pairReplies.delete(reqId); r?.(!!ok); }); store.onChange = () => sync.schedule(); -function createWindow() { - win = new BrowserWindow({ +function newWindow(opts = {}) { + const w = new BrowserWindow({ width: 1360, height: 860, - minWidth: 900, - minHeight: 560, + minWidth: 600, + minHeight: 400, + ...opts, backgroundColor: '#14161d', icon: path.join(__dirname, '..', '..', 'assets', 'icon.png'), frame: false, @@ -108,18 +120,57 @@ function createWindow() { sandbox: true, }, }); + w.on('maximize', () => w.webContents.send('win:state', true)); + w.on('unmaximize', () => w.webContents.send('win:state', false)); + w.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); + return w; +} + +function createWindow() { + win = newWindow({ minWidth: 900, minHeight: 560 }); Menu.setApplicationMenu(null); win.loadFile(path.join(__dirname, '..', 'renderer', 'index.html')); - win.on('maximize', () => send('win:state', true)); + // Hauptfenster zu = MrTerm beenden, auch abgelöste Tab-Fenster schließen + win.on('closed', () => { for (const w of tabWins) if (alive(w)) w.close(); }); // Eingebettete RDP-Fenster (Windows: owned windows) beim Verschieben/Größenändern mitführen const followRdp = () => { for (const s of rdpSessions.values()) { try { s.emb.reposition(); } catch {} } }; win.on('move', followRdp); win.on('resize', followRdp); win.on('restore', followRdp); - win.on('unmaximize', () => send('win:state', false)); - win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); } +// Tab in ein eigenes Fenster lösen: data = { kind, host, id, title, … } wird vom neuen Fenster per win:handoff abgeholt +const handoffs = new Map(); +function openTabWindow(data, pos) { + const token = crypto.randomUUID(); + handoffs.set(token, data); + const w = newWindow({ width: 1000, height: 650, ...(pos ? { x: Math.round(pos.x - 120), y: Math.round(pos.y - 20) } : {}) }); + tabWins.add(w); + w.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'), { query: { detached: token } }); + // Fenster zu: dort noch offene Sitzungen beenden + w.on('closed', () => { + tabWins.delete(w); + for (const [id, o] of sessionOwner) { + if (o !== w) continue; + sessionOwner.delete(id); + for (const m of [ssh, docker, firewall, network]) { try { m.close(id); } catch {} } + } + }); +} +ipcMain.handle('win:detach', (_e, data, pos) => { openTabWindow(data, pos); return true; }); +ipcMain.handle('win:handoff', (_e, token) => { const d = handoffs.get(token); handoffs.delete(token); return d || null; }); +// Sitzung gehört (jetzt) dem aufrufenden Fenster; im Hauptfenster gilt keine Zuordnung +ipcMain.on('win:claim', (e, id) => { + const w = BrowserWindow.fromWebContents(e.sender); + if (w === win) sessionOwner.delete(id); else sessionOwner.set(id, w); +}); +// Tab ins Hauptfenster zurück +ipcMain.handle('win:attach', (_e, data) => { if (alive(win)) { win.webContents.send('win:adopt', data); win.focus(); } return true; }); +// Ein anderes Fenster hat den Tab übernommen (Drag & Drop): beim bisherigen Fenster ohne Trennen entfernen +ipcMain.on('win:released', (e, id) => { + for (const w of [win, ...tabWins]) if (alive(w) && w.webContents !== e.sender) w.webContents.send('win:released', id); +}); + // Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar const OPEN_WHILE_LOCKED = new Set(['app:version']); function handle(channel, fn) { @@ -133,9 +184,10 @@ function handle(channel, fn) { } // ---------- Fenster ---------- -ipcMain.on('win:min', () => win.minimize()); -ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize())); -ipcMain.on('win:close', () => win.close()); +const senderWin = (e) => BrowserWindow.fromWebContents(e.sender); +ipcMain.on('win:min', (e) => senderWin(e)?.minimize()); +ipcMain.on('win:max', (e) => { const w = senderWin(e); if (w) w.isMaximized() ? w.unmaximize() : w.maximize(); }); +ipcMain.on('win:close', (e) => senderWin(e)?.close()); // ---------- App-Sperre (Passwort / FIDO2) ---------- const kdf = (pw, salt, N) => new Promise((resolve, reject) => diff --git a/src/preload.js b/src/preload.js index d3e5a99..fcbe7a7 100644 --- a/src/preload.js +++ b/src/preload.js @@ -20,6 +20,12 @@ contextBridge.exposeInMainWorld('api', { min: () => ipcRenderer.send('win:min'), max: () => ipcRenderer.send('win:max'), close: () => ipcRenderer.send('win:close'), + // Tabs lösen / zurückholen + detach: (data, pos) => ipcRenderer.invoke('win:detach', data, pos), + attach: (data) => ipcRenderer.invoke('win:attach', data), + handoff: (token) => ipcRenderer.invoke('win:handoff', token), + claim: (id) => ipcRenderer.send('win:claim', id), + released: (id) => ipcRenderer.send('win:released', id), }, ssh: { write: (id, d) => ipcRenderer.send('ssh:write', id, d), diff --git a/src/renderer/app.js b/src/renderer/app.js index 90d6817..c1b4c0f 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -159,6 +159,12 @@ async function reload() { S.activeForwards = new Set(await call('fw:active')); if (S.vault.vpns.length) S.vpnStatus = await call('vpn:status').catch(() => ({})); render(); + // Abgelöstes Fenster: übergebenen Tab erst nach dem Entsperren übernehmen (vorher fehlen Tresor und Einstellungen) + if (DETACHED && !S.handoffDone) { + S.handoffDone = true; + const d = await api.win.handoff(DETACHED); + if (d) adoptTab(d); else api.win.close(); + } } // Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title) @@ -1547,10 +1553,14 @@ function addTab(session) { ...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[session.kind] || openTerminal)(session.host) }] : []), ...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []), { label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } }, + ...(DETACHABLE.has(session.kind) ? ['-', { label: T('Open in new window'), icon: 'dup', run: () => detachTab(session) }, + ...(DETACHED ? [{ label: T('Back to main window'), icon: 'home', run: () => attachTab(session) }] : [])] : []), '-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) }, { label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) }, ]); session.tabEl = tab; + tabDrag(tab, session); + if (!WEB) api.win.claim(session.id); $('#tabs').append(tab); $('#sessions').append(session.el); activateTab(session.id); @@ -1578,17 +1588,101 @@ function closeTab(id) { t.dispose(); t.el.remove(); t.tabEl.remove(); S.tabs.splice(i, 1); + if (DETACHED && !S.tabs.length) return api.win.close(); if (S.active === id) activateTab(S.tabs[i]?.id || S.tabs[i - 1]?.id || 'home'); } $('.tab.home').onclick = () => activateTab('home'); +// ============================================================ Tabs verschieben, lösen und zurückholen (Desktop) +// Ziehen in der Tableiste = Reihenfolge ändern; aus dem Fenster ziehen = eigenes Fenster; +// in die Tableiste eines anderen MrTerm-Fensters ziehen = dorthin verschieben. Terminals behalten dabei ihre SSH-Verbindung. +const DETACHED = !WEB && new URLSearchParams(location.search).get('detached'); +const DETACHABLE = new Set(WEB ? [] : ['ssh', 'sftp', 'docker', 'firewall', 'network']); +const TAB_MIME = 'application/x-mrterm-tab'; + +function tabHandoff(t) { + const d = { kind: t.kind, host: t.host, id: t.id, title: t.title }; + if (t.kind === 'ssh') Object.assign(d, { text: t.bufferText(), alive: t.alive }); + return d; +} +// Tab hier entfernen, ohne die SSH-Verbindung zu trennen (andere Tabarten verbinden im neuen Fenster neu) +function releaseTab(t) { t.keepAlive = true; closeTab(t.id); } +function adoptTab(d) { + if (d.kind === 'ssh') return new TerminalSession(d.host, d); + return ({ sftp: openSftp, docker: openDocker, firewall: openFirewall, network: openNetwork }[d.kind])?.(d.host); +} +async function detachTab(t, pos) { await api.win.detach(tabHandoff(t), pos); releaseTab(t); } +async function attachTab(t) { await api.win.attach(tabHandoff(t)); releaseTab(t); } + +function tabDrag(tab, session) { + tab.draggable = true; + tab.addEventListener('dragstart', (e) => { + S.dragTab = session; + e.dataTransfer.effectAllowed = 'move'; + if (DETACHABLE.has(session.kind)) e.dataTransfer.setData(TAB_MIME, JSON.stringify(tabHandoff(session))); + else e.dataTransfer.setData('text/plain', session.title); + }); + tab.addEventListener('dragend', (e) => { + const t = S.dragTab; S.dragTab = null; + $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); + if (!t || !DETACHABLE.has(t.kind) || e.dataTransfer.dropEffect !== 'none') return; + // Außerhalb des Fensters losgelassen → eigenes Fenster an dieser Stelle + const out = e.screenX < window.screenX || e.screenX > window.screenX + window.outerWidth || e.screenY < window.screenY || e.screenY > window.screenY + window.outerHeight; + if (out) detachTab(t, { x: e.screenX, y: e.screenY }); + }); +} +function tabDropTarget(e) { + const tabs = $$('#tabs .tab:not(.home)'); + const over = tabs.find((x) => { const r = x.getBoundingClientRect(); return e.clientX >= r.left && e.clientX <= r.right; }); + if (!over) return null; + const r = over.getBoundingClientRect(); + return { el: over, after: e.clientX > r.left + r.width / 2 }; +} +const tabsBar = $('#tabs'); +tabsBar.addEventListener('dragover', (e) => { + const local = !!S.dragTab, foreign = !local && [...e.dataTransfer.types].includes(TAB_MIME); + if (!local && !foreign) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); + const t = tabDropTarget(e); + if (t && t.el !== S.dragTab?.tabEl) t.el.classList.add(t.after ? 'drop-after' : 'drop-before'); +}); +tabsBar.addEventListener('dragleave', (e) => { if (!tabsBar.contains(e.relatedTarget)) $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); }); +tabsBar.addEventListener('drop', (e) => { + const target = tabDropTarget(e); + $$('#tabs .tab').forEach((x) => x.classList.remove('drop-before', 'drop-after')); + let moved = S.dragTab; + if (!moved) { + // Tab aus einem anderen Fenster + const raw = e.dataTransfer.getData(TAB_MIME); + if (!raw) return; + e.preventDefault(); + const d = JSON.parse(raw); + api.win.released(d.id); + moved = adoptTab(d); + if (!moved) return; + } else e.preventDefault(); + if (target && target.el !== moved.tabEl) target.el.insertAdjacentElement(target.after ? 'afterend' : 'beforebegin', moved.tabEl); + else if (!target) tabsBar.append(moved.tabEl); + // Reihenfolge für Strg+Tab / Strg+1..9 übernehmen + const order = $$('#tabs .tab[data-tab]').map((x) => x.dataset.tab); + S.tabs.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)); + activateTab(moved.id); +}); +if (!WEB) { + api.on('win:adopt', (d) => { adoptTab(d); }); + api.on('win:released', (id) => { const t = S.tabs.find((x) => x.id === id); if (t) releaseTab(t); }); +} + // ============================================================ Terminal-Session class TerminalSession { - constructor(host) { + // attach: { id, title, text, alive } – bestehende SSH-Sitzung aus einem anderen Fenster übernehmen + constructor(host, attach) { this.kind = 'ssh'; - this.id = uid(); + this.id = attach?.id || uid(); this.host = host; - this.title = host.label || host.address; + this.title = attach?.title || host.label || host.address; this.el = h(`
${avatar(host, 22).replace('SSH', '')}${esc(hostSub(host))}
@@ -1607,7 +1701,21 @@ class TerminalSession { this.bindUi(); this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); }); addTab(this); - this.connect(); + if (attach) this.adopt(attach); else this.connect(); + } + + adopt({ text, alive }) { + if (text) this.term.write(text.replace(/\r?\n/g, '\r\n')); + this.alive = !!alive; this.ended = !alive; + setTabState(this, alive ? 'on' : ''); + requestAnimationFrame(() => { this.doFit(); if (this.alive) api.ssh.resize(this.id, this.term.cols, this.term.rows); }); + } + // Gesamter Puffer als Text (beim Lösen in ein anderes Fenster; Farben gehen dabei verloren) + bufferText() { + const b = this.term.buffer.active, out = []; + for (let i = 0; i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? ''); + while (out.length && !out[out.length - 1]) out.pop(); + return out.join('\n'); } initTerm() { @@ -1760,7 +1868,7 @@ class TerminalSession { this.term.write(`\r\n\x1b[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`); } } - dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); } + dispose() { this.unsub(); this.ro.disconnect(); if (!this.keepAlive) api.ssh.close(this.id); this.term.dispose(); } } // Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett function aiMarkdown(md) { @@ -3177,5 +3285,7 @@ api.on('vpn:changed', async () => { S.vpnStatus = await call('vpn:status').catch api.on('toast', (m, t) => toast(m, t)); api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '' : ''; }); +// Abgelöstes Fenster: nur Tabs, ohne Seitenleiste und Tresor-Tab; den übergebenen Tab übernehmen +if (DETACHED) document.body.classList.add('detached'); reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm $('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 80ee429..3dbc6f3 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -28,6 +28,11 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: #tabs::-webkit-scrollbar { height: 0; } .tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px 0 12px; border: none; background: transparent; border-radius: 8px; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; } .tab.home { min-width: 0; } +/* Tabs verschieben / lösen */ +.tab.drop-before { box-shadow: inset 3px 0 0 var(--accent); } +.tab.drop-after { box-shadow: inset -3px 0 0 var(--accent); } +body.detached #sidebar, body.detached .tab.home, body.detached #homeView { display: none !important; } +body.detached .brand { width: auto; padding-right: 18px; } .tab span { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; } .tab:hover { background: var(--hover); color: var(--text); } .tab.active { background: var(--bg); color: var(--text); box-shadow: 0 0 0 1px var(--chrome-line); }