diff --git a/components/GroupDetailsPanel.tsx b/components/GroupDetailsPanel.tsx index ec623b24a..89b4e0807 100644 --- a/components/GroupDetailsPanel.tsx +++ b/components/GroupDetailsPanel.tsx @@ -57,6 +57,7 @@ import { Switch } from "./ui/switch"; import { TerminalFontSelect } from "./settings/TerminalFontSelect"; import { useAvailableFonts } from "../application/state/fontStore"; import { toast } from "./ui/toast"; +import { HostNotesEditor } from "./host/HostNotesEditor"; import { GroupSshSettingsSection } from "./GroupSshSettingsSection"; import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers"; import { TerminalEncodingSelect } from "./TerminalEncodingSelect"; @@ -547,6 +548,7 @@ const GroupDetailsPanel: React.FC = ({ const result: GroupConfig = { path: newPath, + notes: form.notes?.trim() || undefined, // Only include SSH fields if SSH section is enabled ...(sshEnabled && { protocol: 'ssh' as const, @@ -994,6 +996,12 @@ const GroupDetailsPanel: React.FC = ({ )} + update("notes", notes)} + /> + {/* Add Protocol Button — always at the bottom */} {addableProtocols.length > 0 && ( diff --git a/components/HostTreeView.tsx b/components/HostTreeView.tsx index 79a6f9dd1..0c4bd8092 100644 --- a/components/HostTreeView.tsx +++ b/components/HostTreeView.tsx @@ -539,18 +539,21 @@ const TreeNode: React.FC = ({ : : undefined} labelActions={!isMultiSelectMode && ( - + + config.path === node.path)?.notes} label="Group notes" /> + + )} /> diff --git a/components/host/HostNotesIndicator.test.tsx b/components/host/HostNotesIndicator.test.tsx new file mode 100644 index 000000000..8f15815ca --- /dev/null +++ b/components/host/HostNotesIndicator.test.tsx @@ -0,0 +1,11 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { HostNotesIndicator } from "./HostNotesIndicator"; + +test("notes indicators distinguish groups from hosts and hide empty notes", () => { + assert.match(renderToStaticMarkup(), /aria-label="Host notes"/); + assert.match(renderToStaticMarkup(), /aria-label="Group notes"/); + assert.equal(renderToStaticMarkup(), ""); +}); diff --git a/components/host/HostNotesIndicator.tsx b/components/host/HostNotesIndicator.tsx index f894eaf9a..5f85ac845 100644 --- a/components/host/HostNotesIndicator.tsx +++ b/components/host/HostNotesIndicator.tsx @@ -7,11 +7,13 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "../ui/hover-card" export interface HostNotesIndicatorProps { notes?: string; className?: string; + label?: string; } export const HostNotesIndicator: React.FC = ({ notes, className, + label = "Host notes", }) => { const trimmed = notes?.trim(); if (!trimmed) return null; @@ -25,7 +27,7 @@ export const HostNotesIndicator: React.FC = ({ "inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground", className, )} - aria-label="Host notes" + aria-label={label} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} > diff --git a/components/vault/VaultHostListSection.tsx b/components/vault/VaultHostListSection.tsx index 630e3e3ec..38b7fcf14 100644 --- a/components/vault/VaultHostListSection.tsx +++ b/components/vault/VaultHostListSection.tsx @@ -893,6 +893,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
{node.name} + config.path === node.path)?.notes} label="Group notes" /> {!isMultiSelectMode && viewMode !== "grid" && renderGroupEditButton(node.path, true)} {managedGroupPaths.has(node.path) && ( diff --git a/domain/groupConfig.test.ts b/domain/groupConfig.test.ts index 386bedaeb..7b789b2e2 100644 --- a/domain/groupConfig.test.ts +++ b/domain/groupConfig.test.ts @@ -581,3 +581,10 @@ test("applyGroupDefaults keeps host algorithm overrides instead of inheriting", ); assert.deepEqual(result.algorithms, hostOverrides); }); + +test("group notes survive sanitization but are not connection defaults", () => { + const config = { path: "Project", notes: "# VPN\nConnect before SSH.", username: "admin" }; + assert.equal(sanitizeGroupConfig(config).notes, config.notes); + assert.equal(resolveGroupDefaults("Project", [config]).notes, undefined); + assert.equal(resolveGroupDefaults("Project/Servers", [config]).notes, undefined); +}); diff --git a/domain/groupConfig.ts b/domain/groupConfig.ts index f04bb161a..b3c18cbea 100644 --- a/domain/groupConfig.ts +++ b/domain/groupConfig.ts @@ -116,7 +116,7 @@ export function resolveGroupDefaults( ) { continue; } - if (key !== 'path' && value !== undefined) { + if (key !== 'path' && key !== 'notes' && value !== undefined) { if (key === 'proxyProfileId') { delete merged.proxyConfig; } diff --git a/domain/models/connection.ts b/domain/models/connection.ts index 6a1612116..dd09d65b2 100644 --- a/domain/models/connection.ts +++ b/domain/models/connection.ts @@ -412,6 +412,8 @@ export interface GroupNode { /** Default configuration for a group. Hosts in this group inherit these values when not explicitly set. */ export interface GroupConfig { path: string; + /** Markdown notes for this group only; not inherited by hosts or subgroups. */ + notes?: string; order?: number; username?: string; password?: string; diff --git a/domain/syncMerge.test.ts b/domain/syncMerge.test.ts index f63907ab0..a6f432147 100644 --- a/domain/syncMerge.test.ts +++ b/domain/syncMerge.test.ts @@ -620,3 +620,14 @@ test("mergeSyncPayloads keeps host telemetry off merged payloads", () => { assert.equal(unchanged.payload.hosts[0]?.lastConnectedAt, undefined); assert.equal(unchanged.summary.modified.local, 0); }); + +test("group notes sync edits and clearing without dropping connection settings", () => { + const config = { path: "Project", notes: "# VPN\nOld instructions", username: "admin" }; + const base = payload({ customGroups: ["Project"], groupConfigs: [config] }); + const edited = payload({ customGroups: ["Project"], groupConfigs: [{ ...config, notes: "# VPN\nNew instructions" }] }); + const merged = mergeSyncPayloads(base, base, edited).payload; + assert.equal(merged.groupConfigs?.[0].notes, "# VPN\nNew instructions"); + assert.equal(merged.groupConfigs?.[0].username, "admin"); + const cleared = payload({ customGroups: ["Project"], groupConfigs: [{ path: "Project", username: "admin" }] }); + assert.equal(mergeSyncPayloads(merged, merged, cleared).payload.groupConfigs?.[0].notes, undefined); +});