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);