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:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user