From 9013a7e312e401350b5545c0be4af71c2fa28d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:48:52 +0800 Subject: [PATCH] fix terminal popup release behavior (#1403) --- application/i18n/locales/en/systemManager.ts | 2 + application/i18n/locales/ru/systemManager.ts | 2 + .../i18n/locales/zh-CN/systemManager.ts | 2 + .../resolveTerminalSessionExitIntent.test.ts | 12 +- .../state/resolveTerminalSessionExitIntent.ts | 4 + application/state/systemManagerDiagnostics.ts | 16 ++ components/TerminalPopupPage.tsx | 263 ++++++++++++++++-- .../systemManager/DockerContainersPanel.tsx | 54 +++- .../systemManager/DockerImagesPanel.tsx | 2 +- .../systemManager/ProcessManagerTab.tsx | 2 +- components/systemManager/SystemPanelUi.tsx | 50 +++- components/systemManager/TmuxManagerTab.tsx | 10 +- components/systemManager/TmuxSessionCard.tsx | 16 +- .../systemManager/hooks/useSystemManager.ts | 31 ++- .../systemManager/openInteractiveTerminal.ts | 33 ++- .../createTerminalSessionStarters.test.ts | 52 ++++ .../runtime/createTerminalSessionStarters.ts | 3 + domain/systemManager/dockerShell.ts | 6 +- domain/systemManager/tmuxShell.ts | 4 +- domain/systemManager/types.ts | 8 + electron/bridges/crashLogBridge.cjs | 15 + .../windowManager/terminalPopupWindow.cjs | 94 +++++-- electron/main/registerBridges.cjs | 21 +- electron/preload.cjs | 19 ++ electron/preload/api.cjs | 19 +- index.tsx | 32 ++- package.json | 2 +- public/system-icons/tmux.svg | 10 + types/global/netcatty-bridge-system.d.ts | 5 + 29 files changed, 705 insertions(+), 84 deletions(-) create mode 100644 application/state/systemManagerDiagnostics.ts create mode 100644 public/system-icons/tmux.svg diff --git a/application/i18n/locales/en/systemManager.ts b/application/i18n/locales/en/systemManager.ts index fd1a86b8..7f0e3ccc 100644 --- a/application/i18n/locales/en/systemManager.ts +++ b/application/i18n/locales/en/systemManager.ts @@ -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', diff --git a/application/i18n/locales/ru/systemManager.ts b/application/i18n/locales/ru/systemManager.ts index 32b63d22..6f45eaae 100644 --- a/application/i18n/locales/ru/systemManager.ts +++ b/application/i18n/locales/ru/systemManager.ts @@ -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': 'Команда', diff --git a/application/i18n/locales/zh-CN/systemManager.ts b/application/i18n/locales/zh-CN/systemManager.ts index 7c7567bc..8788d60f 100644 --- a/application/i18n/locales/zh-CN/systemManager.ts +++ b/application/i18n/locales/zh-CN/systemManager.ts @@ -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': '命令', diff --git a/application/state/resolveTerminalSessionExitIntent.test.ts b/application/state/resolveTerminalSessionExitIntent.test.ts index be263d1c..8752b3cc 100644 --- a/application/state/resolveTerminalSessionExitIntent.test.ts +++ b/application/state/resolveTerminalSessionExitIntent.test.ts @@ -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); +}); diff --git a/application/state/resolveTerminalSessionExitIntent.ts b/application/state/resolveTerminalSessionExitIntent.ts index 4b2833fc..7efaf865 100644 --- a/application/state/resolveTerminalSessionExitIntent.ts +++ b/application/state/resolveTerminalSessionExitIntent.ts @@ -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; +} diff --git a/application/state/systemManagerDiagnostics.ts b/application/state/systemManagerDiagnostics.ts new file mode 100644 index 00000000..058e20d1 --- /dev/null +++ b/application/state/systemManagerDiagnostics.ts @@ -0,0 +1,16 @@ +import { netcattyBridge } from '../../infrastructure/services/netcattyBridge'; + +export async function writeSystemManagerDiagnostic( + message: string, + extra?: Record, +) { + try { + await netcattyBridge.get()?.logDiagnostic?.({ + source: 'system-manager', + message, + extra, + }); + } catch { + // Diagnostics must never block the user action being diagnosed. + } +} diff --git a/components/TerminalPopupPage.tsx b/components/TerminalPopupPage.tsx index 191e7867..646f9d74 100644 --- a/components/TerminalPopupPage.tsx +++ b/components/TerminalPopupPage.tsx @@ -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; + +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 ( +
+ + + +
+ ); +} + +function TerminalPopupSpinner() { + return ( +
+ + + + + + +
+ ); +} + +function TerminalPopupBlank() { + return ( +
+ ); +} + +function TerminalPopupStartupError({ + message, + closeLabel, + onClose, +}: { + message: string; + closeLabel: string; + onClose: () => void; +}) { + return ( +
+ +
{message}
+ +
+ ); +} + +function TerminalPopupTitleIcon({ icon }: { icon: TerminalPopupPayload['icon'] }) { + if (!icon) return null; + if (icon.kind !== 'image' || !icon.src) return null; + return ( + + {icon.alt + + ); +} /** 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(null); + const [terminalReady, setTerminalReady] = useState(false); + const [startupError, setStartupError] = useState(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 ( -
+
-
-
- {config?.title ?? ''} + {isMac &&
} + +
+
+ {config?.title ?? ''} +
- {isMac ? ( -
- ) : ( - - )} + {!isMac && void close()} />}
{!ready || !config || !host ? ( -
- {t('systemManager.popup.loading')} -
+ + ) : startupError ? ( + void close()} + /> ) : ( -
-
}> +
+ }> { void close(); }} - onSessionExit={() => { - void close(); + 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} /> + {!terminalReady && ( +
+ +
+ )}
)}
diff --git a/components/systemManager/DockerContainersPanel.tsx b/components/systemManager/DockerContainersPanel.tsx index 823dde6c..1841cb8e 100644 --- a/components/systemManager/DockerContainersPanel.tsx +++ b/components/systemManager/DockerContainersPanel.tsx @@ -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; type ContainerFilter = 'all' | 'running' | 'stopped' | 'paused'; +async function buildContainerPopupIcon(image: string): Promise { + 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 (
@@ -306,7 +352,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({ {error && ( - void refresh()} retryLabel={t('history.action.retry')} /> + void refresh()} retryLabel={t('history.action.retry')} loading={loading} /> )} {!error && displayList.length === 0 && !loading && ( diff --git a/components/systemManager/DockerImagesPanel.tsx b/components/systemManager/DockerImagesPanel.tsx index c20095c5..f0af6dc9 100644 --- a/components/systemManager/DockerImagesPanel.tsx +++ b/components/systemManager/DockerImagesPanel.tsx @@ -241,7 +241,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({ {error && ( - void refresh()} retryLabel={t('history.action.retry')} /> + void refresh()} retryLabel={t('history.action.retry')} loading={loading} /> )} {!error && displayList.length === 0 && !loading && ( diff --git a/components/systemManager/ProcessManagerTab.tsx b/components/systemManager/ProcessManagerTab.tsx index 46035c75..fdcba27e 100644 --- a/components/systemManager/ProcessManagerTab.tsx +++ b/components/systemManager/ProcessManagerTab.tsx @@ -312,7 +312,7 @@ export const ProcessManagerTab = memo(function ProcessManagerTab({ {error && ( - void refresh()} retryLabel={t('history.action.retry')} /> + void refresh()} retryLabel={t('history.action.retry')} loading={loading} /> )} {!error && displayList.length === 0 && !loading && ( diff --git a/components/systemManager/SystemPanelUi.tsx b/components/systemManager/SystemPanelUi.tsx index ac14ef8a..b5622372 100644 --- a/components/systemManager/SystemPanelUi.tsx +++ b/components/systemManager/SystemPanelUi.tsx @@ -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 {message}; + } + return ( + + {lines.map((line, index) => ( + + {line} + + ))} + + ); +} + export const SystemPanelShell = memo(function SystemPanelShell({ children, section, @@ -172,7 +198,7 @@ export const SystemPanelEmpty = memo(function SystemPanelEmpty({ return (
- {message} +
); }); @@ -181,16 +207,25 @@ export const SystemPanelError = memo(function SystemPanelError({ message, onRetry, retryLabel, + loading, }: { message: string; onRetry?: () => void; retryLabel?: string; + loading?: boolean; }) { return ( -
-
{message}
+
+ + {onRetry && retryLabel && ( - )} @@ -204,8 +239,9 @@ export const SystemPanelInlineError = memo(function SystemPanelInlineError({ message: string; }) { return ( -
- {message} +
+ + {message}
); }); diff --git a/components/systemManager/TmuxManagerTab.tsx b/components/systemManager/TmuxManagerTab.tsx index b2baf5a3..6db8107a 100644 --- a/components/systemManager/TmuxManagerTab.tsx +++ b/components/systemManager/TmuxManagerTab.tsx @@ -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 })} - {error && } - {!error && displaySessions.length === 0 && !loading && ( )} {error && ( -
- -
+ void refresh()} retryLabel={t('history.action.retry')} loading={loading} /> )} {displaySessions.map((session) => ( ; +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 ( diff --git a/components/systemManager/hooks/useSystemManager.ts b/components/systemManager/hooks/useSystemManager.ts index 720c7d5f..e0fe76a8 100644 --- a/components/systemManager/hooks/useSystemManager.ts +++ b/components/systemManager/hooks/useSystemManager.ts @@ -8,6 +8,19 @@ import { nextPollData } from '../listStable'; type Backend = ReturnType; +function delay(ms: number): Promise { + 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( enabled: boolean, merge?: (prev: T | null, next: T) => T, ) { + const stableT = useStableTranslate(); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -126,10 +140,11 @@ export function usePolling( 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( } } 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( const refresh = useCallback(async () => { failuresRef.current = 0; - await run({ withLoading: true }); + await run({ withLoading: true, minLoadingMs: 450 }); }, [run]); return { data, error, loading, refresh }; diff --git a/components/systemManager/openInteractiveTerminal.ts b/components/systemManager/openInteractiveTerminal.ts index 245dae4c..3bfd1e8a 100644 --- a/components/systemManager/openInteractiveTerminal.ts +++ b/components/systemManager/openInteractiveTerminal.ts @@ -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; +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; } diff --git a/components/terminal/runtime/createTerminalSessionStarters.test.ts b/components/terminal/runtime/createTerminalSessionStarters.test.ts index 10883040..f1c21149 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.test.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.test.ts @@ -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; diff --git a/components/terminal/runtime/createTerminalSessionStarters.ts b/components/terminal/runtime/createTerminalSessionStarters.ts index e219ae4e..0ca1b817 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.ts @@ -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); diff --git a/domain/systemManager/dockerShell.ts b/domain/systemManager/dockerShell.ts index ca5de380..a25d77ce 100644 --- a/domain/systemManager/dockerShell.ts +++ b/domain/systemManager/dockerShell.ts @@ -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}`; } diff --git a/domain/systemManager/tmuxShell.ts b/domain/systemManager/tmuxShell.ts index ba5f0ed2..f67ca92e 100644 --- a/domain/systemManager/tmuxShell.ts +++ b/domain/systemManager/tmuxShell.ts @@ -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}`; } diff --git a/domain/systemManager/types.ts b/domain/systemManager/types.ts index 0a10d9ad..e519d6a6 100644 --- a/domain/systemManager/types.ts +++ b/domain/systemManager/types.ts @@ -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; diff --git a/electron/bridges/crashLogBridge.cjs b/electron/bridges/crashLogBridge.cjs index d7eb134c..45b351cf 100644 --- a/electron/bridges/crashLogBridge.cjs +++ b/electron/bridges/crashLogBridge.cjs @@ -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, }; diff --git a/electron/bridges/windowManager/terminalPopupWindow.cjs b/electron/bridges/windowManager/terminalPopupWindow.cjs index a0373d5e..af38796f 100644 --- a/electron/bridges/windowManager/terminalPopupWindow.cjs +++ b/electron/bridges/windowManager/terminalPopupWindow.cjs @@ -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 }; } diff --git a/electron/main/registerBridges.cjs b/electron/main/registerBridges.cjs index ab143055..f13660ec 100644 --- a/electron/main/registerBridges.cjs +++ b/electron/main/registerBridges.cjs @@ -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" }; } diff --git a/electron/preload.cjs b/electron/preload.cjs index 166ae051..c866f0cd 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -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, diff --git a/electron/preload/api.cjs b/electron/preload/api.cjs index 75edef08..fae987b8 100644 --- a/electron/preload/api.cjs +++ b/electron/preload/api.cjs @@ -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 }); diff --git a/index.tsx b/index.tsx index 09bc24f4..4284adba 100755 --- a/index.tsx +++ b/index.tsx @@ -81,6 +81,36 @@ function SettingsWindowFallback() { ); } +function TerminalPopupWindowFallback() { + return ( +
+ + + + + + +
+ ); +} + 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( - Loading terminal…
}> + }> diff --git a/package.json b/package.json index 101bd852..765e1658 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/system-icons/tmux.svg b/public/system-icons/tmux.svg new file mode 100644 index 00000000..509f360a --- /dev/null +++ b/public/system-icons/tmux.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/types/global/netcatty-bridge-system.d.ts b/types/global/netcatty-bridge-system.d.ts index 41403a87..b91ef2df 100644 --- a/types/global/netcatty-bridge-system.d.ts +++ b/types/global/netcatty-bridge-system.d.ts @@ -105,6 +105,11 @@ declare global { error?: string; popupId?: string; }>; + logDiagnostic?(payload: { + source: string; + message: string; + extra?: Record; + }): Promise<{ success: boolean; error?: string }>; onTerminalPopupConfig?(cb: (payload: import("../../domain/systemManager/types").TerminalPopupPayload) => void): () => void; } }