Files
MrTerm/src/renderer/app.js
T

1520 lines
91 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* global Terminal, FitAddon, WebLinksAddon, SearchAddon, TERM_THEMES */
'use strict';
// ============================================================ Helpers
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const uid = () => crypto.randomUUID();
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; }
const ICONS = {
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M20 20l-4-4"/></svg>',
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
edit: '<svg viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z"/></svg>',
trash: '<svg viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
more: '<svg viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg>',
close: '<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>',
folder: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
file: '<svg viewBox="0 0 24 24"><path d="M6 3h8l4 4v14H6z M14 3v4h4"/></svg>',
term: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3M12 15h5"/></svg>',
screen: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>',
key: '<svg viewBox="0 0 24 24"><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/></svg>',
copy: '<svg viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg>',
play: '<svg viewBox="0 0 24 24"><path d="M7 5l12 7-12 7z"/></svg>',
stop: '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>',
up: '<svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
refresh: '<svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6"/></svg>',
home: '<svg viewBox="0 0 24 24"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg>',
newdir: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM12 10v6M9 13h6"/></svg>',
arrowR: '<svg viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6"/></svg>',
arrowL: '<svg viewBox="0 0 24 24"><path d="M19 12H5M11 6l-6 6 6 6"/></svg>',
code: '<svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg>',
shield: '<svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/></svg>',
lock: '<svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>',
unlock: '<svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 7.5-2"/></svg>',
eye: '<svg viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>',
fwd: '<svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg>',
dup: '<svg viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M4 16V5a1 1 0 0 1 1-1h11"/></svg>',
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
download: '<svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg>',
upload: '<svg viewBox="0 0 24 24"><path d="M12 20V8M6 14l6-6 6 6M4 4h16"/></svg>',
};
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
function colorFor(h) {
if (h.color) return h.color;
let n = 0; for (const c of h.label || h.address || '') n = (n * 31 + c.charCodeAt(0)) >>> 0;
return COLORS[n % COLORS.length];
}
function avatar(hst, size) {
const letter = esc((hst.label || hst.address || '?').trim()[0]?.toUpperCase() || '?');
const proto = hst.protocol === 'rdp' ? 'RDP' : 'SSH';
return `<div class="avatar" style="background:${colorFor(hst)}${size ? `;width:${size}px;height:${size}px` : ''}">${hst.protocol === 'rdp' ? ICONS.screen : letter}<span class="proto">${proto}</span></div>`;
}
function fmtSize(n) {
if (n == null) return '';
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
}
const fmtDate = (t) => (t ? new Date(t).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '');
function relTime(t) {
const s = (Date.now() - t) / 1000;
if (s < 60) return 'gerade eben';
if (s < 3600) return `vor ${Math.floor(s / 60)} Min.`;
if (s < 86400) return `vor ${Math.floor(s / 3600)} Std.`;
return `vor ${Math.floor(s / 86400)} Tagen`;
}
function toast(msg, type = '') {
const el = h(`<div class="toast ${type}">${esc(msg)}</div>`);
$('#toasts').append(el);
setTimeout(() => el.remove(), type === 'error' ? 7000 : 3500);
}
async function call(ch, ...a) {
try { return await api.call(ch, ...a); }
catch (e) { toast(e.message, 'error'); throw e; }
}
// ---------- Modals
function modal({ title, text = '', body = '', buttons = [] }) {
return new Promise((resolve) => {
const bg = h(`<div class="modal-bg"><div class="modal"><div class="mbody"><h3>${esc(title)}</h3>${text ? `<p>${esc(text)}</p>` : ''}${body}</div><div class="mfoot"></div></div></div>`);
const foot = $('.mfoot', bg);
const done = (v) => { bg.remove(); resolve(v); };
buttons.forEach((b) => {
const btn = h(`<button class="btn ${b.cls || ''}">${esc(b.label)}</button>`);
btn.onclick = () => done(b.value === 'form' ? Object.fromEntries($$('[name]', bg).map((i) => [i.name, i.type === 'checkbox' ? i.checked : i.value])) : b.value);
foot.append(btn);
});
bg.addEventListener('mousedown', (e) => { if (e.target === bg) done(null); });
bg.addEventListener('keydown', (e) => {
if (e.key === 'Escape') done(null);
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') { e.preventDefault(); foot.lastElementChild.click(); }
});
$('#modalRoot').append(bg);
($('input,select,textarea', bg) || foot.lastElementChild).focus();
});
}
const confirmBox = (title, text, label = 'Löschen') =>
modal({ title, text, buttons: [{ label: 'Abbrechen', value: false, cls: 'ghost' }, { label, value: true, cls: label === 'Löschen' ? 'danger' : 'primary' }] });
async function promptBox(title, label, value = '', type = 'text') {
const r = await modal({ title, body: `<div class="field"><label>${esc(label)}</label><input name="v" type="${type}" value="${esc(value)}" /></div>`,
buttons: [{ label: 'Abbrechen', value: null, cls: 'ghost' }, { label: 'OK', value: 'form', cls: 'primary' }] });
return r ? r.v : null;
}
// ---------- Kontextmenü
function ctxMenu(x, y, items) {
$$('.ctx').forEach((c) => c.remove());
const m = h('<div class="ctx"></div>');
for (const it of items) {
if (it === '-') { m.append(h('<hr>')); continue; }
const b = h(`<button class="${it.danger ? 'danger' : ''}">${it.icon ? ICONS[it.icon] : ''}<span>${esc(it.label)}</span></button>`);
b.onclick = () => { m.remove(); it.run(); };
m.append(b);
}
document.body.append(m);
const r = m.getBoundingClientRect();
m.style.left = Math.min(x, innerWidth - r.width - 8) + 'px';
m.style.top = Math.min(y, innerHeight - r.height - 8) + 'px';
setTimeout(() => document.addEventListener('mousedown', function off(e) { if (!m.contains(e.target)) { m.remove(); document.removeEventListener('mousedown', off); } }), 0);
}
// ============================================================ State
const S = {
vault: null,
view: 'hosts',
group: null,
search: '',
tag: null,
tabs: [],
active: 'home',
activeForwards: new Set(),
};
const settings = () => S.vault.settings;
async function reload() {
S.vault = await call('vault:get');
const b = $('#vaultBadge');
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>Vault verschlüsselt</span>` : `${ICONS.unlock}<span>Vault unverschlüsselt</span>`;
b.title = S.vault.encrypted ? 'Daten werden mit dem System-Schlüsselbund verschlüsselt.' : 'Kein System-Schlüsselbund verfügbar (z. B. gnome-keyring/kwallet installieren).';
applyAppTheme();
S.activeForwards = new Set(await call('fw:active'));
render();
}
// ============================================================ Navigation
$$('#sidebar .nav[data-view]').forEach((n) => n.addEventListener('click', () => {
S.view = n.dataset.view;
$$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x === n));
closeDrawer();
activateTab('home');
render();
}));
// ============================================================ Einklappbares Menü (Sidebar)
(() => {
const KEY = 'mrterm.nav.collapsed';
const sb = $('#sidebar');
// Tooltips für die Symbolansicht
$$('#navMenu .nav').forEach((n) => { n.title = n.querySelector('span')?.textContent || ''; });
const apply = (collapsed) => {
sb.classList.toggle('nav-collapsed', collapsed);
$('#navToggle').title = collapsed ? 'Menü ausklappen' : 'Menü einklappen';
};
let collapsed = false;
try { collapsed = localStorage.getItem(KEY) === '1'; } catch {}
apply(collapsed);
$('#navToggle').onclick = () => {
collapsed = !collapsed;
apply(collapsed);
try { localStorage.setItem(KEY, collapsed ? '1' : '0'); } catch {}
};
})();
// ============================================================ Verzeichnisbaum (Sidebar)
const TREE_KEY = 'mrterm.tree.open';
const treeOpen = new Set((() => { try { return JSON.parse(localStorage.getItem(TREE_KEY) || '[]'); } catch { return []; } })());
const saveTreeOpen = () => { try { localStorage.setItem(TREE_KEY, JSON.stringify([...treeOpen])); } catch {} };
const CHEV = '<svg viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>';
function goGroup(id) {
S.view = 'hosts'; S.group = id; S.search = ''; S.tag = null;
$$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x.dataset.view === 'hosts'));
closeDrawer();
activateTab('home');
render();
}
async function moveHostToGroup(hostId, groupId) {
const hst = S.vault.hosts.find((x) => x.id === hostId);
if (!hst || (hst.groupId || null) === groupId) return;
await call('vault:upsert', 'hosts', { ...hst, groupId });
if (groupId) { treeOpen.add(groupId); saveTreeOpen(); }
reload();
}
function dropTarget(el, groupId) {
el.addEventListener('dragover', (e) => { if (e.dataTransfer.types.includes('mrterm/host')) { e.preventDefault(); el.classList.add('drop'); } });
el.addEventListener('dragleave', () => el.classList.remove('drop'));
el.addEventListener('drop', (e) => {
e.preventDefault(); el.classList.remove('drop');
const id = e.dataTransfer.getData('mrterm/host');
if (id) moveHostToGroup(id, groupId);
});
}
function renderTree() {
const box = $('#tree');
if (!box || !S.vault) return;
const scroll = box.scrollTop;
box.innerHTML = '';
const { groups, hosts } = S.vault;
const byLabel = (x, y) => (x.label || x.address || '').localeCompare(y.label || y.address || '', undefined, { numeric: true });
const countIn = (gid) => hosts.filter((x) => x.groupId === gid).length + groups.filter((g) => g.parentId === gid).reduce((n, g) => n + countIn(g.id), 0);
const head = h(`<div class="tree-head"><span>Verbindungen</span><button title="Alle einklappen">${ICONS.up}</button></div>`);
$('button', head).onclick = () => { treeOpen.clear(); saveTreeOpen(); renderTree(); };
dropTarget(head, null); // auf die Überschrift ziehen = Stammebene
box.append(head);
if (!groups.length && !hosts.length) { box.append(h('<div class="tree-empty">Noch keine Hosts</div>')); return; }
const pad = (depth) => `padding-left:${4 + depth * 14}px`;
const walk = (parentId, depth) => {
for (const g of groups.filter((x) => (x.parentId || null) === parentId).sort(byLabel)) {
const open = treeOpen.has(g.id);
const active = S.view === 'hosts' && S.group === g.id && S.active === 'home';
const row = h(`<div class="tree-row group ${active ? 'active' : ''}" style="${pad(depth)}" title="${esc(g.label)}">
<span class="chev ${open ? 'open' : ''}">${CHEV}</span><span class="ti" style="color:${g.color || 'var(--folder)'}">${ICONS.folder}</span><span class="tl">${esc(g.label)}</span><span class="tc">${countIn(g.id)}</span></div>`);
const toggle = () => { if (open) treeOpen.delete(g.id); else treeOpen.add(g.id); saveTreeOpen(); renderTree(); };
$('.chev', row).onclick = (e) => { e.stopPropagation(); toggle(); };
row.onclick = () => { if (!open) { treeOpen.add(g.id); saveTreeOpen(); } goGroup(g.id); };
row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
{ label: 'Öffnen', icon: 'folder', run: () => goGroup(g.id) },
{ label: 'Host hinzufügen', icon: 'plus', run: () => { goGroup(g.id); editHost({ groupId: g.id }); } },
{ label: 'Unterordner anlegen', icon: 'newdir', run: () => { goGroup(g.id); editGroup({ parentId: g.id }); } },
{ label: 'Bearbeiten', icon: 'edit', run: () => { goGroup(g.id); editGroup(g); } },
'-',
{ label: 'Löschen', icon: 'trash', danger: true, run: async () => { if (await confirmBox('Gruppe löschen?', `„${g.label}“ wird gelöscht. Hosts bleiben erhalten.`)) { await call('vault:remove', 'groups', g.id); reload(); } } },
]);
dropTarget(row, g.id);
box.append(row);
if (open) walk(g.id, depth + 1);
}
for (const x of hosts.filter((y) => (y.groupId || null) === parentId).sort(byLabel)) {
const live = S.tabs.some((t) => t.host?.id === x.id);
const row = h(`<div class="tree-row host" style="${pad(depth)}" draggable="true" title="${esc(hostSub(x))}">
<span class="chev"></span><span class="ti" style="background:${colorFor(x)}">${x.protocol === 'rdp' ? ICONS.screen : ICONS.term}</span><span class="tl">${esc(x.label || x.address)}</span>${live ? '<span class="tc" style="color:var(--green)">●</span>' : ''}</div>`);
row.ondblclick = () => connectHost(x);
row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(x));
row.ondragstart = (e) => { e.dataTransfer.setData('mrterm/host', x.id); e.dataTransfer.effectAllowed = 'move'; };
box.append(row);
}
};
walk(null, 0);
box.scrollTop = scroll;
}
function render() {
const page = $('#homeView');
const views = { hosts: viewHosts, sftp: viewSftpPicker, keys: viewKeys, forwards: viewForwards, snippets: viewSnippets, known: viewKnown, history: viewHistory, settings: viewSettings };
const scroll = page.scrollTop;
page.innerHTML = '';
views[S.view](page);
page.scrollTop = scroll;
renderTree();
}
function toolbar(placeholder, buttons = []) {
const t = h(`<div class="toolbar"><label class="search">${ICONS.search}<input placeholder="${esc(placeholder)}" value="${esc(S.search)}" /></label></div>`);
const inp = $('input', t);
inp.addEventListener('input', () => { S.search = inp.value; const pos = inp.selectionStart; render(); const n = $('#homeView .search input'); n.focus(); n.setSelectionRange(pos, pos); });
buttons.forEach((b) => { const el = h(`<button class="btn ${b.cls || ''}">${b.icon ? ICONS[b.icon] : ''}${esc(b.label)}</button>`); el.onclick = b.run; t.append(el); });
return t;
}
const matches = (...fields) => { const q = S.search.trim().toLowerCase(); return !q || fields.some((f) => String(f || '').toLowerCase().includes(q)); };
function emptyState(icon, title, text, btnLabel, run) {
const e = h(`<div class="empty"><div class="big">${ICONS[icon]}</div><h3>${esc(title)}</h3><p>${esc(text)}</p></div>`);
if (btnLabel) { const b = h(`<button class="btn primary">${ICONS.plus}${esc(btnLabel)}</button>`); b.onclick = run; e.append(b); }
return e;
}
// ============================================================ Hosts
function hostSub(hst) {
const u = hst.username ? `${hst.username}@` : '';
const port = hst.port && Number(hst.port) !== (hst.protocol === 'rdp' ? 3389 : 22) ? `:${hst.port}` : '';
return `${hst.protocol === 'rdp' ? 'rdp' : 'ssh'}, ${u}${hst.address}${port}`;
}
function viewHosts(page) {
page.append(toolbar('Hosts suchen oder user@host eingeben …', [
{ label: 'Gruppe', icon: 'newdir', run: () => editGroup({ parentId: S.group }) },
{ label: 'Neuer Host', icon: 'plus', cls: 'primary', run: () => editHost({ groupId: S.group }) },
]));
const c = h('<div class="content"></div>');
page.append(c);
const { groups, hosts } = S.vault;
const searching = !!S.search.trim();
if (S.group) {
const trail = []; let g = groups.find((x) => x.id === S.group);
while (g) { trail.unshift(g); g = groups.find((x) => x.id === g.parentId); }
const bc = h('<div class="breadcrumb"></div>');
const root = h('<a>Alle Hosts</a>'); root.onclick = () => { S.group = null; render(); }; bc.append(root);
trail.forEach((t) => { bc.append(' / '); const a = h(`<a>${esc(t.label)}</a>`); a.onclick = () => { S.group = t.id; render(); }; bc.append(a); });
c.append(bc);
}
const allTags = [...new Set(hosts.flatMap((x) => x.tags || []))].sort();
if (allTags.length) {
const ch = h('<div class="chips" style="margin-top:12px"></div>');
allTags.forEach((t) => { const b = h(`<button class="chip ${S.tag === t ? 'active' : ''}">#${esc(t)}</button>`); b.onclick = () => { S.tag = S.tag === t ? null : t; render(); }; ch.append(b); });
c.append(ch);
}
const subGroups = searching ? groups.filter((g) => matches(g.label)) : groups.filter((g) => (g.parentId || null) === S.group);
if (subGroups.length && !S.tag) {
c.append(h(`<div class="section-title">Gruppen <span class="count">${subGroups.length}</span></div>`));
const grid = h('<div class="grid"></div>');
for (const g of subGroups) {
const count = hosts.filter((x) => x.groupId === g.id).length;
const card = h(`<div class="card group-card"><div class="avatar" ${g.color ? `style="background:${g.color}22;color:${g.color}"` : ''}>${ICONS.folder}</div><div class="meta"><div class="title">${esc(g.label)}</div><div class="sub">${count} Host${count === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="edit" title="Bearbeiten">${ICONS.edit}</button></div></div>`);
card.onclick = (e) => { if (e.target.closest('[data-a=edit]')) return editGroup(g); S.group = g.id; S.search = ''; render(); };
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
{ label: 'Bearbeiten', icon: 'edit', run: () => editGroup(g) },
{ label: 'Host hinzufügen', icon: 'plus', run: () => editHost({ groupId: g.id }) },
'-', { label: 'Löschen', icon: 'trash', danger: true, run: async () => { if (await confirmBox('Gruppe löschen?', `„${g.label}“ wird gelöscht. Hosts bleiben erhalten.`)) { await call('vault:remove', 'groups', g.id); reload(); } } },
]);
grid.append(card);
}
c.append(grid);
}
let list = hosts.filter((x) => (searching || S.tag ? true : (x.groupId || null) === S.group));
list = list.filter((x) => matches(x.label, x.address, x.username, ...(x.tags || [])));
if (S.tag) list = list.filter((x) => (x.tags || []).includes(S.tag));
list.sort((a, b) => (a.label || a.address).localeCompare(b.label || b.address));
if (list.length) {
c.append(h(`<div class="section-title">Hosts <span class="count">${list.length}</span></div>`));
const grid = h('<div class="grid"></div>');
list.forEach((hst) => grid.append(hostCard(hst)));
c.append(grid);
} else if (!subGroups.length) {
const q = S.search.trim();
if (q && /^[^\s@]+@[^\s]+$|^[\w.-]+(:\d+)?$/.test(q)) {
c.append(emptyState('term', `Mit ${q} verbinden?`, 'Quick Connect ohne den Host zu speichern.', 'Verbinden', () => quickConnect(q)));
} else if (!hosts.length) {
const e = emptyState('screen', 'Noch keine Hosts', 'Lege deinen ersten SSH- oder RDP-Host an oder importiere ~/.ssh/config.', 'Neuer Host', () => editHost({}));
const imp = h('<button class="btn" style="margin-left:8px">~/.ssh/config importieren</button>');
imp.onclick = importSshConfig; e.append(imp);
c.append(e);
} else c.append(emptyState('search', 'Keine Treffer', 'Passe die Suche oder Filter an.'));
}
}
function hostCard(hst) {
const tags = (hst.tags || []).length ? `<div class="tags">${hst.tags.map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</div>` : '';
const card = h(`<div class="card">${avatar(hst)}<div class="meta"><div class="title">${esc(hst.label || hst.address)}</div><div class="sub">${esc(hostSub(hst))}</div>${tags}</div>
<div class="actions">${hst.protocol !== 'rdp' ? `<button data-a="sftp" title="SFTP">${ICONS.folder}</button>` : ''}<button data-a="edit" title="Bearbeiten">${ICONS.edit}</button></div></div>`);
card.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'edit') return editHost(hst);
if (a === 'sftp') return openSftp(hst);
});
card.addEventListener('dblclick', (e) => { if (!e.target.closest('[data-a]')) connectHost(hst); });
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(hst));
return card;
}
function hostMenu(hst) {
return [
{ 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) },
{ label: 'Duplizieren', icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + ' (Kopie)' }); reload(); } },
{ label: 'Adresse kopieren', icon: 'copy', run: () => call('clipboard:write', `${hst.username ? hst.username + '@' : ''}${hst.address}`) },
'-',
{ label: 'Löschen', icon: 'trash', danger: true, run: async () => { if (await confirmBox('Host löschen?', `„${hst.label || hst.address}“ wird dauerhaft entfernt.`)) { await call('vault:remove', 'hosts', hst.id); reload(); } } },
];
}
function connectHost(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);
toast(`RDP-Sitzung zu ${hst.label || hst.address} gestartet (${client})`, 'ok');
} catch {}
}
function parseQuick(q) {
const m = q.trim().match(/^(?:(rdp|ssh):\/\/)?(?:([^@\s]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/i);
if (!m) return null;
return { protocol: (m[1] || 'ssh').toLowerCase(), username: m[2], address: m[3].replace(/^\[|\]$/g, ''), port: m[4] ? Number(m[4]) : undefined, label: q.trim() };
}
function quickConnect(q) {
const hst = parseQuick(q);
if (!hst) return toast('Ungültige Adresse. Format: user@host:port', 'error');
connectHost(hst);
}
async function importSshConfig() {
try { const n = await call('vault:importSshConfig'); toast(`${n} Host(s) importiert`, 'ok'); reload(); } catch {}
}
// ============================================================ Drawer / Editoren
function openDrawer(title, fields, onSave, extraFooter) {
const d = $('#drawer');
d.innerHTML = '';
const form = h('<div class="form"></div>');
const hd = h(`<header><h2>${esc(title)}</h2><button class="btn ghost sm">${ICONS.close}</button></header>`);
$('button', hd).onclick = closeDrawer;
const ft = h('<footer><div class="grow"></div></footer>');
if (extraFooter) ft.prepend(extraFooter);
const cancel = h('<button class="btn ghost">Abbrechen</button>'); cancel.onclick = closeDrawer;
const save = h('<button class="btn primary">Speichern</button>');
save.onclick = async () => { try { if ((await onSave()) !== false) closeDrawer(); } catch (e) { if (e.message) toast(e.message, 'error'); } };
ft.append(cancel, save);
form.append(...[].concat(fields));
d.append(hd, form, ft);
d.classList.add('open');
d.onkeydown = (e) => { if (e.key === 'Escape') closeDrawer(); if (e.key === 'Enter' && e.ctrlKey) save.click(); };
setTimeout(() => $('input,select,textarea', form)?.focus(), 50);
return form;
}
function closeDrawer() { $('#drawer').classList.remove('open'); }
function field(label, name, value = '', opts = {}) {
const { type = 'text', placeholder = '', hint = '', cls = '', options } = opts;
let input;
if (type === 'select') input = `<select name="${name}">${options.map(([v, l]) => `<option value="${esc(v)}" ${String(v) === String(value ?? '') ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select>`;
else if (type === 'textarea') input = `<textarea name="${name}" placeholder="${esc(placeholder)}" spellcheck="false">${esc(value)}</textarea>`;
else if (type === 'password') input = `<div class="pw-wrap"><input name="${name}" type="password" value="${esc(value)}" placeholder="${esc(placeholder)}" autocomplete="off" /><button type="button" tabindex="-1">${ICONS.eye}</button></div>`;
else input = `<input name="${name}" type="${type}" value="${esc(value)}" placeholder="${esc(placeholder)}" spellcheck="false" />`;
const el = h(`<div class="field ${cls}"><label>${esc(label)}</label>${input}${hint ? `<div class="hint">${esc(hint)}</div>` : ''}</div>`);
const eye = $('.pw-wrap button', el);
if (eye) eye.onclick = () => { const i = $('input', el); i.type = i.type === 'password' ? 'text' : 'password'; };
return el;
}
const row = (...els) => { const r = h('<div class="row"></div>'); r.append(...els); return r; };
const check = (label, name, checked) => h(`<label class="check"><input type="checkbox" name="${name}" ${checked ? 'checked' : ''}/>${esc(label)}</label>`);
const heading = (t) => h(`<h4>${esc(t)}</h4>`);
function formValues(root) {
const o = {};
$$('[name]', root).forEach((i) => { o[i.name] = i.type === 'checkbox' ? i.checked : i.value; });
return o;
}
function colorPicker(current) {
const el = h(`<div class="field"><label>Farbe</label><div class="colors"><input type="hidden" name="color" value="${esc(current || '')}"/></div></div>`);
const wrap = $('.colors', el);
['', ...COLORS].forEach((c) => {
const b = h(`<button type="button" title="${c || 'Automatisch'}" style="background:${c || 'conic-gradient(#6e7bff,#3ecf8e,#ff5f6d,#ffb454,#6e7bff)'}" class="${(current || '') === c ? 'active' : ''}"></button>`);
b.onclick = () => { $('input', wrap).value = c; $$('button', wrap).forEach((x) => x.classList.toggle('active', x === b)); };
wrap.append(b);
});
return el;
}
function groupOptions() {
const out = [['', '— Keine Gruppe —']];
const walk = (parent, depth) => S.vault.groups.filter((g) => (g.parentId || null) === parent).forEach((g) => { out.push([g.id, `${' '.repeat(depth)}${g.label}`]); walk(g.id, depth + 1); });
walk(null, 0);
return out;
}
function editHost(hst = {}) {
let proto = hst.protocol || 'ssh';
const isNew = !hst.id;
const seg = h(`<div class="seg"><button data-p="ssh">SSH</button><button data-p="rdp">RDP</button></div>`);
const sshBox = h('<div></div>');
const rdpBox = h('<div></div>');
const portField = field('Port', 'port', hst.port || '', { type: 'number', placeholder: proto === 'rdp' ? '3389' : '22', cls: 'small' });
const setProto = (p) => {
proto = p;
$$('button', seg).forEach((b) => b.classList.toggle('active', b.dataset.p === p));
sshBox.style.display = p === 'ssh' ? '' : 'none';
rdpBox.style.display = p === 'rdp' ? '' : 'none';
$('input', portField).placeholder = p === 'rdp' ? '3389' : '22';
};
$$('button', seg).forEach((b) => (b.onclick = () => setProto(b.dataset.p)));
const keys = [['', '— Kein Schlüssel —'], ...S.vault.keys.map((k) => [k.id, k.label])];
const jumps = [['', '— Direkt —'], ...S.vault.hosts.filter((x) => x.id !== hst.id && x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address])];
sshBox.append(
field('SSH-Schlüssel', 'keyId', hst.keyId, { type: 'select', options: keys }),
check('SSH-Agent verwenden', 'useAgent', hst.useAgent !== false),
heading('Erweitert'),
field('Jump-Host (ProxyJump)', 'jumpHostId', hst.jumpHostId, { type: 'select', options: jumps }),
field('Startbefehl', 'startupCommand', hst.startupCommand, { placeholder: 'z. B. tmux attach || tmux' }),
field('Umgebungsvariablen', 'env', hst.env, { type: 'textarea', placeholder: 'LANG=de_DE.UTF-8', hint: 'Eine pro Zeile, KEY=VALUE (Server muss AcceptEnv erlauben).' }),
);
rdpBox.append(
field('Domäne', 'domain', hst.domain, { placeholder: 'optional' }),
row(field('Breite', 'rdpWidth', hst.rdpWidth, { type: 'number', placeholder: 'auto' }), field('Höhe', 'rdpHeight', hst.rdpHeight, { type: 'number', placeholder: 'auto' })),
field('Bei Größenänderung', 'rdpResize', hst.rdpResize || 'dynamic', { type: 'select', options: [['dynamic', 'Auflösung dynamisch anpassen'], ['scale', 'Bild skalieren (feste Auflösung)']] }),
check('Vollbild', 'rdpFullscreen', hst.rdpFullscreen),
check('Zwischenablage teilen', 'rdpClipboard', hst.rdpClipboard !== false),
check('Audio wiedergeben', 'rdpAudio', hst.rdpAudio !== false),
check('Lokale Laufwerke/Home freigeben', 'rdpDrives', hst.rdpDrives),
);
const form = openDrawer(isNew ? 'Neuer Host' : 'Host bearbeiten', [
seg,
row(field('Adresse', 'address', hst.address, { placeholder: 'IP oder Hostname' }), portField),
field('Bezeichnung', 'label', hst.label, { placeholder: 'Mein Server' }),
field('Gruppe', 'groupId', hst.groupId, { type: 'select', options: groupOptions() }),
field('Tags', 'tags', (hst.tags || []).join(', '), { placeholder: 'prod, web, db' }),
heading('Anmeldung'),
field('Benutzername', 'username', hst.username, { placeholder: 'root' }),
field('Passwort', 'password', hst.password, { type: 'password', placeholder: 'wird verschlüsselt gespeichert' }),
sshBox, rdpBox,
heading('Darstellung'),
colorPicker(hst.color),
], async () => {
const v = formValues(form);
if (!v.address.trim()) throw new Error('Bitte eine Adresse angeben.');
const item = {
...hst, ...v, protocol: proto,
address: v.address.trim(),
port: v.port ? Number(v.port) : undefined,
tags: v.tags.split(',').map((t) => t.trim()).filter(Boolean),
groupId: v.groupId || null, keyId: v.keyId || null, jumpHostId: v.jumpHostId || null,
};
await call('vault:upsert', 'hosts', item);
reload();
}, isNew ? null : (() => { const b = h(`<button class="btn danger">${ICONS.trash}</button>`); b.title = 'Löschen'; b.onclick = async () => { if (await confirmBox('Host löschen?', `„${hst.label || hst.address}“ wird dauerhaft entfernt.`)) { await call('vault:remove', 'hosts', hst.id); closeDrawer(); reload(); } }; return b; })());
setProto(proto);
return form;
}
function editGroup(g = {}) {
const parents = groupOptions().filter(([id]) => id !== g.id);
const form = openDrawer(g.id ? 'Gruppe bearbeiten' : 'Neue Gruppe', [
field('Name', 'label', g.label, { placeholder: 'Produktion' }),
field('Übergeordnete Gruppe', 'parentId', g.parentId, { type: 'select', options: parents }),
colorPicker(g.color),
], async () => {
const v = formValues(form);
if (!v.label.trim()) throw new Error('Name fehlt.');
await call('vault:upsert', 'groups', { ...g, ...v, parentId: v.parentId || null });
reload();
});
}
// ============================================================ Keychain
function viewKeys(page) {
page.append(toolbar('Schlüssel suchen …', [
{ label: 'Importieren', icon: 'download', run: () => editKey({}, true) },
{ label: 'Generieren', icon: 'plus', cls: 'primary', run: generateKey },
]));
const c = h('<div class="content"></div>'); page.append(c);
const list = S.vault.keys.filter((k) => matches(k.label, k.publicKey));
if (!list.length) return c.append(emptyState('key', 'Keine Schlüssel', 'Generiere einen neuen Ed25519-Schlüssel oder importiere einen bestehenden.', 'Schlüssel generieren', generateKey));
c.append(h(`<div class="section-title">Schlüssel <span class="count">${list.length}</span></div>`));
const grid = h('<div class="grid"></div>');
for (const k of list) {
const type = (k.publicKey || '').split(' ')[0] || 'key';
const used = S.vault.hosts.filter((x) => x.keyId === k.id).length;
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.key}</div><div class="meta"><div class="title">${esc(k.label)}</div><div class="sub">${esc(type)} · ${used} Host${used === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="copy" title="Public Key kopieren">${ICONS.copy}</button><button data-a="edit" title="Bearbeiten">${ICONS.edit}</button></div></div>`);
card.onclick = (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'copy') { call('clipboard:write', k.publicKey || ''); toast('Public Key kopiert', 'ok'); }
else editKey(k);
};
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
{ label: 'Public Key kopieren', icon: 'copy', run: () => { call('clipboard:write', k.publicKey || ''); toast('Public Key kopiert', 'ok'); } },
{ label: 'Bearbeiten', icon: 'edit', run: () => editKey(k) },
'-', { label: 'Löschen', icon: 'trash', danger: true, run: async () => { if (await confirmBox('Schlüssel löschen?', `„${k.label}“ wird aus dem Vault entfernt.`)) { await call('vault:remove', 'keys', k.id); reload(); } } },
]);
grid.append(card);
}
c.append(grid);
}
async function generateKey() {
const r = await modal({
title: 'SSH-Schlüssel generieren',
body: `<div class="field"><label>Name</label><input name="label" value="mrterm-${new Date().toISOString().slice(0, 10)}"/></div>
<div class="field"><label>Typ</label><select name="type"><option value="ed25519">Ed25519 (empfohlen)</option><option value="ecdsa">ECDSA</option><option value="rsa">RSA 4096</option></select></div>
<div class="field"><label>Passphrase (optional)</label><input name="passphrase" type="password"/></div>`,
buttons: [{ label: 'Abbrechen', value: null, cls: 'ghost' }, { label: 'Generieren', value: 'form', cls: 'primary' }],
});
if (!r) return;
const k = await call('key:generate', { type: r.type, passphrase: r.passphrase, comment: r.label });
await call('vault:upsert', 'keys', { label: r.label, privateKey: k.privateKey, publicKey: k.publicKey, passphrase: r.passphrase || undefined });
await call('clipboard:write', k.publicKey);
toast('Schlüssel erzeugt – Public Key in Zwischenablage kopiert', 'ok');
reload();
}
function editKey(k = {}, pick = false) {
const form = openDrawer(k.id ? 'Schlüssel bearbeiten' : 'Schlüssel importieren', [
field('Name', 'label', k.label, { placeholder: 'id_ed25519' }),
field('Private Key', 'privateKey', k.privateKey, { type: 'textarea', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----' }),
field('Passphrase', 'passphrase', k.passphrase, { type: 'password' }),
field('Public Key', 'publicKey', k.publicKey, { type: 'textarea', hint: 'Wird bei Bedarf automatisch aus dem Private Key berechnet.' }),
], async () => {
const v = formValues(form);
if (!v.privateKey.trim()) throw new Error('Private Key fehlt.');
const parsed = await call('key:parse', { privateKey: v.privateKey, passphrase: v.passphrase });
await call('vault:upsert', 'keys', { ...k, ...v, label: v.label || parsed.type, publicKey: v.publicKey.trim() || parsed.publicKey });
reload();
}, (() => { const b = h(`<button class="btn">${ICONS.folder}Datei…</button>`); b.onclick = loadFile; return b; })());
async function loadFile() {
const f = await call('key:pickFile');
if (!f) return;
$('[name=privateKey]', form).value = f.content;
if (!$('[name=label]', form).value) $('[name=label]', form).value = f.name;
if (f.publicKey) $('[name=publicKey]', form).value = f.publicKey;
}
if (pick) loadFile();
}
// ============================================================ Snippets
function viewSnippets(page) {
page.append(toolbar('Snippets suchen …', [{ label: 'Neues Snippet', icon: 'plus', cls: 'primary', run: () => editSnippet() }]));
const c = h('<div class="content"></div>'); page.append(c);
const list = S.vault.snippets.filter((s) => matches(s.label, s.command));
if (!list.length) return c.append(emptyState('code', 'Keine Snippets', 'Speichere häufig genutzte Befehle und sende sie mit einem Klick an ein Terminal.', 'Neues Snippet', () => editSnippet()));
const l = h('<div class="list"></div>');
for (const s of list) {
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="Im aktiven Terminal ausführen">${ICONS.play}</button><button data-a="copy" title="Kopieren">${ICONS.copy}</button><button data-a="del" title="Löschen">${ICONS.trash}</button></div></div>`);
card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'run') runSnippet(s);
else if (a === 'copy') { call('clipboard:write', s.command); toast('Kopiert', 'ok'); }
else if (a === 'del') { if (await confirmBox('Snippet löschen?', s.label)) { await call('vault:remove', 'snippets', s.id); reload(); } }
else editSnippet(s);
};
l.append(card);
}
c.append(l);
}
function editSnippet(s = {}) {
const form = openDrawer(s.id ? 'Snippet bearbeiten' : 'Neues Snippet', [
field('Name', 'label', s.label, { placeholder: 'Systemupdate' }),
field('Befehl(e)', 'command', s.command, { type: 'textarea', placeholder: 'sudo pacman -Syu' }),
check('Enter automatisch senden', 'autoRun', s.autoRun !== false),
], async () => {
const v = formValues(form);
if (!v.command.trim()) throw new Error('Befehl fehlt.');
await call('vault:upsert', 'snippets', { ...s, ...v, label: v.label || v.command.split('\n')[0].slice(0, 40) });
reload();
});
}
function runSnippet(s, session) {
const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh');
if (!t) return toast('Kein offenes Terminal', 'error');
t.sendText(s.command.replace(/\r?\n/g, '\r') + (s.autoRun !== false ? '\r' : ''));
activateTab(t.id);
t.term.focus();
}
// ============================================================ Port Forwarding
function viewForwards(page) {
page.append(toolbar('Regeln suchen …', [{ label: 'Neue Regel', icon: 'plus', cls: 'primary', run: () => editForward() }]));
const c = h('<div class="content"></div>'); page.append(c);
const list = S.vault.forwards.filter((f) => matches(f.label, f.destHost));
if (!list.length) return c.append(emptyState('fwd', 'Keine Port-Weiterleitungen', 'Lokal (-L), Remote (-R) oder dynamisch als SOCKS5-Proxy (-D).', 'Neue Regel', () => editForward()));
const l = h('<div class="list"></div>');
for (const f of list) {
const hst = S.vault.hosts.find((x) => x.id === f.hostId);
const on = S.activeForwards.has(f.id);
const desc = f.type === 'dynamic' ? `SOCKS5 ${f.bindAddress || '127.0.0.1'}:${f.bindPort}`
: f.type === 'remote' ? `Remote ${f.bindPort} → ${f.destHost || '127.0.0.1'}:${f.destPort}`
: `Lokal ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`;
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.fwd}</div><div class="meta"><div class="title">${esc(f.label || desc)} <span class="status-pill ${on ? 'on' : ''}">${on ? 'aktiv' : 'inaktiv'}</span></div><div class="sub">${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}</div></div>
<button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + 'Stoppen' : ICONS.play + 'Starten'}</button><div class="actions"><button data-a="del">${ICONS.trash}</button></div></div>`);
card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'toggle') {
const b = e.target.closest('button'); b.disabled = true;
try {
if (on) await call('fw:stop', f.id);
else { await call('fw:start', f.id); toast('Weiterleitung aktiv', 'ok'); }
} catch {}
reload();
} else if (a === 'del') { if (await confirmBox('Regel löschen?', f.label || desc)) { await call('fw:stop', f.id); await call('vault:remove', 'forwards', f.id); reload(); } }
else editForward(f);
};
l.append(card);
}
c.append(l);
}
function editForward(f = {}) {
const hosts = S.vault.hosts.filter((x) => x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address]);
if (!hosts.length) return toast('Lege zuerst einen SSH-Host an.', 'error');
const form = openDrawer(f.id ? 'Regel bearbeiten' : 'Neue Weiterleitung', [
field('Bezeichnung', 'label', f.label, { placeholder: 'Postgres Prod' }),
field('Typ', 'type', f.type || 'local', { type: 'select', options: [['local', 'Lokal (-L)'], ['remote', 'Remote (-R)'], ['dynamic', 'Dynamisch / SOCKS5 (-D)']] }),
field('Über Host', 'hostId', f.hostId, { type: 'select', options: hosts }),
row(field('Bind-Adresse', 'bindAddress', f.bindAddress, { placeholder: '127.0.0.1' }), field('Bind-Port', 'bindPort', f.bindPort, { type: 'number', placeholder: '8080', cls: 'small' })),
row(field('Ziel-Host', 'destHost', f.destHost, { placeholder: 'localhost' }), field('Ziel-Port', 'destPort', f.destPort, { type: 'number', placeholder: '5432', cls: 'small' })),
], async () => {
const v = formValues(form);
if (!v.bindPort) throw new Error('Bind-Port fehlt.');
if (v.type !== 'dynamic' && !v.destPort) throw new Error('Ziel-Port fehlt.');
await call('vault:upsert', 'forwards', { ...f, ...v });
reload();
});
}
// ============================================================ Known Hosts & Verlauf
function viewKnown(page) {
page.append(toolbar('Known Hosts suchen …'));
const c = h('<div class="content"></div>'); page.append(c);
const entries = Object.entries(S.vault.knownHosts).filter(([id, v]) => matches(id, v.fingerprint));
if (!entries.length) return c.append(emptyState('shield', 'Keine bekannten Hosts', 'Host-Schlüssel werden bei der ersten Verbindung hier gespeichert.'));
const l = h('<div class="list"></div>');
for (const [id, v] of entries) {
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.shield}</div><div class="meta"><div class="title">${esc(id)}</div><div class="sub mono">${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}</div></div><div class="actions"><button title="Entfernen">${ICONS.trash}</button></div></div>`);
$('.actions button', card).onclick = async () => { await call('vault:forgetHost', id); reload(); };
l.append(card);
}
c.append(l);
}
function viewHistory(page) {
page.append(toolbar('Verlauf durchsuchen …'));
const c = h('<div class="content"></div>'); page.append(c);
const items = S.vault.history.map((x) => ({ ...x, host: S.vault.hosts.find((y) => y.id === x.hostId) })).filter((x) => x.host && matches(x.host.label, x.host.address));
if (!items.length) return c.append(emptyState('clock', 'Noch keine Verbindungen', 'Zuletzt genutzte Hosts erscheinen hier.'));
const l = h('<div class="list"></div>');
for (const it of items) {
const card = h(`<div class="card">${avatar(it.host)}<div class="meta"><div class="title">${esc(it.host.label || it.host.address)}</div><div class="sub">${esc(hostSub(it.host))} · ${relTime(it.at)}</div></div><button class="btn sm">Verbinden</button></div>`);
card.onclick = () => connectHost(it.host);
card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(it.host));
l.append(card);
}
c.append(l);
}
// ============================================================ Einstellungen
async function viewSettings(page) {
const st = settings();
page.append(h('<div class="toolbar"><h2 style="margin:0;font-size:18px">Einstellungen</h2></div>'));
const c = h('<div class="content settings-grid"></div>'); page.append(c);
const save = async (patch) => { await call('vault:settings', patch); S.vault.settings = { ...st, ...patch }; S.tabs.forEach((t) => t.applySettings?.()); };
// ---- App-Design
const designCard = h('<div class="settings-card"><h3>Design</h3><div class="theme-list design-list"></div><div class="field" style="margin:16px 0 0"><label>Akzentfarbe</label><div class="colors accent-colors"></div></div></div>');
for (const [id, t] of Object.entries(APP_THEMES)) {
const p = h(`<div class="theme-prev design-prev ${st.appTheme === id ? 'active' : ''}" style="background:${t.bg};color:${t.text}">
<div class="dp"><i style="background:${t.side}"></i><div><b style="background:${t.card}"><s style="background:${t.accent}"></s></b><b style="background:${t.card}"></b></div></div><div class="nm">${esc(t.name)}</div></div>`);
p.onclick = async () => { await save({ appTheme: id }); applyAppTheme(); render(); };
$('.design-list', designCard).append(p);
}
['', ...COLORS.slice(0, 7), '#21c7a8', '#cba6f7'].forEach((col) => {
const b = h(`<button type="button" title="${col || 'Standard des Designs'}" class="${(st.accent || '') === col ? 'active' : ''}" style="background:${col || 'conic-gradient(#6e7bff,#21c7a8,#cba6f7,#ffb454,#6e7bff)'}"></button>`);
b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); };
$('.accent-colors', designCard).append(b);
});
const themeCard = h('<div class="settings-card"><h3>Terminal-Farbschema</h3><div class="theme-list"></div></div>');
const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term];
const autoP = h(`<div class="theme-prev ${st.terminalTheme === 'auto' ? 'active' : ''}" style="background:${autoT.background};color:${autoT.foreground}"><div class="nm">Passend zum Design</div><span style="color:${autoT.green}">user@host</span>:<span style="color:${autoT.blue}">~</span>$ ls<br><span style="color:${autoT.blue}">folgt dem Design</span></div>`);
autoP.onclick = async () => { await save({ terminalTheme: 'auto' }); render(); };
$('.theme-list', themeCard).append(autoP);
for (const [id, t] of Object.entries(TERM_THEMES)) {
const p = h(`<div class="theme-prev ${st.terminalTheme === id ? 'active' : ''}" style="background:${t.background};color:${t.foreground}"><div class="nm">${esc(t.name)}</div><span style="color:${t.green}">user@host</span>:<span style="color:${t.blue}">~</span>$ ls<br><span style="color:${t.blue}">docs</span> <span style="color:${t.green}">run.sh</span> <span style="color:${t.red}">err</span></div>`);
p.onclick = async () => { await save({ terminalTheme: id }); render(); };
$('.theme-list', themeCard).append(p);
}
const termCard = h('<div class="settings-card"><h3>Terminal</h3></div>');
termCard.append(
row(field('Schriftart', 'fontFamily', st.fontFamily), field('Größe', 'fontSize', st.fontSize, { type: 'number', cls: 'small' })),
row(field('Cursor', 'cursorStyle', st.cursorStyle, { type: 'select', options: [['block', 'Block'], ['bar', 'Balken'], ['underline', 'Unterstrich']] }), field('Scrollback-Zeilen', 'scrollback', st.scrollback, { type: 'number' })),
check('Cursor blinkt', 'cursorBlink', st.cursorBlink),
check('Auswahl automatisch kopieren', 'copyOnSelect', st.copyOnSelect),
field('SSH Keep-Alive (Sekunden, 0 = aus)', 'keepAlive', st.keepAlive, { type: 'number' }),
);
termCard.addEventListener('change', () => {
const v = formValues(termCard);
save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 });
});
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 (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);
}
api.call('rdp:detect').then((d) => {
$('.rdp-info', rdpCard).textContent = d.available ? `Verwendeter Client: ${d.client}` : `Kein RDP-Client gefunden. Installation: ${d.hint}`;
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
}).catch(() => {});
const dataCard = h(`<div class="settings-card"><h3>Daten</h3><p style="color:var(--muted);margin-top:0">${S.vault.encrypted ? 'Der Vault ist mit dem Schlüsselbund des Betriebssystems verschlüsselt (Windows DPAPI / libsecret bzw. KWallet).' : 'Der Vault ist nicht verschlüsselt, da kein Schlüsselbund verfügbar ist. Unter Arch/CachyOS: <code>gnome-keyring</code> oder <code>kwallet</code> installieren.'}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
const btns = [
['~/.ssh/config importieren', importSshConfig],
['Backup exportieren', async () => { const p = await call('vault:export'); if (p) toast('Exportiert nach ' + p, 'ok'); }],
['Backup importieren', async () => { if (await call('vault:import')) { toast('Import abgeschlossen', 'ok'); reload(); } }],
];
btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); });
// ---- Import aus anderen Programmen
const importCard = h(`<div class="settings-card"><h3>Import</h3><p style="color:var(--muted);margin-top:0">Ordner und Hosts (RDP &amp; SSH) aus <b>Devolutions Remote Desktop Manager</b> übernehmen. In RDM: <i>Datei → Exportieren → Einträge exportieren</i> als <code>.rdm</code> (XML), JSON oder CSV. Passwörter werden von RDM nur unverschlüsselt exportiert, wenn du das beim Export aktivierst.</p></div>`);
const rdmBtn = h('<button class="btn primary">Aus Remote Desktop Manager importieren…</button>');
rdmBtn.onclick = importRdm;
importCard.append(rdmBtn);
// ---- Updates
const updCard = h(`<div class="settings-card"><h3>Updates</h3><p class="upd-info" style="color:var(--muted);margin-top:0">Installierte Version: …</p></div>`);
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = `Installierte Version: ${v}`; });
const updFields = h('<div></div>');
updFields.append(
field('Repository', 'updateUrl', st.updateUrl, { placeholder: 'https://git.mrblake.cc/MrBlake/MrTerm', hint: 'Gitea/Forgejo-Repository, dessen Releases die Installationspakete enthalten.' }),
field('Zugriffstoken (nur für private Repos)', 'updateToken', st.updateToken, { type: 'password', placeholder: 'optional' }),
check('Beim Start automatisch nach Updates suchen', 'updateAutoCheck', st.updateAutoCheck),
check('Vorabversionen (Pre-Releases) einbeziehen', 'updatePrerelease', st.updatePrerelease),
);
updFields.addEventListener('change', () => save(formValues(updFields)));
const chk = h(`<button class="btn">${ICONS.refresh}Jetzt nach Updates suchen</button>`);
chk.onclick = async () => {
chk.disabled = true;
try { await save(formValues(updFields)); const r = await call('update:check'); if (r.available) showUpdate(r);
else if (r.repoVersion) toast(`Im Repository liegt Version ${r.repoVersion}, aber dafür gibt es noch kein Release mit Installationspaketen. Installiert: ${r.current}.`, 'error');
else if (r.noRelease) toast('Im Repository gibt es noch keine Releases.', 'error');
else toast(`MrTerm ist aktuell (${r.current}).`, 'ok'); } catch {}
chk.disabled = false;
};
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>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>
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>Im Terminal suchen</span>
<span><kbd>Strg</kbd>+<kbd>+/−/0</kbd></span><span>Schriftgröße</span></div></div>`);
c.append(designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
}
// ============================================================ Design
function termTheme() {
const id = settings().terminalTheme;
if (id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
return TERM_THEMES[id] || TERM_THEMES.mrterm;
}
function applyAppTheme() {
const st = settings();
const root = document.documentElement;
if (st.appTheme && st.appTheme !== 'midnight') root.dataset.theme = st.appTheme; else delete root.dataset.theme;
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
S.tabs.forEach((t) => t.applySettings?.());
}
// ============================================================ RDM-Import
async function importRdm() {
const f = await call('import:pickFile', 'Remote Desktop Manager Export', ['rdm', 'xml', 'json', 'csv']);
if (!f) return;
let entries;
try { entries = parseRdmExport(f.name, f.content); } catch (e) { return toast('Import fehlgeschlagen: ' + e.message, 'error'); }
const hosts = entries.filter((e) => e.host);
if (!hosts.length) return toast('Keine RDP- oder SSH-Einträge in der Datei gefunden.', 'error');
const folders = new Set(entries.map((e) => e.folder.join(' / ')).filter(Boolean));
const rdpN = hosts.filter((e) => e.host.protocol === 'rdp').length;
const preview = hosts.slice(0, 8).map((e) => `<div class="card" style="padding:8px 10px;cursor:default">${avatar(e.host)}<div class="meta"><div class="title">${esc(e.host.label)}</div><div class="sub">${esc(e.folder.join(' / ') || 'Stammebene')} · ${esc(hostSub(e.host))}</div></div></div>`).join('');
const ok = await modal({
title: 'Import aus Remote Desktop Manager',
text: `${f.name}: ${hosts.length} Hosts (${rdpN} RDP, ${hosts.length - rdpN} SSH) in ${folders.size} Ordnern gefunden.`,
body: `<div class="list" style="max-height:300px;overflow:auto">${preview}</div>${hosts.length > 8 ? `<p style="margin:10px 0 0">… und ${hosts.length - 8} weitere</p>` : ''}`,
buttons: [{ label: 'Abbrechen', value: false, cls: 'ghost' }, { label: 'Importieren', value: true, cls: 'primary' }],
});
if (!ok) return;
const r = await call('vault:bulkImport', entries);
toast(`${r.hosts} Hosts importiert${r.skipped ? `, ${r.skipped} bereits vorhanden` : ''}`, 'ok');
await reload();
$('[data-view=hosts]').click();
}
// ============================================================ Updates
let updateInfo = null;
async function showUpdate(info) {
updateInfo = info;
const b = $('#updateBadge');
b.style.display = '';
b.querySelector('span').textContent = `Update ${info.version}`;
const r = await modal({
title: `MrTerm ${info.version} ist verfügbar`,
text: `Installiert: ${info.current}${info.asset ? ` · Paket: ${info.asset.name} (${fmtSize(info.asset.size)})` : ''}`,
body: `${info.notes ? `<div class="mono" style="white-space:pre-wrap;max-height:240px;overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:6px">${esc(info.notes)}</div>` : ''}
${info.kind === 'dev' ? '<p>Entwicklungsmodus: Bitte per <code>git pull</code> aktualisieren.</p>' : !info.asset ? '<p>Im Release ist kein passendes Paket für dieses System.</p>' : ''}
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">Lade herunter …</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
buttons: [{ label: 'Später', value: false, cls: 'ghost' }, { label: 'Release-Seite', value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: 'Jetzt installieren', value: true, cls: 'primary' }] : [])],
});
if (r === 'web') return api.call('shell:open', info.url);
if (r !== true) return;
toast('Update wird heruntergeladen …');
try { await call('update:download'); toast('Update wird installiert, MrTerm startet neu …', 'ok'); await call('update:install'); } catch {}
}
api.on('update:available', (info) => showUpdate(info));
api.on('update:progress', (done, total) => {
const b = $('#updateBadge span');
if (b) b.textContent = total ? `Update ${Math.round((done / total) * 100)}%` : 'Update …';
});
// ============================================================ 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+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, [
...(session.host ? [{ label: 'Duplizieren', icon: 'dup', run: () => (session.kind === 'sftp' ? openSftp(session.host) : openTerminal(session.host)) }] : []),
...(session.kind === 'ssh' ? [{ label: 'Neu verbinden', icon: 'refresh', run: () => session.reconnect() }] : []),
{ label: 'Umbenennen', icon: 'edit', run: async () => { const n = await promptBox('Tab umbenennen', 'Titel', session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } },
'-', { label: 'Schließen', icon: 'close', run: () => closeTab(session.id) },
{ label: 'Andere schließen', run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) },
]);
session.tabEl = tab;
$('#tabs').append(tab);
$('#sessions').append(session.el);
activateTab(session.id);
}
function setTabState(session, state) { const d = $('.dot', session.tabEl); if (d) d.className = 'dot ' + state; }
function activateTab(id) {
S.active = id;
$$('#tabs .tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === id));
$('#homeView').classList.toggle('active', id === 'home');
document.body.classList.toggle('in-session', id !== 'home');
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' });
renderTree();
}
function closeTab(id) {
const i = S.tabs.findIndex((t) => t.id === id);
if (i < 0) return;
const t = S.tabs[i];
t.dispose();
t.el.remove(); t.tabEl.remove();
S.tabs.splice(i, 1);
if (S.active === id) activateTab(S.tabs[i]?.id || S.tabs[i - 1]?.id || 'home');
}
$('.tab.home').onclick = () => activateTab('home');
// ============================================================ Terminal-Session
class TerminalSession {
constructor(host) {
this.kind = 'ssh';
this.id = uid();
this.host = host;
this.title = host.label || host.address;
this.el = h(`<div class="session">
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
<button class="btn ghost sm" data-a="sftp" title="SFTP für diesen Host">${ICONS.folder}SFTP</button>
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
<button class="btn ghost sm" data-a="find" title="Suchen (Strg+Shift+F)">${ICONS.search}</button>
</div>
<div class="term-wrap"><div class="term"></div>
<div class="findbar"><input placeholder="Suchen …"/><button class="btn ghost sm" data-f="prev">↑</button><button class="btn ghost sm" data-f="next">↓</button><button class="btn ghost sm" data-f="close">${ICONS.close}</button></div>
<div class="snip-panel"><div class="head">Snippets<button class="btn ghost sm" data-a="snipnew">${ICONS.plus}</button></div><div class="items"></div></div>
</div></div>`);
this.overlay = null;
this.alive = false;
this.initTerm();
this.bindUi();
this.unsub = api.on('ssh:event', (sid, type, payload) => { if (sid === this.id) this.onEvent(type, payload); });
addTab(this);
this.connect();
}
initTerm() {
const st = settings();
this.term = new Terminal({
fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink,
scrollback: st.scrollback, theme: termTheme(), allowProposedApi: true, macOptionIsMeta: true,
});
this.fit = new FitAddon.FitAddon();
this.search = new SearchAddon.SearchAddon();
this.term.loadAddon(this.fit);
this.term.loadAddon(this.search);
this.term.loadAddon(new WebLinksAddon.WebLinksAddon((_e, url) => api.call('shell:open', url)));
this.term.open($('.term', this.el));
this.term.onData((d) => { if (this.alive) api.ssh.write(this.id, d); else if (d === '\r' && this.ended) this.reconnect(); });
this.term.onResize(({ cols, rows }) => { if (this.alive) api.ssh.resize(this.id, cols, rows); });
this.term.onSelectionChange(() => { if (settings().copyOnSelect && this.term.hasSelection()) api.call('clipboard:write', this.term.getSelection()); });
this.term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
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; }
// 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;
});
$('.term', this.el).addEventListener('contextmenu', (e) => {
e.preventDefault();
if (this.term.hasSelection()) { api.call('clipboard:write', this.term.getSelection()); this.term.clearSelection(); toast('Kopiert'); }
else this.paste();
});
this.ro = new ResizeObserver(() => { if (this.el.classList.contains('active')) this.doFit(); });
this.ro.observe($('.term', this.el));
}
bindUi() {
this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'sftp') openSftp(this.host);
if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
if (a === 'snipnew') editSnippet();
if (a === 'find') this.toggleFind();
const f = e.target.closest('[data-f]')?.dataset.f;
const inp = $('.findbar input', this.el);
if (f === 'next') this.search.findNext(inp.value);
if (f === 'prev') this.search.findPrevious(inp.value);
if (f === 'close') this.toggleFind(false);
});
$('.findbar input', this.el).addEventListener('keydown', (e) => {
if (e.key === 'Enter') (e.shiftKey ? this.search.findPrevious : this.search.findNext).call(this.search, e.target.value);
if (e.key === 'Escape') this.toggleFind(false);
});
}
renderSnips() {
const box = $('.snip-panel .items', this.el);
box.innerHTML = '';
if (!S.vault.snippets.length) box.append(h('<div style="color:var(--muted);padding:8px">Noch keine Snippets.</div>'));
S.vault.snippets.forEach((s) => {
const it = h(`<div class="it"><div>${esc(s.label)}</div><div class="mono">${esc(s.command)}</div></div>`);
it.onclick = () => runSnippet(s, this);
box.append(it);
});
}
toggleFind(open) {
const fb = $('.findbar', this.el);
const o = open ?? !fb.classList.contains('open');
fb.classList.toggle('open', o);
if (o) { $('input', fb).focus(); $('input', fb).select(); } else { this.search.clearDecorations?.(); this.term.focus(); }
}
async paste() { const t = await api.call('clipboard:read'); if (t) this.term.paste(t); }
sendText(t) { if (this.alive) api.ssh.write(this.id, t); }
zoom(k) {
const s = this.term.options.fontSize;
this.term.options.fontSize = k === '0' ? settings().fontSize : Math.max(8, Math.min(32, s + (k === '-' ? -1 : 1)));
this.doFit();
}
doFit() { try { this.fit.fit(); } catch {} }
onShow() { this.doFit(); this.term.focus(); }
applySettings() {
const st = settings();
Object.assign(this.term.options, { fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, scrollback: st.scrollback, theme: termTheme() });
this.doFit();
}
showOverlay(msg, err) {
if (!this.overlay) { this.overlay = h('<div class="overlay"></div>'); $('.term-wrap', this.el).append(this.overlay); }
this.overlay.innerHTML = err
? `<div class="msg err">${esc(msg)}</div><div class="row"><button class="btn" data-o="edit">Host bearbeiten</button><button class="btn primary" data-o="retry">${ICONS.refresh}Erneut verbinden</button></div>`
: `<div class="spinner"></div><div class="msg">${esc(msg)}</div><button class="btn ghost" data-o="cancel">Abbrechen</button>`;
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 === 'edit' && this.host.id) { activateTab('home'); editHost(S.vault.hosts.find((x) => x.id === this.host.id) || this.host); }
};
}
hideOverlay() { this.overlay?.remove(); this.overlay = null; }
async connect() {
this.ended = false;
setTabState(this, 'wait');
this.showOverlay(`Verbinde mit ${this.host.address} …`);
this.doFit();
try {
await api.call('ssh:open', this.id, this.host.id || this.host, { cols: this.term.cols, rows: this.term.rows });
this.alive = true;
this.hideOverlay();
setTabState(this, 'on');
this.doFit();
api.ssh.resize(this.id, this.term.cols, this.term.rows);
if (S.active === this.id) this.term.focus();
} catch (e) {
setTabState(this, 'err');
this.showOverlay(e.message, true);
}
}
reconnect() {
api.ssh.close(this.id);
this.alive = false;
this.term.write('\r\n\x1b[90m— Neu verbinden —\x1b[0m\r\n');
this.connect();
}
onEvent(type, payload) {
if (type === 'data') this.term.write(payload);
else if (type === 'status' && this.overlay) $('.msg', this.overlay).textContent = payload;
else if (type === 'error') this.term.write(`\r\n\x1b[31m${payload}\x1b[0m\r\n`);
else if (type === 'closed') {
if (!this.alive) return;
this.alive = false; this.ended = true;
setTabState(this, '');
this.term.write('\r\n\x1b[90mVerbindung beendet. [Enter] zum erneuten Verbinden.\x1b[0m\r\n');
}
}
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
}
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 …'));
const c = h('<div class="content"></div>'); page.append(c);
const list = S.vault.hosts.filter((x) => x.protocol !== 'rdp' && matches(x.label, x.address, x.username));
if (!list.length) return c.append(emptyState('folder', 'Keine SSH-Hosts', 'SFTP benötigt einen SSH-Host.', 'Neuer Host', () => editHost({})));
c.append(h('<div class="section-title">Host für SFTP-Browser öffnen</div>'));
const grid = h('<div class="grid"></div>');
list.forEach((hst) => {
const card = h(`<div class="card">${avatar(hst)}<div class="meta"><div class="title">${esc(hst.label || hst.address)}</div><div class="sub">${esc(hostSub(hst))}</div></div></div>`);
card.onclick = () => openSftp(hst);
grid.append(card);
});
c.append(grid);
}
class FilePane {
constructor(sftp, side) {
this.sftp = sftp; this.side = side; this.cwd = ''; this.files = []; this.sel = new Set(); this.sort = { k: 'name', dir: 1 };
this.el = h(`<div class="pane"><div class="phead"><span class="title">${side === 'local' ? ICONS.screen + 'Lokal' : ICONS.folder + esc(sftp.host.label || sftp.host.address)}</span>
${side === 'local' && api.platform === 'win32' ? '<select class="roots"></select>' : ''}
<button class="btn ghost sm" data-p="up" title="Übergeordneter Ordner">${ICONS.up}</button>
<button class="btn ghost sm" data-p="home" title="Home">${ICONS.home}</button>
<input class="path" spellcheck="false"/>
<button class="btn ghost sm" data-p="refresh" title="Aktualisieren">${ICONS.refresh}</button>
<button class="btn ghost sm" data-p="mkdir" title="Neuer Ordner">${ICONS.newdir}</button>
<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? 'Hochladen →' : '← Herunterladen'}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button></div>
<div class="files"></div></div>`);
this.el.addEventListener('click', (e) => {
const p = e.target.closest('[data-p]')?.dataset.p;
if (p === 'up') this.up();
if (p === 'home') this.go(this.home);
if (p === 'refresh') this.go(this.cwd);
if (p === 'mkdir') this.mkdir();
if (p === 'xfer') this.transferSelected();
});
$('.path', this.el).addEventListener('keydown', (e) => { if (e.key === 'Enter') this.go(e.target.value); });
const files = $('.files', this.el);
files.addEventListener('contextmenu', (e) => { if (!e.target.closest('tr[data-i]')) { e.preventDefault(); ctxMenu(e.clientX, e.clientY, [{ label: 'Neuer Ordner', icon: 'newdir', run: () => this.mkdir() }, { label: 'Aktualisieren', icon: 'refresh', run: () => this.go(this.cwd) }]); } });
// Drag & Drop zwischen den Panes
this.el.addEventListener('dragover', (e) => { if (S.drag && S.drag.pane !== this) { e.preventDefault(); this.el.classList.add('dragover'); } });
this.el.addEventListener('dragleave', (e) => { if (!this.el.contains(e.relatedTarget)) this.el.classList.remove('dragover'); });
this.el.addEventListener('drop', (e) => { e.preventDefault(); this.el.classList.remove('dragover'); if (S.drag && S.drag.pane !== this) S.drag.pane.transferSelected(); S.drag = null; });
}
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
async join(...p) { return this.side === 'local' ? api.call('local:join', ...p) : p.join('/').replace(/\/+/g, '/'); }
async parent(p) { return this.side === 'local' ? api.call('local:parent', p) : (p.replace(/\/[^/]+\/?$/, '') || '/'); }
async list(dir) { return this.side === 'local' ? api.call('local:list', dir) : api.call('sftp:list', this.sftp.id, dir); }
async init(home) {
this.home = home;
if (this.side === 'local' && api.platform === 'win32') {
const sel = $('.roots', this.el);
(await api.call('local:roots')).forEach((r) => sel.append(h(`<option>${esc(r)}</option>`)));
sel.onchange = () => this.go(sel.value);
}
return this.go(home);
}
async up() { this.go(await this.parent(this.cwd)); }
async go(dir) {
try {
const files = await this.list(dir);
this.cwd = dir; this.files = files; this.sel.clear();
$('.path', this.el).value = dir;
const roots = $('.roots', this.el); if (roots) roots.value = dir.slice(0, 2).toUpperCase() + '\\';
this.draw();
} catch (e) { toast(e.message, 'error'); }
}
sorted() {
const { k, dir } = this.sort;
return [...this.files].filter((f) => f.name !== '.' && f.name !== '..').sort((a, b) => (b.isDir - a.isDir) || (k === 'name' ? a.name.localeCompare(b.name, undefined, { numeric: true }) : (a[k] - b[k])) * dir);
}
draw() {
const box = $('.files', this.el);
const arrow = (k) => (this.sort.k === k ? (this.sort.dir > 0 ? ' ▲' : ' ▼') : '');
const rows = this.view = this.sorted();
box.innerHTML = `<table><thead><tr><th data-s="name">Name${arrow('name')}</th><th data-s="size" style="text-align:right">Größe${arrow('size')}</th><th data-s="mtime">Geändert${arrow('mtime')}</th></tr></thead><tbody>
<tr data-up="1"><td class="name"><div><span class="ico-dir">${ICONS.folder}</span><span>..</span></div></td><td></td><td></td></tr>
${rows.map((f, i) => `<tr data-i="${i}" draggable="true" class="${this.sel.has(f.name) ? 'sel' : ''}"><td class="name"><div><span class="${f.isDir ? 'ico-dir' : 'ico-file'}">${f.isDir ? ICONS.folder : ICONS.file}</span><span>${esc(f.name)}</span></div></td><td class="num">${f.isDir ? '' : fmtSize(f.size)}</td><td class="date">${fmtDate(f.mtime)}</td></tr>`).join('')}</tbody></table>`;
$$('th', box).forEach((th) => (th.onclick = () => { const k = th.dataset.s; this.sort = { k, dir: this.sort.k === k ? -this.sort.dir : 1 }; this.draw(); }));
$('tr[data-up]', box).ondblclick = () => this.up();
$$('tr[data-i]', box).forEach((tr) => {
const f = rows[Number(tr.dataset.i)];
tr.onclick = (e) => {
if (e.ctrlKey) this.sel.has(f.name) ? this.sel.delete(f.name) : this.sel.add(f.name);
else if (e.shiftKey && this.lastIdx != null) { const [a, b] = [this.lastIdx, Number(tr.dataset.i)].sort((x, y) => x - y); rows.slice(a, b + 1).forEach((x) => this.sel.add(x.name)); }
else { this.sel.clear(); this.sel.add(f.name); }
this.lastIdx = Number(tr.dataset.i);
$$('tr[data-i]', box).forEach((r) => r.classList.toggle('sel', this.sel.has(rows[Number(r.dataset.i)].name)));
};
tr.ondblclick = async () => {
if (f.isDir) this.go(await this.join(this.cwd, f.name));
else if (this.side === 'local') api.call('local:op', 'open', await this.join(this.cwd, f.name));
else { this.sel = new Set([f.name]); this.transferSelected(); }
};
tr.ondragstart = (e) => { if (!this.sel.has(f.name)) { this.sel = new Set([f.name]); } S.drag = { pane: this }; e.dataTransfer.effectAllowed = 'copy'; };
tr.oncontextmenu = (e) => {
e.preventDefault();
if (!this.sel.has(f.name)) { this.sel = new Set([f.name]); this.draw(); }
ctxMenu(e.clientX, e.clientY, [
{ label: this.side === 'local' ? 'Hochladen' : 'Herunterladen', icon: this.side === 'local' ? 'upload' : 'download', run: () => this.transferSelected() },
...(f.isDir ? [{ label: 'Öffnen', icon: 'folder', run: async () => this.go(await this.join(this.cwd, f.name)) }] : []),
{ label: 'Umbenennen', icon: 'edit', run: () => this.rename(f) },
{ label: 'Pfad kopieren', icon: 'copy', run: async () => api.call('clipboard:write', await this.join(this.cwd, f.name)) },
...(this.side === 'remote' && !f.isDir ? [{ label: 'Rechte (chmod)…', icon: 'lock', run: () => this.chmod(f) }] : []),
'-', { label: this.side === 'local' ? 'In Papierkorb' : 'Löschen', icon: 'trash', danger: true, run: () => this.remove() },
]);
};
});
}
async mkdir() {
const n = await promptBox('Neuer Ordner', 'Name'); if (!n) return;
const p = await this.join(this.cwd, n);
try { this.side === 'local' ? await api.call('local:op', 'mkdir', p) : await api.call('sftp:op', this.sftp.id, 'mkdir', p); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
}
async rename(f) {
const n = await promptBox('Umbenennen', 'Neuer Name', f.name); if (!n || n === f.name) return;
const a = await this.join(this.cwd, f.name), b = await this.join(this.cwd, n);
try { this.side === 'local' ? await api.call('local:op', 'rename', a, b) : await api.call('sftp:op', this.sftp.id, 'rename', a, b); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
}
async chmod(f) {
const m = await promptBox('Rechte ändern', 'Oktal (z. B. 644)', (f.mode & 0o777).toString(8)); if (!m) return;
try { await api.call('sftp:op', this.sftp.id, 'chmod', await this.join(this.cwd, f.name), parseInt(m, 8)); this.go(this.cwd); } catch (e) { toast(e.message, 'error'); }
}
async remove() {
const names = [...this.sel]; if (!names.length) return;
if (!(await confirmBox('Löschen?', `${names.length} Element(e) ${this.side === 'local' ? 'in den Papierkorb verschieben' : 'endgültig vom Server löschen'}: ${names.slice(0, 5).join(', ')}${names.length > 5 ? ' …' : ''}`))) return;
for (const n of names) {
const f = this.files.find((x) => x.name === n);
const p = await this.join(this.cwd, n);
try {
if (this.side === 'local') await api.call('local:op', 'delete', p);
else await api.call('sftp:op', this.sftp.id, f.isDir ? 'rmdir' : 'unlink', p);
} catch (e) { toast(`${n}: ${e.message}`, 'error'); }
}
this.go(this.cwd);
}
async transferSelected() {
const names = [...this.sel];
if (!names.length) return toast('Nichts ausgewählt');
const target = this.other;
for (const n of names) {
const src = await this.join(this.cwd, n), dst = await target.join(target.cwd, n);
const dir = this.side === 'local' ? 'upload' : 'download';
await this.sftp.transfer(dir, dir === 'upload' ? src : dst, dir === 'upload' ? dst : src, n);
}
target.go(target.cwd);
}
}
class SftpSession {
constructor(host) {
this.kind = 'sftp'; this.id = uid(); this.host = host;
this.title = `SFTP · ${host.label || host.address}`;
this.el = h(`<div class="session"><div class="sftp"></div><div class="transfers"></div></div>`);
this.local = new FilePane(this, 'local');
this.remote = new FilePane(this, 'remote');
const wrap = $('.sftp', this.el);
wrap.append(this.local.el, h(`<div class="pane"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>Verbinde mit ${esc(host.address)} …</div></div></div>`));
this.unsub = api.on('sftp:progress', (tid, done, total) => this.progress(tid, done, total));
this.xfers = new Map();
addTab(this);
this.open();
}
async open() {
this.local.init(await api.call('local:home'));
try {
const { home } = await api.call('sftp:open', this.id, this.host.id || this.host);
$('.sftp', this.el).lastElementChild.replaceWith(this.remote.el);
await this.remote.init(home);
setTabState(this, 'on');
} catch (e) {
setTabState(this, 'err');
const pe = $('.pane-empty', this.el);
pe.innerHTML = `<div style="color:var(--red);max-width:420px;text-align:center">${esc(e.message)}</div>`;
const b = h(`<button class="btn primary">${ICONS.refresh}Erneut versuchen</button>`);
b.onclick = () => { closeTab(this.id); openSftp(this.host); };
pe.append(b);
}
}
async transfer(dir, localPath, remotePath, name) {
const tid = uid();
const row = h(`<div class="transfer"><span>${dir === 'upload' ? ICONS.upload : ICONS.download}</span><span class="nm">${esc(name)}</span><span class="pct">0%</span><div class="bar"><i></i></div></div>`);
$('.transfers', this.el).prepend(row);
this.xfers.set(tid, row);
try {
await api.call('sftp:transfer', this.id, dir, localPath, remotePath, tid);
row.classList.add('done'); $('.bar i', row).style.width = '100%'; $('.pct', row).textContent = 'fertig';
setTimeout(() => row.remove(), 5000);
} catch (e) {
row.classList.add('fail'); $('.pct', row).textContent = 'Fehler'; row.title = e.message; toast(`${name}: ${e.message}`, 'error');
setTimeout(() => row.remove(), 10000);
}
}
progress(tid, done, total) {
const row = this.xfers.get(tid); if (!row) return;
const p = total ? Math.round((done / total) * 100) : 0;
$('.bar i', row).style.width = p + '%';
$('.pct', row).textContent = `${p}% · ${fmtSize(done)} / ${fmtSize(total)}`;
}
dispose() { this.unsub(); api.call('sftp:close', this.id).catch(() => {}); }
}
function openSftp(host) { return new SftpSession(host); }
// ============================================================ Command Palette / Quick Connect
function openPalette() {
if ($('.palette')) return;
const bg = h(`<div class="modal-bg"><div class="modal palette"><div class="pin">${ICONS.search}<input placeholder="Host suchen, user@host:port oder rdp://host eingeben …"/></div><div class="res"></div></div></div>`);
const inp = $('input', bg), res = $('.res', bg);
let items = [], idx = 0;
const close = () => bg.remove();
const draw = () => {
const q = inp.value.trim().toLowerCase();
items = [];
const quick = parseQuick(inp.value);
if (q && quick) items.push({ grp: 'Quick Connect', icon: avatar({ ...quick, label: quick.address }), label: `${quick.protocol.toUpperCase()} → ${inp.value.trim()}`, run: () => connectHost(quick) });
const hosts = S.vault.hosts.filter((x) => !q || [x.label, x.address, x.username, ...(x.tags || [])].some((f) => String(f || '').toLowerCase().includes(q)));
const recent = S.vault.history.map((x) => x.hostId);
hosts.sort((a, b) => ((recent.indexOf(a.id) + 1 || 999) - (recent.indexOf(b.id) + 1 || 999)));
hosts.slice(0, 12).forEach((x) => {
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
});
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
[['Neuer Host', () => { activateTab('home'); editHost({}); }], ['Schlüssel generieren', generateKey], ['Einstellungen', () => $('[data-view=settings]').click()]]
.filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: 'Aktionen', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.plus}</div>`, label: l, run }));
idx = Math.min(idx, Math.max(0, items.length - 1));
let last = '';
res.innerHTML = '';
items.forEach((it, i) => {
if (it.grp !== last) { res.append(h(`<div class="grp">${esc(it.grp)}</div>`)); last = it.grp; }
const el = h(`<div class="item ${i === idx ? 'active' : ''}">${it.icon}<div class="meta"><div>${esc(it.label)}</div>${it.sub ? `<div class="sub mono" style="color:var(--muted)">${esc(it.sub.split('\n')[0])}</div>` : ''}</div>${i === idx ? '<span class="k">↵</span>' : ''}</div>`);
el.onclick = () => { close(); it.run(); };
el.onmousemove = () => { if (idx !== i) { idx = i; draw(); } };
res.append(el);
});
$('.item.active', res)?.scrollIntoView({ block: 'nearest' });
};
inp.oninput = () => { idx = 0; draw(); };
inp.onkeydown = (e) => {
if (e.key === 'ArrowDown') { idx = Math.min(items.length - 1, idx + 1); draw(); e.preventDefault(); }
if (e.key === 'ArrowUp') { idx = Math.max(0, idx - 1); draw(); e.preventDefault(); }
if (e.key === 'Enter' && items[idx]) { close(); items[idx].run(); }
if (e.key === 'Escape') close();
};
bg.onmousedown = (e) => { if (e.target === bg) close(); };
$('#modalRoot').append(bg);
draw();
inp.focus();
}
// ============================================================ Globale Events
$('#newTabBtn').onclick = openPalette;
$('#winMin').onclick = api.win.min;
$('#winMax').onclick = api.win.max;
$('#winClose').onclick = api.win.close;
$('#titlebar').addEventListener('dblclick', (e) => { if (e.target.classList.contains('drag-fill') || e.target.id === 'titlebar') api.win.max(); });
if (api.platform === 'darwin') $('.win-ctrls').style.display = 'none';
document.addEventListener('keydown', (e) => {
if (!e.ctrlKey) return;
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)];
const i = ids.indexOf(S.active);
activateTab(ids[(i + (e.shiftKey ? -1 : 1) + ids.length) % ids.length]);
} else if (/^Digit[1-9]$/.test(e.code)) {
e.preventDefault();
const ids = ['home', ...S.tabs.map((t) => t.id)];
const n = Number(e.code.slice(5));
activateTab(n === 9 ? ids[ids.length - 1] : ids[n - 1] || ids[ids.length - 1]);
}
});
api.on('secret:request', async (req) => {
const r = await modal({
title: req.title || 'Anmeldung',
text: req.host,
body: `<div class="field"><label>${esc(req.prompt || 'Passwort')}</label><input name="v" type="${req.echo ? 'text' : 'password'}" autocomplete="off"/></div>`,
buttons: [{ label: 'Abbrechen', value: null, cls: 'ghost' }, { label: 'Anmelden', value: 'form', cls: 'primary' }],
});
api.replySecret(req.reqId, r ? r.v : null);
});
api.on('fw:event', () => reload());
api.on('toast', (m, t) => toast(m, t));
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
reload();
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);