Sidebar: Verzeichnisbaum mit Ordnern und Verbindungen (auf-/zuklappbar, Doppelklick verbindet, Drag & Drop in Ordner)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:44:36 +02:00
co-authored by Claude Opus 5.5
parent 4361d747f3
commit b81a7bd794
3 changed files with 108 additions and 2 deletions
+85 -1
View File
@@ -154,6 +154,88 @@ $$('#sidebar .nav[data-view]').forEach((n) => n.addEventListener('click', () =>
render();
}));
// ============================================================ Verzeichnisbaum (Sidebar)
const TREE_KEY = 'mrterm.tree.open';
const treeOpen = new Set((() => { try { return JSON.parse(localStorage.getItem(TREE_KEY) || '[]'); } catch { return []; } })());
const saveTreeOpen = () => { try { localStorage.setItem(TREE_KEY, JSON.stringify([...treeOpen])); } catch {} };
const CHEV = '<svg viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>';
function goGroup(id) {
S.view = 'hosts'; S.group = id; S.search = ''; S.tag = null;
$$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x.dataset.view === 'hosts'));
closeDrawer();
activateTab('home');
render();
}
async function moveHostToGroup(hostId, groupId) {
const hst = S.vault.hosts.find((x) => x.id === hostId);
if (!hst || (hst.groupId || null) === groupId) return;
await call('vault:upsert', 'hosts', { ...hst, groupId });
if (groupId) { treeOpen.add(groupId); saveTreeOpen(); }
reload();
}
function dropTarget(el, groupId) {
el.addEventListener('dragover', (e) => { if (e.dataTransfer.types.includes('mrterm/host')) { e.preventDefault(); el.classList.add('drop'); } });
el.addEventListener('dragleave', () => el.classList.remove('drop'));
el.addEventListener('drop', (e) => {
e.preventDefault(); el.classList.remove('drop');
const id = e.dataTransfer.getData('mrterm/host');
if (id) moveHostToGroup(id, groupId);
});
}
function renderTree() {
const box = $('#tree');
if (!box || !S.vault) return;
const scroll = box.scrollTop;
box.innerHTML = '';
const { groups, hosts } = S.vault;
const byLabel = (x, y) => (x.label || x.address || '').localeCompare(y.label || y.address || '', undefined, { numeric: true });
const countIn = (gid) => hosts.filter((x) => x.groupId === gid).length + groups.filter((g) => g.parentId === gid).reduce((n, g) => n + countIn(g.id), 0);
const head = h(`<div class="tree-head"><span>Verbindungen</span><button title="Alle einklappen">${ICONS.up}</button></div>`);
$('button', head).onclick = () => { treeOpen.clear(); saveTreeOpen(); renderTree(); };
dropTarget(head, null); // auf die Überschrift ziehen = Stammebene
box.append(head);
if (!groups.length && !hosts.length) { box.append(h('<div class="tree-empty">Noch keine Hosts</div>')); return; }
const pad = (depth) => `padding-left:${4 + depth * 14}px`;
const walk = (parentId, depth) => {
for (const g of groups.filter((x) => (x.parentId || null) === parentId).sort(byLabel)) {
const open = treeOpen.has(g.id);
const active = S.view === 'hosts' && S.group === g.id && S.active === 'home';
const row = h(`<div class="tree-row group ${active ? 'active' : ''}" style="${pad(depth)}" title="${esc(g.label)}">
<span class="chev ${open ? 'open' : ''}">${CHEV}</span><span class="ti" style="color:${g.color || 'var(--folder)'}">${ICONS.folder}</span><span class="tl">${esc(g.label)}</span><span class="tc">${countIn(g.id)}</span></div>`);
const toggle = () => { if (open) treeOpen.delete(g.id); else treeOpen.add(g.id); saveTreeOpen(); renderTree(); };
$('.chev', row).onclick = (e) => { e.stopPropagation(); toggle(); };
row.onclick = () => { if (!open) { treeOpen.add(g.id); saveTreeOpen(); } goGroup(g.id); };
row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
{ label: 'Öffnen', icon: 'folder', run: () => goGroup(g.id) },
{ label: 'Host hinzufügen', icon: 'plus', run: () => { goGroup(g.id); editHost({ groupId: g.id }); } },
{ label: 'Unterordner anlegen', icon: 'newdir', run: () => { goGroup(g.id); editGroup({ parentId: g.id }); } },
{ label: 'Bearbeiten', icon: 'edit', run: () => { goGroup(g.id); editGroup(g); } },
'-',
{ label: 'Löschen', icon: 'trash', danger: true, run: async () => { if (await confirmBox('Gruppe löschen?', `„${g.label}“ wird gelöscht. Hosts bleiben erhalten.`)) { await call('vault:remove', 'groups', g.id); reload(); } } },
]);
dropTarget(row, g.id);
box.append(row);
if (open) walk(g.id, depth + 1);
}
for (const x of hosts.filter((y) => (y.groupId || null) === parentId).sort(byLabel)) {
const live = S.tabs.some((t) => t.host?.id === x.id);
const row = h(`<div class="tree-row host" style="${pad(depth)}" draggable="true" title="${esc(hostSub(x))}">
<span class="chev"></span><span class="ti" style="background:${colorFor(x)}">${x.protocol === 'rdp' ? ICONS.screen : ICONS.term}</span><span class="tl">${esc(x.label || x.address)}</span>${live ? '<span class="tc" style="color:var(--green)">●</span>' : ''}</div>`);
row.ondblclick = () => connectHost(x);
row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(x));
row.ondragstart = (e) => { e.dataTransfer.setData('mrterm/host', x.id); e.dataTransfer.effectAllowed = 'move'; };
box.append(row);
}
};
walk(null, 0);
box.scrollTop = scroll;
}
function render() {
const page = $('#homeView');
const views = { hosts: viewHosts, sftp: viewSftpPicker, keys: viewKeys, forwards: viewForwards, snippets: viewSnippets, known: viewKnown, history: viewHistory, settings: viewSettings };
@@ -161,6 +243,7 @@ function render() {
page.innerHTML = '';
views[S.view](page);
page.scrollTop = scroll;
renderTree();
}
function toolbar(placeholder, buttons = []) {
@@ -845,7 +928,8 @@ function activateTab(id) {
if (t.id === id) requestAnimationFrame(() => t.onShow?.());
else t.onHide?.();
});
$$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
renderTree();
}
function closeTab(id) {
const i = S.tabs.findIndex((t) => t.id === id);
+2 -1
View File
@@ -34,7 +34,8 @@
<button class="nav" data-view="snippets"><svg viewBox="0 0 24 24"><path d="M8 7l-5 5 5 5M16 7l5 5-5 5"/></svg><span>Snippets</span></button>
<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>
<div class="nav-sep"></div>
<div id="tree" class="tree"></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>
+21
View File
@@ -112,6 +112,27 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
#body { flex: 1; display: flex; min-height: 0; position: relative; }
#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; }
#sidebar { min-height: 0; }
.nav-sep { border-top: 1px solid var(--chrome-line); margin: 8px 4px 6px; flex: none; }
.tree { flex: 1; min-height: 60px; overflow: auto; margin: 0 -4px; padding: 0 4px; user-select: none; }
.tree-head { display: flex; align-items: center; justify-content: space-between; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px; color: var(--faint); padding: 4px 6px 6px; border-radius: 6px; }
.tree-head button { border: none; background: none; color: var(--faint); padding: 2px; border-radius: 4px; display: flex; }
.tree-head button:hover { color: var(--text); background: var(--hover); }
.tree-head button svg { width: 14px; height: 14px; }
.tree-row { display: flex; align-items: center; gap: 6px; height: 26px; padding-right: 6px; border-radius: 6px; color: var(--muted); cursor: pointer; white-space: nowrap; font-size: 12.5px; }
.tree-row:hover { background: var(--hover); color: var(--text); }
.tree-row.active { background: var(--nav-active); color: var(--text); }
.tree-row .chev { width: 14px; display: flex; justify-content: center; color: var(--faint); flex: none; }
.tree-row .chev svg { width: 12px; height: 12px; transition: transform .12s; }
.tree-row .chev.open svg { transform: rotate(90deg); }
.tree-row .ti { display: flex; flex: none; }
.tree-row .ti svg { width: 15px; height: 15px; }
.tree-row .tl { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.tree-row .tc { font-size: 10px; color: var(--faint); }
.tree-row.host .ti { width: 16px; height: 16px; border-radius: 4px; align-items: center; justify-content: center; color: white; }
.tree-row.host .ti svg { width: 11px; height: 11px; stroke-width: 2.2; }
.tree-row.drop, .tree-head.drop { outline: 1px dashed var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
.tree-empty { color: var(--faint); font-size: 12px; padding: 4px 8px; }
.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: var(--hover); color: var(--text); }
.nav.active { background: var(--nav-active); color: var(--text); }