diff --git a/src/renderer/app.js b/src/renderer/app.js
index a8e7b91..4de547d 100644
--- a/src/renderer/app.js
+++ b/src/renderer/app.js
@@ -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 = '';
+
+function goGroup(id) {
+ S.view = 'hosts'; S.group = id; S.search = ''; S.tag = null;
+ $$('#sidebar .nav[data-view]').forEach((x) => x.classList.toggle('active', x.dataset.view === 'hosts'));
+ closeDrawer();
+ activateTab('home');
+ render();
+}
+
+async function moveHostToGroup(hostId, groupId) {
+ const hst = S.vault.hosts.find((x) => x.id === hostId);
+ if (!hst || (hst.groupId || null) === groupId) return;
+ await call('vault:upsert', 'hosts', { ...hst, groupId });
+ if (groupId) { treeOpen.add(groupId); saveTreeOpen(); }
+ reload();
+}
+function dropTarget(el, groupId) {
+ el.addEventListener('dragover', (e) => { if (e.dataTransfer.types.includes('mrterm/host')) { e.preventDefault(); el.classList.add('drop'); } });
+ el.addEventListener('dragleave', () => el.classList.remove('drop'));
+ el.addEventListener('drop', (e) => {
+ e.preventDefault(); el.classList.remove('drop');
+ const id = e.dataTransfer.getData('mrterm/host');
+ if (id) moveHostToGroup(id, groupId);
+ });
+}
+
+function renderTree() {
+ const box = $('#tree');
+ if (!box || !S.vault) return;
+ const scroll = box.scrollTop;
+ box.innerHTML = '';
+ const { groups, hosts } = S.vault;
+ const byLabel = (x, y) => (x.label || x.address || '').localeCompare(y.label || y.address || '', undefined, { numeric: true });
+ const countIn = (gid) => hosts.filter((x) => x.groupId === gid).length + groups.filter((g) => g.parentId === gid).reduce((n, g) => n + countIn(g.id), 0);
+
+ const head = h(`
Verbindungen
`);
+ $('button', head).onclick = () => { treeOpen.clear(); saveTreeOpen(); renderTree(); };
+ dropTarget(head, null); // auf die Überschrift ziehen = Stammebene
+ box.append(head);
+ if (!groups.length && !hosts.length) { box.append(h('Noch keine Hosts
')); return; }
+
+ const pad = (depth) => `padding-left:${4 + depth * 14}px`;
+ const walk = (parentId, depth) => {
+ for (const g of groups.filter((x) => (x.parentId || null) === parentId).sort(byLabel)) {
+ const open = treeOpen.has(g.id);
+ const active = S.view === 'hosts' && S.group === g.id && S.active === 'home';
+ const row = h(`
+ ${CHEV}${ICONS.folder}${esc(g.label)}${countIn(g.id)}
`);
+ const toggle = () => { if (open) treeOpen.delete(g.id); else treeOpen.add(g.id); saveTreeOpen(); renderTree(); };
+ $('.chev', row).onclick = (e) => { e.stopPropagation(); toggle(); };
+ row.onclick = () => { if (!open) { treeOpen.add(g.id); saveTreeOpen(); } goGroup(g.id); };
+ row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
+ { label: 'Öffnen', icon: 'folder', run: () => goGroup(g.id) },
+ { label: 'Host hinzufügen', icon: 'plus', run: () => { goGroup(g.id); editHost({ groupId: g.id }); } },
+ { label: 'Unterordner anlegen', icon: 'newdir', run: () => { goGroup(g.id); editGroup({ parentId: g.id }); } },
+ { label: 'Bearbeiten', icon: 'edit', run: () => { goGroup(g.id); editGroup(g); } },
+ '-',
+ { label: 'Löschen', icon: 'trash', danger: true, run: async () => { if (await confirmBox('Gruppe löschen?', `„${g.label}“ wird gelöscht. Hosts bleiben erhalten.`)) { await call('vault:remove', 'groups', g.id); reload(); } } },
+ ]);
+ dropTarget(row, g.id);
+ box.append(row);
+ if (open) walk(g.id, depth + 1);
+ }
+ for (const x of hosts.filter((y) => (y.groupId || null) === parentId).sort(byLabel)) {
+ const live = S.tabs.some((t) => t.host?.id === x.id);
+ const row = h(`
+ ${x.protocol === 'rdp' ? ICONS.screen : ICONS.term}${esc(x.label || x.address)}${live ? '●' : ''}
`);
+ row.ondblclick = () => connectHost(x);
+ row.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, hostMenu(x));
+ row.ondragstart = (e) => { e.dataTransfer.setData('mrterm/host', x.id); e.dataTransfer.effectAllowed = 'move'; };
+ box.append(row);
+ }
+ };
+ walk(null, 0);
+ box.scrollTop = scroll;
+}
+
function render() {
const page = $('#homeView');
const views = { hosts: viewHosts, sftp: viewSftpPicker, keys: viewKeys, forwards: viewForwards, snippets: viewSnippets, known: viewKnown, history: viewHistory, settings: viewSettings };
@@ -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);
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 40d7bee..d6f6020 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -34,7 +34,8 @@
-
+
+
diff --git a/src/renderer/styles.css b/src/renderer/styles.css
index 2f6fca1..167a9ee 100644
--- a/src/renderer/styles.css
+++ b/src/renderer/styles.css
@@ -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); }