[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,273 @@
import React, { useCallback, useMemo } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import {
effectiveDefaultAlgorithms,
SSH_ALGORITHM_CATEGORIES,
SSHAlgorithmCategory,
SUPPORTED_ALGORITHMS_BY_CATEGORY,
} from "../../domain/sshAlgorithmList";
import type { HostAlgorithmOverrides } from "../../domain/models";
import { Button } from "../ui/button";
import { Card } from "../ui/card";
interface Props {
value: HostAlgorithmOverrides | undefined;
onChange: (next: HostAlgorithmOverrides | undefined) => void;
/**
* The host's current `legacyAlgorithms` value, used to seed the very
* first customization in each category with the *effective* default
* list (modern-only vs modern+legacy) rather than the full SUPPORTED
* set. Without this, unchecking a single algorithm in modern mode
* would silently start advertising CBC / arcfour / MD5 algorithms.
*/
legacyEnabled: boolean;
/**
* Algorithm overrides this host would inherit from its group when its
* own field is unset. Used purely for display: an `undefined` value
* here means the host can freely use NetCatty defaults by resetting
* a category; a populated value means the host would inherit those
* lists, and resetting locally falls back to them — the panel
* surfaces that so the user knows the local Reset button doesn't
* jump them to NetCatty's defaults in that case.
*/
inheritedFromGroup?: HostAlgorithmOverrides;
}
const CATEGORY_LABEL_KEY: Record<SSHAlgorithmCategory, string> = {
kex: "hostDetails.algorithms.category.kex",
cipher: "hostDetails.algorithms.category.cipher",
hmac: "hostDetails.algorithms.category.hmac",
serverHostKey: "hostDetails.algorithms.category.serverHostKey",
compress: "hostDetails.algorithms.category.compress",
};
/**
* Per-category SSH algorithm override editor.
*
* When a category's array is `undefined`, that category uses NetCatty's
* negotiated default list. When it's a non-empty array, that array fully
* replaces the offered list for the category.
*
* Picking zero algorithms in a category is equivalent to "use default" —
* an empty array would make ssh2 fail negotiation, so we normalize it
* back to `undefined` on save.
*/
export const AlgorithmOverridesPanel: React.FC<Props> = ({
value,
onChange,
legacyEnabled,
inheritedFromGroup,
}) => {
const { t } = useI18n();
const effectiveDefault = useMemo(
() => effectiveDefaultAlgorithms(legacyEnabled),
[legacyEnabled],
);
// What the runtime *actually* inherits from the group for display
// purposes. `applyGroupDefaults` treats `host.algorithms` as an
// all-or-nothing boundary: once the host carries any local
// `algorithms` object the group's overrides stop being applied — even
// for categories the host didn't override. So as soon as `value` is
// non-undefined we must stop *displaying* inherited categories,
// otherwise the UI lies about what will be negotiated.
//
// The write-side (`updateCategory` / `toggleAlgorithm` / Reset) still
// consults the unconditional `inheritedFromGroup` so that the first
// user edit on an unset host carries the inherited categories into
// the host object, preventing the runtime's silent widening that
// motivated those write-side fixes.
const inheritedForDisplay = useMemo(
() => (value === undefined ? inheritedFromGroup : undefined),
[value, inheritedFromGroup],
);
const inheritedCategories = useMemo(() => {
if (!inheritedForDisplay) return [] as SSHAlgorithmCategory[];
return SSH_ALGORITHM_CATEGORIES.filter((category) => {
const list = inheritedForDisplay[category];
return Array.isArray(list) && list.length > 0;
});
}, [inheritedForDisplay]);
const updateCategory = useCallback(
(category: SSHAlgorithmCategory, selected: string[]) => {
// Start from the inherited group overrides so that touching one
// category doesn't silently drop inheritance for the others.
// `applyGroupDefaults` treats `host.algorithms` as an
// all-or-nothing inherit boundary: once the host carries any
// explicit object, the host's `algorithms` shadows the group's
// `algorithms` entirely. If the user customized cipher locally
// and the group restricted serverHostKey, simply storing
// `{ cipher: [...] }` on the host would lose the group's
// serverHostKey restriction. Persisting the inherited categories
// alongside keeps the effective offer intact.
const base: HostAlgorithmOverrides = inheritedFromGroup
? { ...inheritedFromGroup }
: {};
const next: HostAlgorithmOverrides = { ...base, ...(value ?? {}) };
if (selected.length === 0) {
delete next[category];
} else {
next[category] = selected;
}
const hasAny = Object.values(next).some((arr) => Array.isArray(arr) && arr.length > 0);
onChange(hasAny ? next : undefined);
},
[value, onChange, inheritedFromGroup],
);
const toggleAlgorithm = useCallback(
(category: SSHAlgorithmCategory, algo: string) => {
const current = value?.[category];
if (!current) {
// First click in this category — seed with the *effective* offer
// for this category. If the group has set a list for this
// category, use that (so customizing one entry doesn't lose the
// group's narrowing). Otherwise seed from NetCatty's effective
// default, which already accounts for legacy mode. Seeding from
// SUPPORTED_ALGORITHMS_BY_CATEGORY would silently introduce
// legacy algorithms (CBC, arcfour, MD5) into the offered list.
const baseline = inheritedFromGroup?.[category] ?? effectiveDefault[category];
if (baseline.includes(algo)) {
updateCategory(category, baseline.filter((a) => a !== algo));
} else {
// The user clicked an algorithm not in the baseline — they
// want to opt INTO it. Start the override with the baseline
// plus this extra entry.
updateCategory(category, [...baseline, algo]);
}
return;
}
if (current.includes(algo)) {
updateCategory(category, current.filter((a) => a !== algo));
} else {
updateCategory(category, [...current, algo]);
}
},
[value, updateCategory, effectiveDefault, inheritedFromGroup],
);
const resetCategory = useCallback(
(category: SSHAlgorithmCategory) => {
const inherited = inheritedFromGroup?.[category];
const next: HostAlgorithmOverrides = { ...(value ?? {}) };
if (Array.isArray(inherited) && inherited.length > 0) {
// The group has an override for this category. Just deleting
// `next[category]` would *widen* the effective offer: because
// `applyGroupDefaults` treats `host.algorithms` as an
// all-or-nothing inherit boundary, once any other category
// remains on the host the group's `algorithms` object stops
// being inherited as a whole, and the missing category falls
// back to NetCatty defaults — not the group's narrower list.
// Persist the inherited list verbatim instead, so Reset means
// "use what this host would otherwise inherit" rather than
// "silently switch to NetCatty defaults".
next[category] = inherited.slice();
} else {
delete next[category];
}
const hasAny = Object.values(next).some((arr) => Array.isArray(arr) && arr.length > 0);
onChange(hasAny ? next : undefined);
},
[value, onChange, inheritedFromGroup],
);
const isCustomized = useCallback(
(category: SSHAlgorithmCategory) => {
const local = value?.[category];
if (!Array.isArray(local) || local.length === 0) return false;
// If the host's list is identical (order + contents) to the
// inherited list, the user hasn't really customized it — they
// either reset to the upstream value or never touched it directly.
// Suppressing the "customized" badge in that case keeps the UI
// honest about what the user actually changed.
const inherited = inheritedFromGroup?.[category];
if (Array.isArray(inherited)
&& inherited.length === local.length
&& inherited.every((a, i) => a === local[i])) {
return false;
}
return true;
},
[value, inheritedFromGroup],
);
const isChecked = useCallback(
(category: SSHAlgorithmCategory, algo: string) => {
const current = value?.[category];
if (current) return current.includes(algo);
// No host-local override for this category: reflect what the host
// would actually advertise. Uses `inheritedForDisplay` (the same
// gating the inherited notice uses) so that a host that already
// has any local override stops pretending its empty categories
// still come from the group — `applyGroupDefaults` won't apply
// them, and the runtime falls back to NetCatty defaults.
const baseline = inheritedForDisplay?.[category] ?? effectiveDefault[category];
return baseline.includes(algo);
},
[value, effectiveDefault, inheritedForDisplay],
);
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.algorithms.advanced.desc")}
</p>
{inheritedCategories.length > 0 && (
<div className="flex items-start gap-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<p className="text-xs text-blue-700 dark:text-blue-300 break-words">
{t("hostDetails.algorithms.inheritedNotice")
.replace(
"{categories}",
inheritedCategories.map((c) => t(CATEGORY_LABEL_KEY[c])).join(", "),
)}
</p>
</div>
)}
{SSH_ALGORITHM_CATEGORIES.map((category) => {
const supported = SUPPORTED_ALGORITHMS_BY_CATEGORY[category];
const customized = isCustomized(category);
return (
<Card key={category} className="p-2 space-y-1.5 bg-background border-border/60">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium">
{t(CATEGORY_LABEL_KEY[category])}
{customized && (
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
{t("hostDetails.algorithms.customized")}
</span>
)}
</p>
{customized && (
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2 text-[11px]"
onClick={() => resetCategory(category)}
>
{t("hostDetails.algorithms.reset")}
</Button>
)}
</div>
<div className="grid grid-cols-1 gap-1">
{supported.map((algo) => (
<label
key={algo}
className="flex items-center gap-2 text-[11px] cursor-pointer select-none hover:bg-accent/40 rounded px-1 py-0.5"
>
<input
type="checkbox"
className="h-3 w-3"
checked={isChecked(category, algo)}
onChange={() => toggleAlgorithm(category, algo)}
/>
<span className="font-mono truncate" title={algo}>{algo}</span>
</label>
))}
</div>
</Card>
);
})}
</div>
);
};

