mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
ea5320d94a
* 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>
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
import {
|
|
Bookmark,
|
|
ChevronDown,
|
|
Server,
|
|
Terminal,
|
|
Trash2,
|
|
Usb,
|
|
User,
|
|
} from "lucide-react";
|
|
import React, { memo, useCallback, useMemo, useState } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { cn } from "../lib/utils";
|
|
import { ConnectionLog, Host } from "../types";
|
|
import { ScrollArea } from "./ui/scroll-area";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
|
|
interface ConnectionLogsManagerProps {
|
|
logs: ConnectionLog[];
|
|
hosts: Host[];
|
|
onToggleSaved: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
onClearUnsaved: () => void;
|
|
onOpenLogView: (log: ConnectionLog) => void;
|
|
}
|
|
|
|
// Format date for display
|
|
const formatDate = (timestamp: number, locale: string) => {
|
|
const date = new Date(timestamp);
|
|
return date.toLocaleDateString(locale || undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
};
|
|
|
|
// Format time range
|
|
const formatTimeRange = (start: number, end: number | undefined, locale: string, ongoingLabel: string) => {
|
|
const startDate = new Date(start);
|
|
const startTime = startDate.toLocaleTimeString(locale || undefined, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
if (!end) {
|
|
return `${startTime} - ${ongoingLabel}`;
|
|
}
|
|
|
|
const endDate = new Date(end);
|
|
const endTime = endDate.toLocaleTimeString(locale || undefined, {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
return `${startTime} - ${endTime}`;
|
|
};
|
|
|
|
// Log Item Component
|
|
interface LogItemProps {
|
|
log: ConnectionLog;
|
|
onToggleSaved: (id: string) => void;
|
|
onDelete: (id: string) => void;
|
|
onClick: () => void;
|
|
}
|
|
|
|
const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) => {
|
|
const { t, resolvedLocale } = useI18n();
|
|
const isLocal = log.protocol === "local" || log.hostname === "localhost";
|
|
const isSerial = log.protocol === "serial";
|
|
|
|
return (
|
|
<div
|
|
className="group flex items-center gap-4 px-4 py-3 hover:bg-secondary/60 transition-colors border-b border-border/30 last:border-b-0 cursor-pointer"
|
|
onClick={onClick}
|
|
>
|
|
{/* Date column */}
|
|
<div className="w-32 shrink-0">
|
|
<div className="text-sm font-medium">{formatDate(log.startTime, resolvedLocale)}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{formatTimeRange(log.startTime, log.endTime, resolvedLocale, t("logs.ongoing"))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* User column */}
|
|
<div className="flex items-center gap-2 w-56 shrink-0">
|
|
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
|
<User size={14} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium truncate">{log.localUsername}</div>
|
|
<div className="text-xs text-muted-foreground truncate">{log.localHostname}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Host column */}
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
<div className={cn(
|
|
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
|
|
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
|
|
)}>
|
|
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{isLocal ? "local" : isSerial ? `serial, ${log.hostname}` : `${log.protocol}, ${log.username}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Saved column */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onToggleSaved(log.id);
|
|
}}
|
|
className={cn(
|
|
"p-1.5 rounded-md transition-colors",
|
|
log.saved
|
|
? "text-primary bg-primary/10"
|
|
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
|
|
)}
|
|
>
|
|
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{log.saved ? t("logs.action.unsave") : t("logs.action.save")}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete(log.id);
|
|
}}
|
|
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t("logs.action.delete")}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
LogItem.displayName = "LogItem";
|
|
|
|
const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
|
|
logs,
|
|
hosts: _hosts,
|
|
onToggleSaved,
|
|
onDelete,
|
|
onClearUnsaved: _onClearUnsaved,
|
|
onOpenLogView,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const INITIAL_RENDER_LIMIT = 30;
|
|
const LOAD_MORE_COUNT = 30;
|
|
|
|
// Track how many items to show
|
|
const [renderLimit, setRenderLimit] = useState(INITIAL_RENDER_LIMIT);
|
|
|
|
// Sort logs by newest first
|
|
const filteredLogs = useMemo(() => {
|
|
return [...logs].sort((a, b) => b.startTime - a.startTime);
|
|
}, [logs]);
|
|
|
|
const displayedLogs = useMemo(() => {
|
|
return filteredLogs.slice(0, renderLimit);
|
|
}, [filteredLogs, renderLimit]);
|
|
|
|
const hasMore = filteredLogs.length > renderLimit;
|
|
|
|
const handleLoadMore = useCallback(() => {
|
|
setRenderLimit(prev => prev + LOAD_MORE_COUNT);
|
|
}, []);
|
|
|
|
const handleToggleSaved = useCallback(
|
|
(id: string) => onToggleSaved(id),
|
|
[onToggleSaved],
|
|
);
|
|
|
|
const handleDelete = useCallback(
|
|
(id: string) => onDelete(id),
|
|
[onDelete],
|
|
);
|
|
|
|
// Rendered items
|
|
const renderedItems = useMemo(() => {
|
|
return displayedLogs.map((log) => (
|
|
<LogItem
|
|
key={log.id}
|
|
log={log}
|
|
onToggleSaved={handleToggleSaved}
|
|
onDelete={handleDelete}
|
|
onClick={() => onOpenLogView(log)}
|
|
/>
|
|
));
|
|
}, [displayedLogs, handleToggleSaved, handleDelete, onOpenLogView]);
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
{/* Table Header */}
|
|
{displayedLogs.length > 0 && (
|
|
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium text-muted-foreground border-b border-border/30 bg-secondary/30">
|
|
<div className="w-32 shrink-0 flex items-center gap-1">
|
|
{t("logs.table.date")}
|
|
<ChevronDown size={12} />
|
|
</div>
|
|
<div className="w-56 shrink-0">{t("logs.table.user")}</div>
|
|
<div className="flex-1">{t("logs.table.host")}</div>
|
|
<div className="w-20 shrink-0 flex items-center gap-1">
|
|
{t("logs.table.saved")}
|
|
<Bookmark size={12} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<ScrollArea className="flex-1">
|
|
<div>
|
|
{displayedLogs.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
|
<Terminal size={32} className="opacity-60" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-foreground mb-2">
|
|
{t("logs.empty.title")}
|
|
</h3>
|
|
<p className="text-sm text-center max-w-sm">
|
|
{t("logs.empty.desc")}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{renderedItems}
|
|
{hasMore && (
|
|
<button
|
|
onClick={handleLoadMore}
|
|
className="w-full py-3 text-sm text-primary hover:bg-secondary/50 transition-colors"
|
|
>
|
|
{t("logs.loadMore", { count: Math.min(LOAD_MORE_COUNT, filteredLogs.length - renderLimit) })}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Custom comparison
|
|
const logsManagerAreEqual = (
|
|
prev: ConnectionLogsManagerProps,
|
|
next: ConnectionLogsManagerProps,
|
|
): boolean => {
|
|
return prev.logs === next.logs && prev.hosts === next.hosts;
|
|
};
|
|
|
|
export default memo(ConnectionLogsManager, logsManagerAreEqual);
|