Add connection groups with sidebar sections and combo-box editor

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.
This commit is contained in:
2026-04-16 00:00:00 +01:00
parent 8915500eac
commit 58eb1626f6
2 changed files with 86 additions and 3 deletions
+54
View File
@@ -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;
+32 -3
View File
@@ -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) {