Files
Netcatty/components/PassphraseModal.tsx
T
陈大猫 205d1591e8 feat(terminal): add disconnect/reconnect on workspace split status bar (#2769) (#2771)
* 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>
2026-08-06 15:47:20 +08:00

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;