mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
205d1591e8
* feat(terminal): add disconnect/reconnect controls on session status bar Workspace panes could not reach tab-level reconnect. Expose disconnect (without closing the pane) and reconnect beside the host info actions so split sessions stay operable. Closes #2769. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): abort in-flight boot on status-bar disconnect Disconnect keeps the pane mounted, so cancel's unmount cleanup never runs. Flip isBootActiveRef (and isCancelling) so a late startSSH/startMosh attach cannot resurrect the session after the user disconnects mid-connect. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): ignore stale start UI after status-bar disconnect When disconnect aborts an in-flight connect, starter catch paths must not reopen auth or repopulate errors. Gate SSH/telnet/mosh/ET/local/serial failure UI on isTerminalBootActive and harden the boot effect awaits. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): hide disconnect/reconnect on compact popup terminals Owned command popups use compactToolbar with a one-shot startup command. Exposing reconnect there can re-run that command; keep the controls on normal tabs/workspace panes only. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): use boot epoch so reconnect cannot revive aborted starts Disconnect alone flipping isBootActiveRef is not enough: an immediate reconnect re-arms boot, and a late startSSH from the aborted attempt can still attach. Capture a boot epoch per attempt and ignore stale attaches/UI. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): do not let stale attach abort a newer reconnect tryAttach failure for an expired boot epoch must close the orphan session only. Calling abortSessionStartAfterUnmount() would force disconnected on a reconnect that already re-armed boot. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): reject late host-key prompts after disconnect Disconnect can land before pendingHostKeyRequestId is set. Auto-deny onHostKeyVerification when boot is inactive or status is disconnected so approval UI cannot reopen on an aborted pane. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): cancel wake and MFA when status-bar disconnects Invalidate hibernation reconnect-wake tokens on disconnect so a late wake cannot re-arm boot. Reject/cancel keyboard-interactive prompts for disconnected panes and clear any already-queued MFA for that session. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): ignore stale SSH catch UI and password fallback Check boot attempt currency before resetting reconnect UI state on SSH start failure, and before launching key→password fallback after disconnect so a replacement reconnect is not interrupted. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): guard stale SSH progress and host-key prompts Ignore chain-progress callbacks from superseded boot attempts, and thread bootEpoch through SSH host-key verification so a late prompt from an aborted start cannot overwrite a replacement reconnect. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): correlate MFA and jump host-keys with boot epoch Forward bootEpoch through jump-host verification and keyboard-interactive prompts, and reject superseded MFA when the retained tab has already started a replacement reconnect. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): keep replacement SSH session alive across stale starts Skip closeSession for superseded starts while a newer boot still owns the shared sessionId, and avoid clearing MFA wait state before the attempt-freshness guard on SSH success. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): own shared sessionIds by boot epoch Claim SSH/local registry slots with bootEpoch so a stale start cannot overwrite a replacement, and make closeSession no-op when the payload epoch does not match the live session owner. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): claim bootEpoch for all reconnectable backends Pass bootEpoch through Telnet/Mosh/ET/serial starts, claim registry slots the same way as SSH/local, and dispose displaced owners when a newer boot replaces the shared sessionId. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): protect preload listeners and ET artifacts on displace Defer epoch-scoped closeSession listener teardown until main confirms ownership, and remove ET external-auth temp artifacts when a newer boot displaces the previous registry owner. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): clean rejected Mosh starts and keep replacement listeners Kill superseded Mosh/ET PTYs and auth temp files when claimSessionSlot rejects, and never clear shared preload listeners on epoch-scoped closes. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): settle superseded boots and stop displaced log streams Reject Telnet/SSH claim failures through Promise reject/failReuse instead of uncaught EventEmitter/.then throws, and stop a displaced session's log stream so a logging-disabled reconnect cannot append into the old file. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): keep woken runtime when disconnect cancels hibernate wake Disconnect invalidates in-flight hibernated reconnects with keep mode so a successful wake that already cleared hibernatedRef is not disposed into a dead pane that can never reconnect. Unmount still disposes orphan runtimes. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): bind reused SSH shellPid only to the claimed session Late PID discovery after Copy Tab reuse must not write onto a newer bootEpoch that already replaced the shared sessionId registry slot. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): await plugin cancel cleanup and clear boot epochs on teardown Track plugin start/cancel promises so Disconnect→Reconnect waits until the external session finishes unregistering, and stop unmount teardown from republishing boot epochs into the process-wide map. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): cancel SSH passphrase prompts on disconnect Register a pending-boot AbortSignal for SSH starts, abort it (and session- scoped passphrase requests) from closeSession even before a registry slot exists, and filter/drop late passphrase modals by boot epoch / disconnect. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): close owned boot epoch and scope passphrase cancels Capture the pre-bump epoch for disconnect/teardown closeSession so live transports are not skipped by epoch mismatch, cancel only passphrase prompts at or below that epoch, and revalidate boot currency after async passphrase lookups before queuing a modal. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): tag Telnet auto-login and Mosh ready with bootEpoch Emit bootEpoch on telnet auto-login and mosh:ready events so overlapping Disconnect→Reconnect boots ignore cross-delivered callbacks meant for an older shared sessionId. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): update disconnect assertion and cancel pending serial opens Align the disconnect source regression with invalidateBootEpochForClose, and abort in-flight serialPort.open attempts via pending boot AbortSignal so Disconnect→Reconnect cannot leave a stale open racing the replacement. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(terminal): keep flow-pause leases on skipped epoch closes Worker close:await previously cleared pause ownership in finally even when the worker returned skipped for an epoch mismatch, dropping the live replacement's lease. Only clear on non-skipped closes; epoch-scoped fire-and-forget close similarly defers to onSessionClosed. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
/**
|
|
* Passphrase Modal
|
|
* Modal for requesting passphrase for encrypted SSH keys
|
|
*/
|
|
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
|
import React, { useCallback, useEffect, useState } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { Button } from "./ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "./ui/dialog";
|
|
import { Input } from "./ui/input";
|
|
import { Label } from "./ui/label";
|
|
|
|
export interface PassphraseRequest {
|
|
requestId: string;
|
|
keyPath: string;
|
|
keyName: string;
|
|
hostname?: string;
|
|
sessionId?: string;
|
|
bootEpoch?: number;
|
|
}
|
|
|
|
/** Opt-in only: do not default to remembering key passphrases (#2024). */
|
|
export const DEFAULT_REMEMBER_PASSPHRASE = false;
|
|
|
|
interface PassphraseModalProps {
|
|
request: PassphraseRequest | null;
|
|
onSubmit: (requestId: string, passphrase: string, remember: boolean) => void;
|
|
onCancel: (requestId: string) => void;
|
|
onSkip?: (requestId: string) => void;
|
|
}
|
|
|
|
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
|
request,
|
|
onSubmit,
|
|
onCancel,
|
|
onSkip,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const [passphrase, setPassphrase] = useState("");
|
|
const [showPassphrase, setShowPassphrase] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [rememberPassphrase, setRememberPassphrase] = useState(DEFAULT_REMEMBER_PASSPHRASE);
|
|
|
|
// Reset state when request changes
|
|
useEffect(() => {
|
|
if (request) {
|
|
setPassphrase("");
|
|
setShowPassphrase(false);
|
|
setIsSubmitting(false);
|
|
setRememberPassphrase(DEFAULT_REMEMBER_PASSPHRASE);
|
|
}
|
|
}, [request]);
|
|
|
|
const handleSubmit = useCallback(() => {
|
|
if (!request || isSubmitting || !passphrase) return;
|
|
setIsSubmitting(true);
|
|
onSubmit(request.requestId, passphrase, rememberPassphrase);
|
|
}, [request, passphrase, onSubmit, isSubmitting, rememberPassphrase]);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
if (!request) return;
|
|
onCancel(request.requestId);
|
|
}, [request, onCancel]);
|
|
|
|
const handleSkip = useCallback(() => {
|
|
if (!request || !onSkip) return;
|
|
onSkip(request.requestId);
|
|
}, [request, onSkip]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !isSubmitting && passphrase) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
},
|
|
[handleSubmit, isSubmitting, passphrase]
|
|
);
|
|
|
|
if (!request) return null;
|
|
|
|
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
|
|
|
|
return (
|
|
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
|
<DialogContent className="sm:max-w-[500px]" hideCloseButton>
|
|
<DialogHeader>
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
|
<KeyRound className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<DialogTitle>{t("passphrase.title")}</DialogTitle>
|
|
<DialogDescription className="mt-1 break-words">
|
|
{request.hostname
|
|
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
|
|
: t("passphrase.desc", { keyName: keyDisplayName })}
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="passphrase-input">
|
|
{t("passphrase.label")}
|
|
</Label>
|
|
<div className="relative">
|
|
<Input
|
|
id="passphrase-input"
|
|
type={showPassphrase ? "text" : "password"}
|
|
value={passphrase}
|
|
onChange={(e) => setPassphrase(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder=""
|
|
className="pr-10"
|
|
autoFocus
|
|
disabled={isSubmitting}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
|
onClick={() => setShowPassphrase(!showPassphrase)}
|
|
disabled={isSubmitting}
|
|
>
|
|
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground break-all">
|
|
{t("passphrase.keyPath")}: <code className="text-xs break-all">{request.keyPath}</code>
|
|
</p>
|
|
<label className="flex items-center gap-2 cursor-pointer select-none mt-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={rememberPassphrase}
|
|
onChange={(e) => setRememberPassphrase(e.target.checked)}
|
|
disabled={isSubmitting}
|
|
className="accent-primary"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t("passphrase.remember")}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between pt-2">
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={handleCancel}
|
|
disabled={isSubmitting}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
{onSkip && (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={handleSkip}
|
|
disabled={isSubmitting}
|
|
>
|
|
{t("passphrase.skip")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
{t("passphrase.unlocking")}
|
|
</>
|
|
) : (
|
|
t("passphrase.unlock")
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default PassphraseModal;
|