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
13 changed files with 65 additions and 147 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()}
>
@@ -1,96 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { commandArrowLineJumpSequence } from "./commandArrowLineJump";
const event = {
key: "ArrowLeft", metaKey: true, altKey: false, ctrlKey: false,
shiftKey: false, isComposing: false, keyCode: 37,
};
test("macOS Command arrows send beginning/end-of-line controls", () => {
assert.equal(commandArrowLineJumpSequence(event, true), "\x01");
assert.equal(commandArrowLineJumpSequence({ ...event, key: "ArrowRight" }, true), "\x05");
});
test("other platforms, keys, modifiers and composition are untouched", () => {
assert.equal(commandArrowLineJumpSequence(event, false), null);
for (const override of [
{ metaKey: false }, { altKey: true }, { ctrlKey: true }, { shiftKey: true },
{ isComposing: true }, { keyCode: 229 }, { key: "ArrowUp" }, { key: "ArrowDown" },
{ key: "Home" }, { key: "a" },
]) {
assert.equal(commandArrowLineJumpSequence({ ...event, ...override }, true), null);
}
});
// Exercise the runtime fallback itself, including its protocol/screen guards.
// This avoids constructing the renderer's WebGL/addon stack in Node.
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const start = runtime.indexOf(" const lineJumpSequence =");
const end = runtime.indexOf(" // Autocomplete key handler", start);
const applyStart = runtime.indexOf(" if (lineJumpSequence && ctx.sessionRef.current)");
const applyEnd = runtime.indexOf(" // macOS Option+", applyStart);
assert.ok(start > 0 && end > start && applyEnd > applyStart);
const fallback = `(function () { ${runtime.slice(start, end)} ${runtime.slice(applyStart, applyEnd)} return true; })()`;
for (const mode of ["shell", "alternate", "kitty", "win32", "disconnected", "non-mac", "password", "sudo-picker"]) {
test(`runtime Command arrow fallback: ${mode}`, () => {
const writes: string[] = [];
let prevented = 0;
let stopped = 0;
const result = runInNewContext(fallback, {
e: { ...event, preventDefault: () => prevented++, stopPropagation: () => stopped++ },
term: { buffer: { active: { type: mode === "alternate" ? "alternate" : "normal" } }, modes: { win32InputMode: mode === "win32" } },
ctx: {
sessionRef: { current: mode === "disconnected" ? null : "session" },
passwordPromptActiveRef: { current: mode === "password" },
},
sudoAutofill: { isPromptPending: () => mode === "sudo-picker" },
kittyKeyboardMode: {},
isKittyKeyboardModeActive: () => mode === "kitty",
isMacPlatform: () => mode !== "non-mac",
commandArrowLineJumpSequence,
handleTerminalInputData: (data: string, options: { skipBroadcast: boolean }) => {
assert.equal(options.skipBroadcast, true, "a peer TUI must not receive mapped Ctrl+A/E");
writes.push(data);
},
scrollToBottomAfterInput: () => {},
});
assert.equal(result, mode !== "shell");
assert.deepEqual(writes, mode === "shell" ? ["\x01"] : []);
assert.equal(prevented, mode === "shell" ? 1 : 0);
assert.equal(stopped, prevented);
});
}
for (const key of ["ArrowLeft", "ArrowRight"]) {
test(`Command ${key} bypasses directory autocomplete, bare arrows still navigate it`, () => {
const autocompleteEnd = runtime.indexOf(" const kittySequenceForKeyDown =", end);
assert.ok(autocompleteEnd > end);
const route = `(function () { ${runtime.slice(start, autocompleteEnd)} ${runtime.slice(applyStart, applyEnd)} return true; })()`;
for (const metaKey of [true, false]) {
let autocompleteCalls = 0;
const writes: string[] = [];
runInNewContext(route, {
e: { ...event, key, metaKey, preventDefault: () => {}, stopPropagation: () => {} },
term: { buffer: { active: { type: "normal" } }, modes: {} },
ctx: {
sessionRef: { current: "session" },
// A focused directory panel consumes either arrow.
onAutocompleteKeyEvent: () => { autocompleteCalls++; return false; },
},
sudoAutofill: undefined,
kittyKeyboardMode: {},
isKittyKeyboardModeActive: () => false,
isMacPlatform: () => true,
commandArrowLineJumpSequence,
handleTerminalInputData: (data: string) => writes.push(data),
scrollToBottomAfterInput: () => {},
});
assert.equal(autocompleteCalls, metaKey ? 0 : 1);
assert.deepEqual(writes, metaKey ? [key === "ArrowLeft" ? "\x01" : "\x05"] : []);
}
});
}
@@ -1,11 +0,0 @@
/** macOS line editing fallback for shells using readline/ZLE bindings. */
export function commandArrowLineJumpSequence(
e: Pick<KeyboardEvent, "key" | "metaKey" | "altKey" | "ctrlKey" | "shiftKey" | "isComposing" | "keyCode">,
isMac: boolean,
): string | null {
if (!isMac || !e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return null;
if (e.isComposing || e.keyCode === 229) return null;
if (e.key === "ArrowLeft") return "\x01";
if (e.key === "ArrowRight") return "\x05";
return null;
}
@@ -91,7 +91,6 @@ import {
import { installUserCursorPreferenceGuard } from "./cursorPreference";
import { terminalAltKeyOptions } from "./altKeyOptions";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
import { commandArrowLineJumpSequence } from "./commandArrowLineJump";
import { optionYankLastArgSequence } from "./optionYankLastArg";
import { watchDevicePixelRatio } from "./rendererDprWatch";
import { dispatchWin32InputModeEvent } from "./win32InputMode";
@@ -1959,19 +1958,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return false;
}
// Resolve the local shell chord before autocomplete can consume directory
// arrows, but apply it only after app/snippet shortcuts below.
const lineJumpSequence =
term.buffer.active.type === "normal" &&
ctx.passwordPromptActiveRef?.current !== true &&
!sudoAutofill?.isPromptPending() &&
!term.modes.win32InputMode &&
!isKittyKeyboardModeActive(kittyKeyboardMode)
? commandArrowLineJumpSequence(e, isMacPlatform())
: null;
// Autocomplete key handler (must be checked before other handlers)
if (ctx.onAutocompleteKeyEvent && !lineJumpSequence && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
if (ctx.onAutocompleteKeyEvent && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
const consumed = ctx.onAutocompleteKeyEvent(e);
if (!consumed) return false; // Event was consumed by autocomplete
}
@@ -2279,18 +2267,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return false;
}
// Keep app/snippet shortcuts and negotiated keyboard protocols ahead of
// the shell-only macOS line-editing fallback (issue #3139).
if (lineJumpSequence && ctx.sessionRef.current) {
e.preventDefault();
e.stopPropagation();
// This is a local shell editing shortcut. Peers may be running a TUI
// or another keyboard protocol, so do not fan out the mapped Ctrl key.
handleTerminalInputData(lineJumpSequence, { skipBroadcast: true });
scrollToBottomAfterInput(lineJumpSequence);
return false;
}
// macOS Option+←/→ → Meta-b / Meta-f so the shell jumps by word (discussion
// #826). After kitty mode so apps using the kitty protocol keep their own
// arrow encoding; read live so the toggle applies without reconnecting.
@@ -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);
});