Files
Netcatty/components/LogView.tsx
T
陈大猫 ea5320d94a Fix #954: unify Tooltip styling + replace native selects (#961)
* Fix #954: unify Tooltip styling + replace native selects

Replace native HTML title= tooltips and native <select> dropdowns
with the existing Radix-based Tooltip / Select components so they
share the app's rounded styling, theme tokens and i18n pipeline.
Adds a global TooltipProvider in AppWithProviders so every
descendant Tooltip works without a per-file Provider wrapper.

Scope (driven by the issue #954 examples and "全部都处理" follow-up):

- TerminalLayer toolbar: Add Terminal / Split View / SFTP / Scripts
  / Theme / AI Chat / Move panel / Close panel.
- TopTabs middle bar: quick switcher, more tabs, AI assistant, theme
  toggle, settings; window-control buttons (min/max/close), tray
  close and hotkey reset/disable have their native title dropped per
  the user's explicit opt-out ("可以不用Tooltip,直接全局禁用
  原生title 属性").
- AI panels: AIChatSidePanel session history / new chat / delete,
  ConversationExport, AgentSelector, ChatInput attach / expand /
  permission, ModelSelector, ProviderCard, ai-elements/tool-call.
- SFTP: SftpSidePanel header, SftpBreadcrumb, SftpFileRow,
  SftpPaneToolbar, SftpTabBar, SftpTransferQueue.
- Settings: SettingsPage close, SettingsAppearanceTab theme/accent
  swatches, SettingsFileAssociationsTab edit/remove, SettingsSystemTab
  crash-log paths and global hotkey reset.
- Host vault: HostDetailsPanel (clear / suggestions / show-password /
  key path / browse key), GroupDetailsPanel, KnownHostsManager,
  ConnectionLogsManager, KeychainManager, SyncStatusButton,
  CloudSyncSettings, LogView, QuickSwitcher, ScriptsSidePanel,
  Terminal status bar copy-host + broadcast/focus, ZmodemProgressIndicator.
- Terminal subcomponents: HostKeywordHighlightPopover, TerminalComposeBar,
  TerminalConnectionDialog, TerminalSearchBar.
- Editor: TextEditorPane (subtitle, search, wrap, promote-to-tab).
- TrayPanel session rows and port-forwarding rows.

Native <select> migrated to custom Select component:
- SerialConnectModal (data bits, stop bits, parity, flow control)
- SerialHostDetailsPanel (same four fields)
- HostDetailsPanel backspace behavior
- GroupDetailsPanel backspace behavior
- SettingsTerminalTab local shell picker
- terminal/ThemeSidePanel font weight

Hardcoded English strings extracted to i18n. New keys for both
en and zh-CN: terminal.layer.*, topTabs.*, ai.chat.* (sessionHistory,
attach, collapse, expand, enableAgent), zmodem.*, settings.shortcuts.
resetToDefault. Inline help text on SnippetsManager package-name input
removed because the same hint is already shown in a visible <p> below
the input.

Existing per-file <TooltipProvider> wrappers (SnippetsManager,
ScriptsSidePanel, SelectHostPanel, RuleCard, HostDetailsPanel proxy
section) are left in place — they nest harmlessly under the global
provider and stay self-sufficient for component tests.

Tests:
- tsc clean for changed files (pre-existing repo-wide errors
  unrelated to this PR).
- All 802 tests pass (3 skipped pre-existing).
- HostDetailsPanel.proxyProfile.test and TextEditorPane.test
  updated to wrap with TooltipProvider, matching the runtime
  context now needed by the migrated components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Fix #954: wrap Settings + Tray windows with TooltipProvider

Settings and the tray panel mount as separate Electron windows with
their own React root in index.tsx, so they do not inherit the global
TooltipProvider added under AppWithProviders. After the unified
Tooltip migration, any settings tab that used a Tooltip (Appearance,
Application, FileAssociations, System, Shortcuts, Terminal, AI
ProviderCard, AI ModelSelector) — and TrayPanel — threw
"Tooltip must be used within TooltipProvider" and rendered nothing.

Wrap both branches with TooltipProvider at the same level as
ToastProvider in index.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:14:24 +08:00

343 lines
12 KiB
TypeScript

import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import "@xterm/xterm/css/xterm.css";
import { FileText, Download, Palette, X } from "lucide-react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, TerminalTheme } from "../types";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { useCustomThemes } from "../application/state/customThemeStore";
import { Button } from "./ui/button";
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
interface LogViewProps {
log: ConnectionLog;
defaultTerminalTheme: TerminalTheme;
defaultFontSize: number;
isVisible: boolean;
onClose: () => void;
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
}
const LogViewComponent: React.FC<LogViewProps> = ({
log,
defaultTerminalTheme,
defaultFontSize,
isVisible,
onClose,
onUpdateLog,
}) => {
const { t, resolvedLocale } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const [isReady, setIsReady] = useState(false);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [previewTheme, setPreviewTheme] = useState<TerminalTheme | null>(null);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const explicitThemeId = useMemo(() => {
if (!log.themeId) return undefined;
const exists = TERMINAL_THEMES.some((theme) => theme.id === log.themeId)
|| customThemes.some((theme) => theme.id === log.themeId);
return exists ? log.themeId : undefined;
}, [customThemes, log.themeId]);
useEffect(() => {
if (log.themeId && !explicitThemeId) {
onUpdateLog(log.id, { themeId: undefined });
}
}, [explicitThemeId, log.id, log.themeId, onUpdateLog]);
// Use log's saved theme/fontSize or fall back to defaults
const currentTheme = useMemo(() => {
if (previewTheme) {
return previewTheme;
}
if (explicitThemeId) {
return TERMINAL_THEMES.find(t => t.id === explicitThemeId)
|| customThemes.find(t => t.id === explicitThemeId)
|| defaultTerminalTheme;
}
return defaultTerminalTheme;
}, [customThemes, defaultTerminalTheme, explicitThemeId, previewTheme]);
const currentFontSize = log.fontSize ?? defaultFontSize;
// Format date for display
const formattedDate = useMemo(() => {
const date = new Date(log.startTime);
return date.toLocaleString(resolvedLocale || undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}, [log.startTime, resolvedLocale]);
// Handle theme change
const handleThemeChange = useCallback((themeId: string) => {
onUpdateLog(log.id, { themeId });
}, [log.id, onUpdateLog]);
useEffect(() => {
if (!themeModalOpen) {
setPreviewTheme(null);
}
}, [themeModalOpen]);
// Handle font size change
const handleFontSizeChange = useCallback((fontSize: number) => {
onUpdateLog(log.id, { fontSize });
}, [log.id, onUpdateLog]);
// Handle export
const handleExport = useCallback(async () => {
if (!log.terminalData || isExporting) return;
setIsExporting(true);
try {
const { netcattyBridge } = await import("../infrastructure/services/netcattyBridge");
const bridge = netcattyBridge.get();
if (bridge?.exportSessionLog) {
await bridge.exportSessionLog({
terminalData: log.terminalData,
hostLabel: log.hostLabel,
hostname: log.hostname,
startTime: log.startTime,
format: 'txt',
});
}
} catch (err) {
console.error('Failed to export session log:', err);
} finally {
setIsExporting(false);
}
}, [log.terminalData, log.hostLabel, log.hostname, log.startTime, isExporting]);
// Initialize terminal
useEffect(() => {
if (!containerRef.current || !isVisible) return;
// Create terminal
const term = new XTerm({
fontFamily: '"JetBrains Mono", "SF Mono", Monaco, Menlo, monospace',
fontSize: currentFontSize,
cursorBlink: false,
cursorStyle: "underline",
allowProposedApi: true,
disableStdin: true, // Read-only mode
theme: currentTheme.colors,
scrollback: 10000,
});
termRef.current = term;
// Create fit addon
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
fitAddonRef.current = fitAddon;
// Open terminal
term.open(containerRef.current);
// Try to load WebGL addon for better performance
try {
const webglAddon = new WebglAddon();
term.loadAddon(webglAddon);
} catch {
// WebGL not available, canvas renderer will be used
}
// Fit terminal
setTimeout(() => {
try {
fitAddon.fit();
} catch {
// Ignore fit errors
}
}, 50);
// Write terminal data if available
if (log.terminalData) {
term.write(log.terminalData);
} else {
// No terminal data available
term.writeln("\x1b[2m--- No terminal data captured for this session ---\x1b[0m");
term.writeln("");
term.writeln(`\x1b[36mHost:\x1b[0m ${log.hostname}`);
term.writeln(`\x1b[36mUser:\x1b[0m ${log.username}`);
term.writeln(`\x1b[36mProtocol:\x1b[0m ${log.protocol}`);
term.writeln(`\x1b[36mTime:\x1b[0m ${formattedDate}`);
if (log.endTime) {
const duration = Math.round((log.endTime - log.startTime) / 1000);
const minutes = Math.floor(duration / 60);
const seconds = duration % 60;
term.writeln(`\x1b[36mDuration:\x1b[0m ${minutes}m ${seconds}s`);
}
}
setIsReady(true);
// Cleanup
return () => {
term.dispose();
termRef.current = null;
fitAddonRef.current = null;
setIsReady(false);
};
// Only re-create terminal when visibility or terminalData changes
// Theme and font size updates are handled separately
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible, log.id, log.terminalData]);
// Update theme instantly without recreating terminal
useEffect(() => {
if (termRef.current && isReady) {
termRef.current.options.theme = currentTheme.colors;
}
}, [currentTheme, isReady]);
// Update font size instantly without recreating terminal
useEffect(() => {
if (termRef.current && isReady) {
termRef.current.options.fontSize = currentFontSize;
// Refit after font size change
setTimeout(() => {
try {
fitAddonRef.current?.fit();
} catch {
// Ignore fit errors
}
}, 10);
}
}, [currentFontSize, isReady]);
// Handle resize
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
const handleResize = () => {
if (fitAddonRef.current) {
try {
fitAddonRef.current.fit();
} catch {
// Ignore fit errors
}
}
};
const resizeObserver = new ResizeObserver(handleResize);
if (containerRef.current?.parentElement) {
resizeObserver.observe(containerRef.current.parentElement);
}
return () => {
resizeObserver.disconnect();
};
}, [isVisible]);
const isLocal = log.protocol === "local" || log.hostname === "localhost";
return (
<div className="h-full w-full flex flex-col bg-background">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50 bg-secondary/30 shrink-0">
<div className="flex items-center gap-3">
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
isLocal
? "bg-emerald-500/10 text-emerald-500"
: "bg-blue-500/10 text-blue-500"
)}
>
<FileText size={16} />
</div>
<div>
<div className="text-sm font-medium">
{isLocal ? t("logs.localTerminal") : log.hostname}
</div>
<div className="text-xs text-muted-foreground">
{formattedDate} • {log.localUsername}@{log.localHostname}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Export button */}
{log.terminalData && (
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2"
onClick={handleExport}
disabled={isExporting}
>
<Download size={14} />
<span className="text-xs">{t("logView.export")}</span>
</Button>
)}
{/* Theme & font customization button */}
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2"
onClick={() => setThemeModalOpen(true)}
>
<Palette size={14} />
<span className="text-xs">{t("logView.appearance")}</span>
</Button>
<span className="text-xs text-muted-foreground bg-secondary px-2 py-1 rounded">
{t("logView.readOnly")}
</span>
<Button variant="ghost" size="sm" onClick={onClose}>
<X size={16} />
</Button>
</div>
</div>
{/* Terminal container */}
<div
className="flex-1 overflow-hidden p-2"
style={{ backgroundColor: currentTheme?.colors?.background || '#000000' }}
>
<div ref={containerRef} className="h-full w-full" />
</div>
{/* Theme Customize Modal */}
<ThemeCustomizeModal
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
currentThemeId={explicitThemeId}
displayThemeId={currentTheme.id}
currentFontSize={currentFontSize}
onThemeChange={handleThemeChange}
onThemeReset={() => onUpdateLog(log.id, { themeId: undefined })}
onFontSizeChange={handleFontSizeChange}
onPreviewThemeChange={setPreviewTheme}
/>
</div>
);
};
// Memoization comparison
const logViewAreEqual = (prev: LogViewProps, next: LogViewProps): boolean => {
return (
prev.log.id === next.log.id &&
prev.log.themeId === next.log.themeId &&
prev.log.fontSize === next.log.fontSize &&
prev.isVisible === next.isVisible &&
prev.defaultFontSize === next.defaultFontSize &&
prev.defaultTerminalTheme.id === next.defaultTerminalTheme.id
);
};
export default memo(LogViewComponent, logViewAreEqual);