mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 04:22:38 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0714ca5ee0 | ||
|
|
cae4169948 | ||
|
|
3987e465b6 |
No files matched your search
@@ -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}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ruCoreMessages: Messages = {
|
||||
'sort.newest': 'Сначала новые',
|
||||
'sort.oldest': 'Сначала старые',
|
||||
'sort.group': 'По группе',
|
||||
'sort.ip': 'По IP',
|
||||
'field.label': 'Метка',
|
||||
'field.type': 'Тип',
|
||||
'auth.keyType': 'Тип {type}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user