From 58eb1626f61fb23170126bed99e77b297ddf0fa2 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 16 Apr 2026 00:00:00 +0100 Subject: [PATCH] Add connection groups with sidebar sections and combo-box editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar: - Profiles are now organised into group-based sections - Groups appear alphabetically between Favourites/Recent and Ungrouped - Section key uses 'group:Name' prefix to avoid collisions - Ungrouped profiles fall into 'Ungrouped' (or 'All Connections' if no groups exist) Profile editor: - Add Group combo-box field to General section - Uses HTML datalist backed by distinct group names from all profiles - Typing a new name creates the group; selecting existing reuses it - Help text explains the behaviour Save handler: - folder field now collected from form and trimmed The Go model already had a 'folder' field — this change wires it into the UI end-to-end. --- frontend/src/js/profiles.js | 54 +++++++++++++++++++++++++++++++++++++ frontend/src/js/sidebar.js | 35 +++++++++++++++++++++--- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index 55194a2..8b2c016 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -242,10 +242,63 @@ class ProfileEditor { this._field('text', 'host', 'Host', profile.host, { placeholder: '192.168.1.1 or example.com', required: true }), this._field('number', 'port', 'Port', profile.port, { min: 1, max: 65535, required: true }), this._field('text', 'username', 'Username', profile.username, { placeholder: 'root' }), + this._renderGroupField(profile), this._field('text', 'description', 'Description', profile.description, { placeholder: 'Optional description' }), ]); } + /** + * Renders a combo-box for the Group field — a text input backed by a + * datalist of existing group names. Typing a new name creates a group. + * @param {Object} profile + * @returns {HTMLElement} + * @private + */ + _renderGroupField(profile) { + const group = createElement('div', 'form-group'); + const label = createElement('label', 'form-label', { + textContent: 'Group', + for: 'field-folder', + }); + group.appendChild(label); + + // Collect distinct group names from all profiles + const existingGroups = [...new Set( + this.app.state.profiles + .map((p) => (p.folder || '').trim()) + .filter(Boolean) + )].sort((a, b) => a.localeCompare(b)); + + // Create the datalist so the browser offers autocomplete + const listId = 'folder-suggestions'; + const datalist = document.createElement('datalist'); + datalist.id = listId; + for (const g of existingGroups) { + const opt = document.createElement('option'); + opt.value = g; + datalist.appendChild(opt); + } + group.appendChild(datalist); + + const input = createElement('input', 'form-input', { + type: 'text', + id: 'field-folder', + name: 'folder', + value: profile.folder || '', + placeholder: 'e.g. Production, Staging, Home Lab', + }); + input.setAttribute('list', listId); + input.setAttribute('autocomplete', 'off'); + group.appendChild(input); + + const hint = createElement('span', 'form-hint', { + textContent: 'Choose an existing group or type a new name to create one', + }); + group.appendChild(hint); + + return group; + } + /** @private */ _renderSSHSections(profile) { const ssh = profile.ssh || {}; @@ -694,6 +747,7 @@ class ProfileEditor { p.port = val('port') || p.port; p.username = val('username') || ''; p.description = val('description') || ''; + p.folder = (val('folder') || '').trim(); p.terminalFontFamily = val('terminalFontFamily') || ''; p.terminalFontSize = val('terminalFontSize') || 14; p.terminalScrollback = val('terminalScrollback') || 10000; diff --git a/frontend/src/js/sidebar.js b/frontend/src/js/sidebar.js index 057ac55..1032d82 100644 --- a/frontend/src/js/sidebar.js +++ b/frontend/src/js/sidebar.js @@ -30,6 +30,11 @@ class Sidebar { /** * Full re-render of the sidebar profile list. + * Profiles are organised into: + * 1. Favourites (if any) + * 2. Recent (last 5 connected) + * 3. One section per group (sorted alphabetically) + * 4. Ungrouped (profiles with no group assigned) */ render() { if (!this.el) return; @@ -40,7 +45,7 @@ class Sidebar { // Favourites const favourites = profiles.filter((p) => p.favourite); if (favourites.length > 0) { - this._renderSection('Favourites', 'favourites', favourites); + this._renderSection('★ Favourites', 'favourites', favourites); } // Recent @@ -52,8 +57,32 @@ class Sidebar { this._renderSection('Recent', 'recent', recent); } - // All connections - this._renderSection('All Connections', 'all', profiles); + // ── Group-based sections ───────────────────────────────────── + // Collect distinct groups and sort them alphabetically. + const groupMap = new Map(); + const ungrouped = []; + + for (const p of profiles) { + const grp = (p.folder || '').trim(); + if (grp) { + if (!groupMap.has(grp)) groupMap.set(grp, []); + groupMap.get(grp).push(p); + } else { + ungrouped.push(p); + } + } + + // Render each group as its own collapsible section + const sortedGroups = [...groupMap.keys()].sort((a, b) => a.localeCompare(b)); + for (const grp of sortedGroups) { + this._renderSection(grp, `group:${grp}`, groupMap.get(grp)); + } + + // Ungrouped connections + if (ungrouped.length > 0) { + const label = sortedGroups.length > 0 ? 'Ungrouped' : 'All Connections'; + this._renderSection(label, 'ungrouped', ungrouped); + } // Empty state if (profiles.length === 0) {