View File

@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
test('ChainPanel virtualizes the searchable available-host picker', () => {
const source = readFileSync(new URL('./ChainPanel.tsx', import.meta.url), 'utf8');
assert.match(source, /FixedSizeVirtualList/);
assert.match(source, /items=\{filteredHosts\}/);
assert.match(source, /CHAIN_HOST_VIEWPORT_HEIGHT/);
assert.match(source, /onClick=\{\(\) => onAddHost\(host\.id\)\}/);
});

View File

@@ -0,0 +1,181 @@
/**
* Host Chain Sub-Panel
* Panel for configuring SSH jump host chain
*/
import { ArrowDown,Plus,Search,X } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { AsidePanel, type AsidePanelLayout, type AsidePanelResizeProps } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { FixedSizeVirtualList } from '../ui/FixedSizeVirtualList';
import { Input } from '../ui/input';
import { ScrollArea } from '../ui/scroll-area';
const CHAIN_HOST_ROW_HEIGHT = 52;
const CHAIN_HOST_VIEWPORT_HEIGHT = 256;
export interface ChainPanelProps {
formLabel: string;
formHostname: string;
form: Host;
chainedHosts: Host[];
availableHostsForChain: Host[];
onAddHost: (hostId: string) => void;
onRemoveHost: (index: number) => void;
onClearChain: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export type ChainPanelPropsWithResize = ChainPanelProps & AsidePanelResizeProps;
export const ChainPanel: React.FC<ChainPanelPropsWithResize> = ({
formLabel,
formHostname,
form,
chainedHosts,
availableHostsForChain,
onAddHost,
onRemoveHost,
onClearChain,
onBack,
onCancel,
layout = 'overlay',
resizable,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState('');
const filteredHosts = useMemo(() => {
if (!searchQuery.trim()) return availableHostsForChain;
const q = searchQuery.toLowerCase();
return availableHostsForChain.filter(
(host) => host.label.toLowerCase().includes(q) || host.hostname.toLowerCase().includes(q)
);
}, [availableHostsForChain, searchQuery]);
return (
<AsidePanel
open={true}
onClose={onCancel}
title={t('hostDetails.chain.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
resizeAriaLabel={resizeAriaLabel}
actions={
<Button size="sm" onClick={onBack}>
{t('common.save')}
</Button>
}
>
<ScrollArea className="flex-1">
<div className="p-4 space-y-4 w-0 min-w-full">
{/* Chain visualization */}
<div className="space-y-2">
{chainedHosts.map((host, index) => (
<React.Fragment key={host.id}>
{index > 0 && (
<div className="flex justify-center py-1">
<ArrowDown size={16} className="text-muted-foreground" />
</div>
)}
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
<span className="text-sm font-medium flex-1 min-w-0 truncate">{host.label || host.hostname}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => onRemoveHost(index)}
>
<X size={14} />
</Button>
</div>
</React.Fragment>
))}
{chainedHosts.length > 0 && (
<div className="flex justify-center py-1">
<ArrowDown size={16} className="text-muted-foreground" />
</div>
)}
{/* Target host (current) */}
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
<DistroAvatar
host={form}
fallback={formLabel?.slice(0, 2).toUpperCase() || formHostname?.slice(0, 2).toUpperCase() || "H"}
className="h-8 w-8"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{formLabel || formHostname || t('hostDetails.chain.target')}</div>
<div className="text-xs text-muted-foreground">{t('hostDetails.chain.target')}</div>
</div>
</div>
</div>
{/* Available hosts to add */}
{availableHostsForChain.length > 0 && (
<Card className="p-3 bg-card border-border/80">
<p className="text-xs font-semibold text-muted-foreground mb-2">{t('hostDetails.chain.availableHosts')}</p>
<div className="relative mb-2">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('common.searchPlaceholder')}
className="h-8 pl-8 text-sm"
/>
</div>
<div
className="max-h-64"
style={{
height: Math.min(
filteredHosts.length * CHAIN_HOST_ROW_HEIGHT,
CHAIN_HOST_VIEWPORT_HEIGHT,
),
}}
>
<FixedSizeVirtualList
items={filteredHosts}
itemHeight={CHAIN_HOST_ROW_HEIGHT}
className="h-full"
getItemKey={(host) => host.id}
renderItem={(host, index) => (
<button
key={host.id}
className={`w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left overflow-hidden${index > 0 ? ' mt-1' : ''}`}
onClick={() => onAddHost(host.id)}
>
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">{host.hostname}</div>
</div>
<Plus size={14} className="text-muted-foreground" />
</button>
)}
/>
</div>
</Card>
)}
{chainedHosts.length > 0 && (
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearChain}>
{t('hostDetails.chain.clear')}
</Button>
)}
</div>
</ScrollArea>
</AsidePanel>
);
};
export default ChainPanel;

View File

@@ -0,0 +1,127 @@
/**
* Create Group Sub-Panel
* Panel for creating new groups within the host details
*/
import { FolderPlus,HelpCircle,Plus } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout,type AsidePanelResizeProps } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
interface ToggleRowProps {
label: string;
enabled: boolean;
onToggle: () => void;
}
const ToggleRow: React.FC<ToggleRowProps> = ({ label, enabled, onToggle }) => (
<div className="flex items-center justify-between">
<span className="text-sm">{label}</span>
<button
type="button"
onClick={onToggle}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-4' : 'translate-x-1'
}`}
/>
</button>
</div>
);
export interface CreateGroupPanelProps {
newGroupName: string;
setNewGroupName: (name: string) => void;
newGroupParent: string;
setNewGroupParent: (parent: string) => void;
groups: string[];
onSave: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export type CreateGroupPanelPropsWithResize = CreateGroupPanelProps & AsidePanelResizeProps;
export const CreateGroupPanel: React.FC<CreateGroupPanelPropsWithResize> = ({
newGroupName,
setNewGroupName,
newGroupParent,
setNewGroupParent,
groups,
onSave,
onBack,
onCancel,
layout = 'overlay',
resizable,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
return (
<AsidePanel
open={true}
onClose={onCancel}
title={t('hostDetails.group.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
resizeAriaLabel={resizeAriaLabel}
actions={
<Button size="sm" onClick={onSave} disabled={!newGroupName.trim()}>
{t('common.save')}
</Button>
}
>
<AsidePanelContent>
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs font-semibold">{t('hostDetails.group.general')}</p>
<div className="flex items-center gap-2">
<div className="h-10 w-10 rounded-lg bg-primary/15 flex items-center justify-center">
<FolderPlus size={18} className="text-primary" />
</div>
<Input
placeholder={t('hostDetails.group.namePlaceholder')}
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
className="h-10 flex-1"
autoFocus
/>
</div>
<div className="relative">
<Input
placeholder={t('hostDetails.group.parentPlaceholder')}
value={newGroupParent}
onChange={(e) => setNewGroupParent(e.target.value)}
list="parent-group-options"
className="h-10"
/>
<datalist id="parent-group-options">
{groups.map((g) => <option key={g} value={g} />)}
</datalist>
</div>
</Card>
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold">{t('hostDetails.group.cloudSync')}</p>
<HelpCircle size={14} className="text-muted-foreground" />
</div>
<ToggleRow label={t('hostDetails.group.cloudSync')} enabled={false} onToggle={() => { }} />
</Card>
<Button variant="ghost" className="w-full h-10 gap-2">
<Plus size={16} /> {t('hostDetails.group.addProtocol')}
</Button>
</AsidePanelContent>
</AsidePanel>
);
};
export default CreateGroupPanel;

View File

@@ -0,0 +1,133 @@
/**
* Environment Variables Sub-Panel
* Panel for configuring environment variables for SSH connections
*/
import { Plus,X } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { EnvVar } from '../../types';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout,type AsidePanelResizeProps } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
export interface EnvVarsPanelProps {
hostLabel: string;
hostHostname: string;
environmentVariables: EnvVar[];
newEnvName: string;
newEnvValue: string;
setNewEnvName: (name: string) => void;
setNewEnvValue: (value: string) => void;
onAddEnvVar: () => void;
onRemoveEnvVar: (index: number) => void;
onUpdateEnvVar: (index: number, field: 'name' | 'value', value: string) => void;
onSave: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export type EnvVarsPanelPropsWithResize = EnvVarsPanelProps & AsidePanelResizeProps;
export const EnvVarsPanel: React.FC<EnvVarsPanelPropsWithResize> = ({
hostLabel,
hostHostname,
environmentVariables,
newEnvName,
newEnvValue,
setNewEnvName,
setNewEnvValue,
onAddEnvVar,
onRemoveEnvVar,
onUpdateEnvVar,
onSave,
onBack,
onCancel,
layout = 'overlay',
resizable,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
return (
<AsidePanel
open={true}
onClose={onCancel}
title={t('hostDetails.envVars.title')}
showBackButton={true}
onBack={onBack}
layout={layout}
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
resizeAriaLabel={resizeAriaLabel}
actions={
<Button size="sm" onClick={onSave}>
{t('common.save')}
</Button>
}
>
<AsidePanelContent>
<div className="text-sm text-muted-foreground">
{t('hostDetails.envVars.desc', { host: hostLabel || hostHostname })}
<p className="text-xs mt-1">{t('hostDetails.envVars.note')}</p>
</div>
<Button className="w-full h-10" onClick={onAddEnvVar} disabled={!newEnvName.trim()}>
<Plus size={14} className="mr-2" /> {t('hostDetails.envVars.add')}
</Button>
{/* Existing variables */}
{environmentVariables.map((envVar, index) => (
<Card key={index} className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold">{t('hostDetails.envVars.variable')}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => onRemoveEnvVar(index)}
>
<X size={14} />
</Button>
</div>
<Input
placeholder={t('hostDetails.envVars.variable')}
value={envVar.name}
onChange={(e) => onUpdateEnvVar(index, 'name', e.target.value)}
className="h-10"
/>
<Input
placeholder={t('hostDetails.envVars.value')}
value={envVar.value}
onChange={(e) => onUpdateEnvVar(index, 'value', e.target.value)}
className="h-10"
/>
</Card>
))}
{/* New variable input */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold">{t('hostDetails.envVars.newVariable')}</span>
<X size={14} className="text-muted-foreground opacity-0" />
</div>
<Input
placeholder={t('hostDetails.envVars.variableName')}
value={newEnvName}
onChange={(e) => setNewEnvName(e.target.value)}
className="h-10"
/>
<Input
placeholder={t('hostDetails.envVars.value')}
value={newEnvValue}
onChange={(e) => setNewEnvValue(e.target.value)}
className="h-10"
/>
</Card>
</AsidePanelContent>
</AsidePanel>
);
};
export default EnvVarsPanel;

View File

@@ -0,0 +1,39 @@
import { RotateCcw } from "lucide-react";
import React from "react";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function HostDetailsOverrideReset({
label,
onClick,
className,
size = "md",
}: {
label: string;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
className?: string;
size?: "md" | "sm";
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"shrink-0 border-0 bg-transparent text-primary shadow-none hover:bg-transparent hover:text-primary/80",
size === "md" ? "h-8 w-8" : "h-7 w-7",
className,
)}
onClick={onClick}
aria-label={label}
>
<RotateCcw size={size === "md" ? 14 : 13} />
</Button>
</TooltipTrigger>
<TooltipContent side="left">{label}</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,95 @@
import { HelpCircle } from "lucide-react";
import React from "react";
import { cn } from "../../lib/utils";
import { Card } from "../ui/card";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
export function HostDetailsHelp({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
if (!children) return null;
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"relative -top-px flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground [&>svg]:block",
className,
)}
aria-label={typeof children === "string" ? children : undefined}
>
<HelpCircle size={13} />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-left leading-relaxed">
{children}
</TooltipContent>
</Tooltip>
);
}
export function HostDetailsSection({
icon,
title,
hint,
children,
className,
action,
}: {
icon: React.ReactNode;
title: React.ReactNode;
hint?: React.ReactNode;
children: React.ReactNode;
className?: string;
action?: React.ReactNode;
}) {
return (
<Card className={cn("p-3 space-y-3 bg-card border-border/80 shadow-sm", className)}>
<div className="flex min-h-5 items-center gap-1.5">
<span className="flex h-5 w-4 shrink-0 items-center justify-center text-muted-foreground [&>img]:block [&>img]:h-4 [&>img]:w-4 [&>img]:object-contain [&>svg]:block [&>svg]:h-4 [&>svg]:w-4">
{icon}
</span>
<p className="flex min-h-5 items-center text-xs font-semibold leading-5 text-foreground">
{title}
</p>
{hint && <HostDetailsHelp>{hint}</HostDetailsHelp>}
{action && <div className="ml-auto flex items-center">{action}</div>}
</div>
{children}
</Card>
);
}
export function HostDetailsSettingRow({
label,
hint,
children,
className,
}: {
label: React.ReactNode;
hint?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
// Fixed height so switch / select / input rows stay aligned.
"flex h-12 items-center justify-between gap-3 rounded-lg border border-border/60 bg-secondary/40 px-3",
className,
)}
>
<div className="flex min-h-5 min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-medium leading-5 text-foreground">{label}</span>
{hint && <HostDetailsHelp>{hint}</HostDetailsHelp>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,341 @@
/**
* Proxy Configuration Sub-Panel
* Panel for configuring HTTP/SOCKS5/ProxyCommand proxy settings
*/
import { Globe, KeyRound, SquareTerminal, Trash2 } from 'lucide-react';
import React, { useCallback, useMemo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
formatProxyConfigEndpoint,
formatProxyConfigType,
hasIncompleteProxyIdentity,
hasMissingProxyIdentity,
hasUnreadableProxyCredential,
isProxyCommandConfig,
isValidProxyPort,
} from '../../domain/proxyProfiles';
import { Identity, ProxyConfig, ProxyProfile } from '../../types';
import { AsidePanel, AsidePanelContent, type AsidePanelLayout, type AsidePanelResizeProps } from '../ui/aside-panel';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
export interface ProxyPanelProps {
proxyConfig?: ProxyConfig;
proxyProfiles?: ProxyProfile[];
identities?: Identity[];
selectedProxyProfileId?: string;
onUpdateProxy: (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => void;
onSelectProxyProfile?: (profileId: string | undefined) => void;
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
layout?: AsidePanelLayout;
}
export type ProxyPanelPropsWithResize = ProxyPanelProps & AsidePanelResizeProps;
export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
proxyConfig,
proxyProfiles = [],
identities = [],
selectedProxyProfileId,
onUpdateProxy,
onSelectProxyProfile,
onClearProxy,
onBack,
onCancel,
layout = 'overlay',
resizable,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
const customValue = '__custom__';
const selectedProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === selectedProxyProfileId),
[proxyProfiles, selectedProxyProfileId],
);
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
const isUsingProfile = Boolean(selectedProfile);
const isCommandProxy = isProxyCommandConfig(proxyConfig);
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
const hasManualProxyValue = isCommandProxy ? hasManualProxyCommand : hasManualProxyHost;
const hasInvalidManualProxyPort = !isCommandProxy && hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
const effectiveProxyConfig = selectedProfile?.config ?? proxyConfig;
const hasMissingIdentity = hasMissingProxyIdentity(effectiveProxyConfig, identities);
const hasIncompleteIdentity = hasIncompleteProxyIdentity(effectiveProxyConfig, identities);
const hasUnreadableIdentity = hasUnreadableProxyCredential(effectiveProxyConfig, identities);
const hasInvalidIdentity = hasMissingIdentity || hasIncompleteIdentity || hasUnreadableIdentity;
const canSave = (isUsingProfile && !hasInvalidIdentity) ||
(!isUsingProfile && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
const manualCredentialsValue = '__manual_credentials__';
const missingIdentityValue = '__missing_identity__';
const selectedIdentity = useMemo(
() => identities.find((identity) => identity.id === proxyConfig?.identityId),
[identities, proxyConfig?.identityId],
);
const selectedIdentityValue = selectedIdentity?.id || (hasMissingIdentity ? missingIdentityValue : manualCredentialsValue);
const handleBack = useCallback(() => {
if (hasInvalidManualProxyPort || hasInvalidIdentity) return;
onBack();
}, [hasInvalidManualProxyPort, hasInvalidIdentity, onBack]);
return (
<AsidePanel
open={true}
onClose={onCancel}
title={t('hostDetails.proxyPanel.title')}
showBackButton={true}
onBack={handleBack}
layout={layout}
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
resizeAriaLabel={resizeAriaLabel}
actions={
<Button size="sm" onClick={handleBack} disabled={!canSave}>
{t('common.save')}
</Button>
}
>
<AsidePanelContent>
{(proxyProfiles.length > 0 || hasMissingProfile) && onSelectProxyProfile && (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.savedProxy')}</p>
</div>
<Select
value={selectedValue}
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
>
<SelectTrigger
aria-label={t('hostDetails.proxyPanel.savedProxy')}
className="h-10"
>
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
{proxyProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasMissingProfile && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.missingSaved')}
</div>
)}
{selectedProfile && (
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary" className="text-xs shrink-0">
{formatProxyConfigType(selectedProfile.config)}
</Badge>
<span className="truncate">
{formatProxyConfigEndpoint(selectedProfile.config)}
</span>
</div>
</div>
)}
{selectedProfile && hasMissingIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.missingIdentity')}
</div>
)}
{selectedProfile && hasIncompleteIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.incompleteIdentity')}
</div>
)}
{selectedProfile && hasUnreadableIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.unreadableIdentity')}
</div>
)}
</Card>
)}
{!isUsingProfile && (
<>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t('field.type')}</p>
</div>
<Select
value={proxyConfig?.type || 'http'}
onValueChange={(value) => onUpdateProxy('type', value as ProxyConfig['type'])}
>
<SelectTrigger aria-label={t('field.type')} className="h-10">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="socks5">SOCKS5</SelectItem>
<SelectItem value="command">{t('hostDetails.proxyPanel.command')}</SelectItem>
</SelectContent>
</Select>
</div>
{isCommandProxy ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<SquareTerminal size={14} />
<span>{t('hostDetails.proxyPanel.commandHelp')}</span>
</div>
<Input
aria-label={t('hostDetails.proxyPanel.commandPlaceholder')}
placeholder={t('hostDetails.proxyPanel.commandPlaceholder')}
value={proxyConfig?.command || ""}
onChange={(e) => onUpdateProxy('command', e.target.value)}
className="h-10 font-mono text-xs"
/>
</div>
) : (
<div className="flex gap-2">
<Input
aria-label={t('hostDetails.proxyPanel.hostPlaceholder')}
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
value={proxyConfig?.host || ""}
onChange={(e) => onUpdateProxy('host', e.target.value)}
className="h-10 flex-1"
/>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
<Input
aria-label={t('hostDetails.port')}
type="number"
placeholder="3128"
min={1}
max={65535}
step={1}
value={proxyConfig?.port || ""}
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
className="h-10 w-20 text-center"
/>
</div>
</div>
)}
{hasInvalidManualProxyPort && (
<p className="text-xs text-destructive">
{t('proxyProfiles.error.port')}
</p>
)}
</Card>
{!isCommandProxy && <Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.credentials')}</p>
</div>
<Badge variant="secondary" className="text-xs">{t('common.optional')}</Badge>
</div>
{identities.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{t('hostDetails.proxyPanel.keychainIdentity')}
</p>
<Select
value={selectedIdentityValue}
onValueChange={(value) => {
if (value === missingIdentityValue) return;
onUpdateProxy(
'identityId',
value === manualCredentialsValue ? undefined : value,
);
}}
>
<SelectTrigger
aria-label={t('hostDetails.proxyPanel.keychainIdentity')}
className="h-10"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={manualCredentialsValue}>
{t('hostDetails.proxyPanel.manualCredentials')}
</SelectItem>
{hasMissingIdentity && (
<SelectItem value={missingIdentityValue}>
{t('hostDetails.proxyPanel.missingIdentity')}
</SelectItem>
)}
{identities.map((identity) => (
<SelectItem key={identity.id} value={identity.id}>
{identity.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{hasMissingIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.missingIdentity')}
</div>
)}
{hasIncompleteIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.incompleteIdentity')}
</div>
)}
{hasUnreadableIdentity && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.unreadableIdentity')}
</div>
)}
{selectedIdentity ? (
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary" className="text-xs shrink-0">
{t('hostDetails.proxyPanel.keychainIdentity')}
</Badge>
<span className="truncate">
{selectedIdentity.label} - {selectedIdentity.username}
</span>
</div>
</div>
) : (
<>
<Input
aria-label={t('hostDetails.proxyPanel.usernamePlaceholder')}
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
value={proxyConfig?.username || ""}
onChange={(e) => onUpdateProxy('username', e.target.value)}
className="h-10"
/>
<Input
aria-label={t('hostDetails.proxyPanel.passwordPlaceholder')}
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
type="password"
value={proxyConfig?.password || ""}
onChange={(e) => onUpdateProxy('password', e.target.value)}
className="h-10"
/>
</>
)}
</Card>}
</>
)}
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId) && (
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
</Button>
)}
</AsidePanelContent>
</AsidePanel>
);
};
export default ProxyPanel;

View File

@@ -0,0 +1,24 @@
/**
* Host Details components module
* Re-exports all host details sub-components
*/
export { CreateGroupPanel } from './CreateGroupPanel';
export type { CreateGroupPanelProps } from './CreateGroupPanel';
export { ProxyPanel } from './ProxyPanel';
export type { ProxyPanelProps } from './ProxyPanel';
export { ChainPanel } from './ChainPanel';
export type { ChainPanelProps } from './ChainPanel';
export { EnvVarsPanel } from './EnvVarsPanel';
export type { EnvVarsPanelProps } from './EnvVarsPanel';
export {
HostDetailsHelp,
HostDetailsSection,
HostDetailsSettingRow,
} from './HostDetailsSection';
export { HostDetailsOverrideReset } from './HostDetailsOverrideReset';