* 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>
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
import {
|
|
Folder,
|
|
FolderLock,
|
|
LayoutGrid,
|
|
Plus,
|
|
Search,
|
|
Terminal,
|
|
TerminalSquare,
|
|
} from "lucide-react";
|
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { Host, TerminalSession, Workspace } from "../types";
|
|
import { KeyBinding } from "../domain/models";
|
|
import { useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
|
|
|
type QuickSwitcherItem = {
|
|
type: "host" | "tab" | "workspace" | "action" | "shell";
|
|
id: string;
|
|
data?: Host | TerminalSession | Workspace;
|
|
};
|
|
import { DistroAvatar } from "./DistroAvatar";
|
|
import { Input } from "./ui/input";
|
|
import { ScrollArea } from "./ui/scroll-area";
|
|
|
|
// Compute once at module level
|
|
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
|
|
|
|
// Memoized host item component to prevent unnecessary re-renders
|
|
const HostItem = memo(({
|
|
host,
|
|
isSelected,
|
|
onSelect,
|
|
onMouseEnter,
|
|
}: {
|
|
host: Host;
|
|
isSelected: boolean;
|
|
onSelect: (host: Host) => void;
|
|
onMouseEnter: () => void;
|
|
}) => (
|
|
<div
|
|
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => onSelect(host)}
|
|
onMouseEnter={onMouseEnter}
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<DistroAvatar
|
|
host={host}
|
|
fallback={host.label.slice(0, 2).toUpperCase()}
|
|
size="sm"
|
|
/>
|
|
<span className="text-sm font-medium truncate">{host.label}</span>
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground">
|
|
{host.group ? `Personal / ${host.group}` : "Personal"}
|
|
</div>
|
|
</div>
|
|
));
|
|
HostItem.displayName = "HostItem";
|
|
|
|
interface QuickSwitcherProps {
|
|
isOpen: boolean;
|
|
query: string;
|
|
results: Host[];
|
|
sessions: TerminalSession[];
|
|
workspaces: Workspace[];
|
|
onQueryChange: (value: string) => void;
|
|
onSelect: (host: Host) => void;
|
|
onSelectTab: (tabId: string) => void;
|
|
onClose: () => void;
|
|
onCreateLocalTerminal?: (shell?: { command: string; args?: string[]; name?: string; icon?: string }) => void;
|
|
onCreateWorkspace?: () => void;
|
|
keyBindings?: KeyBinding[];
|
|
showSftpTab: boolean;
|
|
}
|
|
|
|
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
|
isOpen,
|
|
query,
|
|
results,
|
|
sessions,
|
|
workspaces,
|
|
onQueryChange,
|
|
onSelect,
|
|
onSelectTab,
|
|
onClose,
|
|
onCreateLocalTerminal,
|
|
onCreateWorkspace,
|
|
keyBindings,
|
|
showSftpTab,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const discoveredShells = useDiscoveredShells();
|
|
|
|
const filteredShells = useMemo(() => {
|
|
const list = !query.trim()
|
|
? discoveredShells
|
|
: discoveredShells.filter(
|
|
(s) => s.name.toLowerCase().includes(query.toLowerCase()) || s.id.toLowerCase().includes(query.toLowerCase())
|
|
);
|
|
// Default shell first
|
|
return [...list].sort((a, b) => (a.isDefault === b.isDefault ? 0 : a.isDefault ? -1 : 1));
|
|
}, [discoveredShells, query]);
|
|
|
|
// Get hotkey display strings
|
|
const getHotkeyLabel = useCallback((actionId: string) => {
|
|
const binding = keyBindings?.find(k => k.id === actionId);
|
|
if (!binding) return '';
|
|
return IS_MAC ? binding.mac : binding.pc;
|
|
}, [keyBindings]);
|
|
const quickSwitchKey = getHotkeyLabel('quick-switch');
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Reset state when opening
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const focusTimer = window.setTimeout(() => {
|
|
inputRef.current?.focus();
|
|
}, 50);
|
|
|
|
setSelectedIndex(0);
|
|
|
|
return () => {
|
|
window.clearTimeout(focusTimer);
|
|
};
|
|
}, [isOpen]);
|
|
|
|
// Handle clicks outside the container
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
containerRef.current &&
|
|
!containerRef.current.contains(e.target as Node)
|
|
) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, [isOpen, onClose]);
|
|
|
|
// Memoize orphan sessions
|
|
const orphanSessions = useMemo(
|
|
() => sessions.filter((s) => !s.workspaceId),
|
|
[sessions]
|
|
);
|
|
|
|
// Always show categorized view (Hosts/Tabs/Quick connect)
|
|
const showCategorized = true;
|
|
|
|
// Memoize flat items list and index map
|
|
const { flatItems, itemIndexMap } = useMemo(() => {
|
|
const items: QuickSwitcherItem[] = [];
|
|
|
|
if (showCategorized) {
|
|
// Hosts
|
|
results.forEach((host) =>
|
|
items.push({ type: "host", id: host.id, data: host }),
|
|
);
|
|
// Tabs (built-in + sessions + workspaces)
|
|
items.push({ type: "tab", id: "vault" });
|
|
if (showSftpTab) items.push({ type: "tab", id: "sftp" });
|
|
orphanSessions.forEach((s) =>
|
|
items.push({ type: "tab", id: s.id, data: s }),
|
|
);
|
|
workspaces.forEach((w) =>
|
|
items.push({ type: "workspace", id: w.id, data: w }),
|
|
);
|
|
// Local shells (or fallback action if discovery not ready)
|
|
if (filteredShells.length > 0) {
|
|
filteredShells.forEach((shell) =>
|
|
items.push({ type: "shell", id: shell.id }),
|
|
);
|
|
} else {
|
|
items.push({ type: "action", id: "local-terminal" });
|
|
}
|
|
} else {
|
|
// Recent connections only
|
|
results.forEach((host) =>
|
|
items.push({ type: "host", id: host.id, data: host }),
|
|
);
|
|
// Also include matching shells in search results
|
|
filteredShells.forEach((shell) =>
|
|
items.push({ type: "shell", id: shell.id }),
|
|
);
|
|
}
|
|
|
|
// Build index map for O(1) lookup
|
|
const indexMap = new Map<string, number>();
|
|
items.forEach((item, idx) => {
|
|
indexMap.set(`${item.type}:${item.id}`, idx);
|
|
});
|
|
|
|
return { flatItems: items, itemIndexMap: indexMap };
|
|
}, [showCategorized, results, orphanSessions, workspaces, filteredShells, showSftpTab]);
|
|
|
|
// O(1) index lookup
|
|
const getItemIndex = useCallback((type: string, id: string) => {
|
|
return itemIndexMap.get(`${type}:${id}`) ?? -1;
|
|
}, [itemIndexMap]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => Math.min(prev + 1, flatItems.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
|
} else if (e.key === "Enter" && flatItems.length > 0) {
|
|
e.preventDefault();
|
|
const item = flatItems[selectedIndex];
|
|
handleItemSelect(item);
|
|
}
|
|
};
|
|
|
|
const handleItemSelect = (item: QuickSwitcherItem) => {
|
|
switch (item.type) {
|
|
case "host":
|
|
onSelect(item.data as Host);
|
|
break;
|
|
case "tab":
|
|
case "workspace":
|
|
onSelectTab(item.id);
|
|
onClose();
|
|
break;
|
|
case "action":
|
|
if (item.id === "local-terminal" && onCreateLocalTerminal) {
|
|
onCreateLocalTerminal();
|
|
onClose();
|
|
}
|
|
break;
|
|
case "shell": {
|
|
const shell = discoveredShells.find(s => s.id === item.id);
|
|
if (shell && onCreateLocalTerminal) {
|
|
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
|
onClose();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-x-0 top-12 z-50 flex justify-center pt-2"
|
|
style={{ pointerEvents: "none" }}
|
|
>
|
|
<div
|
|
ref={containerRef}
|
|
className="w-full max-w-2xl mx-4 bg-background border border-border rounded-xl shadow-2xl overflow-hidden max-h-[520px] flex flex-col"
|
|
style={{ pointerEvents: "auto" }}
|
|
>
|
|
{/* Search Header */}
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
|
|
<Search size={16} className="text-muted-foreground" />
|
|
<Input
|
|
ref={inputRef}
|
|
value={query}
|
|
onChange={(e) => {
|
|
onQueryChange(e.target.value);
|
|
setSelectedIndex(0);
|
|
}}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={t("qs.search.placeholder")}
|
|
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"
|
|
/>
|
|
{quickSwitchKey && (
|
|
<kbd className="text-[11px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
{quickSwitchKey.replace(/ \+ /g, '+')}
|
|
</kbd>
|
|
)}
|
|
</div>
|
|
|
|
<ScrollArea className="flex-1 h-full">
|
|
{/* Categorized view: Hosts/Tabs/Quick connect */}
|
|
<div>
|
|
{/* Jump To hint + New Workspace action */}
|
|
<div className="px-4 py-2 flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
|
{quickSwitchKey && (
|
|
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
|
{quickSwitchKey.replace(/ \+ /g, '+')}
|
|
</kbd>
|
|
)}
|
|
{onCreateWorkspace && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onCreateWorkspace();
|
|
onClose();
|
|
}}
|
|
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
|
>
|
|
<Plus size={11} />
|
|
<span>New Workspace</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Hosts section */}
|
|
{results.length > 0 && (
|
|
<div>
|
|
<div className="px-4 py-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
Hosts
|
|
</span>
|
|
</div>
|
|
{results.map((host) => (
|
|
<HostItem
|
|
key={host.id}
|
|
host={host}
|
|
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
|
onSelect={onSelect}
|
|
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs section */}
|
|
<div>
|
|
<div className="px-4 py-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
Tabs
|
|
</span>
|
|
</div>
|
|
|
|
{/* Built-in tabs */}
|
|
{(showSftpTab ? ["vault", "sftp"] : ["vault"]).map((tabId) => {
|
|
const idx = getItemIndex("tab", tabId);
|
|
const isSelected = idx === selectedIndex;
|
|
const icon =
|
|
tabId === "vault" ? (
|
|
<FolderLock size={16} />
|
|
) : (
|
|
<Folder size={16} />
|
|
);
|
|
const label = tabId === "vault" ? "Vaults" : "SFTP";
|
|
|
|
return (
|
|
<div
|
|
key={tabId}
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => {
|
|
onSelectTab(tabId);
|
|
onClose();
|
|
}}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
>
|
|
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
|
{icon}
|
|
</div>
|
|
<span className="text-sm font-medium">{label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Workspaces */}
|
|
{workspaces.map((workspace) => {
|
|
const idx = getItemIndex("workspace", workspace.id);
|
|
const isSelected = idx === selectedIndex;
|
|
|
|
return (
|
|
<div
|
|
key={workspace.id}
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => {
|
|
onSelectTab(workspace.id);
|
|
onClose();
|
|
}}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
>
|
|
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
|
<LayoutGrid size={16} />
|
|
</div>
|
|
<span className="text-sm font-medium">
|
|
{workspace.title}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Orphan sessions */}
|
|
{orphanSessions.map((session) => {
|
|
const idx = getItemIndex("tab", session.id);
|
|
const isSelected = idx === selectedIndex;
|
|
|
|
return (
|
|
<div
|
|
key={session.id}
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => {
|
|
onSelectTab(session.id);
|
|
onClose();
|
|
}}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
>
|
|
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
|
<TerminalSquare size={16} />
|
|
</div>
|
|
<span className="text-sm font-medium">
|
|
{session.hostLabel}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Local Shells section */}
|
|
{/* Local Shells or fallback Local Terminal */}
|
|
{filteredShells.length > 0 ? (
|
|
<div>
|
|
<div className="px-4 py-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{t("qs.localShells")}
|
|
</span>
|
|
</div>
|
|
{filteredShells.map((shell) => {
|
|
const idx = getItemIndex("shell", shell.id);
|
|
const isSelected = idx === selectedIndex;
|
|
return (
|
|
<div
|
|
key={shell.id}
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
|
isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => {
|
|
if (onCreateLocalTerminal) {
|
|
onCreateLocalTerminal({ command: shell.command, args: shell.args, name: shell.name, icon: shell.icon });
|
|
onClose();
|
|
}
|
|
}}
|
|
onMouseEnter={() => setSelectedIndex(idx)}
|
|
>
|
|
<img
|
|
src={getShellIconPath(shell.icon)}
|
|
alt={shell.name}
|
|
className={`h-6 w-6 shrink-0${isMonochromeShellIcon(shell.icon) ? " dark:invert" : ""}`}
|
|
/>
|
|
<span className="text-sm font-medium">{shell.name}</span>
|
|
{shell.isDefault && (
|
|
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
{t("qs.default")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : onCreateLocalTerminal && (
|
|
<div>
|
|
<div className="px-4 py-1.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{t("qs.localShells")}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
|
|
getItemIndex("action", "local-terminal") === selectedIndex
|
|
? "bg-primary/15"
|
|
: "hover:bg-muted/50"
|
|
}`}
|
|
onClick={() => {
|
|
onCreateLocalTerminal();
|
|
onClose();
|
|
}}
|
|
onMouseEnter={() =>
|
|
setSelectedIndex(getItemIndex("action", "local-terminal"))
|
|
}
|
|
>
|
|
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
|
<Terminal size={16} />
|
|
</div>
|
|
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const QuickSwitcher = memo(QuickSwitcherInner);
|
|
QuickSwitcher.displayName = "QuickSwitcher";
|