[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,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;