From b81a7bd79453eff9f3b1d4824bd71a4172670cc8 Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 25 Sep 2026 15:44:36 +0200 Subject: [PATCH] Sidebar: Verzeichnisbaum mit Ordnern und Verbindungen (auf-/zuklappbar, Doppelklick verbindet, Drag & Drop in Ordner) Co-Authored-By: Claude Opus 5.5 --- src/renderer/app.js | 86 ++++++++++++++++++++++++++++++++++++++++- src/renderer/index.html | 3 +- src/renderer/styles.css | 21 ++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) 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); }