/* 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const uid = () => crypto.randomUUID(); function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; } const ICONS = { search: '', plus: '', edit: '', trash: '', more: '', close: '', folder: '', file: '', term: '', screen: '', key: '', copy: '', play: '', stop: '', up: '', refresh: '', home: '', newdir: '', arrowR: '', arrowL: '', code: '', shield: '', lock: '', unlock: '', eye: '', fwd: '', dup: '', clock: '', download: '', upload: '', }; 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 `
${hst.protocol === 'rdp' ? ICONS.screen : letter}${proto}
`; } 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(`
${esc(msg)}
`); $('#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(``); const foot = $('.mfoot', bg); const done = (v) => { bg.remove(); resolve(v); }; buttons.forEach((b) => { const btn = h(``); 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: `
`, 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('
'); for (const it of items) { if (it === '-') { m.append(h('
')); continue; } const b = h(``); 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}Vault verschlüsselt` : `${ICONS.unlock}Vault unverschlüsselt`; 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 = ''; 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(`
Verbindungen
`); $('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('
Noch keine Hosts
')); 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(`
${CHEV}${ICONS.folder}${esc(g.label)}${countIn(g.id)}
`); 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(`
${x.protocol === 'rdp' ? ICONS.screen : ICONS.term}${esc(x.label || x.address)}${live ? '●' : ''}
`); 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(`
`); 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(``); 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(`
${ICONS[icon]}

${esc(title)}

${esc(text)}

`); if (btnLabel) { const b = h(``); 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('
'); 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(''); const root = h('Alle Hosts'); root.onclick = () => { S.group = null; render(); }; bc.append(root); trail.forEach((t) => { bc.append(' / '); const a = h(`${esc(t.label)}`); 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('
'); allTags.forEach((t) => { const b = h(``); 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(`
Gruppen ${subGroups.length}
`)); const grid = h('
'); for (const g of subGroups) { const count = hosts.filter((x) => x.groupId === g.id).length; const card = h(`
${ICONS.folder}
${esc(g.label)}
${count} Host${count === 1 ? '' : 's'}
`); 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(`
Hosts ${list.length}
`)); const grid = h('
'); 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(''); 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 ? `
${hst.tags.map((t) => `${esc(t)}`).join('')}
` : ''; const card = h(`
${avatar(hst)}
${esc(hst.label || hst.address)}
${esc(hostSub(hst))}
${tags}
${hst.protocol !== 'rdp' ? `` : ''}
`); 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('
'); const hd = h(`

${esc(title)}

`); $('button', hd).onclick = closeDrawer; const ft = h(''); if (extraFooter) ft.prepend(extraFooter); const cancel = h(''); cancel.onclick = closeDrawer; const save = h(''); 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 = ``; else if (type === 'textarea') input = ``; else if (type === 'password') input = `
`; else input = ``; const el = h(`
${input}${hint ? `
${esc(hint)}
` : ''}
`); 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('
'); r.append(...els); return r; }; const check = (label, name, checked) => h(``); const heading = (t) => h(`

${esc(t)}

`); 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(`
`); const wrap = $('.colors', el); ['', ...COLORS].forEach((c) => { const b = h(``); 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(`
`); const sshBox = h('
'); const rdpBox = h('
'); 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(``); 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('
'); 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(`
Schlüssel ${list.length}
`)); const grid = h('
'); 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(`
${ICONS.key}
${esc(k.label)}
${esc(type)} · ${used} Host${used === 1 ? '' : 's'}
`); 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: `
`, 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(``); 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('
'); 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('
'); for (const s of list) { const card = h(`
${ICONS.code}
${esc(s.label)}
${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}
`); 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('
'); 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('
'); 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(`
${ICONS.fwd}
${esc(f.label || desc)} ${on ? 'aktiv' : 'inaktiv'}
${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}
`); 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('
'); 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('
'); for (const [id, v] of entries) { const card = h(`
${ICONS.shield}
${esc(id)}
${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}
`); $('.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('
'); 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('
'); for (const it of items) { const card = h(`
${avatar(it.host)}
${esc(it.host.label || it.host.address)}
${esc(hostSub(it.host))} · ${relTime(it.at)}
`); 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('

Einstellungen

')); const c = h('
'); 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('

Design

'); for (const [id, t] of Object.entries(APP_THEMES)) { const p = h(`
${esc(t.name)}
`); 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(``); b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); }; $('.accent-colors', designCard).append(b); }); const themeCard = h('

Terminal-Farbschema

'); const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term]; const autoP = h(`
Passend zum Design
user@host:~$ ls
folgt dem Design
`); 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(`
${esc(t.name)}
user@host:~$ ls
docs run.sh err
`); p.onclick = async () => { await save({ terminalTheme: id }); render(); }; $('.theme-list', themeCard).append(p); } const termCard = h('

Terminal

'); 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('

RDP

Prüfe …
'); 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('
Nutzt xfreerdp3 mit /parent-window. MrTerm läuft dafür unter Wayland über XWayland.
')); 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(`

Daten

${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: gnome-keyring oder kwallet installieren.'}

`); 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(``); b.onclick = fn; $('.row', dataCard).append(b); }); // ---- Import aus anderen Programmen const importCard = h(`

Import

Ordner und Hosts (RDP & SSH) aus Devolutions Remote Desktop Manager übernehmen. In RDM: Datei → Exportieren → Einträge exportieren als .rdm (XML), JSON oder CSV. Passwörter werden von RDM nur unverschlüsselt exportiert, wenn du das beim Export aktivierst.

`); const rdmBtn = h(''); rdmBtn.onclick = importRdm; importCard.append(rdmBtn); // ---- Updates const updCard = h(`

Updates

Installierte Version: …

`); api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = `Installierte Version: ${v}`; }); const updFields = h('
'); 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(``); 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(`

Tastenkürzel

Strg+Shift+K / TQuick Connect / Befehlspalette (außerhalb des Terminals auch Strg+K) Strg+Shift+WTab schließen Strg+TabNächster Tab Strg+1..9Zu Tab wechseln Strg+Shift+C/VKopieren / Einfügen im Terminal Strg+Shift+FIm Terminal suchen Strg++/−/0Schriftgröße
`); 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) => `
${avatar(e.host)}
${esc(e.host.label)}
${esc(e.folder.join(' / ') || 'Stammebene')} · ${esc(hostSub(e.host))}
`).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: `
${preview}
${hosts.length > 8 ? `

… und ${hosts.length - 8} weitere

` : ''}`, 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 ? `
${esc(info.notes)}
` : ''} ${info.kind === 'dev' ? '

Entwicklungsmodus: Bitte per git pull aktualisieren.

' : !info.asset ? '

Im Release ist kein passendes Paket für dieses System.

' : ''} `, 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(`
${esc(session.title)}
`); 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(`
${avatar(host, 22).replace('SSH', '')}${esc(hostSub(host))}
Snippets
`); 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('
Noch keine Snippets.
')); S.vault.snippets.forEach((s) => { const it = h(`
${esc(s.label)}
${esc(s.command)}
`); 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('
'); $('.term-wrap', this.el).append(this.overlay); } this.overlay.innerHTML = err ? `
${esc(msg)}
` : `
${esc(msg)}
`; 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(`
${avatar(host, 22)}${esc(hostSub(host))}
${api.platform === 'win32' ? `` : ''}
RDP-Sitzung ausgeblendet, solange ein Dialog geöffnet ist.
`); 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(`
${err ? '' : '
'}
${esc(msg)}
${err ? `` : ''}
`); 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('
'); 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('
Host für SFTP-Browser öffnen
')); const grid = h('
'); list.forEach((hst) => { const card = h(`
${avatar(hst)}
${esc(hst.label || hst.address)}
${esc(hostSub(hst))}
`); 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(`
${side === 'local' ? ICONS.screen + 'Lokal' : ICONS.folder + esc(sftp.host.label || sftp.host.address)} ${side === 'local' && api.platform === 'win32' ? '' : ''}
`); 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(``))); 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 = ` ${rows.map((f, i) => ``).join('')}
Name${arrow('name')}Größe${arrow('size')}Geändert${arrow('mtime')}
${ICONS.folder}..
${f.isDir ? ICONS.folder : ICONS.file}${esc(f.name)}
${f.isDir ? '' : fmtSize(f.size)}${fmtDate(f.mtime)}
`; $$('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(`
`); this.local = new FilePane(this, 'local'); this.remote = new FilePane(this, 'remote'); const wrap = $('.sftp', this.el); wrap.append(this.local.el, h(`
Verbinde mit ${esc(host.address)} …
`)); 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 = `
${esc(e.message)}
`; const b = h(``); b.onclick = () => { closeTab(this.id); openSftp(this.host); }; pe.append(b); } } async transfer(dir, localPath, remotePath, name) { const tid = uid(); const row = h(`
${dir === 'upload' ? ICONS.upload : ICONS.download}${esc(name)}0%
`); $('.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(``); 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: `
${ICONS.folder}
`, 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: `
${ICONS.code}
`, 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: `
${ICONS.plus}
`, 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(`
${esc(it.grp)}
`)); last = it.grp; } const el = h(`
${it.icon}
${esc(it.label)}
${it.sub ? `
${esc(it.sub.split('\n')[0])}
` : ''}
${i === idx ? '↵' : ''}
`); 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: `
`, 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 ? '' : ''; }); reload(); $('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);