fix terminal popup release behavior (#1403)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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': 'Команда',
|
||||
|
||||
@@ -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': '命令',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
16
application/state/systemManagerDiagnostics.ts
Normal file
16
application/state/systemManagerDiagnostics.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -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="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 shrink-0 h-9 flex items-center border-b border-border/50"
|
||||
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">
|
||||
{config?.title ?? ''}
|
||||
{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 ? (
|
||||
<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>
|
||||
)}
|
||||
{!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={() => {
|
||||
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}
|
||||
/>
|
||||
</Suspense>
|
||||
{!terminalReady && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10">
|
||||
<TerminalPopupSpinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
32
index.tsx
32
index.tsx
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
10
public/system-icons/tmux.svg
Normal file
10
public/system-icons/tmux.svg
Normal 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 |
5
types/global/netcatty-bridge-system.d.ts
vendored
5
types/global/netcatty-bridge-system.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user