fix terminal popup release behavior (#1403)

This commit is contained in:
陈大猫
2026-06-11 14:48:52 +08:00
committed by GitHub
parent afefbd953f
commit 9013a7e312
29 changed files with 705 additions and 84 deletions

View File

@@ -10,6 +10,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': 'Opening terminal…',
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
'systemManager.errors.loadProcesses': 'Failed to load processes',
'systemManager.errors.loadTmux': 'Failed to load tmux sessions',
@@ -20,6 +21,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.processes.search': 'Search processes…',
'systemManager.processes.command': 'Command',

View File

@@ -10,6 +10,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': 'Открытие терминала…',
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
'systemManager.errors.loadProcesses': 'Не удалось загрузить процессы',
'systemManager.errors.loadTmux': 'Не удалось загрузить сессии tmux',
@@ -20,6 +21,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.processes.search': 'Поиск процессов…',
'systemManager.processes.command': 'Команда',

View File

@@ -10,6 +10,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': '正在打开终端…',
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
'systemManager.errors.loadProcesses': '加载进程列表失败',
'systemManager.errors.loadTmux': '加载 tmux 会话失败',
@@ -20,6 +21,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.processes.search': '搜索进程…',
'systemManager.processes.command': '命令',

View File

@@ -1,7 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
import {
resolveTerminalSessionExitIntent,
shouldCloseTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
@@ -30,3 +33,10 @@ test("backend closed events keep the tab and mark it disconnected", () => {
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
});

View File

@@ -20,3 +20,7 @@ export function resolveTerminalSessionExitIntent(
// so the user can inspect output and reconnect.
return { kind: "markDisconnected" };
}
export function shouldCloseTerminalPopupOnExit(evt: TerminalSessionExitEvent): boolean {
return evt.reason === "exited" && evt.exitCode === 0;
}

View File

@@ -0,0 +1,16 @@
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export async function writeSystemManagerDiagnostic(
message: string,
extra?: Record<string, unknown>,
) {
try {
await netcattyBridge.get()?.logDiagnostic?.({
source: 'system-manager',
message,
extra,
});
} catch {
// Diagnostics must never block the user action being diagnosed.
}
}

View File

@@ -1,17 +1,177 @@
import { X } from 'lucide-react';
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
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 {
@@ -37,7 +197,13 @@ function TerminalPopupPageInner() {
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) => {
@@ -73,37 +239,66 @@ function TerminalPopupPageInner() {
}, [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 bg-background text-foreground" data-section="terminal-popup">
<div
className="app-drag shrink-0 h-9 flex items-center border-b border-border/50"
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"
>
<div className={isMac ? 'w-[76px] shrink-0' : 'w-3 shrink-0'} />
<div className="flex-1 min-w-0 text-center text-xs text-muted-foreground truncate px-2">
{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>
{isMac ? (
<div className="w-[76px] shrink-0" />
) : (
<button
type="button"
onClick={() => void close()}
className="app-no-drag shrink-0 w-9 h-9 flex items-center justify-center hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
aria-label={t('common.close')}
>
<X size={14} />
</button>
)}
</div>
{!isMac && <TerminalPopupWindowControls mac={false} onClose={() => void close()} />}
</div>
{!ready || !config || !host ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
{t('systemManager.popup.loading')}
</div>
<TerminalPopupSpinner />
) : startupError ? (
<TerminalPopupStartupError
message={startupError}
closeLabel={t('common.close')}
onClose={() => void close()}
/>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-sm opacity-70"></div>}>
<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}
@@ -127,12 +322,26 @@ function TerminalPopupPageInner() {
onCloseSession={() => {
void close();
}}
onSessionExit={() => {
onSessionExit={(_closedSessionId, evt) => {
if (shouldCloseTerminalPopupOnExit(evt)) {
void close();
return;
}
if (!terminalReady && config.startupCommand) {
setStartupError(t('systemManager.popup.startupFailed'));
}
}}
onStatusChange={() => {}}
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>

View File

@@ -2,8 +2,9 @@ import { Box, FileText, Play, RotateCcw, Square, Terminal } from 'lucide-react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { writeSystemManagerDiagnostic } from '../../application/state/systemManagerDiagnostics';
import type { TerminalSession } from '../../types';
import type { DockerContainerAction, DockerContainerInfo } from '../../domain/systemManager/types';
import type { DockerContainerAction, DockerContainerInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
@@ -31,6 +32,23 @@ import { showSystemManagerError } from './systemManagerToast';
type Backend = ReturnType<typeof useSystemManagerBackend>;
type ContainerFilter = 'all' | 'running' | 'stopped' | 'paused';
async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon> {
const {
dockerIconTileStyle,
resolveDockerIconPresentation,
resolveDockerImageIcon,
} = await import('../../domain/systemManager/dockerImageIcons');
const iconId = resolveDockerImageIcon(image);
const presentation = resolveDockerIconPresentation(iconId);
const tile = dockerIconTileStyle(presentation.displayIconId);
return {
kind: 'image',
src: presentation.iconUrl,
backgroundColor: tile.background,
alt: '',
};
}
interface DockerContainersPanelProps {
sessionId: string;
parentSession: TerminalSession;
@@ -247,29 +265,57 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
const openShell = useCallback(async (container: DockerContainerInfo) => {
const id = container.id.slice(0, 12);
await writeSystemManagerDiagnostic('docker open shell clicked', {
sessionId,
containerId: id,
containerName: container.name,
image: container.image,
state: container.state,
});
const result = await openInteractiveTerminal(
backend,
parentSession,
`docker: ${container.name || id}`,
buildDockerExecShellCommand(id),
{ icon: await buildContainerPopupIcon(container.image) },
);
if (!result.success) {
await writeSystemManagerDiagnostic('docker open shell failed', {
sessionId,
containerId: id,
containerName: container.name,
error: result.error,
});
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
}
}, [backend, parentSession, t]);
}, [backend, parentSession, sessionId, t]);
const openLogs = useCallback(async (container: DockerContainerInfo) => {
const id = container.id.slice(0, 12);
await writeSystemManagerDiagnostic('docker open logs clicked', {
sessionId,
containerId: id,
containerName: container.name,
image: container.image,
state: container.state,
});
const result = await openInteractiveTerminal(
backend,
parentSession,
`logs: ${container.name || id}`,
buildDockerLogsCommand(id),
{ icon: await buildContainerPopupIcon(container.image) },
);
if (!result.success) {
await writeSystemManagerDiagnostic('docker open logs failed', {
sessionId,
containerId: id,
containerName: container.name,
error: result.error,
});
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
}
}, [backend, parentSession, t]);
}, [backend, parentSession, sessionId, t]);
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
@@ -306,7 +352,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
<SystemPanelList>
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} />
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.empty')} />

View File

@@ -241,7 +241,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
<SystemPanelList>
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} />
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={Layers} message={t('systemManager.docker.imagesEmpty')} />

View File

@@ -312,7 +312,7 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({
<SystemPanelList>
{error && (
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} />
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{!error && displayList.length === 0 && !loading && (
<SystemPanelEmpty icon={LayoutList} message={t('systemManager.empty')} />

View File

@@ -1,9 +1,35 @@
import { Loader2, RefreshCw, Search } from 'lucide-react';
import { Loader2, RefreshCw, Search, Unplug } from 'lucide-react';
import React, { memo, useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
import { Input } from '../ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
function splitPanelMessage(message: string): string[] {
return message.match(/[^。.!?]+[。.!?]?/g)?.map((line) => line.trim()).filter(Boolean) ?? [message];
}
function SystemPanelMessage({
message,
className,
}: {
message: string;
className?: string;
}) {
const lines = splitPanelMessage(message);
if (lines.length <= 1) {
return <span className={className}>{message}</span>;
}
return (
<span className={className}>
{lines.map((line, index) => (
<span key={`${line}-${index}`} className="block">
{line}
</span>
))}
</span>
);
}
export const SystemPanelShell = memo(function SystemPanelShell({
children,
section,
@@ -172,7 +198,7 @@ export const SystemPanelEmpty = memo(function SystemPanelEmpty({
return (
<div className="flex flex-col items-center justify-center py-10 px-4 text-muted-foreground text-center">
<Icon size={24} className="opacity-40 mb-2" />
<span className="text-xs">{message}</span>
<SystemPanelMessage message={message} className="max-w-[260px] text-xs leading-5" />
</div>
);
});
@@ -181,16 +207,25 @@ export const SystemPanelError = memo(function SystemPanelError({
message,
onRetry,
retryLabel,
loading,
}: {
message: string;
onRetry?: () => void;
retryLabel?: string;
loading?: boolean;
}) {
return (
<div className="px-3 py-3 text-xs text-center">
<div className="text-destructive mb-2">{message}</div>
<div className="flex h-full min-h-[180px] flex-col items-center justify-center px-6 py-10 text-center text-muted-foreground">
<Unplug size={24} className="mb-2 opacity-40" />
<SystemPanelMessage message={message} className="max-w-[260px] break-words text-xs leading-5" />
{onRetry && retryLabel && (
<button type="button" onClick={onRetry} className="text-primary hover:underline">
<button
type="button"
onClick={onRetry}
disabled={loading}
className="mt-3 inline-flex h-7 items-center gap-1.5 rounded px-2 text-[11px] text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
>
<RefreshCw size={12} className={cn(loading && 'animate-spin')} />
{retryLabel}
</button>
)}
@@ -204,8 +239,9 @@ export const SystemPanelInlineError = memo(function SystemPanelInlineError({
message: string;
}) {
return (
<div className="shrink-0 px-3 py-2 text-[11px] text-destructive border-b border-border/30 bg-destructive/5">
{message}
<div className="shrink-0 flex items-center gap-2 px-3 py-2 text-[11px] text-muted-foreground border-b border-border/30 bg-muted/20">
<Unplug size={12} className="shrink-0 opacity-60" />
<span className="min-w-0 truncate">{message}</span>
</div>
);
});

View File

@@ -7,8 +7,8 @@ import type { TmuxSessionInfo } from '../../domain/systemManager/types';
import { tmuxSessionInfoEqual } from '../../domain/systemManager/pollEquals';
import {
SystemPanelEmpty,
SystemPanelError,
SystemPanelIconButton,
SystemPanelInlineError,
SystemPanelList,
SystemPanelMetaBar,
SystemPanelRefreshButton,
@@ -139,18 +139,12 @@ export const TmuxManagerTab = memo(function TmuxManagerTab({
{t('systemManager.tmux.meta', { count: displaySessions.length })}
</SystemPanelMetaBar>
{error && <SystemPanelInlineError message={error} />}
<SystemPanelList>
{!error && displaySessions.length === 0 && !loading && (
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.empty')} />
)}
{error && (
<div className="px-3 pb-3 text-center">
<button type="button" className="text-xs text-primary hover:underline" onClick={() => void refresh()}>
{t('history.action.retry')}
</button>
</div>
<SystemPanelError message={error} onRetry={() => void refresh()} retryLabel={t('history.action.retry')} loading={loading} />
)}
{displaySessions.map((session) => (
<TmuxSessionCard

View File

@@ -23,8 +23,14 @@ import {
} from './SystemPanelUi';
import { SystemPanelPromptDialog } from './SystemPanelPromptDialog';
import { openInteractiveTerminal } from './openInteractiveTerminal';
import { showSystemManagerError } from './systemManagerToast';
type Backend = ReturnType<typeof useSystemManagerBackend>;
const TMUX_POPUP_ICON = {
kind: 'image',
src: '/system-icons/tmux.svg',
alt: 'tmux',
} as const;
type RenamePromptTarget =
| { kind: 'session' }
@@ -145,13 +151,19 @@ export const TmuxSessionCard = memo(function TmuxSessionCard({
&& pending.action === action
&& pending.windowIndex === windowIndex;
const handleAttach = (windowIndex?: number) => {
void openInteractiveTerminal(
const handleAttach = async (windowIndex?: number) => {
const result = await openInteractiveTerminal(
backend,
parentSession,
windowIndex !== undefined ? `tmux: ${session.name}:${windowIndex}` : `tmux: ${session.name}`,
buildTmuxAttachCommand(session.name, windowIndex),
{ icon: TMUX_POPUP_ICON },
);
if (!result.success) {
const message = result.error || t('systemManager.errors.actionFailed');
setActionError(message);
showSystemManagerError(message, t('common.error'));
}
};
return (

View File

@@ -8,6 +8,19 @@ import { nextPollData } from '../listStable';
type Backend = ReturnType<typeof useSystemManagerBackend>;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function normalizePollingErrorMessage(error: unknown, t: I18nContextValue['t']): string {
const message = error instanceof Error ? error.message : String(error || 'Unknown error');
const lower = message.toLowerCase();
if (lower.includes('channel open failure') || lower.includes('unable to exec')) {
return t('systemManager.errors.sshChannelUnavailable');
}
return message;
}
/** Stable i18n ref so polling fetchers do not reset when locale re-renders. */
export function useStableTranslate(): I18nContextValue['t'] {
const { t } = useI18n();
@@ -101,6 +114,7 @@ export function usePolling<T>(
enabled: boolean,
merge?: (prev: T | null, next: T) => T,
) {
const stableT = useStableTranslate();
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -126,10 +140,11 @@ export function usePolling<T>(
return intervalMs;
}, [intervalMs]);
const run = useCallback(async (options?: { withLoading?: boolean }) => {
const run = useCallback(async (options?: { withLoading?: boolean; minLoadingMs?: number }) => {
if (!enabled || inflightRef.current) return;
inflightRef.current = true;
const showLoading = options?.withLoading ?? !hasDataRef.current;
const startedAt = Date.now();
if (showLoading) setLoading(true);
try {
const result = await fetcherRef.current();
@@ -145,12 +160,18 @@ export function usePolling<T>(
}
} catch (err) {
failuresRef.current += 1;
setError(err instanceof Error ? err.message : 'Unknown error');
setData(null);
hasDataRef.current = false;
setError(normalizePollingErrorMessage(err, stableT));
} finally {
inflightRef.current = false;
if (showLoading) setLoading(false);
if (showLoading) {
const remaining = Math.max(0, (options?.minLoadingMs ?? 0) - (Date.now() - startedAt));
if (remaining > 0) await delay(remaining);
setLoading(false);
}
}, [enabled]);
}
}, [enabled, stableT]);
const scheduleNextPoll = useCallback(() => {
clearPollTimer();
@@ -181,7 +202,7 @@ export function usePolling<T>(
const refresh = useCallback(async () => {
failuresRef.current = 0;
await run({ withLoading: true });
await run({ withLoading: true, minLoadingMs: 450 });
}, [run]);
return { data, error, loading, refresh };

View File

@@ -1,26 +1,55 @@
import { canReuseTerminalConnection } from '../../application/state/terminalConnectionReuse';
import { writeSystemManagerDiagnostic } from '../../application/state/systemManagerDiagnostics';
import type { TerminalSession } from '../../types';
import type { TerminalPopupIcon } from '../../domain/systemManager/types';
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
type Backend = ReturnType<typeof useSystemManagerBackend>;
function buildPopupTitle(parentSession: TerminalSession, title: string): string {
const hostLabel = parentSession.hostLabel.trim();
const cleanTitle = title.trim();
if (!hostLabel || !cleanTitle) return cleanTitle || hostLabel;
if (cleanTitle === hostLabel || cleanTitle.startsWith(`${hostLabel} · `)) return cleanTitle;
return `${hostLabel} · ${cleanTitle}`;
}
export async function openInteractiveTerminal(
backend: Backend,
parentSession: TerminalSession,
title: string,
startupCommand: string,
options?: { icon?: TerminalPopupIcon },
): Promise<{ success: boolean; error?: string }> {
const canReuseConnection = canReuseTerminalConnection(parentSession);
const popupTitle = buildPopupTitle(parentSession, title);
await writeSystemManagerDiagnostic('openInteractiveTerminal requested', {
title: popupTitle,
parentSessionId: parentSession.id,
parentProtocol: parentSession.protocol,
parentHostLabel: parentSession.hostLabel,
startupCommand,
canReuseConnection,
hasIcon: !!options?.icon,
});
const result = await backend.openTerminalPopup({
title,
title: popupTitle,
icon: options?.icon,
parentSessionId: parentSession.id,
startupCommand,
sourceSession: {
...parentSession,
startupCommand,
reuseConnectionFromSessionId: canReuseTerminalConnection(parentSession)
reuseConnectionFromSessionId: canReuseConnection
? parentSession.id
: undefined,
},
});
await writeSystemManagerDiagnostic('openInteractiveTerminal result', {
title: popupTitle,
success: result.success,
error: result.error,
popupId: result.popupId,
});
return result;
}

View File

@@ -559,6 +559,58 @@ test("local session captures paste cleanup writes in terminal log data", async (
assert.deepEqual(capturedLogData, ["line 3 with enough content", "\x1b[K"]);
});
test("local session runs startup command after attaching", async () => {
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
const attached: string[] = [];
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sessionWrites.push({ id, data, automated: options?.automated });
},
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "local-host",
label: "Local",
hostname: "local",
username: "",
protocol: "local",
},
terminalSettings: { startupCommandDelayMs: 0 },
terminalBackend,
startupCommand: "docker logs -f --tail 200 abc123",
promptLineBreakStateRef: undefined,
onSessionAttached: (id: string) => attached.push(id),
});
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(attached, ["local-session"]);
assert.deepEqual(sessionWrites, [{
id: "local-session",
data: "docker logs -f --tail 200 abc123\r",
automated: true,
}]);
});
test("local session resets terminal timestamp state when reusing a terminal", async () => {
const writes: string[] = [];
let onData: ((data: string) => void) | null = null;

View File

@@ -1124,6 +1124,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.onSessionExit?.(ctx.sessionId, evt);
});
ctx.onSessionAttached?.(id);
scheduleStartupCommand(ctx, term, id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);

View File

@@ -3,15 +3,17 @@ export function sanitizeDockerContainerId(id: string): string {
return String(id || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 64);
}
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
/** Interactive shell into a container — prefer bash, fall back to sh. */
export function buildDockerExecShellCommand(containerId: string): string {
const safeId = sanitizeDockerContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return `docker exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
return `${CLEAR_STARTUP_OUTPUT} exec docker exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
}
export function buildDockerLogsCommand(containerId: string): string {
const safeId = sanitizeDockerContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return `docker logs -f --tail 200 ${safeId}`;
return `${CLEAR_STARTUP_OUTPUT} exec docker logs -f --tail 200 ${safeId}`;
}

View File

@@ -3,9 +3,11 @@ export function shQuote(str: string): string {
return `'${String(str).replace(/'/g, "'\"'\"'")}'`;
}
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
export function buildTmuxAttachCommand(sessionName: string, windowIndex?: number): string {
const target = windowIndex !== undefined
? `${shQuote(sessionName)}:${windowIndex}`
: shQuote(sessionName);
return `tmux attach -t ${target}`;
return `${CLEAR_STARTUP_OUTPUT} exec tmux attach -t ${target}`;
}

View File

@@ -121,9 +121,17 @@ export type DockerImageManageAction =
export type SystemManagerSubTab = 'processes' | 'tmux' | 'docker';
export interface TerminalPopupIcon {
kind: 'image';
src: string;
backgroundColor?: string;
alt?: string;
}
export interface TerminalPopupPayload {
popupId?: string;
title: string;
icon?: TerminalPopupIcon;
parentSessionId: string;
sourceSession: import('../../types').TerminalSession;
startupCommand: string;

View File

@@ -128,6 +128,10 @@ function captureError(source, err, extra) {
}
}
function captureDiagnostic(source, message, extra) {
captureError(source, new Error(String(message || "diagnostic")), extra);
}
/**
* Delete log files older than LOG_RETENTION_DAYS.
*/
@@ -317,10 +321,21 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:crashLogs:read", async (_event, { fileName }) => readLog(fileName));
ipcMain.handle("netcatty:crashLogs:clear", async () => clearLogs());
ipcMain.handle("netcatty:crashLogs:openDir", async () => openDir());
ipcMain.handle("netcatty:diagnostics:log", async (_event, payload) => {
const source = typeof payload?.source === "string" && payload.source.trim()
? payload.source.trim()
: "renderer-diagnostic";
const message = typeof payload?.message === "string" && payload.message.trim()
? payload.message.trim()
: "diagnostic";
captureDiagnostic(source, message, payload?.extra);
return { success: true };
});
}
module.exports = {
init,
captureError,
captureDiagnostic,
registerHandlers,
};

View File

@@ -1,5 +1,7 @@
/* eslint-disable no-undef */
const crashLogBridge = require("../crashLogBridge.cjs");
function createTerminalPopupWindowApi(ctx) {
with (ctx) {
const terminalPopupWindows = new Map();
@@ -29,6 +31,14 @@ function createTerminalPopupWindowApi(ctx) {
? payload.title.trim()
: "Terminal";
crashLogBridge.captureDiagnostic("terminal-popup", "creating popup window", {
popupId: payload?.popupId,
title,
isDev,
popupX,
popupY,
});
const win = new BrowserWindow({
title,
width: popupWidth,
@@ -39,9 +49,8 @@ function createTerminalPopupWindowApi(ctx) {
backgroundColor,
icon: appIcon,
show: false,
frame: isMac,
titleBarStyle: isMac ? "hiddenInset" : undefined,
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
frame: false,
...(isMac ? { trafficLightPosition: { x: 12, y: 12 } } : {}),
webPreferences: {
preload,
contextIsolation: true,
@@ -53,6 +62,11 @@ function createTerminalPopupWindowApi(ctx) {
const popupId = String(payload?.popupId || Date.now());
terminalPopupWindows.set(popupId, win);
crashLogBridge.captureDiagnostic("terminal-popup", "popup BrowserWindow created", {
popupId,
title,
webContentsId: win.webContents?.id,
});
try {
win.webContents?.setWindowOpenHandler?.(
@@ -66,6 +80,30 @@ function createTerminalPopupWindowApi(ctx) {
terminalPopupWindows.delete(popupId);
});
try {
win.webContents?.on?.("did-fail-load", (_event, errorCode, errorDescription, validatedURL) => {
console.warn("[TerminalPopup] Failed to load renderer", {
popupId,
errorCode,
errorDescription,
validatedURL,
});
});
win.webContents?.on?.("render-process-gone", (_event, details) => {
console.warn("[TerminalPopup] Renderer process gone", { popupId, details });
});
win.webContents?.on?.("console-message", (_event, level, message, line, sourceId) => {
crashLogBridge.captureDiagnostic("terminal-popup-console", message, {
popupId,
level,
line,
sourceId,
});
});
} catch {
// ignore diagnostics wiring failures
}
win.on("page-title-updated", (e) => { e.preventDefault(); });
try {
@@ -76,34 +114,56 @@ function createTerminalPopupWindowApi(ctx) {
applyWindowOpacityToWindow(win);
const popupPath = "/#/terminal-popup";
if (isMac) {
try {
win.setWindowButtonVisibility(true);
} catch {
// ignore
}
try {
win.setWindowButtonPosition({ x: 12, y: 12 });
} catch {
// ignore
}
}
const popupPath = "#/terminal-popup";
if (isDev) {
try {
const baseUrl = getDevRendererBaseUrl(devServerUrl);
crashLogBridge.captureDiagnostic("terminal-popup", "loading dev popup URL", {
popupId,
url: `${baseUrl}${popupPath}`,
});
await win.loadURL(`${baseUrl}${popupPath}`);
} catch (e) {
console.warn("[TerminalPopup] Dev server not reachable", e);
crashLogBridge.captureError("terminal-popup", e, {
popupId,
step: "load dev popup URL",
});
await win.loadURL(`app://netcatty/index.html${popupPath}`);
}
} else {
crashLogBridge.captureDiagnostic("terminal-popup", "loading packaged popup URL", {
popupId,
url: `app://netcatty/index.html${popupPath}`,
});
await win.loadURL(`app://netcatty/index.html${popupPath}`);
}
const delivery = await sendWhenRendererReady(
win,
"netcatty:window:terminalPopupConfig",
{ ...payload, popupId },
{ timeoutMs: 10000 },
);
if (!delivery.success) {
try { win.destroy(); } catch { /* ignore */ }
terminalPopupWindows.delete(popupId);
return { success: false, error: delivery.error || "Popup failed to receive config" };
}
win.webContents.send("netcatty:window:terminalPopupConfig", { ...payload, popupId });
crashLogBridge.captureDiagnostic("terminal-popup", "popup config delivered", {
popupId,
title,
});
showAndFocusWindow(win);
crashLogBridge.captureDiagnostic("terminal-popup", "popup window shown", {
popupId,
title,
visible: typeof win.isVisible === "function" ? win.isVisible() : undefined,
});
return { success: true, popupId };
}

View File

@@ -360,8 +360,16 @@ function createBridgeRegistrar(context) {
if (!payload || typeof payload !== "object") {
return { success: false, error: "Invalid popup payload" };
}
crashLogBridge.captureDiagnostic("terminal-popup", "openTerminalPopup IPC received", {
title: payload.title,
parentSessionId: payload.parentSessionId,
startupCommand: payload.startupCommand,
sourceSessionId: payload.sourceSession?.id,
sourceProtocol: payload.sourceSession?.protocol,
sourceHostLabel: payload.sourceSession?.hostLabel,
});
const sourceWindow = BrowserWindow.fromWebContents(event.sender);
return await getWindowManager().openTerminalPopupWindow(electronModule, {
const result = await getWindowManager().openTerminalPopupWindow(electronModule, {
preload,
devServerUrl: effectiveDevServerUrl,
isDev,
@@ -370,7 +378,18 @@ function createBridgeRegistrar(context) {
electronDir,
sourceWindow,
}, payload);
crashLogBridge.captureDiagnostic("terminal-popup", "openTerminalPopup IPC result", {
title: payload.title,
success: result?.success,
error: result?.error,
popupId: result?.popupId,
});
return result;
} catch (err) {
crashLogBridge.captureError("terminal-popup", err, {
title: payload?.title,
parentSessionId: payload?.parentSessionId,
});
console.error("[Main] Failed to open terminal popup:", err);
return { success: false, error: err?.message || "Failed to open terminal popup" };
}

View File

@@ -31,6 +31,10 @@ const updateAvailableListeners = new Set();
const updateNotAvailableListeners = new Set();
const updateErrorListeners = new Set();
const updateNeedsSaveListeners = new Set();
const terminalPopupConfigState = {
pending: null,
listeners: new Set(),
};
function cleanupTransferListeners(transferId) {
transferProgressListeners.delete(transferId);
@@ -129,6 +133,20 @@ ipcRenderer.on("netcatty:zmodem:detect", (_event, payload) => {
if (!set) return;
set.forEach((cb) => { try { cb({ type: "detect", ...payload }); } catch {} });
});
ipcRenderer.on("netcatty:window:terminalPopupConfig", (_event, payload) => {
if (terminalPopupConfigState.listeners.size === 0) {
terminalPopupConfigState.pending = payload;
return;
}
terminalPopupConfigState.listeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("Terminal popup config callback failed", err);
}
});
});
ipcRenderer.on("netcatty:zmodem:progress", (_event, payload) => {
const set = zmodemListeners.get(payload.sessionId);
if (!set) return;
@@ -656,6 +674,7 @@ const api = createPreloadApi({
updateNotAvailableListeners,
updateErrorListeners,
updateNeedsSaveListeners,
terminalPopupConfigState,
uploadProgressListeners,
uploadCompleteListeners,
uploadErrorListeners,

View File

@@ -128,10 +128,23 @@ function createPreloadApi(ctx) {
openTerminalPopup: async (payload) => {
return ipcRenderer.invoke("netcatty:window:openTerminalPopup", payload);
},
logDiagnostic: async (payload) => {
return ipcRenderer.invoke("netcatty:diagnostics:log", payload);
},
onTerminalPopupConfig: (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on("netcatty:window:terminalPopupConfig", handler);
return () => ipcRenderer.removeListener("netcatty:window:terminalPopupConfig", handler);
terminalPopupConfigState.listeners.add(cb);
if (terminalPopupConfigState.pending) {
const pending = terminalPopupConfigState.pending;
terminalPopupConfigState.pending = null;
queueMicrotask(() => {
try {
cb(pending);
} catch (err) {
console.error("Terminal popup config callback failed", err);
}
});
}
return () => terminalPopupConfigState.listeners.delete(cb);
},
readRemoteHistory: async (sessionId, limit) => {
return ipcRenderer.invoke("netcatty:ssh:readRemoteHistory", { sessionId, limit });

View File

@@ -81,6 +81,36 @@ function SettingsWindowFallback() {
);
}
function TerminalPopupWindowFallback() {
return (
<div
style={{
width: '100vw',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0b1015',
color: '#d7e0ea',
}}
>
<svg width="28" height="28" viewBox="0 0 28 28" aria-label="Loading" style={{ opacity: 0.8 }}>
<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>
);
}
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
@@ -129,7 +159,7 @@ const renderApp = () => {
root.render(
<ToastProvider>
<TooltipProvider delayDuration={300}>
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading terminal</div>}>
<Suspense fallback={<TerminalPopupWindowFallback />}>
<LazyTerminalPopupPage />
</Suspense>
</TooltipProvider>

View File

@@ -12,7 +12,7 @@
"netcatty-tool-cli": "./electron/cli/netcatty-tool-cli.cjs"
},
"scripts": {
"dev": "npm run fetch:mosh:dev && npm run fetch:et:dev && npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
"dev": "npm run fetch:mosh:dev && npm run fetch:et:dev && npm run lint && concurrently -k \"vite --strictPort\" \"npm:dev:electron\"",
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
"prebuild": "node scripts/copy-monaco.cjs",
"fetch:mosh": "node scripts/fetch-mosh-binaries.cjs",

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Derived from the official tmux logo: https://github.com/tmux/tmux/blob/master/logo/tmux-logo.svg -->
<svg width="160" height="160" viewBox="0 0 160 160" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<g fill="#1BB91F">
<path d="M0,116 L160,116 L160,144.996128 C160,153.282538 153.278035,160 145.001535,160 L14.9984654,160 C6.71504169,160 0,153.293415 0,144.996128 L0,116 Z M0,116 L160,116 L160,146 L0,146 L0,116 Z" />
</g>
<path d="M83,70 L83,0 L77,0 L77,146 L83,146 L83,76 L160,76 L160,70 L83,70 Z M0,15.0064867 C0,6.71863293 6.72196489,0 14.9984654,0 L145.001535,0 C153.284958,0 160,6.72491953 160,15.0064867 L160,146 L0,146 L0,15.0064867 Z" fill="#3C3C3C" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 779 B

View File

@@ -105,6 +105,11 @@ declare global {
error?: string;
popupId?: string;
}>;
logDiagnostic?(payload: {
source: string;
message: string;
extra?: Record<string, unknown>;
}): Promise<{ success: boolean; error?: string }>;
onTerminalPopupConfig?(cb: (payload: import("../../domain/systemManager/types").TerminalPopupPayload) => void): () => void;
}
}