Compare commits

..
Author SHA1 Message Date
bincxz 0714ca5ee0 fix: compare IPv6 host addresses numerically 2026-09-12 17:07:22 +08:00
bincxz cae4169948 fix: translate IP sorting in Spanish 2026-09-12 17:02:45 +08:00
bincxz 3987e465b6 feat: add optional IP sorting for vault hosts 2026-09-12 16:56:25 +08:00
19 changed files with 163 additions and 137 deletions

No files matched your search

+1
View File
@@ -56,6 +56,7 @@ export const enCoreMessages: Messages = {
'sort.newest': 'Newest to oldest',
'sort.oldest': 'Oldest to newest',
'sort.group': 'By group',
'sort.ip': 'By IP',
'field.label': 'Label',
'field.type': 'Type',
'auth.keyType': 'Type {type}',
+1
View File
@@ -55,6 +55,7 @@ export const esCoreMessages: Messages = {
'sort.za': 'Z-a',
'sort.newest': 'Más reciente a más antiguo',
'sort.oldest': 'Más antiguo a más reciente',
'sort.ip': 'Por IP',
'sort.group': 'Por grupo',
'field.label': 'Etiqueta',
'field.type': 'Tipo',
+1
View File
@@ -55,6 +55,7 @@ export const ruCoreMessages: Messages = {
'sort.newest': 'Сначала новые',
'sort.oldest': 'Сначала старые',
'sort.group': 'По группе',
'sort.ip': 'По IP',
'field.label': 'Метка',
'field.type': 'Тип',
'auth.keyType': 'Тип {type}',
+1
View File
@@ -42,6 +42,7 @@ export const zhCNCoreMessages: Messages = {
'sort.newest': '从新到旧',
'sort.oldest': '从旧到新',
'sort.group': '按分组',
'sort.ip': '按 IP',
'field.label': 'Label',
'field.type': '类型',
'auth.keyType': '类型 {type}',
+1
View File
@@ -42,6 +42,7 @@ export const zhTWCoreMessages: Messages = {
'sort.newest': '從新到舊',
'sort.oldest': '從舊到新',
'sort.group': '依群組',
'sort.ip': '依 IP',
'field.label': 'Label',
'field.type': '型別',
'auth.keyType': '型別 {type}',
+17
View File
@@ -478,3 +478,20 @@ test("HostTreeView announces sibling position within each tree level", () => {
assert.equal(byKey.get("host:host-c")?.posInSet, 1);
assert.equal(byKey.get("host:host-c")?.setSize, 1);
});
test("HostTreeView IP sorting stays inside each group and leaves manual order intact", () => {
const pair = (prefix: string, group = ""): Host[] => [
{ ...baseHost, id: `${prefix}-ten`, label: "Alpha", hostname: "10.0.0.10", group, order: 1 },
{ ...baseHost, id: `${prefix}-two`, label: "Zulu", hostname: "10.0.0.2", group, order: 2 },
];
const groupTree: GroupNode[] = [{
name: "prod", path: "prod", hosts: pair("prod", "prod"),
children: { east: { name: "east", path: "prod/east", hosts: pair("east", "prod/east"), children: {} } },
}];
const options = {
groupTree, ungroupedHosts: pair("root"), expandedPaths: new Set(["prod", "prod/east"]), groupConfigs: [],
};
const keys = (sortMode: "ip" | "manual") => buildVisibleHostTreeItems({ ...options, sortMode }).map(item => item.key);
assert.deepEqual(keys("ip"), ["group:prod", "group:prod/east", "host:east-two", "host:east-ten", "host:prod-two", "host:prod-ten", "host:root-two", "host:root-ten"]);
assert.deepEqual(keys("manual"), ["group:prod", "group:prod/east", "host:east-ten", "host:east-two", "host:prod-ten", "host:prod-two", "host:root-ten", "host:root-two"]);
});
+3 -1
View File
@@ -1,3 +1,4 @@
import { compareHostAddresses } from "../domain/hostAddressSort";
import { CheckSquare, Edit2, FileSymlink, Server, Square, Expand, Minimize2 } from 'lucide-react';
import { useVirtualizer } from '@tanstack/react-virtual';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -39,7 +40,7 @@ const getTreeGroupDropIntent = (
const hasDragType = (dataTransfer: DataTransfer, type: string) =>
Array.from(dataTransfer.types).includes(type);
type HostTreeSortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
type HostTreeSortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group' | 'ip';
export type VisibleHostTreeItem =
| {
@@ -90,6 +91,7 @@ const sortHostTreeHosts = (hosts: Host[], sortMode: HostTreeSortMode): Host[] =>
if (sortMode === 'newest') return (b.createdAt || 0) - (a.createdAt || 0);
if (sortMode === 'oldest') return (a.createdAt || 0) - (b.createdAt || 0);
if (sortMode === 'manual') return 0;
if (sortMode === 'ip') return compareHostAddresses(a.hostname, b.hostname) || a.label.localeCompare(b.label);
return a.label.localeCompare(b.label);
});
return sortMode === 'manual' ? sortByVaultOrder(sorted) : sorted;
+1 -1
View File
@@ -880,7 +880,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
<SortDropdown
value={sortMode}
onChange={(mode) => {
if (mode !== "group") setSortMode(mode);
if (mode !== "group" && mode !== "ip") setSortMode(mode);
}}
modes={["manual", "az", "za", "newest", "oldest"]}
className={vaultHeaderIconButtonClass}
+3
View File
@@ -1,3 +1,4 @@
import { compareHostAddresses } from "../domain/hostAddressSort";
import {
Check,
CheckSquare,
@@ -200,6 +201,8 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
result = [...result].sort((a, b) => {
switch (sortMode) {
case 'ip':
return compareHostAddresses(a.hostname, b.hostname) || a.label.localeCompare(b.label);
case 'az':
return a.label.localeCompare(b.label);
case 'za':
+1
View File
@@ -1738,6 +1738,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
value={sortMode}
onChange={setSortMode}
className={vaultHeaderIconButtonClass}
modes={['manual', 'az', 'za', 'newest', 'oldest', 'group']}
/>
<Tooltip>
<TooltipTrigger asChild>
@@ -162,3 +162,12 @@ test("Hosts manual sort mode uses saved order", () => {
assert.ok(markup.indexOf("Zulu Host") < markup.indexOf("Alpha Host"));
});
test("Hosts IP sort is restored and compares octets instead of labels", () => {
const markup = renderVault("ip", [
{ ...host("ten", "Alpha Ten", 1), hostname: "10.0.0.10" },
{ ...host("two", "Zulu Two", 2), hostname: "10.0.0.2" },
]);
assert.ok(markup.includes("Zulu Two"));
assert.ok(markup.indexOf("Zulu Two") < markup.indexOf("Alpha Ten"));
});
+2 -1
View File
@@ -207,7 +207,8 @@ const isSortMode = (value: string): value is SortMode =>
value === "za" ||
value === "newest" ||
value === "oldest" ||
value === "group";
value === "group" ||
value === "ip";
// Props without isActive - it's now subscribed internally
interface VaultViewProps {
@@ -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.
+3 -2
View File
@@ -1,10 +1,10 @@
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,FolderTree,GripVertical,SortAsc,SortDesc } from 'lucide-react';
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,FolderTree,GripVertical,Network,SortAsc,SortDesc } from 'lucide-react';
import React from 'react';
import { useI18n } from "../../application/i18n/I18nProvider";
import { Button } from './button';
import { Dropdown,DropdownContent,DropdownTrigger } from './dropdown';
export type SortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
export type SortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group' | 'ip';
const SORT_OPTIONS: Record<SortMode, { labelKey: string; icon: React.ReactElement; triggerIcon: React.ReactElement }> = {
manual: { labelKey: 'sort.manual', icon: <GripVertical className="w-4 h-4 shrink-0" />, triggerIcon: <GripVertical className="w-4 h-4" /> },
@@ -13,6 +13,7 @@ const SORT_OPTIONS: Record<SortMode, { labelKey: string; icon: React.ReactElemen
newest: { labelKey: 'sort.newest', icon: <Calendar className="w-4 h-4 shrink-0" />, triggerIcon: <Calendar className="w-4 h-4" /> },
oldest: { labelKey: 'sort.oldest', icon: <CalendarClock className="w-4 h-4 shrink-0" />, triggerIcon: <CalendarClock className="w-4 h-4" /> },
group: { labelKey: 'sort.group', icon: <FolderTree className="w-4 h-4 shrink-0" />, triggerIcon: <FolderTree className="w-4 h-4" /> },
ip: { labelKey: 'sort.ip', icon: <Network className="w-4 h-4 shrink-0" />, triggerIcon: <Network className="w-4 h-4" /> },
};
interface SortDropdownProps {
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo } from "react";
import { compareHostAddresses } from "../../domain/hostAddressSort";
import { upsertKnownHost } from "../../domain/knownHosts";
import { sortByVaultOrder, sortVaultStringsByOrder } from "../../domain/vaultOrder";
import { matchesHostSearchQuery, matchesSearchQuery } from "../../lib/searchMatcher";
@@ -123,6 +124,10 @@ export function useVaultHostCollections({
const groupCmp = groupA.localeCompare(groupB);
return groupCmp !== 0 ? groupCmp : a.label.localeCompare(b.label);
}
case "ip": {
const addrCmp = compareHostAddresses(a.hostname, b.hostname);
return addrCmp !== 0 ? addrCmp : a.label.localeCompare(b.label);
}
default:
return 0;
}
+43
View File
@@ -0,0 +1,43 @@
import test from "node:test";
import assert from "node:assert/strict";
import { compareHostAddresses } from "./hostAddressSort.ts";
test("compareHostAddresses sorts IPv4 numerically", () => {
const addresses = ["10.0.0.10", "10.0.0.2", "192.168.1.1", "10.0.0.1"];
const sorted = [...addresses].sort(compareHostAddresses);
assert.deepEqual(sorted, ["10.0.0.1", "10.0.0.2", "10.0.0.10", "192.168.1.1"]);
});
test("compareHostAddresses places IPv4 before hostnames", () => {
assert.ok(compareHostAddresses("10.0.0.1", "db.example.com") < 0);
assert.ok(compareHostAddresses("db.example.com", "10.0.0.1") > 0);
});
test("compareHostAddresses sorts hostnames with numeric awareness", () => {
const names = ["host10.example.com", "host2.example.com", "host1.example.com"];
const sorted = [...names].sort(compareHostAddresses);
assert.deepEqual(sorted, [
"host1.example.com",
"host2.example.com",
"host10.example.com",
]);
});
test("compareHostAddresses treats equal addresses as equal", () => {
assert.equal(compareHostAddresses("10.0.0.1", "10.0.0.1"), 0);
assert.equal(compareHostAddresses(" 10.0.0.1 ", "10.0.0.1"), 0);
});
test("compareHostAddresses sorts IPv6 hexadecimal words numerically", () => {
assert.deepEqual(["2001:db8::10", "2001:db8::f", "2001:db8::2"].sort(compareHostAddresses), ["2001:db8::2", "2001:db8::f", "2001:db8::10"]);
assert.equal(compareHostAddresses("[2001:db8::f]", "2001:0db8:0:0:0:0:0:000f"), 0);
assert.equal(compareHostAddresses("fe80::1%eth0", "fe80::1%eth1"), 0);
assert.equal(compareHostAddresses("::ffff:192.0.2.1", "::ffff:c000:201"), 0);
assert.ok(compareHostAddresses("::", "::1") < 0);
});
test("compareHostAddresses orders mixed address families before hostnames", () => {
assert.deepEqual(["host2", "2001:db8::1", "10.0.0.2", "host1", "::1"].sort(compareHostAddresses), ["10.0.0.2", "::1", "2001:db8::1", "host1", "host2"]);
assert.ok(compareHostAddresses("::1", "not:an:ip") < 0);
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Compare host address strings for vault list sorting.
* IPv4 and IPv6 addresses sort numerically; hostnames fall back to
* case-insensitive numeric-aware string compare. Mixed lists sort
* IPv4 first, then IPv6, then hostnames.
*/
export function compareHostAddresses(left: string, right: string): number {
const a = (left || "").trim().toLowerCase();
const b = (right || "").trim().toLowerCase();
if (a === b) return 0;
const aV4 = parseIPv4Octets(a);
const bV4 = parseIPv4Octets(b);
if (aV4 && bV4) {
for (let i = 0; i < 4; i += 1) {
if (aV4[i] !== bV4[i]) return aV4[i] - bV4[i];
}
return 0;
}
if (aV4) return -1;
if (bV4) return 1;
const aV6 = parseIPv6Words(a);
const bV6 = parseIPv6Words(b);
if (aV6 && bV6) {
for (let i = 0; i < 8; i += 1) {
if (aV6[i] !== bV6[i]) return aV6[i] - bV6[i];
}
return 0;
}
if (aV6) return -1;
if (bV6) return 1;
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
}
function parseIPv4Octets(value: string): number[] | null {
const parts = value.split(".");
if (parts.length !== 4) return null;
const octets: number[] = [];
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const n = Number(part);
if (!Number.isInteger(n) || n < 0 || n > 255) return null;
// Reject leading zeros like "01" except a single "0"
if (part.length > 1 && part.startsWith("0")) return null;
octets.push(n);
}
return octets;
}
function parseIPv6Words(value: string): number[] | null {
// Let the platform URL parser validate/canonicalize compressed and IPv4-mapped
// IPv6. Zone identifiers identify an interface, not part of the IP value.
const unwrapped = value.startsWith("[") && value.endsWith("]") ? value.slice(1, -1) : value;
const address = unwrapped.split("%")[0];
if (!address.includes(":") || !/^[0-9a-f:.]+$/.test(address)) return null;
try {
const canonical = new URL(`http://[${address}]/`).hostname.slice(1, -1);
const [left, right] = canonical.split("::");
const head = left ? left.split(":") : [];
const tail = right ? right.split(":") : [];
const words = right === undefined ? head : [...head, ...Array(8 - head.length - tail.length).fill("0"), ...tail];
return words.map(word => parseInt(word, 16));
} catch {
return null;
}
}