Compare commits

...
Author SHA1 Message Date
bincxz 6cdae7af9c fix: keep group notes visible during tree multi-select 2026-09-12 17:28:27 +08:00
bincxz 3e17cdd62e feat: add Markdown notes to host groups 2026-09-12 17:10:59 +08:00
10 changed files with 64 additions and 15 deletions

No files matched your search

+8
View File
@@ -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<GroupDetailsPanelPropsWithResize> = ({
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<GroupDetailsPanelPropsWithResize> = ({
</HostDetailsSection>
</>)}
<HostNotesEditor
panelKey={groupPath}
value={form.notes ?? ""}
onChange={(notes) => update("notes", notes)}
/>
{/* Add Protocol Button — always at the bottom */}
{addableProtocols.length > 0 && (
<Dropdown open={addProtocolOpen} onOpenChange={setAddProtocolOpen}>
+2
View File
@@ -168,6 +168,7 @@ test("HostTreeView shows selected groups in multi-select mode", () => {
children: {},
hosts: [],
}]}
groupConfigs={[{ path: "production", notes: "# VPN instructions" }]}
hosts={[]}
expandedPaths={new Set<string>()}
onTogglePath={() => undefined}
@@ -188,6 +189,7 @@ test("HostTreeView shows selected groups in multi-select mode", () => {
/>,
);
assert.match(markup, /aria-label="Group notes"/);
assert.match(markup, /data-selected="true"/);
assert.match(markup, /data-group-path="production"/);
assert.match(markup, /role="tree"/);
+18 -13
View File
@@ -538,19 +538,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
? <CheckSquare size={16} />
: <Square size={16} />
: undefined}
labelActions={!isMultiSelectMode && (
<button
aria-label={`Edit ${node.name}`}
tabIndex={-1}
data-host-tree-group-edit-button={node.path}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={12} />
</button>
labelActions={(
<span className="inline-flex items-center gap-1">
<HostNotesIndicator notes={groupConfigs.find((config) => config.path === node.path)?.notes} label="Group notes" />
{!isMultiSelectMode && (
<button
aria-label={`Edit ${node.name}`}
tabIndex={-1}
data-host-tree-group-edit-button={node.path}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onEditGroup(node.path);
}}
>
<Edit2 size={12} />
</button>
)}
</span>
)}
/>
</CollapsibleTrigger>
@@ -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(<HostNotesIndicator notes="Host" />), /aria-label="Host notes"/);
assert.match(renderToStaticMarkup(<HostNotesIndicator notes="# Project" label="Group notes" />), /aria-label="Group notes"/);
assert.equal(renderToStaticMarkup(<HostNotesIndicator notes=" " label="Group notes" />), "");
});
+3 -1
View File
@@ -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<HostNotesIndicatorProps> = ({
notes,
className,
label = "Host notes",
}) => {
const trimmed = notes?.trim();
if (!trimmed) return null;
@@ -25,7 +27,7 @@ export const HostNotesIndicator: React.FC<HostNotesIndicatorProps> = ({
"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()}
>
@@ -893,6 +893,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold flex items-center gap-1.5 min-w-0">
<span className="truncate">{node.name}</span>
<HostNotesIndicator notes={groupConfigs.find((config) => config.path === node.path)?.notes} label="Group notes" />
{!isMultiSelectMode && viewMode !== "grid" && renderGroupEditButton(node.path, true)}
{managedGroupPaths.has(node.path) && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
+7
View File
@@ -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);
});
+1 -1
View File
@@ -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;
}
+2
View File
@@ -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;
+11
View File
@@ -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);
});