RDM-Import, App-Designs, Auto-Update über Gitea, Logo; SFTP-Namensspalte repariert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:04:19 +02:00
co-authored by Claude Opus 5.5
parent a5eccc7764
commit 972fe0d316
16 changed files with 650 additions and 46 deletions
+124 -15
View File
@@ -140,14 +140,15 @@ async function reload() {
const b = $('#vaultBadge');
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>Vault verschlüsselt</span>` : `${ICONS.unlock}<span>Vault unverschlüsselt</span>`;
b.title = S.vault.encrypted ? 'Daten werden mit dem System-Schlüsselbund verschlüsselt.' : 'Kein System-Schlüsselbund verfügbar (z. B. gnome-keyring/kwallet installieren).';
applyAppTheme();
S.activeForwards = new Set(await call('fw:active'));
render();
}
// ============================================================ Navigation
$$('#sidebar .nav').forEach((n) => n.addEventListener('click', () => {
$$('#sidebar .nav[data-view]').forEach((n) => n.addEventListener('click', () => {
S.view = n.dataset.view;
$$('#sidebar .nav').forEach((x) => x.classList.toggle('active', x === n));
$$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x === n));
closeDrawer();
activateTab('home');
render();
@@ -456,7 +457,7 @@ function viewKeys(page) {
for (const k of list) {
const type = (k.publicKey || '').split(' ')[0] || 'key';
const used = S.vault.hosts.filter((x) => x.keyId === k.id).length;
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:var(--orange)">${ICONS.key}</div><div class="meta"><div class="title">${esc(k.label)}</div><div class="sub">${esc(type)} · ${used} Host${used === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="copy" title="Public Key kopieren">${ICONS.copy}</button><button data-a="edit" title="Bearbeiten">${ICONS.edit}</button></div></div>`);
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--orange)">${ICONS.key}</div><div class="meta"><div class="title">${esc(k.label)}</div><div class="sub">${esc(type)} · ${used} Host${used === 1 ? '' : 's'}</div></div><div class="actions"><button data-a="copy" title="Public Key kopieren">${ICONS.copy}</button><button data-a="edit" title="Bearbeiten">${ICONS.edit}</button></div></div>`);
card.onclick = (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'copy') { call('clipboard:write', k.publicKey || ''); toast('Public Key kopiert', 'ok'); }
@@ -519,7 +520,7 @@ function viewSnippets(page) {
if (!list.length) return c.append(emptyState('code', 'Keine Snippets', 'Speichere häufig genutzte Befehle und sende sie mit einem Klick an ein Terminal.', 'Neues Snippet', () => editSnippet()));
const l = h('<div class="list"></div>');
for (const s of list) {
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="Im aktiven Terminal ausführen">${ICONS.play}</button><button data-a="copy" title="Kopieren">${ICONS.copy}</button><button data-a="del" title="Löschen">${ICONS.trash}</button></div></div>`);
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="Im aktiven Terminal ausführen">${ICONS.play}</button><button data-a="copy" title="Kopieren">${ICONS.copy}</button><button data-a="del" title="Löschen">${ICONS.trash}</button></div></div>`);
card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'run') runSnippet(s);
@@ -564,7 +565,7 @@ function viewForwards(page) {
const desc = f.type === 'dynamic' ? `SOCKS5 ${f.bindAddress || '127.0.0.1'}:${f.bindPort}`
: f.type === 'remote' ? `Remote ${f.bindPort} → ${f.destHost || '127.0.0.1'}:${f.destPort}`
: `Lokal ${f.bindAddress || '127.0.0.1'}:${f.bindPort} → ${f.destHost}:${f.destPort}`;
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.fwd}</div><div class="meta"><div class="title">${esc(f.label || desc)} <span class="status-pill ${on ? 'on' : ''}">${on ? 'aktiv' : 'inaktiv'}</span></div><div class="sub">${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}</div></div>
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:${on ? 'var(--green)' : 'var(--muted)'}">${ICONS.fwd}</div><div class="meta"><div class="title">${esc(f.label || desc)} <span class="status-pill ${on ? 'on' : ''}">${on ? 'aktiv' : 'inaktiv'}</span></div><div class="sub">${esc(desc)} · via ${esc(hst ? hst.label || hst.address : '??')}</div></div>
<button class="btn sm ${on ? '' : 'primary'}" data-a="toggle">${on ? ICONS.stop + 'Stoppen' : ICONS.play + 'Starten'}</button><div class="actions"><button data-a="del">${ICONS.trash}</button></div></div>`);
card.onclick = async (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
@@ -608,7 +609,7 @@ function viewKnown(page) {
if (!entries.length) return c.append(emptyState('shield', 'Keine bekannten Hosts', 'Host-Schlüssel werden bei der ersten Verbindung hier gespeichert.'));
const l = h('<div class="list"></div>');
for (const [id, v] of entries) {
const card = h(`<div class="card"><div class="avatar" style="background:#2a2f45;color:var(--green)">${ICONS.shield}</div><div class="meta"><div class="title">${esc(id)}</div><div class="sub mono">${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}</div></div><div class="actions"><button title="Entfernen">${ICONS.trash}</button></div></div>`);
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.shield}</div><div class="meta"><div class="title">${esc(id)}</div><div class="sub mono">${esc(v.fingerprint)} · ${fmtDate(v.addedAt)}</div></div><div class="actions"><button title="Entfernen">${ICONS.trash}</button></div></div>`);
$('.actions button', card).onclick = async () => { await call('vault:forgetHost', id); reload(); };
l.append(card);
}
@@ -636,7 +637,25 @@ async function viewSettings(page) {
const c = h('<div class="content settings-grid"></div>'); page.append(c);
const save = async (patch) => { await call('vault:settings', patch); S.vault.settings = { ...st, ...patch }; S.tabs.forEach((t) => t.applySettings?.()); };
// ---- App-Design
const designCard = h('<div class="settings-card"><h3>Design</h3><div class="theme-list design-list"></div><div class="field" style="margin:16px 0 0"><label>Akzentfarbe</label><div class="colors accent-colors"></div></div></div>');
for (const [id, t] of Object.entries(APP_THEMES)) {
const p = h(`<div class="theme-prev design-prev ${st.appTheme === id ? 'active' : ''}" style="background:${t.bg};color:${t.text}">
<div class="dp"><i style="background:${t.side}"></i><div><b style="background:${t.card}"><s style="background:${t.accent}"></s></b><b style="background:${t.card}"></b></div></div><div class="nm">${esc(t.name)}</div></div>`);
p.onclick = async () => { await save({ appTheme: id }); applyAppTheme(); render(); };
$('.design-list', designCard).append(p);
}
['', ...COLORS.slice(0, 7), '#21c7a8', '#cba6f7'].forEach((col) => {
const b = h(`<button type="button" title="${col || 'Standard des Designs'}" class="${(st.accent || '') === col ? 'active' : ''}" style="background:${col || 'conic-gradient(#6e7bff,#21c7a8,#cba6f7,#ffb454,#6e7bff)'}"></button>`);
b.onclick = async () => { await save({ accent: col }); applyAppTheme(); render(); };
$('.accent-colors', designCard).append(b);
});
const themeCard = h('<div class="settings-card"><h3>Terminal-Farbschema</h3><div class="theme-list"></div></div>');
const autoT = TERM_THEMES[(APP_THEMES[st.appTheme] || APP_THEMES.midnight).term];
const autoP = h(`<div class="theme-prev ${st.terminalTheme === 'auto' ? 'active' : ''}" style="background:${autoT.background};color:${autoT.foreground}"><div class="nm">Passend zum Design</div><span style="color:${autoT.green}">user@host</span>:<span style="color:${autoT.blue}">~</span>$ ls<br><span style="color:${autoT.blue}">folgt dem Design</span></div>`);
autoP.onclick = async () => { await save({ terminalTheme: 'auto' }); render(); };
$('.theme-list', themeCard).append(autoP);
for (const [id, t] of Object.entries(TERM_THEMES)) {
const p = h(`<div class="theme-prev ${st.terminalTheme === id ? 'active' : ''}" style="background:${t.background};color:${t.foreground}"><div class="nm">${esc(t.name)}</div><span style="color:${t.green}">user@host</span>:<span style="color:${t.blue}">~</span>$ ls<br><span style="color:${t.blue}">docs</span> <span style="color:${t.green}">run.sh</span> <span style="color:${t.red}">err</span></div>`);
p.onclick = async () => { await save({ terminalTheme: id }); render(); };
@@ -675,6 +694,31 @@ async function viewSettings(page) {
];
btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); });
// ---- Import aus anderen Programmen
const importCard = h(`<div class="settings-card"><h3>Import</h3><p style="color:var(--muted);margin-top:0">Ordner und Hosts (RDP &amp; SSH) aus <b>Devolutions Remote Desktop Manager</b> übernehmen. In RDM: <i>Datei → Exportieren → Einträge exportieren</i> als <code>.rdm</code> (XML), JSON oder CSV. Passwörter werden von RDM nur unverschlüsselt exportiert, wenn du das beim Export aktivierst.</p></div>`);
const rdmBtn = h('<button class="btn primary">Aus Remote Desktop Manager importieren…</button>');
rdmBtn.onclick = importRdm;
importCard.append(rdmBtn);
// ---- Updates
const updCard = h(`<div class="settings-card"><h3>Updates</h3><p class="upd-info" style="color:var(--muted);margin-top:0">Installierte Version: …</p></div>`);
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = `Installierte Version: ${v}`; });
const updFields = h('<div></div>');
updFields.append(
field('Repository', 'updateUrl', st.updateUrl, { placeholder: 'https://git.mrblake.cc/MrBlake/MrTerm', hint: 'Gitea/Forgejo-Repository, dessen Releases die Installationspakete enthalten.' }),
field('Zugriffstoken (nur für private Repos)', 'updateToken', st.updateToken, { type: 'password', placeholder: 'optional' }),
check('Beim Start automatisch nach Updates suchen', 'updateAutoCheck', st.updateAutoCheck),
check('Vorabversionen (Pre-Releases) einbeziehen', 'updatePrerelease', st.updatePrerelease),
);
updFields.addEventListener('change', () => save(formValues(updFields)));
const chk = h(`<button class="btn">${ICONS.refresh}Jetzt nach Updates suchen</button>`);
chk.onclick = async () => {
chk.disabled = true;
try { await save(formValues(updFields)); const r = await call('update:check'); if (r.available) showUpdate(r); else toast(`MrTerm ist aktuell (${r.current}).`, 'ok'); } catch {}
chk.disabled = false;
};
updCard.append(updFields, chk);
const keysCard = h(`<div class="settings-card"><h3>Tastenkürzel</h3><div style="display:grid;grid-template-columns:auto 1fr;gap:8px 18px;color:var(--muted)">
<span><kbd>Strg</kbd>+<kbd>K</kbd> / <kbd>Strg</kbd>+<kbd>T</kbd></span><span>Quick Connect / Befehlspalette</span>
<span><kbd>Strg</kbd>+<kbd>W</kbd></span><span>Tab schließen</span>
@@ -683,9 +727,73 @@ async function viewSettings(page) {
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>C/V</kbd></span><span>Kopieren / Einfügen im Terminal</span>
<span><kbd>Strg</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>Im Terminal suchen</span>
<span><kbd>Strg</kbd>+<kbd>+/−/0</kbd></span><span>Schriftgröße</span></div></div>`);
c.append(themeCard, termCard, rdpCard, dataCard, keysCard);
c.append(designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
}
// ============================================================ Design
function termTheme() {
const id = settings().terminalTheme;
if (id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
return TERM_THEMES[id] || TERM_THEMES.mrterm;
}
function applyAppTheme() {
const st = settings();
const root = document.documentElement;
if (st.appTheme && st.appTheme !== 'midnight') root.dataset.theme = st.appTheme; else delete root.dataset.theme;
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
S.tabs.forEach((t) => t.applySettings?.());
}
// ============================================================ RDM-Import
async function importRdm() {
const f = await call('import:pickFile', 'Remote Desktop Manager Export', ['rdm', 'xml', 'json', 'csv']);
if (!f) return;
let entries;
try { entries = parseRdmExport(f.name, f.content); } catch (e) { return toast('Import fehlgeschlagen: ' + e.message, 'error'); }
const hosts = entries.filter((e) => e.host);
if (!hosts.length) return toast('Keine RDP- oder SSH-Einträge in der Datei gefunden.', 'error');
const folders = new Set(entries.map((e) => e.folder.join(' / ')).filter(Boolean));
const rdpN = hosts.filter((e) => e.host.protocol === 'rdp').length;
const preview = hosts.slice(0, 8).map((e) => `<div class="card" style="padding:8px 10px;cursor:default">${avatar(e.host)}<div class="meta"><div class="title">${esc(e.host.label)}</div><div class="sub">${esc(e.folder.join(' / ') || 'Stammebene')} · ${esc(hostSub(e.host))}</div></div></div>`).join('');
const ok = await modal({
title: 'Import aus Remote Desktop Manager',
text: `${f.name}: ${hosts.length} Hosts (${rdpN} RDP, ${hosts.length - rdpN} SSH) in ${folders.size} Ordnern gefunden.`,
body: `<div class="list" style="max-height:300px;overflow:auto">${preview}</div>${hosts.length > 8 ? `<p style="margin:10px 0 0">… und ${hosts.length - 8} weitere</p>` : ''}`,
buttons: [{ label: 'Abbrechen', value: false, cls: 'ghost' }, { label: 'Importieren', value: true, cls: 'primary' }],
});
if (!ok) return;
const r = await call('vault:bulkImport', entries);
toast(`${r.hosts} Hosts importiert${r.skipped ? `, ${r.skipped} bereits vorhanden` : ''}`, 'ok');
await reload();
$('[data-view=hosts]').click();
}
// ============================================================ Updates
let updateInfo = null;
async function showUpdate(info) {
updateInfo = info;
const b = $('#updateBadge');
b.style.display = '';
b.querySelector('span').textContent = `Update ${info.version}`;
const r = await modal({
title: `MrTerm ${info.version} ist verfügbar`,
text: `Installiert: ${info.current}${info.asset ? ` · Paket: ${info.asset.name} (${fmtSize(info.asset.size)})` : ''}`,
body: `${info.notes ? `<div class="mono" style="white-space:pre-wrap;max-height:240px;overflow:auto;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:6px">${esc(info.notes)}</div>` : ''}
${info.kind === 'dev' ? '<p>Entwicklungsmodus: Bitte per <code>git pull</code> aktualisieren.</p>' : !info.asset ? '<p>Im Release ist kein passendes Paket für dieses System.</p>' : ''}
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">Lade herunter …</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
buttons: [{ label: 'Später', value: false, cls: 'ghost' }, { label: 'Release-Seite', value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: 'Jetzt installieren', value: true, cls: 'primary' }] : [])],
});
if (r === 'web') return api.call('shell:open', info.url);
if (r !== true) return;
toast('Update wird heruntergeladen …');
try { await call('update:download'); toast('Update wird installiert, MrTerm startet neu …', 'ok'); await call('update:install'); } catch {}
}
api.on('update:available', (info) => showUpdate(info));
api.on('update:progress', (done, total) => {
const b = $('#updateBadge span');
if (b) b.textContent = total ? `Update ${Math.round((done / total) * 100)}%` : 'Update …';
});
// ============================================================ Tabs
function addTab(session) {
S.tabs.push(session);
@@ -758,7 +866,7 @@ class TerminalSession {
const st = settings();
this.term = new Terminal({
fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink,
scrollback: st.scrollback, theme: TERM_THEMES[st.terminalTheme] || TERM_THEMES.mrterm, allowProposedApi: true, macOptionIsMeta: true,
scrollback: st.scrollback, theme: termTheme(), allowProposedApi: true, macOptionIsMeta: true,
});
this.fit = new FitAddon.FitAddon();
this.search = new SearchAddon.SearchAddon();
@@ -836,7 +944,7 @@ class TerminalSession {
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: TERM_THEMES[st.terminalTheme] || TERM_THEMES.mrterm });
Object.assign(this.term.options, { fontFamily: st.fontFamily, fontSize: st.fontSize, cursorStyle: st.cursorStyle, cursorBlink: st.cursorBlink, scrollback: st.scrollback, theme: termTheme() });
this.doFit();
}
@@ -956,7 +1064,7 @@ class FilePane {
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, 3).toUpperCase();
const roots = $('.roots', this.el); if (roots) roots.value = dir.slice(0, 2).toUpperCase() + '\';
this.draw();
} catch (e) { toast(e.message, 'error'); }
}
@@ -969,8 +1077,8 @@ class FilePane {
const arrow = (k) => (this.sort.k === k ? (this.sort.dir > 0 ? ' ▲' : ' ▼') : '');
const rows = this.view = this.sorted();
box.innerHTML = `<table><thead><tr><th data-s="name">Name${arrow('name')}</th><th data-s="size" style="text-align:right">Größe${arrow('size')}</th><th data-s="mtime">Geändert${arrow('mtime')}</th></tr></thead><tbody>
<tr data-up="1"><td class="name"><span class="ico-dir">${ICONS.folder}</span><span>..</span></td><td></td><td></td></tr>
${rows.map((f, i) => `<tr data-i="${i}" draggable="true" class="${this.sel.has(f.name) ? 'sel' : ''}"><td class="name"><span class="${f.isDir ? 'ico-dir' : 'ico-file'}">${f.isDir ? ICONS.folder : ICONS.file}</span><span>${esc(f.name)}</span></td><td class="num">${f.isDir ? '' : fmtSize(f.size)}</td><td class="date">${fmtDate(f.mtime)}</td></tr>`).join('')}</tbody></table>`;
<tr data-up="1"><td class="name"><div><span class="ico-dir">${ICONS.folder}</span><span>..</span></div></td><td></td><td></td></tr>
${rows.map((f, i) => `<tr data-i="${i}" draggable="true" class="${this.sel.has(f.name) ? 'sel' : ''}"><td class="name"><div><span class="${f.isDir ? 'ico-dir' : 'ico-file'}">${f.isDir ? ICONS.folder : ICONS.file}</span><span>${esc(f.name)}</span></div></td><td class="num">${f.isDir ? '' : fmtSize(f.size)}</td><td class="date">${fmtDate(f.mtime)}</td></tr>`).join('')}</tbody></table>`;
$$('th', box).forEach((th) => (th.onclick = () => { const k = th.dataset.s; this.sort = { k, dir: this.sort.k === k ? -this.sort.dir : 1 }; this.draw(); }));
$('tr[data-up]', box).ondblclick = () => this.up();
$$('tr[data-i]', box).forEach((tr) => {
@@ -1113,11 +1221,11 @@ function openPalette() {
hosts.sort((a, b) => ((recent.indexOf(a.id) + 1 || 999) - (recent.indexOf(b.id) + 1 || 999)));
hosts.slice(0, 12).forEach((x) => {
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:#2a2f45;color:#6ea8ff">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
});
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:#2a2f45;color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
[['Neuer Host', () => { activateTab('home'); editHost({}); }], ['Schlüssel generieren', generateKey], ['Einstellungen', () => $('[data-view=settings]').click()]]
.filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: 'Aktionen', icon: `<div class="avatar" style="background:#2a2f45;color:var(--accent-2)">${ICONS.plus}</div>`, label: l, run }));
.filter(([l]) => !q || l.toLowerCase().includes(q)).forEach(([l, run]) => items.push({ grp: 'Aktionen', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.plus}</div>`, label: l, run }));
idx = Math.min(idx, Math.max(0, items.length - 1));
let last = '';
res.innerHTML = '';
@@ -1182,3 +1290,4 @@ api.on('toast', (m, t) => toast(m, t));
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
reload();
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);
+3 -1
View File
@@ -10,7 +10,7 @@
<body>
<div id="app">
<header id="titlebar">
<div class="brand"><span class="logo">&gt;_</span>MrTerm</div>
<div class="brand"><img class="logo" src="logo.png" alt="" />Mr<span class="brand-term">Term</span></div>
<div id="tabs">
<button class="tab home active" data-tab="home" title="Vault">
<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span>
@@ -35,6 +35,7 @@
<button class="nav" data-view="known"><svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M9 12l2 2 4-4"/></svg><span>Known Hosts</span></button>
<button class="nav" data-view="history"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>Verlauf</span></button>
<div class="spacer"></div>
<button id="updateBadge" class="nav update-badge" style="display:none"><svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg><span>Update</span></button>
<div id="vaultBadge" class="vault-badge"></div>
<button class="nav" data-view="settings"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span>Einstellungen</span></button>
</nav>
@@ -56,6 +57,7 @@
<script src="../../node_modules/@xterm/addon-web-links/lib/addon-web-links.js"></script>
<script src="../../node_modules/@xterm/addon-search/lib/addon-search.js"></script>
<script src="themes.js"></script>
<script src="rdm-import.js"></script>
<script src="app.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+142
View File
@@ -0,0 +1,142 @@
// Parser für Exporte aus Devolutions Remote Desktop Manager.
// Unterstützt: .rdm / .xml (Export "Remote Desktop Manager-Datei"), .json und .csv.
// Liefert [{ folder: ['A','B'], host?: {...} }] für vault:bulkImport.
'use strict';
(function () {
// Numerische ConnectionType-Werte aus RDM-JSON (die gängigsten)
const NUMERIC_TYPES = { 1: 'RDPConfigured', 25: 'Group', 77: 'SSHShell', 24: 'Putty', 108: 'SSHShell' };
function classify(type) {
const t = String(NUMERIC_TYPES[type] || type || '').toLowerCase();
if (!t) return null;
if (t === 'group' || t === 'folder' || t === 'repository') return 'folder';
if (t.startsWith('rdp') || t === 'remotedesktop' || t === 'microsoftremotedesktop') return 'rdp';
if (t.includes('ssh') || t === 'putty' || t === 'terminal' || t === 'sshshell') return 'ssh';
return null; // VNC, Web, Credentials, Dokumente … werden übersprungen
}
const splitPath = (p) => String(p || '').split(/[\\/]/).map((s) => s.trim()).filter(Boolean);
function splitHostPort(url, defPort) {
let address = String(url || '').trim().replace(/^(rdp|ssh|https?):\/\//i, '').replace(/\/.*$/, '');
let port;
const m = address.match(/^\[(.+)\](?::(\d+))?$/) || address.match(/^([^:]+):(\d+)$/);
if (m) { address = m[1]; port = m[2] ? Number(m[2]) : undefined; }
return { address, port: port && port !== defPort ? port : undefined };
}
function makeEntry(r) {
const kind = classify(r.type);
if (!kind) return null;
let folder = splitPath(r.group);
if (kind === 'folder') {
// RDM speichert bei Ordnern meist den vollständigen Pfad inkl. eigenem Namen im Feld "Group"
if (r.name && folder[folder.length - 1] !== r.name) folder.push(r.name);
return folder.length ? { folder } : null;
}
const defPort = kind === 'rdp' ? 3389 : 22;
const hp = splitHostPort(r.host || r.url, defPort);
if (!hp.address) return null;
const port = Number(r.port) && Number(r.port) !== defPort ? Number(r.port) : hp.port;
const host = {
protocol: kind,
label: r.name || hp.address,
address: hp.address,
port,
username: r.username || undefined,
domain: kind === 'rdp' ? r.domain || undefined : undefined,
tags: ['rdm'],
};
if (r.password) host.password = r.password;
if (r.description) host.notes = r.description;
return { folder, host };
}
// ---------- XML ----------
function child(el, path) {
let cur = el;
for (const name of path.split('/')) {
cur = [...(cur?.children || [])].find((c) => c.localName.toLowerCase() === name.toLowerCase());
if (!cur) return '';
}
return cur.textContent.trim();
}
const firstOf = (el, paths) => { for (const p of paths) { const v = child(el, p); if (v) return v; } return ''; };
function parseXml(text) {
const doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.querySelector('parsererror')) throw new Error('Datei ist kein gültiges XML.');
const conns = [...doc.getElementsByTagName('Connection')].filter((c) => child(c, 'ConnectionType'));
return conns.map((c) => makeEntry({
type: child(c, 'ConnectionType'),
name: child(c, 'Name'),
group: firstOf(c, ['Group', 'GroupName', 'Folder']),
url: firstOf(c, ['Url', 'Host', 'HostName']),
host: firstOf(c, ['Terminal/Host', 'Putty/Host', 'SSHTunnel/Host', 'RDP/Host']),
port: firstOf(c, ['Terminal/HostPort', 'Putty/Port', 'RDP/Port', 'Port', 'HostPort']),
username: firstOf(c, ['RDP/UserName', 'Terminal/Username', 'Terminal/UserName', 'Putty/UserName', 'Putty/Username', 'UserName', 'Username']),
domain: firstOf(c, ['RDP/Domain', 'Domain']),
description: child(c, 'Description'),
})).filter(Boolean);
}
// ---------- JSON ----------
function parseJson(text) {
const data = JSON.parse(text);
const list = Array.isArray(data) ? data : data.Connections || data.connections || data.Entries || [];
const g = (o, ...keys) => { for (const k of keys) { const v = k.split('.').reduce((a, p) => a?.[p], o); if (v != null && v !== '') return v; } return ''; };
return list.map((c) => makeEntry({
type: g(c, 'ConnectionType', 'Type'),
name: g(c, 'Name'),
group: g(c, 'Group', 'GroupName', 'Folder'),
url: g(c, 'Url', 'Host'),
host: g(c, 'Terminal.Host', 'Putty.Host', 'RDP.Host'),
port: g(c, 'Terminal.HostPort', 'Putty.Port', 'RDP.Port', 'Port'),
username: g(c, 'RDP.UserName', 'Terminal.Username', 'Putty.UserName', 'UserName', 'Username'),
domain: g(c, 'RDP.Domain', 'Domain'),
description: g(c, 'Description'),
})).filter(Boolean);
}
// ---------- CSV ----------
function csvRows(text) {
const delim = (text.split('\n')[0].match(/;/g) || []).length > (text.split('\n')[0].match(/,/g) || []).length ? ';' : ',';
const rows = []; let row = [], cur = '', q = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (q) {
if (ch === '"' && text[i + 1] === '"') { cur += '"'; i++; }
else if (ch === '"') q = false;
else cur += ch;
} else if (ch === '"') q = true;
else if (ch === delim) { row.push(cur); cur = ''; }
else if (ch === '\n' || ch === '\r') { if (ch === '\r' && text[i + 1] === '\n') i++; row.push(cur); rows.push(row); row = []; cur = ''; }
else cur += ch;
}
if (cur || row.length) { row.push(cur); rows.push(row); }
return rows.filter((r) => r.some((c) => c.trim()));
}
function parseCsv(text) {
const [head, ...rows] = csvRows(text);
const idx = (...names) => head.findIndex((h) => names.includes(h.trim().toLowerCase().replace(/[\s_]/g, '')));
const col = {
type: idx('connectiontype', 'type', 'protocol'), name: idx('name', 'displayname'), group: idx('group', 'folder', 'groupname'),
host: idx('host', 'hostname', 'url', 'computername', 'address'), port: idx('port', 'hostport'),
username: idx('username', 'user', 'login'), domain: idx('domain'), password: idx('password'), description: idx('description'),
};
if (col.host < 0 && col.type < 0) throw new Error('CSV-Spalten nicht erkannt (erwartet u. a. Name, Host, ConnectionType, Group).');
return rows.map((r) => {
const v = (k) => (col[k] >= 0 ? (r[col[k]] || '').trim() : '');
return makeEntry({ type: v('type') || 'RDPConfigured', name: v('name'), group: v('group'), url: v('host'), port: v('port'), username: v('username'), domain: v('domain'), password: v('password'), description: v('description') });
}).filter(Boolean);
}
window.parseRdmExport = function (name, content) {
const t = content.trimStart();
if (/\.json$/i.test(name) || t.startsWith('{') || t.startsWith('[')) return parseJson(content);
if (/\.csv$/i.test(name)) return parseCsv(content);
if (t.startsWith('<')) return parseXml(content);
return parseCsv(content);
};
})();
+86 -27
View File
@@ -13,12 +13,59 @@
--green: #3ecf8e;
--red: #ff5f6d;
--orange: #ffb454;
--accent-3: #b36bff;
--chrome: #0f1117;
--chrome-line: #1f2330;
--hover: #1a1d27;
--nav-active: #1f2233;
--icon-bg: #2a2f45;
--folder: #6ea8ff;
--row-line: #1c1f29;
--overlay: #13151ce6;
--tint: 255 255 255;
--radius: 10px;
--titlebar: 42px;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, 'Noto Sans', sans-serif;
font-size: 13px;
color: var(--text);
}
/* ---------- App-Designs ---------- */
:root { --accent-2: color-mix(in srgb, var(--accent) 78%, white); }
[data-theme='navy'] {
--bg: #141729; --bg-2: #181c33; --panel: #1c2139; --card: #212742; --card-hover: #29304f; --border: #2e3657;
--text: #e8eaf6; --muted: #8e95b8; --faint: #5f6690; --accent: #21c7a8; --accent-3: #3a8dff;
--chrome: #0f1122; --chrome-line: #20264a; --hover: #1b2040; --nav-active: #232a52; --icon-bg: #2a3260; --row-line: #1c2140; --overlay: #141729e6;
}
[data-theme='nord'] {
--bg: #2e3440; --bg-2: #323846; --panel: #3b4252; --card: #3b4252; --card-hover: #434c5e; --border: #4c566a;
--text: #eceff4; --muted: #b5bdcc; --faint: #7b8598; --accent: #88c0d0; --accent-3: #81a1c1; --green: #a3be8c; --red: #bf616a; --orange: #d08770;
--chrome: #272c36; --chrome-line: #3b4252; --hover: #3b4252; --nav-active: #434c5e; --icon-bg: #4c566a; --folder: #81a1c1; --row-line: #3b4252; --overlay: #2e3440e6;
}
[data-theme='dracula'] {
--bg: #282a36; --bg-2: #2c2e3b; --panel: #313343; --card: #343746; --card-hover: #3e4153; --border: #44475a;
--text: #f8f8f2; --muted: #b3b6c8; --faint: #6272a4; --accent: #bd93f9; --accent-3: #ff79c6; --green: #50fa7b; --red: #ff5555; --orange: #ffb86c;
--chrome: #21222c; --chrome-line: #343746; --hover: #2f3140; --nav-active: #383a4c; --icon-bg: #44475a; --folder: #8be9fd; --row-line: #313343; --overlay: #282a36e6;
}
[data-theme='mocha'] {
--bg: #1e1e2e; --bg-2: #232334; --panel: #28283b; --card: #2a2a3d; --card-hover: #313244; --border: #3b3b52;
--text: #cdd6f4; --muted: #a6adc8; --faint: #6c7086; --accent: #cba6f7; --accent-3: #f5c2e7; --green: #a6e3a1; --red: #f38ba8; --orange: #fab387;
--chrome: #181825; --chrome-line: #292939; --hover: #252536; --nav-active: #313244; --icon-bg: #3a3a55; --folder: #89b4fa; --row-line: #28283b; --overlay: #1e1e2ee6;
}
[data-theme='forest'] {
--bg: #141a17; --bg-2: #18201c; --panel: #1c2621; --card: #1f2a24; --card-hover: #26342c; --border: #2d3d34;
--text: #e3ece6; --muted: #92a69a; --faint: #5e7266; --accent: #4cc38a; --accent-3: #b5e655; --folder: #7cc6a5;
--chrome: #0f1411; --chrome-line: #1f2a24; --hover: #1a231e; --nav-active: #213029; --icon-bg: #294034; --row-line: #1c2621; --overlay: #141a17e6;
}
[data-theme='light'] {
--bg: #f6f7fb; --bg-2: #eef0f6; --panel: #ffffff; --card: #ffffff; --card-hover: #f0f2fa; --border: #dde1ec;
--text: #1f2330; --muted: #5d6477; --faint: #9097aa; --accent: #5b67f1; --accent-3: #9b59f5; --green: #1f9d63; --red: #d63a4a; --orange: #c97a00;
--chrome: #e9ecf4; --chrome-line: #d8dce8; --hover: #dfe3ee; --nav-active: #d9ddf5; --icon-bg: #e6e9f7; --folder: #3d7de0; --row-line: #eceef5; --overlay: #f6f7fbe6;
--tint: 0 0 0;
--accent-2: color-mix(in srgb, var(--accent) 85%, black);
}
[data-theme='light'] .card { border-color: var(--border); }
[data-theme='light'] .avatar .proto { background: var(--panel); }
[data-theme='light'] ::-webkit-scrollbar-thumb { background: #c5cad8; background-clip: padding-box; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
button { font: inherit; color: inherit; cursor: pointer; }
@@ -31,15 +78,17 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
#app { display: flex; flex-direction: column; height: 100vh; }
/* ---------- Titlebar & Tabs ---------- */
#titlebar { height: var(--titlebar); display: flex; align-items: center; background: #0f1117; -webkit-app-region: drag; border-bottom: 1px solid #1f2330; flex: none; }
#titlebar { height: var(--titlebar); display: flex; align-items: center; background: var(--chrome); -webkit-app-region: drag; border-bottom: 1px solid var(--chrome-line); flex: none; }
.brand { width: 212px; padding-left: 16px; font-weight: 700; letter-spacing: .3px; display: flex; align-items: center; gap: 10px; flex: none; }
.logo { background: linear-gradient(135deg, var(--accent), #b36bff); color: white; font-family: monospace; font-size: 11px; padding: 3px 6px; border-radius: 6px; }
.logo { width: 24px; height: 24px; border-radius: 6px; }
.brand { gap: 8px !important; }
.brand-term { color: #5ef0c4; margin-left: -8px; }
#tabs { display: flex; gap: 4px; height: 100%; align-items: flex-end; overflow-x: auto; min-width: 0; -webkit-app-region: no-drag; }
#tabs::-webkit-scrollbar { height: 0; }
.tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px 0 12px; border: none; background: transparent; border-radius: 8px 8px 0 0; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; }
.tab.home { min-width: 0; }
.tab span { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; }
.tab:hover { background: #1a1d27; color: var(--text); }
.tab:hover { background: var(--hover); color: var(--text); }
.tab.active { background: var(--bg); color: var(--text); }
.tab .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
.tab .dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
@@ -47,25 +96,25 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.tab .dot.wait { background: var(--orange); animation: pulse 1s infinite; }
.tab .x { opacity: 0; border: none; background: none; padding: 2px; border-radius: 4px; color: var(--muted); display: flex; }
.tab:hover .x, .tab.active .x { opacity: 1; }
.tab .x:hover { background: #ffffff18; color: var(--text); }
.tab .x:hover { background: rgb(var(--tint) / 0.094); color: var(--text); }
.tab .x svg { width: 12px; height: 12px; }
@keyframes pulse { 50% { opacity: .35; } }
.icon-btn { -webkit-app-region: no-drag; border: none; background: transparent; color: var(--muted); width: 30px; height: 30px; border-radius: 8px; font-size: 18px; margin-left: 4px; flex: none; }
.icon-btn:hover { background: #1f2330; color: var(--text); }
.icon-btn:hover { background: var(--chrome-line); color: var(--text); }
.drag-fill { flex: 1; min-width: 40px; height: 100%; }
.win-ctrls { display: flex; height: 100%; -webkit-app-region: no-drag; }
.win-ctrls button { width: 46px; border: none; background: transparent; color: var(--muted); display: flex; align-items: center; justify-content: center; }
.win-ctrls button svg { width: 12px; height: 12px; stroke-width: 1.2; }
.win-ctrls button:hover { background: #ffffff12; color: var(--text); }
.win-ctrls button:hover { background: rgb(var(--tint) / 0.071); color: var(--text); }
.win-ctrls button.close:hover { background: #e81123; color: white; }
/* ---------- Layout ---------- */
#body { flex: 1; display: flex; min-height: 0; position: relative; }
#sidebar { width: 212px; background: #0f1117; padding: 10px 10px 12px; display: flex; flex-direction: column; gap: 2px; border-right: 1px solid #1f2330; flex: none; }
#sidebar { width: 212px; background: var(--chrome); padding: 10px 10px 12px; display: flex; flex-direction: column; gap: 2px; border-right: 1px solid var(--chrome-line); flex: none; }
#sidebar .spacer { flex: 1; }
.nav { display: flex; align-items: center; gap: 12px; border: none; background: none; padding: 9px 12px; border-radius: 8px; color: var(--muted); text-align: left; font-weight: 500; }
.nav:hover { background: #1a1d27; color: var(--text); }
.nav.active { background: #1f2233; color: var(--text); }
.nav:hover { background: var(--hover); color: var(--text); }
.nav.active { background: var(--nav-active); color: var(--text); }
.nav.active svg { color: var(--accent-2); }
.vault-badge { font-size: 11px; color: var(--faint); padding: 8px 12px; display: flex; gap: 6px; align-items: center; }
.vault-badge svg { width: 13px; height: 13px; }
@@ -87,7 +136,7 @@ body.in-session #sidebar { display: none; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn.primary:hover { background: var(--accent-2); }
.btn.danger { color: var(--red); }
.btn.danger:hover { background: #ff5f6d1a; }
.btn.danger:hover { background: color-mix(in srgb, var(--red) 10%, transparent); }
.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn.ghost:hover { background: var(--card); color: var(--text); }
.btn.sm { height: 28px; padding: 0 10px; font-size: 12px; }
@@ -97,7 +146,7 @@ body.in-session #sidebar { display: none; }
.section-title .count { background: var(--card); padding: 1px 7px; border-radius: 10px; font-size: 11px; }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip { border: 1px solid var(--border); background: transparent; color: var(--muted); padding: 4px 10px; border-radius: 20px; font-size: 12px; }
.chip.active, .chip:hover { background: #6e7bff22; border-color: var(--accent); color: var(--text); }
.chip.active, .chip:hover { background: color-mix(in srgb, var(--accent) 13%, transparent); border-color: var(--accent); color: var(--text); }
/* ---------- Cards ---------- */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
@@ -110,14 +159,14 @@ body.in-session #sidebar { display: none; }
.card .actions { display: flex; gap: 2px; opacity: 0; transition: opacity .12s; }
.card:hover .actions { opacity: 1; }
.card .actions button { border: none; background: none; color: var(--muted); padding: 6px; border-radius: 6px; display: flex; }
.card .actions button:hover { background: #ffffff14; color: var(--text); }
.card .actions button:hover { background: rgb(var(--tint) / 0.078); color: var(--text); }
.card .actions svg { width: 16px; height: 16px; }
.avatar { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px; color: white; flex: none; position: relative; }
.avatar svg { width: 20px; height: 20px; }
.avatar .proto { position: absolute; right: -4px; bottom: -4px; font-size: 8px; background: #0f1117; color: var(--muted); padding: 1px 3px; border-radius: 4px; border: 1px solid var(--border); font-weight: 700; }
.group-card .avatar { background: #2a2f45; color: var(--accent-2); }
.avatar .proto { position: absolute; right: -4px; bottom: -4px; font-size: 8px; background: var(--chrome); color: var(--muted); padding: 1px 3px; border-radius: 4px; border: 1px solid var(--border); font-weight: 700; }
.group-card .avatar { background: var(--icon-bg); color: var(--accent-2); }
.tags { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
.tag { font-size: 10px; background: #ffffff0f; color: var(--muted); padding: 1px 6px; border-radius: 4px; }
.tag { font-size: 10px; background: rgb(var(--tint) / 0.059); color: var(--muted); padding: 1px 6px; border-radius: 4px; }
.empty { text-align: center; color: var(--muted); padding: 70px 20px; }
.empty .big { width: 64px; height: 64px; margin: 0 auto 16px; border-radius: 18px; background: var(--card); display: flex; align-items: center; justify-content: center; color: var(--accent-2); }
.empty .big svg { width: 30px; height: 30px; }
@@ -128,8 +177,8 @@ body.in-session #sidebar { display: none; }
.list { display: flex; flex-direction: column; gap: 6px; }
.list .card { padding: 11px 14px; }
.mono { font-family: 'Cascadia Code', 'JetBrains Mono', Consolas, monospace; font-size: 12px; }
.status-pill { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: #ffffff10; color: var(--muted); }
.status-pill.on { background: #3ecf8e22; color: var(--green); }
.status-pill { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: rgb(var(--tint) / 0.063); color: var(--muted); }
.status-pill.on { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); }
/* ---------- Drawer (Editor) ---------- */
.drawer { position: absolute; top: 0; right: 0; bottom: 0; width: 400px; background: var(--bg-2); border-left: 1px solid var(--border); transform: translateX(100%); transition: transform .18s ease; z-index: 20; display: flex; flex-direction: column; box-shadow: -20px 0 40px #0006; }
@@ -167,7 +216,7 @@ body.in-session #sidebar { display: none; }
.session .term-wrap { flex: 1; position: relative; min-height: 0; display: flex; }
.session .term { flex: 1; padding: 8px 0 4px 10px; min-width: 0; }
.session .term .xterm { height: 100%; }
.session .overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: #13151ce6; z-index: 3; }
.session .overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: var(--overlay); z-index: 3; }
.session .overlay .spinner { width: 36px; height: 36px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.9s linear infinite; }
.session .overlay .msg { color: var(--muted); max-width: 520px; text-align: center; }
.session .overlay .msg.err { color: var(--red); }
@@ -191,17 +240,18 @@ body.in-session #sidebar { display: none; }
.pane .phead input { flex: 1; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; min-width: 60px; font-family: 'Cascadia Code', Consolas, monospace; font-size: 12px; }
.pane .phead select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px; }
.pane .files { flex: 1; overflow: auto; }
.pane.dragover .files { outline: 2px dashed var(--accent); outline-offset: -6px; background: #6e7bff0d; }
.pane.dragover .files { outline: 2px dashed var(--accent); outline-offset: -6px; background: color-mix(in srgb, var(--accent) 5%, transparent); }
.pane table { width: 100%; border-collapse: collapse; }
.pane th { position: sticky; top: 0; background: var(--bg); text-align: left; font-size: 11px; color: var(--faint); font-weight: 600; padding: 7px 10px; text-transform: uppercase; letter-spacing: .4px; border-bottom: 1px solid var(--border); cursor: pointer; }
.pane td { padding: 6px 10px; white-space: nowrap; border-bottom: 1px solid #1c1f29; }
.pane td.name { display: flex; align-items: center; gap: 8px; max-width: 0; width: 100%; }
.pane td.name span { overflow: hidden; text-overflow: ellipsis; }
.pane td { padding: 6px 10px; white-space: nowrap; border-bottom: 1px solid var(--row-line); }
.pane td.name { max-width: 0; width: 100%; }
.pane td.name > div { display: flex; align-items: center; gap: 8px; min-width: 0; }
.pane td.name span:last-child { overflow: hidden; text-overflow: ellipsis; }
.pane td.num { color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.pane td.date { color: var(--muted); }
.pane tr:hover td { background: #ffffff06; }
.pane tr.sel td { background: #6e7bff26; }
.pane .ico-dir { color: #6ea8ff; }
.pane tr:hover td { background: rgb(var(--tint) / 0.024); }
.pane tr.sel td { background: color-mix(in srgb, var(--accent) 15%, transparent); }
.pane .ico-dir { color: var(--folder); }
.pane .ico-file { color: var(--faint); }
.pane-empty { flex: 1; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 14px; color: var(--muted); }
.host-picker { width: min(520px, 90%); max-height: 60%; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
@@ -228,12 +278,12 @@ body.in-session #sidebar { display: none; }
.palette .res { max-height: 50vh; overflow: auto; padding: 8px; }
.palette .item { display: flex; gap: 12px; align-items: center; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.palette .item .avatar { width: 32px; height: 32px; font-size: 13px; border-radius: 8px; }
.palette .item.active { background: #6e7bff26; }
.palette .item.active { background: color-mix(in srgb, var(--accent) 15%, transparent); }
.palette .item .k { margin-left: auto; color: var(--faint); font-size: 11px; }
.palette .grp { font-size: 11px; color: var(--faint); padding: 8px 10px 4px; text-transform: uppercase; letter-spacing: .5px; }
.ctx { position: fixed; z-index: 200; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 5px; min-width: 190px; box-shadow: 0 14px 40px #000a; }
.ctx button { display: flex; align-items: center; gap: 10px; width: 100%; border: none; background: none; padding: 7px 10px; border-radius: 6px; text-align: left; }
.ctx button:hover { background: #6e7bff33; }
.ctx button:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
.ctx button.danger { color: var(--red); }
.ctx hr { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
.ctx svg { width: 15px; height: 15px; color: var(--muted); }
@@ -250,3 +300,12 @@ body.in-session #sidebar { display: none; }
.theme-prev.active { border-color: var(--accent); }
.theme-prev .nm { font-family: 'Segoe UI', sans-serif; font-weight: 600; margin-bottom: 4px; }
kbd { background: var(--card); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; padding: 0 5px; font-size: 11px; font-family: inherit; }
.update-badge { color: var(--accent-2) !important; background: color-mix(in srgb, var(--accent) 14%, transparent) !important; }
.design-prev { padding: 8px; }
.design-prev .dp { display: flex; gap: 6px; height: 62px; margin-bottom: 8px; }
.design-prev .dp i { width: 22%; border-radius: 4px; }
.design-prev .dp > div { flex: 1; display: flex; flex-direction: column; gap: 6px; }
.design-prev .dp b { flex: 1; border-radius: 4px; position: relative; }
.design-prev .dp s { position: absolute; left: 6px; top: 50%; transform: translateY(-50%); width: 12px; height: 12px; border-radius: 3px; }
.design-prev .nm { margin: 0; }
[data-theme='light'] .brand-term { color: #14a37a; }
+17
View File
@@ -22,3 +22,20 @@ window.TERM_THEMES = {
black: '#2b2f3a', red: '#d63a4a', green: '#1f9d63', yellow: '#b68600', blue: '#3d63dd', magenta: '#9d45c9', cyan: '#138a9a', white: '#c9ccd6',
brightBlack: '#6b7080', brightRed: '#e8505f', brightGreen: '#27b474', brightYellow: '#cc9a00', brightBlue: '#5a7cf0', brightMagenta: '#b25bdd', brightCyan: '#1ca3b5', brightWhite: '#ffffff' },
};
TERM_THEMES.mocha = { name: 'Catppuccin Mocha', background: '#1e1e2e', foreground: '#cdd6f4', cursor: '#f5e0dc', selectionBackground: '#585b7066',
black: '#45475a', red: '#f38ba8', green: '#a6e3a1', yellow: '#f9e2af', blue: '#89b4fa', magenta: '#f5c2e7', cyan: '#94e2d5', white: '#bac2de',
brightBlack: '#585b70', brightRed: '#f38ba8', brightGreen: '#a6e3a1', brightYellow: '#f9e2af', brightBlue: '#89b4fa', brightMagenta: '#f5c2e7', brightCyan: '#94e2d5', brightWhite: '#a6adc8' };
TERM_THEMES.navy = { ...TERM_THEMES.mrterm, name: 'Navy', background: '#141729', cursor: '#21c7a8', cursorAccent: '#141729', selectionBackground: '#21c7a844', black: '#212742' };
TERM_THEMES.forest = { ...TERM_THEMES.mrterm, name: 'Forest', background: '#141a17', foreground: '#e3ece6', cursor: '#4cc38a', cursorAccent: '#141a17', selectionBackground: '#4cc38a44', black: '#1f2a24' };
// App-Designs: Vorschaufarben + passendes Terminal-Schema (für "Passend zum Design")
window.APP_THEMES = {
midnight: { name: 'Midnight', bg: '#13151c', side: '#0f1117', card: '#212533', accent: '#6e7bff', text: '#e6e8ef', term: 'mrterm' },
navy: { name: 'Navy', bg: '#141729', side: '#0f1122', card: '#212742', accent: '#21c7a8', text: '#e8eaf6', term: 'navy' },
nord: { name: 'Nord', bg: '#2e3440', side: '#272c36', card: '#3b4252', accent: '#88c0d0', text: '#eceff4', term: 'nord' },
dracula: { name: 'Dracula', bg: '#282a36', side: '#21222c', card: '#343746', accent: '#bd93f9', text: '#f8f8f2', term: 'dracula' },
mocha: { name: 'Catppuccin', bg: '#1e1e2e', side: '#181825', card: '#2a2a3d', accent: '#cba6f7', text: '#cdd6f4', term: 'mocha' },
forest: { name: 'Forest', bg: '#141a17', side: '#0f1411', card: '#1f2a24', accent: '#4cc38a', text: '#e3ece6', term: 'forest' },
light: { name: 'Hell', bg: '#f6f7fb', side: '#e9ecf4', card: '#ffffff', accent: '#5b67f1', text: '#1f2330', term: 'light' },
};