359 lines
13 KiB
TypeScript
359 lines
13 KiB
TypeScript
import { Copy, Minus, Square, Unplug, X } from 'lucide-react';
|
|
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { I18nProvider, useI18n } from '../application/i18n/I18nProvider';
|
|
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
|
|
import { useSettingsState } from '../application/state/useSettingsState';
|
|
import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWindow';
|
|
import { useVaultState } from '../application/state/useVaultState';
|
|
import { useWindowControls } from '../application/state/useWindowControls';
|
|
import { shouldCloseTerminalPopupOnExit } from '../application/state/resolveTerminalSessionExitIntent';
|
|
import type { TerminalPopupPayload } from '../domain/systemManager/types';
|
|
import type { TerminalTheme } from '../domain/models';
|
|
import type { Host } from '../types';
|
|
import { cn } from '../lib/utils';
|
|
|
|
const Terminal = lazy(() => import('./Terminal'));
|
|
|
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
|
const POPUP_STARTUP_REVEAL_EXTRA_DELAY_MS = 900;
|
|
const POPUP_STARTUP_REVEAL_MIN_DELAY_MS = 1500;
|
|
const POPUP_STARTUP_REVEAL_MAX_DELAY_MS = 12000;
|
|
|
|
type PopupThemeVars = React.CSSProperties & Record<string, string>;
|
|
|
|
const buildPopupThemeVars = (theme: TerminalTheme): PopupThemeVars => {
|
|
const { colors } = theme;
|
|
return {
|
|
'--terminal-popup-bg': colors.background,
|
|
'--terminal-popup-fg': colors.foreground,
|
|
'--terminal-popup-muted': colors.foreground,
|
|
'--terminal-popup-accent': colors.cursor,
|
|
'--terminal-popup-control-hover': `color-mix(in srgb, ${colors.foreground} 10%, transparent)`,
|
|
};
|
|
};
|
|
|
|
function TerminalPopupWindowControls({ mac, onClose }: { mac: boolean; onClose: () => void }) {
|
|
const { minimize, maximize, isMaximized: fetchIsMaximized } = useWindowControls();
|
|
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void fetchIsMaximized().then((value) => {
|
|
if (!cancelled) setIsWindowMaximized(!!value);
|
|
});
|
|
const handleResize = () => {
|
|
void fetchIsMaximized().then((value) => setIsWindowMaximized(!!value));
|
|
};
|
|
window.addEventListener('resize', handleResize);
|
|
return () => {
|
|
cancelled = true;
|
|
window.removeEventListener('resize', handleResize);
|
|
};
|
|
}, [fetchIsMaximized]);
|
|
|
|
const handleMaximize = async () => {
|
|
const value = await maximize();
|
|
setIsWindowMaximized(!!value);
|
|
};
|
|
|
|
if (mac) return null;
|
|
|
|
const buttonClass =
|
|
'app-no-drag flex h-10 w-11 items-center justify-center text-[color:var(--terminal-popup-muted)] transition-colors hover:bg-[color:var(--terminal-popup-control-hover)] hover:text-[color:var(--terminal-popup-fg)]';
|
|
|
|
return (
|
|
<div className="app-no-drag ml-auto flex h-10 shrink-0 items-center">
|
|
<button type="button" onClick={() => void minimize()} className={buttonClass} aria-label="Minimize">
|
|
<Minus size={15} />
|
|
</button>
|
|
<button type="button" onClick={() => void handleMaximize()} className={buttonClass} aria-label={isWindowMaximized ? 'Restore' : 'Maximize'}>
|
|
{isWindowMaximized ? <Copy size={14} /> : <Square size={13} />}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="app-no-drag flex h-10 w-11 items-center justify-center text-[color:var(--terminal-popup-fg)] opacity-80 transition-colors hover:bg-[color:var(--terminal-popup-control-hover)] hover:opacity-100"
|
|
aria-label="Close"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TerminalPopupSpinner() {
|
|
return (
|
|
<div className="h-full flex-1 flex items-center justify-center bg-[color:var(--terminal-popup-bg)] text-[color:var(--terminal-popup-fg)]">
|
|
<svg
|
|
width="28"
|
|
height="28"
|
|
viewBox="0 0 28 28"
|
|
aria-label="Loading"
|
|
className="opacity-80"
|
|
>
|
|
<circle
|
|
cx="14"
|
|
cy="14"
|
|
r="11"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
opacity="0.18"
|
|
/>
|
|
<path
|
|
d="M25 14a11 11 0 0 0-11-11"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeWidth="2"
|
|
>
|
|
<animateTransform
|
|
attributeName="transform"
|
|
dur="0.75s"
|
|
from="0 14 14"
|
|
repeatCount="indefinite"
|
|
to="360 14 14"
|
|
type="rotate"
|
|
/>
|
|
</path>
|
|
</svg>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TerminalPopupBlank() {
|
|
return (
|
|
<div className="h-full flex-1 bg-[color:var(--terminal-popup-bg)]" />
|
|
);
|
|
}
|
|
|
|
function TerminalPopupStartupError({
|
|
message,
|
|
closeLabel,
|
|
onClose,
|
|
}: {
|
|
message: string;
|
|
closeLabel: string;
|
|
onClose: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex-1 flex flex-col items-center justify-center bg-[color:var(--terminal-popup-bg)] px-6 text-center text-[color:var(--terminal-popup-fg)]">
|
|
<Unplug size={24} className="mb-3 opacity-45" />
|
|
<div className="max-w-[300px] text-xs leading-5 opacity-70">{message}</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="app-no-drag mt-4 h-7 rounded px-3 text-[11px] opacity-70 transition-colors hover:bg-[color:var(--terminal-popup-control-hover)] hover:opacity-100"
|
|
>
|
|
{closeLabel}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TerminalPopupTitleIcon({ icon }: { icon: TerminalPopupPayload['icon'] }) {
|
|
if (!icon) return null;
|
|
if (icon.kind !== 'image' || !icon.src) return null;
|
|
return (
|
|
<span
|
|
className="pointer-events-none ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-[3px]"
|
|
style={{
|
|
backgroundColor: icon.backgroundColor ?? 'transparent',
|
|
}}
|
|
>
|
|
<img
|
|
src={icon.src}
|
|
alt={icon.alt ?? ''}
|
|
width={11}
|
|
height={11}
|
|
className="max-h-[11px] max-w-[11px] rounded-[2px] object-contain"
|
|
draggable={false}
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Fallback when the parent session's host is no longer in the vault (e.g. quick connect). */
|
|
function buildHostFromSession(source: TerminalPopupPayload['sourceSession']): Host {
|
|
return {
|
|
id: source.hostId,
|
|
label: source.hostLabel,
|
|
hostname: source.hostname,
|
|
username: source.username,
|
|
port: source.port ?? (source.protocol === 'local' ? undefined : 22),
|
|
protocol: source.protocol === 'local' ? 'local' : 'ssh',
|
|
tags: [],
|
|
os: 'linux',
|
|
moshEnabled: source.moshEnabled,
|
|
etEnabled: source.etEnabled,
|
|
charset: source.charset,
|
|
};
|
|
}
|
|
|
|
function TerminalPopupPageInner() {
|
|
const { t } = useI18n();
|
|
const { close, setWindowTitle, onPopupConfig } = useTerminalPopupWindow();
|
|
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
|
|
const settings = useSettingsState();
|
|
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages } = useVaultState();
|
|
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
|
|
const [terminalReady, setTerminalReady] = useState(false);
|
|
const [startupError, setStartupError] = useState<string | null>(null);
|
|
const sessionId = useMemo(() => crypto.randomUUID(), []);
|
|
const popupThemeVars = useMemo(
|
|
() => buildPopupThemeVars(settings.currentTerminalTheme),
|
|
[settings.currentTerminalTheme],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = onPopupConfig((payload) => {
|
|
setConfig(payload);
|
|
if (payload.title) {
|
|
void setWindowTitle(payload.title);
|
|
}
|
|
});
|
|
// Main delivers the popup payload as soon as the renderer reports ready
|
|
// (and destroys the window if it never does) — so report ready only after
|
|
// the config listener above is registered.
|
|
notifyRendererReady();
|
|
return unsubscribe;
|
|
}, [notifyRendererReady, onPopupConfig, setWindowTitle]);
|
|
|
|
useEffect(() => {
|
|
return onWindowCommandCloseRequested(() => {
|
|
void close();
|
|
});
|
|
}, [close, onWindowCommandCloseRequested]);
|
|
|
|
const host = useMemo(() => {
|
|
if (!config) return null;
|
|
const vaultHost = hosts.find((h) => h.id === config.sourceSession.hostId);
|
|
return vaultHost ?? buildHostFromSession(config.sourceSession);
|
|
}, [config, hosts]);
|
|
|
|
const reuseId = useMemo(() => {
|
|
if (!config) return undefined;
|
|
return canReuseTerminalConnection(config.sourceSession)
|
|
? config.parentSessionId
|
|
: undefined;
|
|
}, [config]);
|
|
|
|
const ready = Boolean(config && host && vaultInitialized);
|
|
const startupRevealDelayMs = useMemo(() => {
|
|
if (!config?.startupCommand) return 0;
|
|
const configuredDelay = settings.terminalSettings?.startupCommandDelayMs;
|
|
const startupDelay = typeof configuredDelay === 'number' && Number.isFinite(configuredDelay)
|
|
? Math.max(0, configuredDelay)
|
|
: 600;
|
|
return Math.min(
|
|
POPUP_STARTUP_REVEAL_MAX_DELAY_MS,
|
|
Math.max(POPUP_STARTUP_REVEAL_MIN_DELAY_MS, startupDelay + POPUP_STARTUP_REVEAL_EXTRA_DELAY_MS),
|
|
);
|
|
}, [config?.startupCommand, settings.terminalSettings?.startupCommandDelayMs]);
|
|
const revealTerminal = useCallback(() => {
|
|
setTerminalReady(true);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setTerminalReady(false);
|
|
setStartupError(null);
|
|
}, [config?.popupId, sessionId]);
|
|
|
|
useEffect(() => {
|
|
if (!ready) return undefined;
|
|
const timeout = window.setTimeout(() => setTerminalReady(true), startupRevealDelayMs);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [config?.popupId, ready, startupRevealDelayMs]);
|
|
|
|
return (
|
|
<div
|
|
className="h-screen flex flex-col overflow-hidden bg-[color:var(--terminal-popup-bg)] text-[color:var(--terminal-popup-fg)]"
|
|
data-section="terminal-popup"
|
|
style={popupThemeVars}
|
|
>
|
|
<div
|
|
className="app-drag relative shrink-0 h-9 flex items-center bg-[color:var(--terminal-popup-bg)]"
|
|
data-section="terminal-popup-titlebar"
|
|
>
|
|
{isMac && <div className="h-9 w-[92px] shrink-0" />}
|
|
<TerminalPopupTitleIcon icon={config?.icon} />
|
|
<div className={cn(
|
|
'min-w-0 flex-1 pr-3 text-left text-[12px] font-medium text-[color:var(--terminal-popup-fg)] opacity-70',
|
|
config?.icon ? 'pl-1.5' : 'pl-3',
|
|
!isMac && 'pl-4 text-left',
|
|
)}>
|
|
<div className="max-w-full truncate">
|
|
{config?.title ?? ''}
|
|
</div>
|
|
</div>
|
|
{!isMac && <TerminalPopupWindowControls mac={false} onClose={() => void close()} />}
|
|
</div>
|
|
{!ready || !config || !host ? (
|
|
<TerminalPopupSpinner />
|
|
) : startupError ? (
|
|
<TerminalPopupStartupError
|
|
message={startupError}
|
|
closeLabel={t('common.close')}
|
|
onClose={() => void close()}
|
|
/>
|
|
) : (
|
|
<div className="relative flex-1 min-h-0 flex flex-col bg-[color:var(--terminal-popup-bg)]">
|
|
<Suspense fallback={<TerminalPopupBlank />}>
|
|
<Terminal
|
|
host={host}
|
|
keys={keys}
|
|
identities={identities}
|
|
snippets={snippets}
|
|
snippetPackages={snippetPackages}
|
|
compactToolbar
|
|
knownHosts={knownHosts}
|
|
isVisible
|
|
isFocused
|
|
fontFamilyId={settings.terminalFontFamilyId}
|
|
fontSize={settings.terminalFontSize}
|
|
terminalTheme={settings.currentTerminalTheme}
|
|
followAppTerminalTheme={settings.followAppTerminalTheme}
|
|
accentMode={settings.accentMode}
|
|
customAccent={settings.customAccent}
|
|
terminalSettings={settings.terminalSettings}
|
|
sessionId={sessionId}
|
|
startupCommand={config.startupCommand}
|
|
reuseConnectionFromSessionId={reuseId}
|
|
onCloseSession={() => {
|
|
void close();
|
|
}}
|
|
onSessionExit={(_closedSessionId, evt) => {
|
|
if (shouldCloseTerminalPopupOnExit(evt)) {
|
|
void close();
|
|
return;
|
|
}
|
|
if (!terminalReady && config.startupCommand) {
|
|
setStartupError(t('systemManager.popup.startupFailed'));
|
|
}
|
|
}}
|
|
onStatusChange={(_changedSessionId, status) => {
|
|
if (!config.startupCommand && status === 'connected') revealTerminal();
|
|
}}
|
|
onTerminalDataCapture={revealTerminal}
|
|
/>
|
|
</Suspense>
|
|
{!terminalReady && (
|
|
<div className="pointer-events-none absolute inset-0 z-10">
|
|
<TerminalPopupSpinner />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TerminalPopupPage() {
|
|
const settings = useSettingsState();
|
|
return (
|
|
<I18nProvider locale={settings.uiLanguage}>
|
|
<TerminalPopupPageInner />
|
|
</I18nProvider>
|
|
);
|
|
}
|