/* 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 T = I18N.t; 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(I18N.lang, { dateStyle: 'short', timeStyle: 'short' }) : ''); function relTime(t) { const s = (Date.now() - t) / 1000; if (s < 60) return T('just now'); if (s < 3600) return T('{n} min ago', { n: Math.floor(s / 60) }); if (s < 86400) return T('{n} h ago', { n: Math.floor(s / 3600) }); return T('{n} days ago', { n: Math.floor(s / 86400) }); } 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 = T('Delete')) => modal({ title, text, buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label, value: true, cls: label === T('Delete') ? 'danger' : 'primary' }] }); async function promptBox(title, label, value = '', type = 'text') { const r = await modal({ title, body: `
`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('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'); I18N.setLanguage(settings().language, navigator.language); applyStatic(); const b = $('#vaultBadge'); b.innerHTML = S.vault.encrypted ? `${ICONS.lock}${T('Vault encrypted')}` : `${ICONS.unlock}${T('Vault not encrypted')}`; b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).'); applyAppTheme(); S.activeForwards = new Set(await call('fw:active')); render(); } // Statische Texte aus index.html übersetzen (englischer Originaltext steht in data-i18n / data-i18n-title) function applyStatic() { document.documentElement.lang = I18N.lang; $$('[data-i18n]').forEach((el) => { el.textContent = T(el.dataset.i18n); }); $$('[data-i18n-title]').forEach((el) => { el.title = T(el.dataset.i18nTitle); }); // Tooltips für die eingeklappte Symbolansicht $$('#navMenu .nav').forEach((n) => { n.title = n.querySelector('span')?.textContent || ''; }); $('#navToggle').title = $('#sidebar').classList.contains('nav-collapsed') ? T('Expand menu') : T('Collapse menu'); } // ============================================================ 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'); const apply = (collapsed) => { sb.classList.toggle('nav-collapsed', collapsed); $('#navToggle').title = collapsed ? T('Expand menu') : T('Collapse menu'); }; 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(`
${T('Connections')}
`); $('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(`
${T('No hosts yet')}
`)); 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: T('Open'), icon: 'folder', run: () => goGroup(g.id) }, { label: T('Add host'), icon: 'plus', run: () => { goGroup(g.id); editHost({ groupId: g.id }); } }, { label: T('Create subfolder'), icon: 'newdir', run: () => { goGroup(g.id); editGroup({ parentId: g.id }); } }, { label: T('Edit'), icon: 'edit', run: () => { goGroup(g.id); editGroup(g); } }, '-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { 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(T('Search hosts or enter user@host …'), [ { label: T('Group'), icon: 'newdir', run: () => editGroup({ parentId: S.group }) }, { label: T('New 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(`${esc(T('All 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(`
${T('Groups')} ${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: T('Edit'), icon: 'edit', run: () => editGroup(g) }, { label: T('Add host'), icon: 'plus', run: () => editHost({ groupId: g.id }) }, '-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete group?'), T('“{name}” will be deleted. Hosts are kept.', { name: g.label }))) { 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', T('Connect to {q}?', { q }), T('Quick Connect without saving the host.'), T('Connect'), () => quickConnect(q))); } else if (!hosts.length) { const e = emptyState('screen', T('No hosts yet'), T('Add your first SSH or RDP host or import ~/.ssh/config.'), T('New host'), () => editHost({})); const imp = h(``); imp.onclick = importSshConfig; e.append(imp); c.append(e); } else c.append(emptyState('search', T('No results'), T('Adjust the search or filters.'))); } } 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' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) }, ...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []), ...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }] : []), '-', { label: T('Edit'), icon: 'edit', run: () => editHost(hst) }, { label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } }, { label: T('Copy address'), icon: 'copy', run: () => call('clipboard:write', `${hst.username ? hst.username + '@' : ''}${hst.address}`) }, '-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hst.label || hst.address }))) { 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(T('RDP in a tab is not possible ({reason}) – opening a separate window.', { reason: s.reason })); } launchRdp(hst); } async function launchRdp(hst) { try { const client = await call('rdp:launch', hst.id || hst); toast(T('RDP session to {name} started ({client})', { name: hst.label || hst.address, 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(T('Invalid address. Format: user@host:port'), 'error'); connectHost(hst); } async function importSshConfig() { try { const n = await call('vault:importSshConfig'); toast(T('{n} host(s) imported', { n }), '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 = [['', T('— No group —')]]; 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 = [['', T('— No key —')], ...S.vault.keys.map((k) => [k.id, k.label])]; const jumps = [['', T('— Direct —')], ...S.vault.hosts.filter((x) => x.id !== hst.id && x.protocol !== 'rdp').map((x) => [x.id, x.label || x.address])]; sshBox.append( field(T('SSH key'), 'keyId', hst.keyId, { type: 'select', options: keys }), check(T('Use SSH agent'), 'useAgent', hst.useAgent !== false), heading(T('Advanced')), field(T('Jump host (ProxyJump)'), 'jumpHostId', hst.jumpHostId, { type: 'select', options: jumps }), field(T('Startup command'), 'startupCommand', hst.startupCommand, { placeholder: T('e.g. tmux attach || tmux') }), field(T('Environment variables'), 'env', hst.env, { type: 'textarea', placeholder: 'LANG=de_DE.UTF-8', hint: T('One per line, KEY=VALUE (server must allow AcceptEnv).') }), ); rdpBox.append( field(T('Domain'), 'domain', hst.domain, { placeholder: 'optional' }), row(field(T('Width'), 'rdpWidth', hst.rdpWidth, { type: 'number', placeholder: 'auto' }), field(T('Height'), 'rdpHeight', hst.rdpHeight, { type: 'number', placeholder: 'auto' })), field(T('On resize'), 'rdpResize', hst.rdpResize || 'dynamic', { type: 'select', options: [['dynamic', T('Adjust resolution dynamically')], ['scale', T('Scale image (fixed resolution)')]] }), check(T('Fullscreen'), 'rdpFullscreen', hst.rdpFullscreen), check(T('Share clipboard'), 'rdpClipboard', hst.rdpClipboard !== false), check(T('Play audio'), 'rdpAudio', hst.rdpAudio !== false), check(T('Share local drives/home'), 'rdpDrives', hst.rdpDrives), ); const form = openDrawer(isNew ? T('New host') : T('Edit host'), [ seg, row(field(T('Address'), 'address', hst.address, { placeholder: T('IP or hostname') }), portField), field(T('Label'), 'label', hst.label, { placeholder: T('My server') }), field(T('Group'), 'groupId', hst.groupId, { type: 'select', options: groupOptions() }), field('Tags', 'tags', (hst.tags || []).join(', '), { placeholder: 'prod, web, db' }), heading(T('Authentication')), field(T('Username'), 'username', hst.username, { placeholder: 'root' }), field(T('Password'), 'password', hst.password, { type: 'password', placeholder: T('stored encrypted') }), sshBox, rdpBox, heading(T('Appearance')), colorPicker(hst.color), ], async () => { const v = formValues(form); if (!v.address.trim()) throw new Error(T('Please enter an address.')); 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 = T('Delete'); b.onclick = async () => { if (await confirmBox(T('Delete host?'), T('“{name}” will be permanently removed.', { name: hst.label || hst.address }))) { 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 ? T('Edit group') : T('New group'), [ field('Name', 'label', g.label, { placeholder: T('Production') }), field(T('Parent group'), 'parentId', g.parentId, { type: 'select', options: parents }), colorPicker(g.color), ], async () => { const v = formValues(form); if (!v.label.trim()) throw new Error(T('Name is missing.')); await call('vault:upsert', 'groups', { ...g, ...v, parentId: v.parentId || null }); reload(); }); } // ============================================================ Keychain function viewKeys(page) { page.append(toolbar(T('Search keys …'), [ { label: T('Import'), icon: 'download', run: () => editKey({}, true) }, { label: T('Generate'), 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', T('No keys'), T('Generate a new Ed25519 key or import an existing one.'), T('Generate key'), generateKey)); c.append(h(`
${T('Keys')} ${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(T('Public key copied'), 'ok'); } else editKey(k); }; card.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ { label: T('Copy public key'), icon: 'copy', run: () => { call('clipboard:write', k.publicKey || ''); toast(T('Public key copied'), 'ok'); } }, { label: T('Edit'), icon: 'edit', run: () => editKey(k) }, '-', { label: T('Delete'), icon: 'trash', danger: true, run: async () => { if (await confirmBox(T('Delete key?'), T('“{name}” will be removed from the vault.', { name: k.label }))) { await call('vault:remove', 'keys', k.id); reload(); } } }, ]); grid.append(card); } c.append(grid); } async function generateKey() { const r = await modal({ title: T('Generate SSH key'), body: `
`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Generate'), 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(T('Key generated – public key copied to clipboard'), 'ok'); reload(); } function editKey(k = {}, pick = false) { const form = openDrawer(k.id ? T('Edit key') : T('Import key'), [ 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: T('Derived from the private key automatically if left empty.') }), ], async () => { const v = formValues(form); if (!v.privateKey.trim()) throw new Error(T('Private key is missing.')); 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(T('Search snippets …'), [{ label: T('New 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', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New 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(T('Copied'), 'ok'); } else if (a === 'del') { if (await confirmBox(T('Delete snippet?'), 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 ? T('Edit snippet') : T('New snippet'), [ field('Name', 'label', s.label, { placeholder: T('System update') }), field(T('Command(s)'), 'command', s.command, { type: 'textarea', placeholder: 'sudo pacman -Syu' }), check(T('Send Enter automatically'), 'autoRun', s.autoRun !== false), ], async () => { const v = formValues(form); if (!v.command.trim()) throw new Error(T('Command is missing.')); 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(T('No open 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(T('Search rules …'), [{ label: T('New rule'), 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', T('No port forwards'), T('Local (-L), remote (-R) or dynamic as SOCKS5 proxy (-D).'), T('New rule'), () => 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}` : `${T('Local')} ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`; const card = h(`
${ICONS.fwd}
${esc(f.label || desc)} ${on ? T('active') : T('inactive')}
${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(T('Forward active'), 'ok'); } } catch {} reload(); } else if (a === 'del') { if (await confirmBox(T('Delete rule?'), 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(T('Add an SSH host first.'), 'error'); const form = openDrawer(f.id ? T('Edit rule') : T('New forward'), [ field(T('Label'), 'label', f.label, { placeholder: 'Postgres Prod' }), field(T('Type'), 'type', f.type || 'local', { type: 'select', options: [['local', T('Local (-L)')], ['remote', 'Remote (-R)'], ['dynamic', T('Dynamic / SOCKS5 (-D)')]] }), field(T('Via host'), 'hostId', f.hostId, { type: 'select', options: hosts }), row(field(T('Bind address'), 'bindAddress', f.bindAddress, { placeholder: '127.0.0.1' }), field(T('Bind port'), 'bindPort', f.bindPort, { type: 'number', placeholder: '8080', cls: 'small' })), row(field(T('Target host'), 'destHost', f.destHost, { placeholder: 'localhost' }), field(T('Target port'), 'destPort', f.destPort, { type: 'number', placeholder: '5432', cls: 'small' })), ], async () => { const v = formValues(form); if (!v.bindPort) throw new Error(T('Bind port is missing.')); if (v.type !== 'dynamic' && !v.destPort) throw new Error(T('Target port is missing.')); await call('vault:upsert', 'forwards', { ...f, ...v }); reload(); }); } // ============================================================ Known Hosts & Verlauf function viewKnown(page) { page.append(toolbar(T('Search known hosts …'))); 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', T('No known hosts'), T('Host keys are stored here on first connection.'))); 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(T('Search history …'))); 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', T('No connections yet'), T('Recently used hosts appear here.'))); 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(`

${T('Settings')}

`)); 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?.()); }; // ---- Sprache const langCard = h(`

${T('Language')}

`); const langField = field(T('Language'), 'language', st.language || 'auto', { type: 'select', options: [['auto', T('System default')], ...I18N.LANGUAGES] }); langField.onchange = async () => { await save({ language: $('select', langField).value }); I18N.setLanguage(settings().language, navigator.language); reload(); }; langCard.append(langField); // ---- App-Design const designCard = h(`

Design

`); for (const [id, t] of Object.entries(APP_THEMES)) { const p = h(`
${esc(T(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(`

${T('Terminal color scheme')}

`); const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term]; const autoP = h(`
${T('Match theme')}
user@host:~$ ls
${T('follows the theme')}
`); 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(T('Font'), 'fontFamily', st.fontFamily), field(T('Size'), 'fontSize', st.fontSize, { type: 'number', cls: 'small' })), row(field('Cursor', 'cursorStyle', st.cursorStyle, { type: 'select', options: [['block', 'Block'], ['bar', T('Bar')], ['underline', T('Underline')]] }), field(T('Scrollback lines'), 'scrollback', st.scrollback, { type: 'number' })), check(T('Blinking cursor'), 'cursorBlink', st.cursorBlink), check(T('Copy on select'), 'copyOnSelect', st.copyOnSelect), field(T('SSH keep-alive (seconds, 0 = off)'), '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

${T('Checking …')}
`); const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false); emb.onchange = async () => { await save({ rdpEmbed: $('input', emb).checked }); if (api.platform === 'linux') toast(T('Takes effect after restarting MrTerm (X11/XWayland mode).')); }; rdpCard.append(emb); if (api.platform === 'linux') rdpCard.append(h(`
${T('Uses xfreerdp3 with /parent-window. For this, MrTerm runs via XWayland under Wayland.')}
`)); if (api.platform !== 'win32') { const f = field(T('FreeRDP client (separate window)'), 'rdpClientLinux', st.rdpClientLinux, { type: 'select', options: [['auto', T('Automatic')], ['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 ? T('Client in use: {client}', { client: d.client }) : T('No RDP client found. Install: {hint}', { hint: d.hint }); if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)'; }).catch(() => {}); const dataCard = h(`

${T('Data')}

${S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install gnome-keyring or kwallet.')}

`); const btns = [ [T('Import ~/.ssh/config'), importSshConfig], [T('Export backup'), async () => { const p = await call('vault:export'); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); }], [T('Import backup'), async () => { if (await call('vault:import')) { toast(T('Import complete'), 'ok'); reload(); } }], ]; btns.forEach(([l, fn]) => { const b = h(``); b.onclick = fn; $('.row', dataCard).append(b); }); // ---- Import aus anderen Programmen const importCard = h(`

${T('Import')}

${T('Import folders and hosts (RDP & SSH) from Devolutions Remote Desktop Manager. In RDM: File → Export → Export entries as .rdm (XML), JSON or CSV. RDM only exports passwords unencrypted if you enable that during export.')}

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

Updates

${T('Installed version: {v}', { v: '…' })}

`); api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = T('Installed version: {v}', { v }); }); const updFields = h('
'); updFields.append( field('Repository', 'updateUrl', st.updateUrl, { placeholder: 'https://git.mrblake.cc/MrBlake/MrTerm', hint: T('Gitea/Forgejo repository whose releases contain the installation packages.') }), field(T('Access token (private repos only)'), 'updateToken', st.updateToken, { type: 'password', placeholder: 'optional' }), check(T('Check for updates on startup'), 'updateAutoCheck', st.updateAutoCheck), check(T('Include pre-releases'), '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(T('The repository has version {repo}, but there is no release with installation packages for it yet. Installed: {current}.', { repo: r.repoVersion, current: r.current }), 'error'); else if (r.noRelease) toast(T('The repository has no releases yet.'), 'error'); else toast(T('MrTerm is up to date ({v}).', { v: r.current }), 'ok'); } catch {} chk.disabled = false; }; updCard.append(updFields, chk); const C = `${T('Ctrl')}`; const keysCard = h(`

${T('Keyboard shortcuts')}

${C}+Shift+K / T${T('Quick Connect / command palette (outside the terminal also {k})', { k: `${C}+K` })} ${C}+Shift+W${T('Close tab')} ${C}+Tab${T('Next tab')} ${C}+1..9${T('Switch to tab')} ${C}+Shift+C/V${T('Copy / paste in terminal')} ${C}+Shift+F${T('Search in terminal')} ${C}++/−/0${T('Font size')}
`); c.append(langCard, 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(T('Import failed: {msg}', { msg: e.message }), 'error'); } const hosts = entries.filter((e) => e.host); if (!hosts.length) return toast(T('No RDP or SSH entries found in the file.'), '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(' / ') || T('Root level'))} · ${esc(hostSub(e.host))}
`).join(''); const ok = await modal({ title: T('Import from Remote Desktop Manager'), text: T('{file}: found {n} hosts ({rdp} RDP, {ssh} SSH) in {folders} folders.', { file: f.name, n: hosts.length, rdp: rdpN, ssh: hosts.length - rdpN, folders: folders.size }), body: `
${preview}
${hosts.length > 8 ? `

${T('… and {n} more', { n: hosts.length - 8 })}

` : ''}`, buttons: [{ label: T('Cancel'), value: false, cls: 'ghost' }, { label: T('Import'), value: true, cls: 'primary' }], }); if (!ok) return; const r = await call('vault:bulkImport', entries); toast(T('{n} hosts imported', { n: r.hosts }) + (r.skipped ? T(', {n} already existed', { n: r.skipped }) : ''), '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: T('MrTerm {v} is available', { v: info.version }), text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''), body: `${info.notes ? `
${esc(info.notes)}
` : ''} ${info.kind === 'dev' ? `

${T('Development mode: please update via git pull.')}

` : !info.asset ? `

${T('The release contains no package for this system.')}

` : ''} `, buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])], }); if (r === 'web') return api.call('shell:open', info.url); if (r !== true) return; toast(T('Downloading update …')); try { await call('update:download'); toast(T('Installing update, MrTerm will restart …'), '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: T('Duplicate'), icon: 'dup', run: () => (session.kind === 'sftp' ? openSftp(session.host) : openTerminal(session.host)) }] : []), ...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []), { label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } }, '-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) }, { label: T('Close others'), run: () => S.tabs.filter((t) => t.id !== session.id).forEach((t) => closeTab(t.id)) }, ]); session.tabEl = tab; $('#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)); $('.term', this.el).style.background = termTheme().background; // Innenabstand in Terminalfarbe 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(T('Copied')); } 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(`
${T('No snippets yet.')}
`)); 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() }); $('.term', this.el).style.background = termTheme().background; 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(T('Connecting to {host} …', { host: 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— ${T('Reconnecting')} —\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[90m${T('Connection closed. Press [Enter] to reconnect.')}\x1b[0m\r\n`); } } dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); } } 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' ? `` : ''}
${T('RDP session hidden while a dialog is open.')}
`); 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(T('Connecting to {host} … Login or certificate prompts may appear in a separate window.', { host: this.host.address })); 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(T('The RDP window could not be embedded.'), 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((payload?.code ? T('RDP session ended (code {code}).', { code: payload.code }) : T('RDP session ended.')) + (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(T('Session continues in a separate window'), '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(T('Choose a host for SFTP …'))); 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', T('No SSH hosts'), T('SFTP requires an SSH host.'), T('New host'), () => editHost({}))); c.append(h(`
${T('Open host in SFTP browser')}
`)); 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 + T('Local') : 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: T('New folder'), icon: 'newdir', run: () => this.mkdir() }, { label: T('Refresh'), 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')}${T('Size')}${arrow('size')}${T('Modified')}${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' ? T('Upload') : T('Download'), icon: this.side === 'local' ? 'upload' : 'download', run: () => this.transferSelected() }, ...(f.isDir ? [{ label: T('Open'), icon: 'folder', run: async () => this.go(await this.join(this.cwd, f.name)) }] : []), { label: T('Rename'), icon: 'edit', run: () => this.rename(f) }, { label: T('Copy path'), icon: 'copy', run: async () => api.call('clipboard:write', await this.join(this.cwd, f.name)) }, ...(this.side === 'remote' && !f.isDir ? [{ label: T('Permissions (chmod)…'), icon: 'lock', run: () => this.chmod(f) }] : []), '-', { label: this.side === 'local' ? T('Move to trash') : T('Delete'), icon: 'trash', danger: true, run: () => this.remove() }, ]); }; }); } async mkdir() { const n = await promptBox(T('New folder'), T('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(T('Rename'), T('New 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(T('Change permissions'), T('Octal (e.g. 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(T('Delete?'), T(this.side === 'local' ? 'Move {n} item(s) to the trash: {list}' : 'Permanently delete {n} item(s) from the server: {list}', { n: names.length, list: 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(T('Nothing selected')); 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(`
${esc(T('Connecting to {host} …', { host: 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 = T('done'); setTimeout(() => row.remove(), 5000); } catch (e) { row.classList.add('fail'); $('.pct', row).textContent = T('Error'); 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) })); [[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]] .filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: T('Actions'), 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 || T('Authentication'), text: req.host, body: `
`, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Log in'), 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);