mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-04 13:02:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0d032ec3c | ||
|
|
28f7193c4a | ||
|
|
51d4d97419 |
No files matched your search
@@ -57,7 +57,6 @@ 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";
|
||||
@@ -548,7 +547,6 @@ 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,
|
||||
@@ -996,12 +994,6 @@ 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}>
|
||||
|
||||
@@ -168,7 +168,6 @@ test("HostTreeView shows selected groups in multi-select mode", () => {
|
||||
children: {},
|
||||
hosts: [],
|
||||
}]}
|
||||
groupConfigs={[{ path: "production", notes: "# VPN instructions" }]}
|
||||
hosts={[]}
|
||||
expandedPaths={new Set<string>()}
|
||||
onTogglePath={() => undefined}
|
||||
@@ -189,7 +188,6 @@ 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"/);
|
||||
|
||||
+13
-18
@@ -538,24 +538,19 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
? <CheckSquare size={16} />
|
||||
: <Square size={16} />
|
||||
: undefined}
|
||||
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>
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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" />), "");
|
||||
});
|
||||
@@ -7,13 +7,11 @@ 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;
|
||||
@@ -27,7 +25,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={label}
|
||||
aria-label="Host notes"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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"] : []);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 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,6 +91,7 @@ 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";
|
||||
@@ -1958,8 +1959,19 @@ 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 && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
|
||||
if (ctx.onAutocompleteKeyEvent && !lineJumpSequence && !isKittyKeyboardModeActive(kittyKeyboardMode)) {
|
||||
const consumed = ctx.onAutocompleteKeyEvent(e);
|
||||
if (!consumed) return false; // Event was consumed by autocomplete
|
||||
}
|
||||
@@ -2267,6 +2279,18 @@ 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,7 +893,6 @@ 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">
|
||||
|
||||
@@ -581,10 +581,3 @@ 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);
|
||||
});
|
||||
@@ -116,7 +116,7 @@ export function resolveGroupDefaults(
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (key !== 'path' && key !== 'notes' && value !== undefined) {
|
||||
if (key !== 'path' && value !== undefined) {
|
||||
if (key === 'proxyProfileId') {
|
||||
delete merged.proxyConfig;
|
||||
}
|
||||
|
||||
@@ -412,8 +412,6 @@ 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;
|
||||
|
||||
@@ -620,14 +620,3 @@ 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);
|
||||
});
|
||||
Reference in new issue
Block a user