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>
This commit is contained in:
陈大猫
2026-05-12 20:14:24 +08:00
committed by GitHub
parent ffd3111b71
commit ea5320d94a
53 changed files with 2043 additions and 1476 deletions

View File

@@ -55,6 +55,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from './components/ui/input'; import { Input } from './components/ui/input';
import { Label } from './components/ui/label'; import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast'; import { ToastProvider, toast } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { VaultView, VaultSection } from './components/VaultView'; import { VaultView, VaultSection } from './components/VaultView';
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog'; import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog'; import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog';
@@ -2436,7 +2437,9 @@ function AppWithProviders() {
return ( return (
<I18nProvider locale={settings.uiLanguage}> <I18nProvider locale={settings.uiLanguage}>
<ToastProvider> <ToastProvider>
<App settings={settings} /> <TooltipProvider delayDuration={300}>
<App settings={settings} />
</TooltipProvider>
</ToastProvider> </ToastProvider>
</I18nProvider> </I18nProvider>
); );

View File

@@ -2054,6 +2054,32 @@ const en: Messages = {
'ai.safety.blocklist.reset': 'Reset to defaults', 'ai.safety.blocklist.reset': 'Reset to defaults',
'ai.safety.blocklist.add': 'Add pattern', 'ai.safety.blocklist.add': 'Add pattern',
'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.', 'ai.safety.note': 'Command Blocklist, Command Timeout, and Observer mode are enforced at the MCP Server level, applying to all agent types. Confirm mode and Max Iterations are fully enforced for the built-in agent; ACP agents may have their own internal controls for these settings.',
// Unified tooltips for terminal workspace and top tabs (issue #954)
'terminal.layer.addTerminal': 'Add Terminal',
'terminal.layer.switchToSplitView': 'Switch to Split View',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
'terminal.layer.theme': 'Theme',
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',
'topTabs.toggleTheme': 'Toggle theme',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',
'ai.chat.attach': 'Attach',
'ai.chat.collapse': 'Collapse',
'ai.chat.expand': 'Expand',
'ai.chat.enableAgent': 'Enable {name}',
'zmodem.waitingForRemote': 'Waiting for remote...',
'zmodem.uploading': 'Uploading',
'zmodem.downloading': 'Downloading',
'zmodem.cancelTransfer': 'Cancel transfer (Ctrl+C)',
'settings.shortcuts.resetToDefault': 'Reset to default',
}; };
export default en; export default en;

View File

@@ -2063,6 +2063,32 @@ const zhCN: Messages = {
'ai.safety.blocklist.reset': '恢复默认', 'ai.safety.blocklist.reset': '恢复默认',
'ai.safety.blocklist.add': '添加规则', 'ai.safety.blocklist.add': '添加规则',
'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行ACP Agent 可能有自己的内部控制。', 'ai.safety.note': '命令黑名单、命令超时和观察者模式通过 MCP Server 层强制执行,对所有 Agent 类型生效。确认模式和最大迭代次数对内置 Agent 完全强制执行ACP Agent 可能有自己的内部控制。',
// 统一终端工作区和顶部标签的 tooltip 文案 (issue #954)
'terminal.layer.addTerminal': '添加终端',
'terminal.layer.switchToSplitView': '切换到分屏视图',
'terminal.layer.sftp': '文件传输',
'terminal.layer.scripts': '脚本',
'terminal.layer.theme': '主题',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',
'topTabs.toggleTheme': '切换主题',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',
'ai.chat.attach': '附件',
'ai.chat.collapse': '收起',
'ai.chat.expand': '展开',
'ai.chat.enableAgent': '启用 {name}',
'zmodem.waitingForRemote': '等待远端...',
'zmodem.uploading': '上传中',
'zmodem.downloading': '下载中',
'zmodem.cancelTransfer': '取消传输 (Ctrl+C)',
'settings.shortcuts.resetToDefault': '重置为默认',
}; };
export default zhCN; export default zhCN;

View File

@@ -38,6 +38,7 @@ import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery'; import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area'; import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import AgentSelector from './ai/AgentSelector'; import AgentSelector from './ai/AgentSelector';
import ChatInput from './ai/ChatInput'; import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList'; import ChatMessageList from './ai/ChatMessageList';
@@ -1035,24 +1036,32 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
session={activeSession} session={activeSession}
onExport={handleExport} onExport={handleExport}
/> />
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground" variant="ghost"
onClick={() => setShowHistory(!showHistory)} size="icon"
title="Session history" className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
> onClick={() => setShowHistory(!showHistory)}
<History size={14} /> >
</Button> <History size={14} />
<Button </Button>
variant="ghost" </TooltipTrigger>
size="icon" <TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary" </Tooltip>
onClick={handleNewChat} <Tooltip>
title="New chat" <TooltipTrigger asChild>
> <Button
<Plus size={15} /> variant="ghost"
</Button> size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
>
<Plus size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
@@ -1199,13 +1208,17 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}> <span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
{timeStr} {timeStr}
</span> </span>
<button <Tooltip>
onClick={(e) => onDelete(e, session.id)} <TooltipTrigger asChild>
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton} <button
title="Delete" onClick={(e) => onDelete(e, session.id)}
> className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
<Trash2 size={12} /> >
</button> <Trash2 size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
); );

View File

@@ -54,6 +54,7 @@ import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { toast } from './ui/toast'; import { toast } from './ui/toast';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
// ============================================================================ // ============================================================================
// Provider Icons // Provider Icons
@@ -377,12 +378,14 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
</span> </span>
</div> </div>
) : error ? ( ) : error ? (
<p <Tooltip>
className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help" <TooltipTrigger asChild>
title={error} <p className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help">
> {error}
{error} </p>
</p> </TooltipTrigger>
<TooltipContent>{error}</TooltipContent>
</Tooltip>
) : ( ) : (
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')} {isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
@@ -1904,9 +1907,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</div> </div>
</div> </div>
{entry.error && ( {entry.error && (
<span className="text-xs text-red-500 truncate max-w-24" title={entry.error}> <Tooltip>
{t('cloudSync.history.error')} <TooltipTrigger asChild>
</span> <span className="text-xs text-red-500 truncate max-w-24 cursor-default">
{t('cloudSync.history.error')}
</span>
</TooltipTrigger>
<TooltipContent>{entry.error}</TooltipContent>
</Tooltip>
)} )}
</div> </div>
))} ))}

View File

@@ -12,6 +12,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types"; import { ConnectionLog, Host } from "../types";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
interface ConnectionLogsManagerProps { interface ConnectionLogsManagerProps {
logs: ConnectionLog[]; logs: ConnectionLog[];
@@ -108,31 +109,39 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
{/* Saved column */} {/* Saved column */}
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<button <Tooltip>
onClick={(e) => { <TooltipTrigger asChild>
e.stopPropagation(); <button
onToggleSaved(log.id); onClick={(e) => {
}} e.stopPropagation();
className={cn( onToggleSaved(log.id);
"p-1.5 rounded-md transition-colors", }}
log.saved className={cn(
? "text-primary bg-primary/10" "p-1.5 rounded-md transition-colors",
: "text-muted-foreground hover:text-primary hover:bg-primary/10" log.saved
)} ? "text-primary bg-primary/10"
title={log.saved ? t("logs.action.unsave") : t("logs.action.save")} : "text-muted-foreground hover:text-primary hover:bg-primary/10"
> )}
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} /> >
</button> <Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
<button </button>
onClick={(e) => { </TooltipTrigger>
e.stopPropagation(); <TooltipContent>{log.saved ? t("logs.action.unsave") : t("logs.action.save")}</TooltipContent>
onDelete(log.id); </Tooltip>
}} <Tooltip>
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100" <TooltipTrigger asChild>
title={t("logs.action.delete")} <button
> onClick={(e) => {
<Trash2 size={16} /> e.stopPropagation();
</button> 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>
</div> </div>
); );

View File

@@ -51,9 +51,11 @@ import { Combobox } from "./ui/combobox";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown"; import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { TerminalFontSelect } from "./settings/TerminalFontSelect"; import { TerminalFontSelect } from "./settings/TerminalFontSelect";
import { useAvailableFonts } from "../application/state/fontStore"; import { useAvailableFonts } from "../application/state/fontStore";
import { toast } from "./ui/toast"; import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select"; type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
@@ -814,29 +816,33 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
} }
}} }}
/> />
<Button <Tooltip>
variant="secondary" <TooltipTrigger asChild>
size="icon" <Button
className="h-8 w-8 shrink-0" variant="secondary"
title={t("hostDetails.credential.browseKeyFile")} size="icon"
onClick={async () => { className="h-8 w-8 shrink-0"
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty; onClick={async () => {
if (!bridge?.selectFile) return; const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const filePath = await bridge.selectFile( if (!bridge?.selectFile) return;
"Select SSH Private Key", const filePath = await bridge.selectFile(
undefined, "Select SSH Private Key",
[{ name: "All Files", extensions: ["*"] }] undefined,
); [{ name: "All Files", extensions: ["*"] }]
if (filePath) { );
const paths = [...(form.identityFilePaths || []), filePath]; if (filePath) {
update("identityFilePaths", paths); const paths = [...(form.identityFilePaths || []), filePath];
update("identityFileId", undefined); update("identityFilePaths", paths);
update("authMethod", "key"); update("identityFileId", undefined);
} update("authMethod", "key");
}} }
> }}
<FolderOpen size={14} /> >
</Button> <FolderOpen size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
</Tooltip>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -871,16 +877,20 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
/> />
{/* Backspace behavior */} {/* Backspace behavior */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p> <p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<select <Select
className="h-8 rounded-md border border-input bg-background px-2 text-xs" value={form.backspaceBehavior ?? "default"}
value={form.backspaceBehavior ?? ""} onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
> >
<option value="">{t("hostDetails.backspaceBehavior.default")}</option> <SelectTrigger className="h-8 w-auto text-xs">
<option value="ctrl-h">^H (0x08)</option> <SelectValue />
</select> </SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</div> </div>
{/* Proxy */} {/* Proxy */}
@@ -895,14 +905,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
</div> </div>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{(form.proxyConfig?.host || form.proxyProfileId) && ( {(form.proxyConfig?.host || form.proxyProfileId) && (
<div title={proxySummaryLabel} className="min-w-0"> <Tooltip>
<Badge <TooltipTrigger asChild>
variant="secondary" <div className="min-w-0 cursor-default">
className="max-w-[160px] truncate text-xs" <Badge
> variant="secondary"
{proxySummaryLabel} className="max-w-[160px] truncate text-xs"
</Badge> >
</div> {proxySummaryLabel}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent>{proxySummaryLabel}</TooltipContent>
</Tooltip>
)} )}
<ChevronRight size={14} className="text-muted-foreground" /> <ChevronRight size={14} className="text-muted-foreground" />
</div> </div>

View File

@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx"; import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host } from "../types.ts"; import type { Host } from "../types.ts";
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx"; import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const hostWithMissingProxyProfile: Host = { const hostWithMissingProxyProfile: Host = {
id: "host-1", id: "host-1",
@@ -26,20 +27,24 @@ const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
React.createElement( React.createElement(
I18nProvider, I18nProvider,
{ locale: "en" }, { locale: "en" },
React.createElement(HostDetailsPanel, { React.createElement(
initialData, TooltipProvider,
availableKeys: [], null,
identities: [], React.createElement(HostDetailsPanel, {
proxyProfiles: [], initialData,
groups: [], availableKeys: [],
managedSources: [], identities: [],
allTags: [], proxyProfiles: [],
allHosts: [], groups: [],
terminalThemeId: "default", managedSources: [],
terminalFontSize: 14, allTags: [],
onSave: () => {}, allHosts: [],
onCancel: () => {}, terminalThemeId: "default",
}), terminalFontSize: 14,
onSave: () => {},
onCancel: () => {},
}),
),
), ),
); );
@@ -111,29 +116,33 @@ test("HostDetailsPanel displays inherited telnet port before falling back to 23"
React.createElement( React.createElement(
I18nProvider, I18nProvider,
{ locale: "en" }, { locale: "en" },
React.createElement(HostDetailsPanel, { React.createElement(
initialData: { TooltipProvider,
...hostWithMissingProxyProfile, null,
protocol: "telnet", React.createElement(HostDetailsPanel, {
telnetEnabled: true, initialData: {
telnetPort: undefined, ...hostWithMissingProxyProfile,
port: undefined, protocol: "telnet",
group: "network", telnetEnabled: true,
proxyProfileId: undefined, telnetPort: undefined,
}, port: undefined,
availableKeys: [], group: "network",
identities: [], proxyProfileId: undefined,
proxyProfiles: [], },
groups: ["network"], availableKeys: [],
managedSources: [], identities: [],
allTags: [], proxyProfiles: [],
allHosts: [], groups: ["network"],
terminalThemeId: "default", managedSources: [],
terminalFontSize: 14, allTags: [],
groupConfigs: [{ path: "network", telnetPort: 2325 }], allHosts: [],
onSave: () => {}, terminalThemeId: "default",
onCancel: () => {}, terminalFontSize: 14,
}), groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
),
), ),
); );
@@ -145,29 +154,33 @@ test("HostDetailsPanel uses group telnet port instead of ssh port for optional t
React.createElement( React.createElement(
I18nProvider, I18nProvider,
{ locale: "en" }, { locale: "en" },
React.createElement(HostDetailsPanel, { React.createElement(
initialData: { TooltipProvider,
...hostWithMissingProxyProfile, null,
protocol: "ssh", React.createElement(HostDetailsPanel, {
telnetEnabled: true, initialData: {
telnetPort: undefined, ...hostWithMissingProxyProfile,
port: 2222, protocol: "ssh",
group: "network", telnetEnabled: true,
proxyProfileId: undefined, telnetPort: undefined,
}, port: 2222,
availableKeys: [], group: "network",
identities: [], proxyProfileId: undefined,
proxyProfiles: [], },
groups: ["network"], availableKeys: [],
managedSources: [], identities: [],
allTags: [], proxyProfiles: [],
allHosts: [], groups: ["network"],
terminalThemeId: "default", managedSources: [],
terminalFontSize: 14, allTags: [],
groupConfigs: [{ path: "network", telnetPort: 2325 }], allHosts: [],
onSave: () => {}, terminalThemeId: "default",
onCancel: () => {}, terminalFontSize: 14,
}), groupConfigs: [{ path: "network", telnetPort: 2325 }],
onSave: () => {},
onCancel: () => {},
}),
),
), ),
); );
@@ -181,35 +194,39 @@ test("HostDetailsPanel displays inherited telnet credentials", () => {
React.createElement( React.createElement(
I18nProvider, I18nProvider,
{ locale: "en" }, { locale: "en" },
React.createElement(HostDetailsPanel, { React.createElement(
initialData: { TooltipProvider,
...hostWithMissingProxyProfile, null,
protocol: "telnet", React.createElement(HostDetailsPanel, {
telnetEnabled: true, initialData: {
telnetUsername: undefined, ...hostWithMissingProxyProfile,
telnetPassword: undefined, protocol: "telnet",
username: "ssh-user", telnetEnabled: true,
password: "ssh-password", telnetUsername: undefined,
group: "network", telnetPassword: undefined,
proxyProfileId: undefined, username: "ssh-user",
}, password: "ssh-password",
availableKeys: [], group: "network",
identities: [], proxyProfileId: undefined,
proxyProfiles: [], },
groups: ["network"], availableKeys: [],
managedSources: [], identities: [],
allTags: [], proxyProfiles: [],
allHosts: [], groups: ["network"],
terminalThemeId: "default", managedSources: [],
terminalFontSize: 14, allTags: [],
groupConfigs: [{ allHosts: [],
path: "network", terminalThemeId: "default",
telnetUsername: "group-telnet-user", terminalFontSize: 14,
telnetPassword: "group-telnet-password", groupConfigs: [{
}], path: "network",
onSave: () => {}, telnetUsername: "group-telnet-user",
onCancel: () => {}, telnetPassword: "group-telnet-password",
}), }],
onSave: () => {},
onCancel: () => {},
}),
),
), ),
); );

View File

@@ -938,15 +938,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{selectedIdentity.label} {selectedIdentity.label}
</div> </div>
</div> </div>
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-8 w-8 shrink-0" variant="ghost"
onClick={clearIdentity} size="icon"
title={t("common.clear")} className="h-8 w-8 shrink-0"
> onClick={clearIdentity}
<X size={14} /> >
</Button> <X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.clear")}</TooltipContent>
</Tooltip>
</div> </div>
) : form.identityId ? ( ) : form.identityId ? (
<div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60"> <div className="flex items-center gap-2 h-10 px-3 rounded-md border border-border/70 bg-secondary/60">
@@ -956,15 +960,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.identity.missing")} {t("hostDetails.identity.missing")}
</div> </div>
</div> </div>
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-8 w-8 shrink-0" variant="ghost"
onClick={clearIdentity} size="icon"
title={t("common.clear")} className="h-8 w-8 shrink-0"
> onClick={clearIdentity}
<X size={14} /> >
</Button> <X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.clear")}</TooltipContent>
</Tooltip>
</div> </div>
) : ( ) : (
(() => { (() => {
@@ -1019,29 +1027,33 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}} }}
className="h-10 pr-9" className="h-10 pr-9"
/> />
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors" <button
onClick={() => { type="button"
setIdentitySuggestionsOpen((prev) => { className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
if (prev) return false; onClick={() => {
const q = (form.username || "") setIdentitySuggestionsOpen((prev) => {
.toLowerCase() if (prev) return false;
.trim(); const q = (form.username || "")
const matches = q .toLowerCase()
? identities.filter( .trim();
(i) => const matches = q
i.label.toLowerCase().includes(q) || ? identities.filter(
i.username.toLowerCase().includes(q), (i) =>
) i.label.toLowerCase().includes(q) ||
: identities; i.username.toLowerCase().includes(q),
return matches.length > 0; )
}); : identities;
}} return matches.length > 0;
title={t("hostDetails.identity.suggestions")} });
> }}
<ChevronDown size={16} /> >
</button> <ChevronDown size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.identity.suggestions")}</TooltipContent>
</Tooltip>
</div> </div>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
@@ -1123,14 +1135,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onChange={(e) => update("password", e.target.value)} onChange={(e) => update("password", e.target.value)}
className="h-10 pr-10" className="h-10 pr-10"
/> />
<button <Tooltip>
type="button" <TooltipTrigger asChild>
onClick={() => setShowPassword(!showPassword)} <button
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors" type="button"
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")} onClick={() => setShowPassword(!showPassword)}
> className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />} >
</button> {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</TooltipTrigger>
<TooltipContent>{showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}</TooltipContent>
</Tooltip>
</div> </div>
)} )}
@@ -1153,9 +1169,14 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{form.identityFilePaths.map((keyPath, idx) => ( {form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden"> <div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60 overflow-hidden">
<FileKey size={14} className="text-primary shrink-0" /> <FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}> <Tooltip>
{keyPath} <TooltipTrigger asChild>
</span> <span className="text-xs w-0 flex-1 truncate font-mono cursor-default">
{keyPath}
</span>
</TooltipTrigger>
<TooltipContent>{keyPath}</TooltipContent>
</Tooltip>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -1366,26 +1387,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
} }
}} }}
/> />
<Button <Tooltip>
variant="secondary" <TooltipTrigger asChild>
size="icon" <Button
className="h-8 w-8 shrink-0" variant="secondary"
title={t("hostDetails.credential.browseKeyFile")} size="icon"
onClick={async () => { className="h-8 w-8 shrink-0"
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty; onClick={async () => {
if (!bridge?.selectFile) return; const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
const filePath = await bridge.selectFile( if (!bridge?.selectFile) return;
"Select SSH Private Key", const filePath = await bridge.selectFile(
undefined, "Select SSH Private Key",
[{ name: "All Files", extensions: ["*"] }] undefined,
); [{ name: "All Files", extensions: ["*"] }]
if (filePath) { );
addLocalKeyFilePath(filePath); if (filePath) {
} addLocalKeyFilePath(filePath);
}} }
> }}
<FolderOpen size={14} /> >
</Button> <FolderOpen size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
</Tooltip>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -1794,16 +1819,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p> </p>
</div> </div>
)} )}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p> <p className="text-xs text-muted-foreground">{t("hostDetails.backspaceBehavior")}</p>
<select <Select
className="h-8 rounded-md border border-input bg-background px-2 text-xs" value={form.backspaceBehavior ?? "default"}
value={form.backspaceBehavior ?? ""} onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
> >
<option value="">{t("hostDetails.backspaceBehavior.default")}</option> <SelectTrigger className="h-8 w-auto text-xs">
<option value="ctrl-h">^H (0x08)</option> <SelectValue />
</select> </SelectTrigger>
<SelectContent>
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
</SelectContent>
</Select>
</div> </div>
</Card> </Card>

View File

@@ -54,6 +54,7 @@ import { Input } from "./ui/input";
import { Label } from "./ui/label"; import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea"; import { Textarea } from "./ui/textarea";
import { toast } from "./ui/toast"; import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
// Import utilities and components from keychain module // Import utilities and components from keychain module
import { import {
@@ -1168,9 +1169,14 @@ echo $3 >> "$FILE"`);
</Label> </Label>
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60"> <div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<FileKey size={14} className="text-primary shrink-0" /> <FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs font-mono truncate" title={draftKey.filePath}> <Tooltip>
{draftKey.filePath} <TooltipTrigger asChild>
</span> <span className="text-xs font-mono truncate cursor-default">
{draftKey.filePath}
</span>
</TooltipTrigger>
<TooltipContent>{draftKey.filePath}</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
)} )}

View File

@@ -37,6 +37,7 @@ import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area"; import { ScrollArea } from "./ui/scroll-area";
import { SortDropdown, SortMode } from "./ui/sort-dropdown"; import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { toast } from "./ui/toast"; import { toast } from "./ui/toast";
interface KnownHostsManagerProps { interface KnownHostsManagerProps {
@@ -122,27 +123,35 @@ const HostItem = React.memo<HostItemProps>(
{/* Quick action buttons on hover */} {/* Quick action buttons on hover */}
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{!converted && ( {!converted && (
<button <Tooltip>
className="p-1 rounded hover:bg-primary/20 text-primary" <TooltipTrigger asChild>
onClick={(e) => { <button
e.stopPropagation(); className="p-1 rounded hover:bg-primary/20 text-primary"
onConvertToHost(knownHost); onClick={(e) => {
}} e.stopPropagation();
title={t("action.convertToHost")} onConvertToHost(knownHost);
> }}
<ArrowRight size={12} /> >
</button> <ArrowRight size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)} )}
<button <Tooltip>
className="p-1 rounded hover:bg-destructive/20 text-destructive" <TooltipTrigger asChild>
onClick={(e) => { <button
e.stopPropagation(); className="p-1 rounded hover:bg-destructive/20 text-destructive"
onDelete(knownHost.id); onClick={(e) => {
}} e.stopPropagation();
title={t("action.remove")} onDelete(knownHost.id);
> }}
<Trash2 size={12} /> >
</button> <Trash2 size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("action.remove")}</TooltipContent>
</Tooltip>
</div> </div>
<div className="flex items-center gap-3 h-full"> <div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0"> <div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
@@ -193,18 +202,22 @@ const HostItem = React.memo<HostItemProps>(
</div> </div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!converted && ( {!converted && (
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-8 w-8" variant="ghost"
onClick={(e) => { size="icon"
e.stopPropagation(); className="h-8 w-8"
onConvertToHost(knownHost); onClick={(e) => {
}} e.stopPropagation();
title={t("action.convertToHost")} onConvertToHost(knownHost);
> }}
<ArrowRight size={14} /> >
</Button> <ArrowRight size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
</Tooltip>
)} )}
</div> </div>
</div> </div>

View File

@@ -277,7 +277,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
className="gap-1.5 h-8 px-2" className="gap-1.5 h-8 px-2"
onClick={handleExport} onClick={handleExport}
disabled={isExporting} disabled={isExporting}
title={t("logView.export")}
> >
<Download size={14} /> <Download size={14} />
<span className="text-xs">{t("logView.export")}</span> <span className="text-xs">{t("logView.export")}</span>
@@ -290,7 +289,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
size="sm" size="sm"
className="gap-1.5 h-8 px-2" className="gap-1.5 h-8 px-2"
onClick={() => setThemeModalOpen(true)} onClick={() => setThemeModalOpen(true)}
title={t("logView.customizeAppearance")}
> >
<Palette size={14} /> <Palette size={14} />
<span className="text-xs">{t("logView.appearance")}</span> <span className="text-xs">{t("logView.appearance")}</span>

View File

@@ -298,7 +298,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onClose(); 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" 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"
title="New Workspace"
> >
<Plus size={11} /> <Plus size={11} />
<span>New Workspace</span> <span>New Workspace</span>

View File

@@ -249,15 +249,19 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
className="h-7 pl-7 text-xs bg-muted/30 border-none" className="h-7 pl-7 text-xs bg-muted/30 border-none"
/> />
</div> </div>
<button <Tooltip>
type="button" <TooltipTrigger asChild>
onClick={handleAddSnippet} <button
title={t('snippets.action.newSnippet')} type="button"
aria-label={t('snippets.action.newSnippet')} onClick={handleAddSnippet}
className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors" aria-label={t('snippets.action.newSnippet')}
> className="shrink-0 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
<Plus size={14} /> >
</button> <Plus size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('snippets.action.newSnippet')}</TooltipContent>
</Tooltip>
</div> </div>
{/* Content */} {/* Content */}

View File

@@ -20,6 +20,7 @@ import {
} from './ui/dialog'; } from './ui/dialog';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
interface SerialPort { interface SerialPort {
@@ -262,35 +263,41 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label> <Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<select <Select
id="data-bits" value={String(dataBits)}
value={dataBits} onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
> >
{DATA_BITS.map((bits) => ( <SelectTrigger id="data-bits">
<option key={bits} value={bits}> <SelectValue />
{bits} </SelectTrigger>
</option> <SelectContent>
))} {DATA_BITS.map((bits) => (
</select> <SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{/* Stop Bits */} {/* Stop Bits */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label> <Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<select <Select
id="stop-bits" value={String(stopBits)}
value={stopBits} onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
> >
{STOP_BITS.map((bits) => ( <SelectTrigger id="stop-bits">
<option key={bits} value={bits}> <SelectValue />
{bits} </SelectTrigger>
</option> <SelectContent>
))} {STOP_BITS.map((bits) => (
</select> <SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && ( {isStopBits15 && (
<p className="text-xs text-yellow-500"> <p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')} {t('serial.field.stopBits15Warning')}
@@ -302,35 +309,41 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
{/* Parity */} {/* Parity */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label> <Label htmlFor="parity">{t('serial.field.parity')}</Label>
<select <Select
id="parity"
value={parity} value={parity}
onChange={(e) => setParity(e.target.value as SerialParity)} onValueChange={(v) => setParity(v as SerialParity)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
> >
{PARITY_OPTIONS.map((option) => ( <SelectTrigger id="parity">
<option key={option} value={option}> <SelectValue />
{t(`serial.parity.${option}`)} </SelectTrigger>
</option> <SelectContent>
))} {PARITY_OPTIONS.map((option) => (
</select> <SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{/* Flow Control */} {/* Flow Control */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label> <Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<select <Select
id="flow-control"
value={flowControl} value={flowControl}
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)} onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
> >
{FLOW_CONTROL_OPTIONS.map((option) => ( <SelectTrigger id="flow-control">
<option key={option} value={option}> <SelectValue />
{t(`serial.flowControl.${option}`)} </SelectTrigger>
</option> <SelectContent>
))} {FLOW_CONTROL_OPTIONS.map((option) => (
</select> <SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{/* Terminal Options */} {/* Terminal Options */}

View File

@@ -12,6 +12,7 @@ import { Button } from './ui/button';
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox'; import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { import {
AsidePanel, AsidePanel,
@@ -291,35 +292,41 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label> <Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<select <Select
id="data-bits" value={String(dataBits)}
value={dataBits} onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
onChange={(e) => setDataBits(parseInt(e.target.value, 10) as 5 | 6 | 7 | 8)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
> >
{DATA_BITS.map((bits) => ( <SelectTrigger id="data-bits">
<option key={bits} value={bits}> <SelectValue />
{bits} </SelectTrigger>
</option> <SelectContent>
))} {DATA_BITS.map((bits) => (
</select> <SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{/* Stop Bits */} {/* Stop Bits */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label> <Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<select <Select
id="stop-bits" value={String(stopBits)}
value={stopBits} onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
onChange={(e) => setStopBits(parseFloat(e.target.value) as 1 | 1.5 | 2)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
> >
{STOP_BITS.map((bits) => ( <SelectTrigger id="stop-bits">
<option key={bits} value={bits}> <SelectValue />
{bits} </SelectTrigger>
</option> <SelectContent>
))} {STOP_BITS.map((bits) => (
</select> <SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && ( {isStopBits15 && (
<p className="text-xs text-yellow-500"> <p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')} {t('serial.field.stopBits15Warning')}
@@ -331,35 +338,41 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
{/* Parity */} {/* Parity */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label> <Label htmlFor="parity">{t('serial.field.parity')}</Label>
<select <Select
id="parity"
value={parity} value={parity}
onChange={(e) => setParity(e.target.value as SerialParity)} onValueChange={(v) => setParity(v as SerialParity)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
> >
{PARITY_OPTIONS.map((option) => ( <SelectTrigger id="parity">
<option key={option} value={option}> <SelectValue />
{t(`serial.parity.${option}`)} </SelectTrigger>
</option> <SelectContent>
))} {PARITY_OPTIONS.map((option) => (
</select> <SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{/* Flow Control */} {/* Flow Control */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label> <Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<select <Select
id="flow-control"
value={flowControl} value={flowControl}
onChange={(e) => setFlowControl(e.target.value as SerialFlowControl)} onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
> >
{FLOW_CONTROL_OPTIONS.map((option) => ( <SelectTrigger id="flow-control">
<option key={option} value={option}> <SelectValue />
{t(`serial.flowControl.${option}`)} </SelectTrigger>
</option> <SelectContent>
))} {FLOW_CONTROL_OPTIONS.map((option) => (
</select> <SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
{/* Terminal Options */} {/* Terminal Options */}

View File

@@ -20,6 +20,7 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab"; import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab")); const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
@@ -187,13 +188,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
<div className="flex items-center justify-between px-4 py-2"> <div className="flex items-center justify-between px-4 py-2">
<h1 className="text-lg font-semibold">{t("settings.title")}</h1> <h1 className="text-lg font-semibold">{t("settings.title")}</h1>
{!isMac && ( {!isMac && (
<button <Tooltip>
onClick={handleClose} <TooltipTrigger asChild>
className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground" <button
title={t("common.close")} onClick={handleClose}
> className="app-no-drag w-8 h-8 flex items-center justify-center rounded-md hover:bg-destructive/20 hover:text-destructive transition-colors text-muted-foreground"
<X size={16} /> >
</button> <X size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t("common.close")}</TooltipContent>
</Tooltip>
)} )}
</div> </div>
</div> </div>

View File

@@ -26,6 +26,7 @@ import type { DropEntry } from "../lib/sftpFileUtils";
import { Host, Identity, SSHKey } from "../types"; import { Host, Identity, SSHKey } from "../types";
import type { TransferTask } from "../types"; import type { TransferTask } from "../types";
import { toast } from "./ui/toast"; import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { DistroAvatar } from "./DistroAvatar"; import { DistroAvatar } from "./DistroAvatar";
import { SftpPaneView } from "./sftp/SftpPaneView"; import { SftpPaneView } from "./sftp/SftpPaneView";
@@ -653,18 +654,22 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
size="sm" size="sm"
className="h-5 w-5 rounded-sm shrink-0" className="h-5 w-5 rounded-sm shrink-0"
/> />
<div <Tooltip>
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate" <TooltipTrigger asChild>
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`} <div className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate cursor-default">
> <span className="font-medium">
<span className="font-medium"> {displayHost.label}
{displayHost.label} </span>
</span> <span className="mx-1 text-muted-foreground">·</span>
<span className="mx-1 text-muted-foreground">·</span> <span className="font-mono text-muted-foreground">
<span className="font-mono text-muted-foreground"> {(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22} </span>
</span> </div>
</div> </TooltipTrigger>
<TooltipContent>
{`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
)} )}

View File

@@ -745,21 +745,25 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
actions={ actions={
<> <>
{editingSnippet.id && ( {editingSnippet.id && (
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-8 w-8 text-destructive hover:text-destructive" variant="ghost"
onClick={() => { size="icon"
const id = editingSnippet.id; className="h-8 w-8 text-destructive hover:text-destructive"
if (!id) return; onClick={() => {
onDelete(id); const id = editingSnippet.id;
handleClosePanel(); if (!id) return;
}} onDelete(id);
aria-label={t('common.delete')} handleClosePanel();
title={t('common.delete')} }}
> aria-label={t('common.delete')}
<Trash2 size={16} /> >
</Button> <Trash2 size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.delete')}</TooltipContent>
</Tooltip>
)} )}
<Button <Button
variant="ghost" variant="ghost"
@@ -839,18 +843,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p> <p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
{editingSnippet.shortkey && ( {editingSnippet.shortkey && (
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="sm" <Button
className="h-6 px-2 text-xs" variant="ghost"
onClick={() => { size="sm"
setEditingSnippet(prev => ({ ...prev, shortkey: undefined })); className="h-6 px-2 text-xs"
setShortkeyError(null); onClick={() => {
}} setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
title={t('snippets.shortkey.clear')} setShortkeyError(null);
> }}
<RotateCcw size={12} /> >
</Button> <RotateCcw size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('snippets.shortkey.clear')}</TooltipContent>
</Tooltip>
)} )}
</div> </div>
<button <button
@@ -1269,7 +1277,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
value={newPackageName} value={newPackageName}
onChange={(e) => setNewPackageName(e.target.value)} onChange={(e) => setNewPackageName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && createPackage()} onKeyDown={(e) => e.key === 'Enter' && createPackage()}
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
/> />
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p> <p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
</div> </div>

View File

@@ -35,6 +35,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from './ui/popover'; } from './ui/popover';
import { toast } from './ui/toast'; import { toast } from './ui/toast';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
// ============================================================================ // ============================================================================
// Provider Icons // Provider Icons
@@ -169,26 +170,30 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
return ( return (
<Popover open={isOpen} onOpenChange={setIsOpen}> <Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="ghost" <PopoverTrigger asChild>
size="icon" <Button
className={cn( variant="ghost"
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag", size="icon"
className className={cn(
)} "h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
title={t('sync.cloudSync')} className
> )}
{getButtonIcon()} >
{getButtonIcon()}
{/* Status indicator dot */} {/* Status indicator dot */}
<StatusIndicator <StatusIndicator
status={overallStatus} status={overallStatus}
size="sm" size="sm"
className="absolute top-0.5 right-0.5 ring-2 ring-background" className="absolute top-0.5 right-0.5 ring-2 ring-background"
/> />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t('sync.cloudSync')}</TooltipContent>
</Tooltip>
<PopoverContent <PopoverContent
key={syncStateKey} key={syncStateKey}
@@ -222,16 +227,20 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
</div> </div>
{onOpenSettings && ( {onOpenSettings && (
<button <Tooltip>
onClick={() => { <TooltipTrigger asChild>
setIsOpen(false); <button
onOpenSettings(); onClick={() => {
}} setIsOpen(false);
className="p-1 rounded hover:bg-muted transition-colors" onOpenSettings();
title={t('sync.settings')} }}
> className="p-1 rounded hover:bg-muted transition-colors"
<Settings size={14} className="text-muted-foreground" /> >
</button> <Settings size={14} className="text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent>{t('sync.settings')}</TooltipContent>
</Tooltip>
)} )}
</div> </div>
</div> </div>

View File

@@ -35,6 +35,7 @@ import { useTerminalBackend } from "../application/state/useTerminalBackend";
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer // SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { toast } from "./ui/toast"; import { toast } from "./ui/toast";
import { useAvailableFonts } from "../application/state/fontStore"; import { useAvailableFonts } from "../application/state/fontStore";
import { composeFontFamilyStack, type SupportedPlatform } from "../infrastructure/config/cjkFonts"; import { composeFontFamilyStack, type SupportedPlatform } from "../infrastructure/config/cjkFonts";
@@ -1877,21 +1878,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)} )}
/> />
{host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && ( {host.protocol !== "local" && host.hostname && host.hostname !== "localhost" && (
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="ml-0.5 p-0.5 rounded hover:bg-[color:var(--terminal-toolbar-btn-hover)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0" <button
onClick={() => { type="button"
void navigator.clipboard.writeText(host.hostname).then(() => { className="ml-0.5 p-0.5 rounded hover:bg-[color:var(--terminal-toolbar-btn-hover)] transition-colors opacity-60 hover:opacity-100 flex-shrink-0"
toast.success(t("terminal.statusbar.copyHostname.toast", { hostname: host.hostname })); onClick={() => {
}).catch(() => { void navigator.clipboard.writeText(host.hostname).then(() => {
toast.error(t("terminal.statusbar.copyHostname.error")); toast.success(t("terminal.statusbar.copyHostname.toast", { hostname: host.hostname }));
}); }).catch(() => {
}} toast.error(t("terminal.statusbar.copyHostname.error"));
title={t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })} });
aria-label={t("terminal.statusbar.copyHostname.label")} }}
> aria-label={t("terminal.statusbar.copyHostname.label")}
<Copy size={10} /> >
</button> <Copy size={10} />
</button>
</TooltipTrigger>
<TooltipContent>{t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}</TooltipContent>
</Tooltip>
)} )}
</div> </div>
{/* Server Stats Display */} {/* Server Stats Display */}
@@ -1902,7 +1907,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<button <button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0" className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.cpu")} aria-label={t("terminal.serverStats.cpu")}
> >
<Cpu size={10} className="flex-shrink-0" /> <Cpu size={10} className="flex-shrink-0" />
<span> <span>
@@ -1971,7 +1976,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<button <button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0" className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.memory")} aria-label={t("terminal.serverStats.memory")}
> >
<MemoryStick size={10} className="flex-shrink-0" /> <MemoryStick size={10} className="flex-shrink-0" />
<span> <span>
@@ -1993,12 +1998,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
{serverStats.memTotal !== null && ( {serverStats.memTotal !== null && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="w-full h-3 bg-muted rounded overflow-hidden flex"> <div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{/* Used (green) */} {/* Used (green) — exact value shown in legend below */}
{serverStats.memUsed !== null && serverStats.memUsed > 0 && ( {serverStats.memUsed !== null && serverStats.memUsed > 0 && (
<div <div
className="h-full bg-emerald-500" className="h-full bg-emerald-500"
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }} style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memUsed")}: ${(serverStats.memUsed / 1024).toFixed(1)}G`}
/> />
)} )}
{/* Buffers (blue) */} {/* Buffers (blue) */}
@@ -2006,7 +2010,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div <div
className="h-full bg-blue-500" className="h-full bg-blue-500"
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }} style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memBuffers")}: ${(serverStats.memBuffers / 1024).toFixed(1)}G`}
/> />
)} )}
{/* Cached (amber/orange) */} {/* Cached (amber/orange) */}
@@ -2014,7 +2017,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div <div
className="h-full bg-amber-500" className="h-full bg-amber-500"
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }} style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memCached")}: ${(serverStats.memCached / 1024).toFixed(1)}G`}
/> />
)} )}
</div> </div>
@@ -2048,7 +2050,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div <div
className="h-full bg-rose-500" className="h-full bg-rose-500"
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }} style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
/> />
)} )}
</div> </div>
@@ -2081,9 +2082,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }} style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
/> />
</div> </div>
<span className="flex-shrink-0 font-mono truncate max-w-[140px]" title={proc.command}> <Tooltip>
{proc.command.split('/').pop()?.split(' ')[0] || proc.command} <TooltipTrigger asChild>
</span> <span className="flex-shrink-0 font-mono truncate max-w-[140px] cursor-default">
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
</TooltipTrigger>
<TooltipContent>{proc.command}</TooltipContent>
</Tooltip>
</div> </div>
))} ))}
</div> </div>
@@ -2097,7 +2103,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<button <button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0" className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.disk")} aria-label={t("terminal.serverStats.disk")}
> >
<HardDrive size={10} className="flex-shrink-0" /> <HardDrive size={10} className="flex-shrink-0" />
<span className={cn( <span className={cn(
@@ -2125,9 +2131,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
{serverStats.disks.map((disk, index) => ( {serverStats.disks.map((disk, index) => (
<div key={index} className="flex flex-col gap-1 min-w-[180px]"> <div key={index} className="flex flex-col gap-1 min-w-[180px]">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]" title={disk.mountPoint}> <Tooltip>
{disk.mountPoint} <TooltipTrigger asChild>
</span> <span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px] cursor-default">
{disk.mountPoint}
</span>
</TooltipTrigger>
<TooltipContent>{disk.mountPoint}</TooltipContent>
</Tooltip>
<span className={cn( <span className={cn(
"text-[11px] font-medium whitespace-nowrap", "text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400" disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
@@ -2159,7 +2170,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<button <button
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0" className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.network")} aria-label={t("terminal.serverStats.network")}
> >
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" /> <ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span> <span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
@@ -2203,40 +2214,48 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div className="flex-1" /> <div className="flex-1" />
<div className="flex items-center gap-0.5 flex-shrink-0"> <div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && ( {inWorkspace && onToggleBroadcast && (
<Button <Tooltip>
variant="secondary" <TooltipTrigger asChild>
size="icon" <Button
className={cn( variant="secondary"
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]", size="icon"
"bg-transparent hover:bg-transparent", className={cn(
isBroadcastEnabled && "text-green-500", "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
)} "bg-transparent hover:bg-transparent",
onClick={onToggleBroadcast} isBroadcastEnabled && "text-green-500",
title={ )}
isBroadcastEnabled onClick={onToggleBroadcast}
aria-label={
isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
>
<Radio size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>
{isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable") ? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable") : t("terminal.toolbar.broadcastEnable")}
} </TooltipContent>
aria-label={ </Tooltip>
isBroadcastEnabled
? t("terminal.toolbar.broadcastDisable")
: t("terminal.toolbar.broadcastEnable")
}
>
<Radio size={12} />
</Button>
)} )}
{inWorkspace && !isFocusMode && onExpandToFocus && ( {inWorkspace && !isFocusMode && onExpandToFocus && (
<Button <Tooltip>
variant="secondary" <TooltipTrigger asChild>
size="icon" <Button
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent" variant="secondary"
onClick={onExpandToFocus} size="icon"
title={t("terminal.toolbar.focusMode")} className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
aria-label={t("terminal.toolbar.focusMode")} onClick={onExpandToFocus}
> aria-label={t("terminal.toolbar.focusMode")}
<Maximize2 size={12} /> >
</Button> <Maximize2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.toolbar.focusMode")}</TooltipContent>
</Tooltip>
)} )}
{renderControls({ showClose: inWorkspace })} {renderControls({ showClose: inWorkspace })}
</div> </div>

View File

@@ -41,6 +41,8 @@ import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig'; import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { materializeHostProxyProfile } from '../domain/proxyProfiles'; import { materializeHostProxyProfile } from '../domain/proxyProfiles';
import { DistroAvatar } from './DistroAvatar'; import { DistroAvatar } from './DistroAvatar';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { useI18n } from '../application/i18n/I18nProvider';
import Terminal from './Terminal'; import Terminal from './Terminal';
import { SftpSidePanel } from './SftpSidePanel'; import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel'; import { ScriptsSidePanel } from './ScriptsSidePanel';
@@ -507,6 +509,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
toggleScriptsSidePanelRef, toggleScriptsSidePanelRef,
activeSidePanelTabRef, activeSidePanelTabRef,
}) => { }) => {
const { t } = useI18n();
// Subscribe to activeTabId from external store // Subscribe to activeTabId from external store
const activeTabId = useActiveTabId(); const activeTabId = useActiveTabId();
const isVaultActive = activeTabId === 'vault'; const isVaultActive = activeTabId === 'vault';
@@ -2099,27 +2102,35 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
/> />
</div> </div>
{onRequestAddToWorkspace && ( {onRequestAddToWorkspace && (
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="sm" <Button
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit" variant="ghost"
style={{ color: mutedFg }} size="sm"
onClick={() => onRequestAddToWorkspace(activeWorkspace.id)} className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
title="Add Terminal" style={{ color: mutedFg }}
> onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}
<Plus size={14} /> >
</Button> <Plus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.addTerminal')}</TooltipContent>
</Tooltip>
)} )}
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="sm" <Button
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit" variant="ghost"
style={{ color: mutedFg }} size="sm"
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)} className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
title="Switch to Split View" style={{ color: mutedFg }}
> onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
<Columns2 size={14} /> >
</Button> <Columns2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.switchToSplitView')}</TooltipContent>
</Tooltip>
</div> </div>
{/* Session list */} {/* Session list */}
@@ -2252,111 +2263,137 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
borderBottom: '1px solid var(--terminal-sidepanel-border)', borderBottom: '1px solid var(--terminal-sidepanel-border)',
}} }}
> >
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
data-tab-id="sftp" variant="ghost"
data-tab-type="sidepanel" size="icon"
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'} data-tab-id="sftp"
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent" data-tab-type="sidepanel"
style={{ data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
backgroundColor: activeSidePanelTab === 'sftp' className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)' style={{
: 'transparent', backgroundColor: activeSidePanelTab === 'sftp'
color: activeSidePanelTab === 'sftp' ? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
? 'var(--terminal-sidepanel-fg)' : 'transparent',
: 'var(--terminal-sidepanel-muted)', color: activeSidePanelTab === 'sftp'
}} ? 'var(--terminal-sidepanel-fg)'
onClick={handleToggleSftpFromBar} : 'var(--terminal-sidepanel-muted)',
title="SFTP" }}
> onClick={handleToggleSftpFromBar}
<FolderTree size={15} /> >
</Button> <FolderTree size={15} />
<Button </Button>
variant="ghost" </TooltipTrigger>
size="icon" <TooltipContent>{t('terminal.layer.sftp')}</TooltipContent>
data-tab-id="scripts" </Tooltip>
data-tab-type="sidepanel" <Tooltip>
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'} <TooltipTrigger asChild>
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent" <Button
style={{ variant="ghost"
backgroundColor: activeSidePanelTab === 'scripts' size="icon"
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)' data-tab-id="scripts"
: 'transparent', data-tab-type="sidepanel"
color: activeSidePanelTab === 'scripts' data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
? 'var(--terminal-sidepanel-fg)' className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
: 'var(--terminal-sidepanel-muted)', style={{
}} backgroundColor: activeSidePanelTab === 'scripts'
onClick={handleOpenScripts} ? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
title="Scripts" : 'transparent',
> color: activeSidePanelTab === 'scripts'
<Zap size={15} /> ? 'var(--terminal-sidepanel-fg)'
</Button> : 'var(--terminal-sidepanel-muted)',
<Button }}
variant="ghost" onClick={handleOpenScripts}
size="icon" >
data-tab-id="theme" <Zap size={15} />
data-tab-type="sidepanel" </Button>
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'} </TooltipTrigger>
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent" <TooltipContent>{t('terminal.layer.scripts')}</TooltipContent>
style={{ </Tooltip>
backgroundColor: activeSidePanelTab === 'theme' <Tooltip>
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)' <TooltipTrigger asChild>
: 'transparent', <Button
color: activeSidePanelTab === 'theme' variant="ghost"
? 'var(--terminal-sidepanel-fg)' size="icon"
: 'var(--terminal-sidepanel-muted)', data-tab-id="theme"
}} data-tab-type="sidepanel"
onClick={handleOpenTheme} data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
title="Theme" className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
> style={{
<Palette size={15} /> backgroundColor: activeSidePanelTab === 'theme'
</Button> ? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
<Button : 'transparent',
variant="ghost" color: activeSidePanelTab === 'theme'
size="icon" ? 'var(--terminal-sidepanel-fg)'
data-tab-id="ai" : 'var(--terminal-sidepanel-muted)',
data-tab-type="sidepanel" }}
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'} onClick={handleOpenTheme}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent" >
style={{ <Palette size={15} />
backgroundColor: activeSidePanelTab === 'ai' </Button>
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)' </TooltipTrigger>
: 'transparent', <TooltipContent>{t('terminal.layer.theme')}</TooltipContent>
color: activeSidePanelTab === 'ai' </Tooltip>
? 'var(--terminal-sidepanel-fg)' <Tooltip>
: 'var(--terminal-sidepanel-muted)', <TooltipTrigger asChild>
}} <Button
onClick={handleOpenAI} variant="ghost"
title="AI Chat" size="icon"
> data-tab-id="ai"
<MessageSquare size={15} /> data-tab-type="sidepanel"
</Button> data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
backgroundColor: activeSidePanelTab === 'ai'
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
: 'transparent',
color: activeSidePanelTab === 'ai'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleOpenAI}
>
<MessageSquare size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.aiChat')}</TooltipContent>
</Tooltip>
<div className="flex-1" /> <div className="flex-1" />
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-7 w-7 rounded-md p-0 hover:bg-transparent" variant="ghost"
style={{ size="icon"
color: 'var(--terminal-sidepanel-muted)', className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
}} style={{
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')} color: 'var(--terminal-sidepanel-muted)',
title={sidePanelPosition === 'left' ? 'Move panel to right' : 'Move panel to left'} }}
> onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
{sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />} >
</Button> {sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />}
<Button </Button>
variant="ghost" </TooltipTrigger>
size="icon" <TooltipContent>
className="h-7 w-7 rounded-md p-0 hover:bg-transparent" {sidePanelPosition === 'left' ? t('terminal.layer.movePanelRight') : t('terminal.layer.movePanelLeft')}
style={{ </TooltipContent>
color: 'var(--terminal-sidepanel-muted)', </Tooltip>
}} <Tooltip>
onClick={handleCloseSidePanel} <TooltipTrigger asChild>
title="Close panel" <Button
> variant="ghost"
<X size={15} /> size="icon"
</Button> className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleCloseSidePanel}
>
<X size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.layer.closePanel')}</TooltipContent>
</Tooltip>
</div> </div>
)} )}
<div className="flex-1 min-h-0 relative"> <div className="flex-1 min-h-0 relative">

View File

@@ -14,6 +14,7 @@ import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells'; import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { SyncStatusButton } from './SyncStatusButton'; import { SyncStatusButton } from './SyncStatusButton';
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion) // Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
@@ -205,7 +206,6 @@ const WindowControls: React.FC = memo(() => {
onClick={handleMinimize} onClick={handleMinimize}
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag" className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }} style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="Minimize"
> >
<Minus size={16} /> <Minus size={16} />
</button> </button>
@@ -213,20 +213,16 @@ const WindowControls: React.FC = memo(() => {
onClick={handleMaximize} onClick={handleMaximize}
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag" className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }} style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title={isMaximized ? "Restore" : "Maximize"}
> >
{isMaximized ? ( {isMaximized ? (
// Restore icon (two overlapping squares)
<Copy size={14} /> <Copy size={14} />
) : ( ) : (
// Maximize icon (single square)
<Square size={14} /> <Square size={14} />
)} )}
</button> </button>
<button <button
onClick={handleClose} onClick={handleClose}
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag" className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
title="Close"
> >
<X size={16} /> <X size={16} />
</button> </button>
@@ -577,60 +573,63 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText; const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText;
return ( return (
<div <Tooltip key={tabId}>
key={tabId} <TooltipTrigger asChild>
data-tab-id={tabId} <div
data-tab-type="editor" data-tab-id={tabId}
data-state={isActive ? 'active' : 'inactive'} data-tab-type="editor"
onClick={() => onSelectTab(tabId)} data-state={isActive ? 'active' : 'inactive'}
title={tooltip} onClick={() => onSelectTab(tabId)}
className={cn( className={cn(
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0", "netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
)} )}
style={{ style={{
backgroundColor: isActive backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))' ? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent', : 'transparent',
color: isActive color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))' ? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))', : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!isActive) { if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)'; e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))'; e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (!isActive) { if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))'; e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
} }
}} }}
> >
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<FileIcon <FileIcon
size={14} size={14}
className="shrink-0" className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }} style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/> />
<span className="truncate flex items-center gap-0.5"> <span className="truncate flex items-center gap-0.5">
{dirty && <span className="text-primary mr-0.5"></span>} {dirty && <span className="text-primary mr-0.5"></span>}
{editorTab.fileName} {editorTab.fileName}
{suffix && <span className="text-muted-foreground ml-1">{suffix}</span>} {suffix && <span className="text-muted-foreground ml-1">{suffix}</span>}
</span> </span>
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRequestCloseEditorTab(editorTab.id); onRequestCloseEditorTab(editorTab.id);
}} }}
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors" className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
aria-label="Close editor tab" aria-label="Close editor tab"
> >
<X size={12} /> <X size={12} />
</button> </button>
</div> </div>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
); );
} }
@@ -1016,16 +1015,20 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{renderOrderedTabs()} {renderOrderedTabs()}
{/* Add new tab button - follows last tab when not overflowing */} {/* Add new tab button - follows last tab when not overflowing */}
{!hasOverflow && ( {!hasOverflow && (
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none" variant="ghost"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }} size="icon"
onClick={onOpenQuickSwitcher} className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
title="Open quick switcher" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
> onClick={onOpenQuickSwitcher}
<Plus size={14} /> >
</Button> <Plus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.openQuickSwitcher')}</TooltipContent>
</Tooltip>
)} )}
{/* Draggable spacer - fixed width handle at the end */} {/* Draggable spacer - fixed width handle at the end */}
<div className="min-w-[20px] h-7 app-drag flex-shrink-0" style={dragRegionStyle} /> <div className="min-w-[20px] h-7 app-drag flex-shrink-0" style={dragRegionStyle} />
@@ -1042,56 +1045,72 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* More tabs button - only when overflowing */} {/* More tabs button - only when overflowing */}
{hasOverflow && ( {hasOverflow && (
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none" variant="ghost"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }} size="icon"
onClick={onOpenQuickSwitcher} className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
title="More tabs" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
> onClick={onOpenQuickSwitcher}
<MoreHorizontal size={14} /> >
</Button> <MoreHorizontal size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.moreTabs')}</TooltipContent>
</Tooltip>
)} )}
{/* Fixed right controls */} {/* Fixed right controls */}
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}> <div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-6 w-6 app-no-drag" variant="ghost"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }} size="icon"
title="AI Assistant" className="h-6 w-6 app-no-drag"
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))} style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
> onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
<Sparkles size={16} /> >
</Button> <Sparkles size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.aiAssistant')}</TooltipContent>
</Tooltip>
<Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}> <Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}>
<Bell size={16} /> <Bell size={16} />
</Button> </Button>
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} /> <SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-6 w-6 app-no-drag" variant="ghost"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }} size="icon"
onClick={onToggleTheme} className="h-6 w-6 app-no-drag"
disabled={isImmersiveActive && !followAppTerminalTheme} style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="Toggle theme" onClick={onToggleTheme}
> disabled={isImmersiveActive && !followAppTerminalTheme}
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />} >
</Button> {theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</Button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.toggleTheme')}</TooltipContent>
</Tooltip>
</div> </div>
{/* Settings gear button - sits to the left of WindowControls on win/linux, at the right edge on mac */} {/* Settings gear button - sits to the left of WindowControls on win/linux, at the right edge on mac */}
<div className="self-stretch flex items-stretch"> <div className="self-stretch flex items-stretch">
<button <Tooltip>
onClick={onOpenSettings} <TooltipTrigger asChild>
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag" <button
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }} onClick={onOpenSettings}
title="Open Settings" className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
> style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
<Settings size={16} /> >
</button> <Settings size={16} />
</button>
</TooltipTrigger>
<TooltipContent>{t('topTabs.openSettings')}</TooltipContent>
</Tooltip>
</div> </div>
{/* Custom window controls for Windows/Linux */} {/* Custom window controls for Windows/Linux */}
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>} {!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}

View File

@@ -4,6 +4,7 @@ import { useSessionState } from "../application/state/useSessionState";
import { usePortForwardingState } from "../application/state/usePortForwardingState"; import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState"; import { useVaultState } from "../application/state/useVaultState";
import { toast } from "./ui/toast"; import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider"; import { useI18n } from "../application/i18n/I18nProvider";
import { I18nProvider } from "../application/i18n/I18nProvider"; import { I18nProvider } from "../application/i18n/I18nProvider";
@@ -78,28 +79,31 @@ const WorkspaceGroup: React.FC<{
{expanded && ( {expanded && (
<div className="ml-4 mt-0.5 space-y-0.5"> <div className="ml-4 mt-0.5 space-y-0.5">
{sessions.map((s) => ( {sessions.map((s) => (
<button <Tooltip key={s.id}>
key={s.id} <TooltipTrigger asChild>
title={s.hostLabel || s.label} <button
onClick={() => { onClick={() => {
// Jump to session (using session id) // Jump to session (using session id)
void jumpToSession(s.id); void jumpToSession(s.id);
}} }}
className={cn( className={cn(
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm", "w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
s.status === "connected" ? "" : "text-muted-foreground", s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted/60" : "", activeTabId === s.id ? "bg-muted/60" : "",
)} )}
> >
<span className="flex items-center gap-2 min-w-0"> <span className="flex items-center gap-2 min-w-0">
<StatusDot <StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"} status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"} spinning={s.status === "connecting"}
/> />
<span className="truncate">{s.hostLabel || s.label}</span> <span className="truncate">{s.hostLabel || s.label}</span>
</span> </span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span> <span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button> </button>
</TooltipTrigger>
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
</Tooltip>
))} ))}
</div> </div>
)} )}
@@ -219,17 +223,20 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
<span className="text-sm font-medium">Netcatty</span> <span className="text-sm font-medium">Netcatty</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <Tooltip>
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground" <TooltipTrigger asChild>
onClick={handleOpenMain} <button
title={t("tray.openMainWindow")} className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
> onClick={handleOpenMain}
<Maximize2 size={14} /> >
</button> <Maximize2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("tray.openMainWindow")}</TooltipContent>
</Tooltip>
<button <button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground" className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleClose} onClick={handleClose}
title="Close"
> >
<X size={14} /> <X size={14} />
</button> </button>
@@ -277,27 +284,30 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
))} ))}
{/* Solo sessions */} {/* Solo sessions */}
{soloSessions.map((s) => ( {soloSessions.map((s) => (
<button <Tooltip key={s.id}>
key={s.id} <TooltipTrigger asChild>
title={s.hostLabel || s.label} <button
onClick={() => { onClick={() => {
void jumpToSession(s.id); void jumpToSession(s.id);
}} }}
className={cn( className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between", "w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
s.status === "connected" ? "" : "text-muted-foreground", s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted" : "", activeTabId === s.id ? "bg-muted" : "",
)} )}
> >
<span className="flex items-center gap-2 min-w-0"> <span className="flex items-center gap-2 min-w-0">
<StatusDot <StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"} status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"} spinning={s.status === "connecting"}
/> />
<span className="truncate">{s.hostLabel || s.label}</span> <span className="truncate">{s.hostLabel || s.label}</span>
</span> </span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span> <span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button> </button>
</TooltipTrigger>
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
</Tooltip>
))} ))}
</div> </div>
</div> </div>
@@ -307,16 +317,20 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
{activeSession && ( {activeSession && (
<div> <div>
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div> <div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
className="w-full justify-start px-2 h-8" <Button
title={activeSession.hostLabel || activeSession.label} variant="ghost"
onClick={() => { className="w-full justify-start px-2 h-8"
void jumpToSession(activeSession.id); onClick={() => {
}} void jumpToSession(activeSession.id);
> }}
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span> >
</Button> <span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{activeSession.hostLabel || activeSession.label}</TooltipContent>
</Tooltip>
</div> </div>
)} )}
@@ -332,55 +346,58 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
: `${rule.localPort}${rule.remoteHost}:${rule.remotePort}`); : `${rule.localPort}${rule.remoteHost}:${rule.remotePort}`);
return ( return (
<button <Tooltip key={rule.id}>
key={rule.id} <TooltipTrigger asChild>
disabled={isConnecting} <button
title={label} disabled={isConnecting}
onClick={() => { onClick={() => {
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined; const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!rawHost) { if (!rawHost) {
toast.error(t("pf.error.hostNotFound")); toast.error(t("pf.error.hostNotFound"));
return; return;
} }
if (isActive) { if (isActive) {
void stopTunnel(rule.id); void stopTunnel(rule.id);
} else { } else {
const resolveEffectiveHost = (host: Host) => { const resolveEffectiveHost = (host: Host) => {
const withGroupDefaults = host.group const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet }) ? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet }); : applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles); return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
}; };
const host = resolveEffectiveHost(rawHost); const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => { void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error); if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings); }, rule.autoStart, terminalSettings);
} }
}} }}
className={cn( className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between", "w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
isConnecting ? "opacity-60" : "", isConnecting ? "opacity-60" : "",
)} )}
> >
<span className="flex items-center gap-2 min-w-0"> <span className="flex items-center gap-2 min-w-0">
<StatusDot <StatusDot
status={ status={
rule.status === "active" rule.status === "active"
? "success" ? "success"
: rule.status === "connecting" : rule.status === "connecting"
? "warning" ? "warning"
: rule.status === "error" : rule.status === "error"
? "error" ? "error"
: "neutral" : "neutral"
} }
spinning={rule.status === "connecting"} spinning={rule.status === "connecting"}
/> />
<span className="truncate">{label}</span> <span className="truncate">{label}</span>
</span> </span>
<span className="ml-2 text-xs text-muted-foreground"> <span className="ml-2 text-xs text-muted-foreground">
{t(`tray.status.${rule.status}`)} {t(`tray.status.${rule.status}`)}
</span> </span>
</button> </button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
); );
})} })}
</div> </div>

View File

@@ -1907,21 +1907,25 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onChange={setSortMode} onChange={setSortMode}
className="h-10 w-10" className="h-10 w-10"
/> />
<Button <Tooltip>
variant={isMultiSelectMode ? "secondary" : "ghost"} <TooltipTrigger asChild>
size="icon" <Button
className="h-10 w-10" variant={isMultiSelectMode ? "secondary" : "ghost"}
onClick={() => { size="icon"
if (isMultiSelectMode) { className="h-10 w-10"
clearHostSelection(); onClick={() => {
} else { if (isMultiSelectMode) {
setIsMultiSelectMode(true); clearHostSelection();
} } else {
}} setIsMultiSelectMode(true);
title={t("vault.hosts.multiSelect")} }
> }}
<CheckSquare size={16} /> >
</Button> <CheckSquare size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("vault.hosts.multiSelect")}</TooltipContent>
</Tooltip>
</div> </div>
{/* New Host split button — collapses with an animation when the {/* New Host split button — collapses with an animation when the
host details / new-host aside panel is open, since the button host details / new-host aside panel is open, since the button

View File

@@ -4,6 +4,7 @@ import { cn } from '../../lib/utils';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react'; import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useI18n } from '../../application/i18n/I18nProvider'; import { useI18n } from '../../application/i18n/I18nProvider';
/** /**
@@ -142,9 +143,14 @@ export const ToolCall = ({
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" /> : <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
} }
{name === 'terminal_execute' && args?.command ? ( {name === 'terminal_execute' && args?.command ? (
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}> <Tooltip>
<span className="text-muted-foreground/40">$ </span>{String(args.command)} <TooltipTrigger asChild>
</span> <span className="font-mono text-muted-foreground/70 truncate cursor-default">
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
</span>
</TooltipTrigger>
<TooltipContent>{String(args.command)}</TooltipContent>
</Tooltip>
) : ( ) : (
<span className="font-mono text-muted-foreground/70 truncate">{name}</span> <span className="font-mono text-muted-foreground/70 truncate">{name}</span>
)} )}

View File

@@ -20,6 +20,7 @@ import {
DropdownContent, DropdownContent,
DropdownTrigger, DropdownTrigger,
} from '../ui/dropdown'; } from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface AgentSelectorProps { interface AgentSelectorProps {
currentAgentId: string; currentAgentId: string;
@@ -80,6 +81,7 @@ const DiscoveredAgentRow: React.FC<{
agent: DiscoveredAgent; agent: DiscoveredAgent;
onEnable: () => void; onEnable: () => void;
}> = ({ agent, onEnable }) => { }> = ({ agent, onEnable }) => {
const { t } = useI18n();
const agentLike: AgentInfo = { const agentLike: AgentInfo = {
id: `discovered_${agent.command}`, id: `discovered_${agent.command}`,
name: agent.name, name: agent.name,
@@ -98,13 +100,17 @@ const DiscoveredAgentRow: React.FC<{
{agent.version || agent.path} {agent.version || agent.path}
</span> </span>
</div> </div>
<button <Tooltip>
onClick={onEnable} <TooltipTrigger asChild>
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer" <button
title={`Enable ${agent.name}`} onClick={onEnable}
> className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
<Plus size={12} /> >
</button> <Plus size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.enableAgent', { name: agent.name })}</TooltipContent>
</Tooltip>
</div> </div>
); );
}; };
@@ -250,14 +256,18 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<SectionLabel <SectionLabel
action={ action={
onRediscover && ( onRediscover && (
<button <Tooltip>
onClick={onRediscover} <TooltipTrigger asChild>
disabled={isDiscovering} <button
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50" onClick={onRediscover}
title={t('ai.chat.rescan')} disabled={isDiscovering}
> className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} /> >
</button> <RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.rescan')}</TooltipContent>
</Tooltip>
) )
} }
> >

View File

@@ -22,6 +22,7 @@ import type { PromptInputStatus } from '../ai-elements/prompt-input';
import { formatThinkingLabel } from '../../infrastructure/ai/types'; import { formatThinkingLabel } from '../../infrastructure/ai/types';
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types'; import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '../ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Keep in sync with the popover's Tailwind max-width below. // Keep in sync with the popover's Tailwind max-width below.
const MODEL_PICKER_MAX_WIDTH = 360; const MODEL_PICKER_MAX_WIDTH = 360;
@@ -415,24 +416,27 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div className="px-3 pt-3 pb-1.5"> <div className="px-3 pt-3 pb-1.5">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedUserSkills.map((skill) => ( {selectedUserSkills.map((skill) => (
<div <Tooltip key={skill.id}>
key={skill.id} <TooltipTrigger asChild>
className={selectedSkillChipClassName} <div
title={skill.description || skill.name || skill.slug} className={selectedSkillChipClassName}
> >
<Package size={11} className="text-primary/72 shrink-0" /> <Package size={11} className="text-primary/72 shrink-0" />
<span className="truncate max-w-[180px]"> <span className="truncate max-w-[180px]">
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`} {skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
</span> </span>
<button <button
type="button" type="button"
onClick={() => onRemoveUserSkill?.(skill.slug)} onClick={() => onRemoveUserSkill?.(skill.slug)}
className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer" className="inline-flex h-4.5 w-4.5 items-center justify-center rounded-full text-foreground/42 hover:bg-primary/10 hover:text-foreground/72 transition-colors cursor-pointer"
aria-label={`Remove skill ${skill.name || skill.slug}`} aria-label={`Remove skill ${skill.name || skill.slug}`}
> >
<X size={9} /> <X size={9} />
</button> </button>
</div> </div>
</TooltipTrigger>
<TooltipContent>{skill.description || skill.name || skill.slug}</TooltipContent>
</Tooltip>
))} ))}
</div> </div>
</div> </div>
@@ -450,14 +454,18 @@ const ChatInput: React.FC<ChatInputProps> = ({
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
maxLength={100000} maxLength={100000}
/> />
<button <Tooltip>
type="button" <TooltipTrigger asChild>
onClick={() => setExpanded((e) => !e)} <button
className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer" type="button"
title={expanded ? 'Collapse' : 'Expand'} onClick={() => setExpanded((e) => !e)}
> className="absolute top-3.5 right-3 rounded-md p-1 text-muted-foreground/38 hover:text-muted-foreground/72 hover:bg-muted/25 transition-colors cursor-pointer"
<Expand size={12} /> >
</button> <Expand size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{expanded ? t('ai.chat.collapse') : t('ai.chat.expand')}</TooltipContent>
</Tooltip>
</div> </div>
{/* @ mention popover */} {/* @ mention popover */}
@@ -557,25 +565,29 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Footer toolbar */} {/* Footer toolbar */}
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0"> <PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
<PromptInputTools className="gap-1 flex-wrap"> <PromptInputTools className="gap-1 flex-wrap">
<button <Tooltip>
ref={attachBtnRef} <TooltipTrigger asChild>
type="button" <button
onClick={() => { ref={attachBtnRef}
if (!showAttachMenu) { type="button"
const rect = attachBtnRef.current?.getBoundingClientRect(); onClick={() => {
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 }); if (!showAttachMenu) {
setActiveMenu('attach'); const rect = attachBtnRef.current?.getBoundingClientRect();
} else { if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
closeAllMenus(); setActiveMenu('attach');
} } else {
}} closeAllMenus();
className={iconButtonClassName} }
title="Attach" }}
aria-label="Attach file" className={iconButtonClassName}
aria-expanded={showAttachMenu} aria-label={t('ai.chat.attach')}
> aria-expanded={showAttachMenu}
<Plus size={13} /> >
</button> <Plus size={13} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.attach')}</TooltipContent>
</Tooltip>
{showAttachMenu && menuPos && createPortal( {showAttachMenu && menuPos && createPortal(
<> <>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} /> <div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
@@ -743,33 +755,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* Permission mode chip — only for Catty Agent */} {/* Permission mode chip — only for Catty Agent */}
{permissionMode && onPermissionModeChange && ( {permissionMode && onPermissionModeChange && (
<> <>
<button <Tooltip>
ref={permBtnRef} <TooltipTrigger asChild>
type="button" <button
onClick={() => { ref={permBtnRef}
if (!showPermPicker) { type="button"
const rect = permBtnRef.current?.getBoundingClientRect(); onClick={() => {
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 }); if (!showPermPicker) {
setActiveMenu('perm'); const rect = permBtnRef.current?.getBoundingClientRect();
} else { if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
closeAllMenus(); setActiveMenu('perm');
} } else {
}} closeAllMenus();
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`} }
title={t('ai.safety.permissionMode')} }}
aria-label="Permission mode" className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
aria-expanded={showPermPicker} aria-label={t('ai.safety.permissionMode')}
> aria-expanded={showPermPicker}
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />} >
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />} {permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />} {permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
<span className="truncate max-w-[72px]"> {permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
{permissionMode === 'observer' && t('ai.chat.permObserver')} <span className="truncate max-w-[72px]">
{permissionMode === 'confirm' && t('ai.chat.permConfirm')} {permissionMode === 'observer' && t('ai.chat.permObserver')}
{permissionMode === 'autonomous' && t('ai.chat.permAuto')} {permissionMode === 'confirm' && t('ai.chat.permConfirm')}
</span> {permissionMode === 'autonomous' && t('ai.chat.permAuto')}
<ChevronDown size={9} className="text-muted-foreground/50" /> </span>
</button> <ChevronDown size={9} className="text-muted-foreground/50" />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.safety.permissionMode')}</TooltipContent>
</Tooltip>
{showPermPicker && menuPos && createPortal( {showPermPicker && menuPos && createPortal(
<> <>
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} /> <div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />

View File

@@ -15,6 +15,7 @@ import {
DropdownContent, DropdownContent,
DropdownTrigger, DropdownTrigger,
} from '../ui/dropdown'; } from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface ConversationExportProps { interface ConversationExportProps {
session: AISession | null; session: AISession | null;
@@ -45,17 +46,21 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
return ( return (
<Dropdown> <Dropdown>
<DropdownTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="ghost" <DropdownTrigger asChild>
size="icon" <Button
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'} variant="ghost"
disabled={!hasMessages} size="icon"
title={t('ai.chat.exportConversation')} className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
> disabled={!hasMessages}
<Download size={14} /> >
</Button> <Download size={14} />
</DropdownTrigger> </Button>
</DropdownTrigger>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.exportConversation')}</TooltipContent>
</Tooltip>
<DropdownContent <DropdownContent
align="end" align="end"
sideOffset={6} sideOffset={6}

View File

@@ -8,6 +8,10 @@ import {
isTextEditorReadOnly, isTextEditorReadOnly,
TextEditorPromoteButton, TextEditorPromoteButton,
} from "./TextEditorPane.tsx"; } from "./TextEditorPane.tsx";
import { TooltipProvider } from "../ui/tooltip.tsx";
const wrap = (child: React.ReactElement) =>
React.createElement(TooltipProvider, null, child);
test("disables promoting a modal editor to a tab while a save is running", () => { test("disables promoting a modal editor to a tab while a save is running", () => {
assert.equal(canPromoteTextEditor({ saving: true }), false); assert.equal(canPromoteTextEditor({ saving: true }), false);
@@ -18,18 +22,22 @@ test("disables promoting a modal editor to a tab while a save is running", () =>
test("renders the promote button disabled while a save is running", () => { test("renders the promote button disabled while a save is running", () => {
const savingMarkup = renderToStaticMarkup( const savingMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, { wrap(
saving: true, React.createElement(TextEditorPromoteButton, {
onPromoteToTab: () => {}, saving: true,
title: "Maximize", onPromoteToTab: () => {},
}), title: "Maximize",
}),
),
); );
const idleMarkup = renderToStaticMarkup( const idleMarkup = renderToStaticMarkup(
React.createElement(TextEditorPromoteButton, { wrap(
saving: false, React.createElement(TextEditorPromoteButton, {
onPromoteToTab: () => {}, saving: false,
title: "Maximize", onPromoteToTab: () => {},
}), title: "Maximize",
}),
),
); );
assert.match(savingMarkup, /disabled=""/); assert.match(savingMarkup, /disabled=""/);

View File

@@ -28,6 +28,7 @@ import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils'; import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox'; import { Combobox } from '../ui/combobox';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
// Map our language IDs to Monaco language IDs // Map our language IDs to Monaco language IDs
const languageIdToMonaco = (langId: string): string => { const languageIdToMonaco = (langId: string): string => {
@@ -186,16 +187,20 @@ export const TextEditorPromoteButton: React.FC<{
onPromoteToTab: () => void; onPromoteToTab: () => void;
title: string; title: string;
}> = ({ saving, onPromoteToTab, title }) => ( }> = ({ saving, onPromoteToTab, title }) => (
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-7 w-7" variant="ghost"
onClick={onPromoteToTab} size="icon"
disabled={!canPromoteTextEditor({ saving })} className="h-7 w-7"
title={title} onClick={onPromoteToTab}
> disabled={!canPromoteTextEditor({ saving })}
<Maximize2 size={14} /> >
</Button> <Maximize2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{title}</TooltipContent>
</Tooltip>
); );
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
@@ -479,34 +484,47 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
{fileName} {fileName}
</span> </span>
{subtitle && ( {subtitle && (
<span className="text-xs text-muted-foreground truncate" title={subtitle}> <Tooltip>
{subtitle} <TooltipTrigger asChild>
</span> <span className="text-xs text-muted-foreground truncate cursor-default">
{subtitle}
</span>
</TooltipTrigger>
<TooltipContent>{subtitle}</TooltipContent>
</Tooltip>
)} )}
{saveError && <span className="text-xs text-destructive truncate">{saveError}</span>} {saveError && <span className="text-xs text-destructive truncate">{saveError}</span>}
</div> </div>
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
{/* Search button */} {/* Search button */}
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-7 w-7" variant="ghost"
onClick={handleSearch} size="icon"
title={t('common.search')} className="h-7 w-7"
> onClick={handleSearch}
<Search size={14} /> >
</Button> <Search size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.search')}</TooltipContent>
</Tooltip>
{/* Word wrap toggle */} {/* Word wrap toggle */}
<Button <Tooltip>
variant={wordWrap ? 'secondary' : 'ghost'} <TooltipTrigger asChild>
size="icon" <Button
className="h-7 w-7" variant={wordWrap ? 'secondary' : 'ghost'}
onClick={onToggleWordWrap} size="icon"
title={t('sftp.editor.wordWrap')} className="h-7 w-7"
> onClick={onToggleWordWrap}
<WrapText size={14} /> >
</Button> <WrapText size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.editor.wordWrap')}</TooltipContent>
</Tooltip>
{/* Language selector */} {/* Language selector */}
<Combobox <Combobox

View File

@@ -11,6 +11,7 @@ import { Combobox } from '../ui/combobox';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Popover,PopoverContent,PopoverTrigger } from '../ui/popover'; import { Popover,PopoverContent,PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface IdentityPanelProps { interface IdentityPanelProps {
draftIdentity: Partial<Identity>; draftIdentity: Partial<Identity>;
@@ -129,15 +130,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
<span className="text-sm flex-1 truncate"> <span className="text-sm flex-1 truncate">
{selectedKey?.label || t('hostDetails.credential.missing')} {selectedKey?.label || t('hostDetails.credential.missing')}
</span> </span>
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-6 w-6" variant="ghost"
onClick={clearSelectedKey} size="icon"
title={t('common.clear')} className="h-6 w-6"
> onClick={clearSelectedKey}
<X size={12} /> >
</Button> <X size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.clear')}</TooltipContent>
</Tooltip>
</div> </div>
)} )}
@@ -202,15 +207,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
icon={<Key size={14} className="text-muted-foreground" />} icon={<Key size={14} className="text-muted-foreground" />}
className="flex-1" className="flex-1"
/> />
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-8 w-8 shrink-0" variant="ghost"
onClick={() => setSelectedCredentialType(null)} size="icon"
title={t('common.cancel')} className="h-8 w-8 shrink-0"
> onClick={() => setSelectedCredentialType(null)}
<X size={14} /> >
</Button> <X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.cancel')}</TooltipContent>
</Tooltip>
</div> </div>
)} )}
@@ -230,15 +239,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
icon={<Shield size={14} className="text-muted-foreground" />} icon={<Shield size={14} className="text-muted-foreground" />}
className="flex-1" className="flex-1"
/> />
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-8 w-8 shrink-0" variant="ghost"
onClick={() => setSelectedCredentialType(null)} size="icon"
title={t('common.cancel')} className="h-8 w-8 shrink-0"
> onClick={() => setSelectedCredentialType(null)}
<X size={14} /> >
</Button> <X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('common.cancel')}</TooltipContent>
</Tooltip>
</div> </div>
)} )}

View File

@@ -12,6 +12,7 @@ import { TrafficDiagram } from '../TrafficDiagram';
import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel'; import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Switch } from '../ui/switch'; import { Switch } from '../ui/switch';
import { getTypeLabel } from './utils'; import { getTypeLabel } from './utils';
@@ -183,14 +184,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
> >
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
<button <Tooltip>
className="text-xs text-muted-foreground hover:text-foreground/80 flex items-center gap-1 px-2 py-1 rounded hover:bg-foreground/5 transition-colors" <TooltipTrigger asChild>
onClick={onOpenWizard} <button
title={t('pf.form.openWizardTitle')} className="text-xs text-muted-foreground hover:text-foreground/80 flex items-center gap-1 px-2 py-1 rounded hover:bg-foreground/5 transition-colors"
> onClick={onOpenWizard}
<Zap size={12} /> >
{t('pf.form.openWizard')} <Zap size={12} />
</button> {t('pf.form.openWizard')}
</button>
</TooltipTrigger>
<TooltipContent>{t('pf.form.openWizardTitle')}</TooltipContent>
</Tooltip>
</div> </div>
</AsidePanelFooter> </AsidePanelFooter>
</AsidePanel> </AsidePanel>

View File

@@ -68,13 +68,26 @@ export const RuleCard: React.FC<RuleCardProps> = ({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold truncate">{rule.label}</span> <span className="text-sm font-semibold truncate">{rule.label}</span>
<span {rule.status === 'error' && rule.error ? (
className={cn( <Tooltip>
"h-2 w-2 rounded-full flex-shrink-0", <TooltipTrigger asChild>
getStatusColor(rule.status) <span
)} className={cn(
title={rule.status === 'error' && rule.error ? rule.error : undefined} "h-2 w-2 rounded-full flex-shrink-0 cursor-default",
/> getStatusColor(rule.status)
)}
/>
</TooltipTrigger>
<TooltipContent>{rule.error}</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"h-2 w-2 rounded-full flex-shrink-0",
getStatusColor(rule.status)
)}
/>
)}
</div> </div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground"> <div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>

View File

@@ -7,6 +7,7 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { cn } from "../../../lib/utils"; import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui"; import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect"; import { FontSelect } from "../FontSelect";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
export default function SettingsAppearanceTab(props: { export default function SettingsAppearanceTab(props: {
theme: "dark" | "light" | "system"; theme: "dark" | "light" | "system";
@@ -122,20 +123,23 @@ export default function SettingsAppearanceTab(props: {
) => ( ) => (
<div className="flex flex-wrap gap-2 justify-end"> <div className="flex flex-wrap gap-2 justify-end">
{options.map((preset) => ( {options.map((preset) => (
<button <Tooltip key={preset.id}>
key={preset.id} <TooltipTrigger asChild>
onClick={() => onChange(preset.id)} <button
className={cn( onClick={() => onChange(preset.id)}
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70", className={cn(
value === preset.id "w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
? "ring-2 ring-offset-2 ring-foreground scale-110" value === preset.id
: "hover:scale-105", ? "ring-2 ring-offset-2 ring-foreground scale-110"
)} : "hover:scale-105",
style={getHslStyle(preset.tokens.background)} )}
title={preset.name} style={getHslStyle(preset.tokens.background)}
> >
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />} {value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
</button> </button>
</TooltipTrigger>
<TooltipContent>{preset.name}</TooltipContent>
</Tooltip>
))} ))}
</div> </div>
); );
@@ -212,42 +216,49 @@ export default function SettingsAppearanceTab(props: {
<div className="text-sm font-medium">{t("settings.appearance.accentColor.custom")}</div> <div className="text-sm font-medium">{t("settings.appearance.accentColor.custom")}</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{ACCENT_COLORS.map((c) => ( {ACCENT_COLORS.map((c) => (
<button <Tooltip key={c.name}>
key={c.name} <TooltipTrigger asChild>
onClick={() => setCustomAccent(c.value)} <button
className={cn( onClick={() => setCustomAccent(c.value)}
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm", className={cn(
customAccent === c.value "w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
? "ring-2 ring-offset-2 ring-foreground scale-110" customAccent === c.value
: "hover:scale-105", ? "ring-2 ring-offset-2 ring-foreground scale-110"
)} : "hover:scale-105",
style={getHslStyle(c.value)} )}
title={c.name} style={getHslStyle(c.value)}
> >
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />} {customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
</button> </button>
</TooltipTrigger>
<TooltipContent>{c.name}</TooltipContent>
</Tooltip>
))} ))}
<label <Tooltip>
className={cn( <TooltipTrigger asChild>
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer", <label
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500", className={cn(
!ACCENT_COLORS.some((c) => c.value === customAccent) "w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
? "ring-2 ring-offset-2 ring-foreground scale-110" "bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
: "hover:scale-105", !ACCENT_COLORS.some((c) => c.value === customAccent)
)} ? "ring-2 ring-offset-2 ring-foreground scale-110"
title={t("settings.appearance.customColor")} : "hover:scale-105",
> )}
<input >
type="color" <input
className="sr-only" type="color"
onChange={(e) => setCustomAccent(hexToHsl(e.target.value))} className="sr-only"
/> onChange={(e) => setCustomAccent(hexToHsl(e.target.value))}
{!ACCENT_COLORS.some((c) => c.value === customAccent) ? ( />
<Check className="text-white drop-shadow-md" size={10} /> {!ACCENT_COLORS.some((c) => c.value === customAccent) ? (
) : ( <Check className="text-white drop-shadow-md" size={10} />
<Palette size={12} className="text-white drop-shadow-md" /> ) : (
)} <Palette size={12} className="text-white drop-shadow-md" />
</label> )}
</label>
</TooltipTrigger>
<TooltipContent>{t("settings.appearance.customColor")}</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
)} )}

View File

@@ -11,6 +11,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { cn } from "../../../lib/utils"; import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button"; import { Button } from "../../ui/button";
import { Label } from "../../ui/label"; import { Label } from "../../ui/label";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { SectionHeader, SettingsTabContent } from "../settings-ui"; import { SectionHeader, SettingsTabContent } from "../settings-ui";
const getOpenerLabel = ( const getOpenerLabel = (
@@ -527,31 +528,44 @@ export default function SettingsFileAssociationsTab() {
</td> </td>
<td className="px-4 py-3 text-muted-foreground"> <td className="px-4 py-3 text-muted-foreground">
{openerType === 'system-app' && systemApp ? ( {openerType === 'system-app' && systemApp ? (
<span title={systemApp.path}>{systemApp.name}</span> <Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{systemApp.name}</span>
</TooltipTrigger>
<TooltipContent>{systemApp.path}</TooltipContent>
</Tooltip>
) : ( ) : (
getOpenerLabel(openerType, systemApp, t) getOpenerLabel(openerType, systemApp, t)
)} )}
</td> </td>
<td className="px-4 py-3 text-right space-x-1"> <td className="px-4 py-3 text-right space-x-1">
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="h-7 w-7" variant="ghost"
onClick={() => handleEdit(extension)} size="icon"
disabled={editingExtension === extension} className="h-7 w-7"
title={t('common.edit')} onClick={() => handleEdit(extension)}
> disabled={editingExtension === extension}
<Pencil size={14} /> >
</Button> <Pencil size={14} />
<Button </Button>
variant="ghost" </TooltipTrigger>
size="icon" <TooltipContent>{t('common.edit')}</TooltipContent>
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10" </Tooltip>
onClick={() => handleRemove(extension)} <Tooltip>
title={t('settings.sftpFileAssociations.remove')} <TooltipTrigger asChild>
> <Button
<Trash2 size={14} /> variant="ghost"
</Button> size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemove(extension)}
>
<Trash2 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('settings.sftpFileAssociations.remove')}</TooltipContent>
</Tooltip>
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -230,7 +230,7 @@ export default function SettingsShortcutsTab(props: {
<button <button
onClick={() => updateKeyBinding?.(binding.id, scheme, "Disabled")} onClick={() => updateKeyBinding?.(binding.id, scheme, "Disabled")}
className="p-1 hover:bg-muted rounded" className="p-1 hover:bg-muted rounded"
title={t("settings.shortcuts.setDisabled")} aria-label={t("settings.shortcuts.setDisabled")}
> >
<Ban size={12} /> <Ban size={12} />
</button> </button>
@@ -238,7 +238,7 @@ export default function SettingsShortcutsTab(props: {
<button <button
onClick={() => resetKeyBinding?.(binding.id, scheme)} onClick={() => resetKeyBinding?.(binding.id, scheme)}
className="p-1 hover:bg-muted rounded" className="p-1 hover:bg-muted rounded"
title="Reset to default" aria-label={t("settings.shortcuts.resetToDefault")}
> >
<RotateCcw size={12} /> <RotateCcw size={12} />
</button> </button>

View File

@@ -10,6 +10,7 @@ import type { UpdateState } from '../../../application/state/useUpdateCheck';
import { SessionLogFormat, keyEventToString } from "../../../domain/models"; import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs"; import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button"; import { Button } from "../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import { Toggle, Select, SettingRow } from "../settings-ui"; import { Toggle, Select, SettingRow } from "../settings-ui";
import { cn } from "../../../lib/utils"; import { cn } from "../../../lib/utils";
@@ -637,9 +638,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`); if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
const text = parts.join(' '); const text = parts.join(' ');
return text ? ( return text ? (
<div className="text-muted-foreground truncate" title={text}> <Tooltip>
{text} <TooltipTrigger asChild>
</div> <div className="text-muted-foreground truncate cursor-default">
{text}
</div>
</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
) : null; ) : null;
})()} })()}
{entry.stack && ( {entry.stack && (
@@ -678,14 +684,18 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
<Trash2 size={14} /> <Trash2 size={14} />
{t("settings.system.crashLogs.clear")} {t("settings.system.crashLogs.clear")}
</Button> </Button>
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
onClick={handleOpenCrashLogsDir} variant="ghost"
title={t("settings.system.openFolder")} size="icon"
> onClick={handleOpenCrashLogsDir}
<FolderOpen size={16} /> >
</Button> <FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
</Tooltip>
</div> </div>
{crashLogClearResult && ( {crashLogClearResult && (
@@ -716,16 +726,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{isLoading ? "..." : (tempDirInfo?.path ?? "-")} {isLoading ? "..." : (tempDirInfo?.path ?? "-")}
</p> </p>
</div> </div>
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
className="shrink-0" variant="ghost"
onClick={handleOpenTempDir} size="icon"
disabled={!tempDirInfo?.path} className="shrink-0"
title={t("settings.system.openFolder")} onClick={handleOpenTempDir}
> disabled={!tempDirInfo?.path}
<FolderOpen size={16} /> >
</Button> <FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
</Tooltip>
</div> </div>
{/* Stats */} {/* Stats */}
@@ -823,15 +837,19 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
{t("settings.sessionLogs.browse")} {t("settings.sessionLogs.browse")}
</Button> </Button>
{sessionLogsDir && ( {sessionLogsDir && (
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="icon" <Button
onClick={handleOpenSessionLogsDir} variant="ghost"
className="shrink-0" size="icon"
title={t("settings.sessionLogs.openFolder")} onClick={handleOpenSessionLogsDir}
> className="shrink-0"
<FolderOpen size={16} /> >
</Button> <FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.sessionLogs.openFolder")}</TooltipContent>
</Tooltip>
)} )}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -902,13 +920,17 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
: toggleWindowHotkey || t("settings.globalHotkey.notSet")} : toggleWindowHotkey || t("settings.globalHotkey.notSet")}
</button> </button>
{toggleWindowHotkey && ( {toggleWindowHotkey && (
<button <Tooltip>
onClick={handleResetHotkey} <TooltipTrigger asChild>
className="p-1 hover:bg-muted rounded" <button
title={t("settings.globalHotkey.reset")} onClick={handleResetHotkey}
> className="p-1 hover:bg-muted rounded"
<RotateCcw size={14} /> >
</button> <RotateCcw size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("settings.globalHotkey.reset")}</TooltipContent>
</Tooltip>
)} )}
</div> </div>
</SettingRow> </SettingRow>

View File

@@ -19,6 +19,7 @@ import { Button } from "../../ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
import { Input } from "../../ui/input"; import { Input } from "../../ui/input";
import { Label } from "../../ui/label"; import { Label } from "../../ui/label";
import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui"; import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { ThemeSelectModal } from "../ThemeSelectModal"; import { ThemeSelectModal } from "../ThemeSelectModal";
import { TerminalFontSelect } from "../TerminalFontSelect"; import { TerminalFontSelect } from "../TerminalFontSelect";
@@ -960,35 +961,41 @@ export default function SettingsTerminalTab(props: {
description={t("settings.terminal.localShell.shell.desc")} description={t("settings.terminal.localShell.shell.desc")}
> >
<div className="flex flex-col gap-1 items-end"> <div className="flex flex-col gap-1 items-end">
<select <ShadcnSelect
className="h-9 w-48 rounded-md border border-input bg-background px-3 text-sm"
value={ value={
showCustomShellInput showCustomShellInput
? "__custom__" ? "__custom__"
: terminalSettings.localShell || "" : (terminalSettings.localShell || "__default__")
} }
onChange={(e) => { onValueChange={(value) => {
const value = e.target.value;
if (value === "__custom__") { if (value === "__custom__") {
setCustomShellDraft(terminalSettings.localShell || ""); setCustomShellDraft(terminalSettings.localShell || "");
setCustomShellModalOpen(true); setCustomShellModalOpen(true);
} else if (value === "__default__") {
setShowCustomShellInput(false);
updateTerminalSetting("localShell", "");
} else { } else {
setShowCustomShellInput(false); setShowCustomShellInput(false);
updateTerminalSetting("localShell", value); updateTerminalSetting("localShell", value);
} }
}} }}
> >
<option value=""> <SelectTrigger className="h-9 w-48 text-sm">
{t("settings.terminal.localShell.shell.default")} <SelectValue />
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""} </SelectTrigger>
</option> <SelectContent>
{discoveredShells.map((shell) => ( <SelectItem value="__default__">
<option key={shell.id} value={shell.id}> {t("settings.terminal.localShell.shell.default")}
{shell.name} {defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
</option> </SelectItem>
))} {discoveredShells.map((shell) => (
<option value="__custom__">{t("settings.terminal.localShell.shell.custom")}</option> <SelectItem key={shell.id} value={shell.id}>
</select> {shell.name}
</SelectItem>
))}
<SelectItem value="__custom__">{t("settings.terminal.localShell.shell.custom")}</SelectItem>
</SelectContent>
</ShadcnSelect>
{showCustomShellInput && ( {showCustomShellInput && (
<span className="text-xs text-muted-foreground truncate max-w-48"> <span className="text-xs text-muted-foreground truncate max-w-48">
{terminalSettings.localShell} {terminalSettings.localShell}

View File

@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw } from "lucide-react";
import type { AIProviderId } from "../../../../infrastructure/ai/types"; import type { AIProviderId } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider"; import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button"; import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { cn } from "../../../../lib/utils"; import { cn } from "../../../../lib/utils";
import type { FetchedModel } from "./types"; import type { FetchedModel } from "./types";
import { getFetchBridge } from "./types"; import { getFetchBridge } from "./types";
@@ -120,16 +121,20 @@ export const ModelSelector: React.FC<{
)} )}
</div> </div>
{canFetch && ( {canFetch && (
<Button <Tooltip>
variant="outline" <TooltipTrigger asChild>
size="sm" <Button
onClick={() => { setHasFetched(false); void fetchModels(); }} variant="outline"
disabled={isLoading} size="sm"
className="shrink-0 px-2" onClick={() => { setHasFetched(false); void fetchModels(); }}
title={t('ai.providers.refreshModels')} disabled={isLoading}
> className="shrink-0 px-2"
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} /> >
</Button> <RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.refreshModels')}</TooltipContent>
</Tooltip>
)} )}
</div> </div>

View File

@@ -3,6 +3,7 @@ import { Pencil, Trash2 } from "lucide-react";
import type { ProviderConfig } from "../../../../infrastructure/ai/types"; import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import { useI18n } from "../../../../application/i18n/I18nProvider"; import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Toggle } from "../../settings-ui"; import { Toggle } from "../../settings-ui";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { cn } from "../../../../lib/utils"; import { cn } from "../../../../lib/utils";
import { ProviderIconBadge } from "./ProviderIconBadge"; import { ProviderIconBadge } from "./ProviderIconBadge";
import { ProviderConfigForm } from "./ProviderConfigForm"; import { ProviderConfigForm } from "./ProviderConfigForm";
@@ -61,20 +62,28 @@ export const ProviderCard: React.FC<{
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <Tooltip>
onClick={onEdit} <TooltipTrigger asChild>
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" <button
title={t('ai.providers.configure')} onClick={onEdit}
> className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
<Pencil size={14} /> >
</button> <Pencil size={14} />
<button </button>
onClick={onRemove} </TooltipTrigger>
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" <TooltipContent>{t('ai.providers.configure')}</TooltipContent>
title={t('ai.providers.remove')} </Tooltip>
> <Tooltip>
<Trash2 size={14} /> <TooltipTrigger asChild>
</button> <button
onClick={onRemove}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.providers.remove')}</TooltipContent>
</Tooltip>
<Toggle checked={provider.enabled} onChange={onToggleEnabled} /> <Toggle checked={provider.enabled} onChange={onToggleEnabled} />
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import { ChevronDown, ChevronRight, Home, MoreHorizontal } from 'lucide-react';
import React, { memo, useCallback, useMemo, useState } from 'react'; import React, { memo, useCallback, useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider'; import { useI18n } from '../../application/i18n/I18nProvider';
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown'; import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
interface SftpBreadcrumbProps { interface SftpBreadcrumbProps {
@@ -89,76 +90,90 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
const showDriveDropdown = isWindowsPath && isLocal && !!onListDrives; const showDriveDropdown = isWindowsPath && isLocal && !!onListDrives;
return ( return (
<div <Tooltip>
className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden" <TooltipTrigger asChild>
title={path} <div className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden cursor-default">
> <Tooltip>
<button <TooltipTrigger asChild>
onClick={onHome}
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
title={t("sftp.goHome")}
>
<Home size={12} />
</button>
<ChevronRight size={12} className="opacity-40 shrink-0" />
{visibleParts.map(({ part, originalIndex }, displayIdx) => {
const partPath = buildPath(originalIndex);
const isLast = originalIndex === parts.length - 1;
const showEllipsisBefore = needsTruncation && displayIdx === 1;
return (
<React.Fragment key={partPath}>
{showEllipsisBefore && (
<>
<span
className="px-1 py-0.5 shrink-0 flex items-center text-muted-foreground cursor-default"
title={`${t("sftp.showHiddenPaths")}: ${hiddenParts.map(h => h.part).join(' > ')}`}
>
<MoreHorizontal size={14} />
</span>
<ChevronRight size={12} className="opacity-40 shrink-0" />
</>
)}
{originalIndex === 0 && showDriveDropdown ? (
<Dropdown open={driveDropdownOpen} onOpenChange={handleDriveDropdownOpen}>
<DropdownTrigger asChild>
<button className="hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 shrink-0 flex items-center gap-0.5">
{part}
<ChevronDown size={10} className="opacity-60" />
</button>
</DropdownTrigger>
<DropdownContent align="start" className="w-16 p-1">
{drives.map(drive => (
<button
key={drive}
onClick={() => { onNavigate(drive + '\\'); setDriveDropdownOpen(false); }}
className={cn(
"w-full text-left px-2 py-1 text-xs rounded hover:bg-secondary/60",
drive === part && "bg-secondary font-medium"
)}
>
{drive}
</button>
))}
</DropdownContent>
</Dropdown>
) : (
<button <button
onClick={() => onNavigate(partPath)} onClick={onHome}
className={cn( className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px] shrink-0",
isLast && "text-foreground font-medium"
)}
title={part}
> >
{part} <Home size={12} />
</button> </button>
)} </TooltipTrigger>
{!isLast && <ChevronRight size={12} className="opacity-40 shrink-0" />} <TooltipContent>{t("sftp.goHome")}</TooltipContent>
</React.Fragment> </Tooltip>
); <ChevronRight size={12} className="opacity-40 shrink-0" />
})} {visibleParts.map(({ part, originalIndex }, displayIdx) => {
</div> const partPath = buildPath(originalIndex);
const isLast = originalIndex === parts.length - 1;
const showEllipsisBefore = needsTruncation && displayIdx === 1;
return (
<React.Fragment key={partPath}>
{showEllipsisBefore && (
<>
<Tooltip>
<TooltipTrigger asChild>
<span className="px-1 py-0.5 shrink-0 flex items-center text-muted-foreground cursor-default">
<MoreHorizontal size={14} />
</span>
</TooltipTrigger>
<TooltipContent>
{`${t("sftp.showHiddenPaths")}: ${hiddenParts.map(h => h.part).join(' > ')}`}
</TooltipContent>
</Tooltip>
<ChevronRight size={12} className="opacity-40 shrink-0" />
</>
)}
{originalIndex === 0 && showDriveDropdown ? (
<Dropdown open={driveDropdownOpen} onOpenChange={handleDriveDropdownOpen}>
<DropdownTrigger asChild>
<button className="hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 shrink-0 flex items-center gap-0.5">
{part}
<ChevronDown size={10} className="opacity-60" />
</button>
</DropdownTrigger>
<DropdownContent align="start" className="w-16 p-1">
{drives.map(drive => (
<button
key={drive}
onClick={() => { onNavigate(drive + '\\'); setDriveDropdownOpen(false); }}
className={cn(
"w-full text-left px-2 py-1 text-xs rounded hover:bg-secondary/60",
drive === part && "bg-secondary font-medium"
)}
>
{drive}
</button>
))}
</DropdownContent>
</Dropdown>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onNavigate(partPath)}
className={cn(
"hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary/60 truncate max-w-[120px] shrink-0",
isLast && "text-foreground font-medium"
)}
>
{part}
</button>
</TooltipTrigger>
<TooltipContent>{part}</TooltipContent>
</Tooltip>
)}
{!isLast && <ChevronRight size={12} className="opacity-40 shrink-0" />}
</React.Fragment>
);
})}
</div>
</TooltipTrigger>
<TooltipContent>{path}</TooltipContent>
</Tooltip>
); );
}; };

View File

@@ -4,6 +4,7 @@
import { Folder, Link } from 'lucide-react'; import { Folder, Link } from 'lucide-react';
import React, { memo, useCallback } from 'react'; import React, { memo, useCallback } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { SftpFileEntry } from '../../types'; import { SftpFileEntry } from '../../types';
import { buildSftpColumnTemplate, ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils'; import { buildSftpColumnTemplate, ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
@@ -106,17 +107,21 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
/> />
)} )}
</div> </div>
<span <Tooltip>
className={cn( <TooltipTrigger asChild>
"truncate", <span
entry.type === 'symlink' && "italic pr-1", className={cn(
isSelectionVisible && "font-medium", "truncate cursor-default",
)} entry.type === 'symlink' && "italic pr-1",
title={entry.name} isSelectionVisible && "font-medium",
> )}
{entry.name} >
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>} {entry.name}
</span> {entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>
</TooltipTrigger>
<TooltipContent>{entry.name}</TooltipContent>
</Tooltip>
</div> </div>
<span className={cn("text-xs truncate", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>{modifiedLabel}</span> <span className={cn("text-xs truncate", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>{modifiedLabel}</span>
<span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}> <span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>

View File

@@ -477,22 +477,26 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
)} )}
</div> </div>
) : ( ) : (
<div <Tooltip>
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors" <TooltipTrigger asChild>
onDoubleClick={handlePathDoubleClick} <div
title={t("sftp.path.doubleClickToEdit")} className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
> onDoubleClick={handlePathDoubleClick}
<SftpBreadcrumb >
path={displayPath} <SftpBreadcrumb
onNavigate={onNavigateTo} path={displayPath}
onHome={() => onNavigate={onNavigateTo}
pane.connection?.homeDir && onHome={() =>
onNavigateTo(pane.connection.homeDir) pane.connection?.homeDir &&
} onNavigateTo(pane.connection.homeDir)
isLocal={!isRemote} }
onListDrives={onListDrives} isLocal={!isRemote}
/> onListDrives={onListDrives}
</div> />
</div>
</TooltipTrigger>
<TooltipContent>{t("sftp.path.doubleClickToEdit")}</TooltipContent>
</Tooltip>
)} )}
{/* Bookmark button with dropdown */} {/* Bookmark button with dropdown */}
@@ -555,15 +559,19 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
{bm.global && ( {bm.global && (
<Globe size={10} className="shrink-0 text-primary" /> <Globe size={10} className="shrink-0 text-primary" />
)} )}
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="flex-1 text-left text-xs truncate font-mono" <button
onClick={() => onNavigateToBookmark(bm.path)} type="button"
title={bm.path} className="flex-1 text-left text-xs truncate font-mono"
> onClick={() => onNavigateToBookmark(bm.path)}
{bm.label} >
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span> {bm.label}
</button> <span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
</TooltipTrigger>
<TooltipContent>{bm.path}</TooltipContent>
</Tooltip>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@@ -21,6 +21,7 @@ import React, {
import { useI18n } from "../../application/i18n/I18nProvider"; import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger"; import { logger } from "../../lib/logger";
import { useRenderTracker } from "../../lib/useRenderTracker"; import { useRenderTracker } from "../../lib/useRenderTracker";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { useActiveTabId } from "./SftpContext"; import { useActiveTabId } from "./SftpContext";
@@ -395,13 +396,17 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
</div> </div>
{/* Add tab button */} {/* Add tab button */}
<button <Tooltip>
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer" <TooltipTrigger asChild>
onClick={handleAddTabClick} <button
title={t("sftp.tabs.addTab")} className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
> onClick={handleAddTabClick}
<Plus size={14} /> >
</button> <Plus size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t("sftp.tabs.addTab")}</TooltipContent>
</Tooltip>
</div> </div>
); );
}; };

View File

@@ -9,6 +9,7 @@ import {
} from "../../infrastructure/config/storageKeys"; } from "../../infrastructure/config/storageKeys";
import type { TransferTask } from "../../types"; import type { TransferTask } from "../../types";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { SftpTransferItem } from "./SftpTransferItem"; import { SftpTransferItem } from "./SftpTransferItem";
type SftpState = ReturnType<typeof useSftpState>; type SftpState = ReturnType<typeof useSftpState>;
@@ -344,13 +345,17 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
className="border-t border-border/70 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0" className="border-t border-border/70 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0"
style={{ height: clampPanelHeight(panelHeight) }} style={{ height: clampPanelHeight(panelHeight) }}
> >
<div <Tooltip>
className="group flex h-3 cursor-row-resize items-center justify-center border-b border-border/30 text-muted-foreground/70" <TooltipTrigger asChild>
onMouseDown={handleResizeStart} <div
title={t("sftp.transfers.dragToResize")} className="group flex h-3 cursor-row-resize items-center justify-center border-b border-border/30 text-muted-foreground/70"
> onMouseDown={handleResizeStart}
<GripHorizontal size={14} className="transition-colors group-hover:text-foreground/80" /> >
</div> <GripHorizontal size={14} className="transition-colors group-hover:text-foreground/80" />
</div>
</TooltipTrigger>
<TooltipContent>{t("sftp.transfers.dragToResize")}</TooltipContent>
</Tooltip>
<div className="flex items-center justify-between border-b border-border/40 px-3 py-1.5 text-[11px] text-muted-foreground"> <div className="flex items-center justify-between border-b border-border/40 px-3 py-1.5 text-[11px] text-muted-foreground">
<span className="font-medium"> <span className="font-medium">

View File

@@ -11,6 +11,7 @@ import { Button } from '../ui/button';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '../ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export interface HostKeywordHighlightPopoverProps { export interface HostKeywordHighlightPopoverProps {
host?: Host; host?: Host;
@@ -120,18 +121,22 @@ export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverPr
return ( return (
<Popover open={isOpen} onOpenChange={setIsOpen}> <Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="secondary" <PopoverTrigger asChild>
size="icon" <Button
className={buttonClassName} variant="secondary"
title={t('terminal.toolbar.hostHighlight.title')} size="icon"
aria-label={t('terminal.toolbar.hostHighlight.title')} className={buttonClassName}
disabled={isDisabled} aria-label={t('terminal.toolbar.hostHighlight.title')}
> disabled={isDisabled}
<Highlighter size={12} /> >
</Button> <Highlighter size={12} />
</PopoverTrigger> </Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t('terminal.toolbar.hostHighlight.title')}</TooltipContent>
</Tooltip>
<PopoverContent className="w-80 p-0" align="start" side="top"> <PopoverContent className="w-80 p-0" align="start" side="top">
<div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between"> <div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground"> <span className="text-xs font-semibold uppercase text-muted-foreground">
@@ -175,18 +180,22 @@ export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverPr
key={rule.id} key={rule.id}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent/50 group" className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent/50 group"
> >
<button <Tooltip>
type="button" <TooltipTrigger asChild>
onClick={() => handleToggleRule(rule.id)} <button
className={` type="button"
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors onClick={() => handleToggleRule(rule.id)}
${rule.enabled className={`
? 'bg-primary border-primary' flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
: 'bg-transparent border-muted-foreground/50' ${rule.enabled
} ? 'bg-primary border-primary'
`} : 'bg-transparent border-muted-foreground/50'
title={rule.enabled ? t('common.enabled') : t('common.disabled')} }
/> `}
/>
</TooltipTrigger>
<TooltipContent>{rule.enabled ? t('common.enabled') : t('common.disabled')}</TooltipContent>
</Tooltip>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div <div
className="text-xs font-medium truncate" className="text-xs font-medium truncate"

View File

@@ -8,6 +8,7 @@
import { Radio, X } from 'lucide-react'; import { Radio, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider'; import { useI18n } from '../../application/i18n/I18nProvider';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
export interface TerminalComposeBarProps { export interface TerminalComposeBarProps {
@@ -83,12 +84,14 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Broadcast indicator */} {/* Broadcast indicator */}
{isBroadcastEnabled && ( {isBroadcastEnabled && (
<div <Tooltip>
className="flex items-center" <TooltipTrigger asChild>
title={t("terminal.composeBar.broadcasting")} <div className="flex items-center cursor-default">
> <Radio size={14} className="text-amber-400 animate-pulse" />
<Radio size={14} className="text-amber-400 animate-pulse" /> </div>
</div> </TooltipTrigger>
<TooltipContent>{t("terminal.composeBar.broadcasting")}</TooltipContent>
</Tooltip>
)} )}
{/* Borderless input — lives flush on the terminal bg so the {/* Borderless input — lives flush on the terminal bg so the
@@ -114,25 +117,29 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
/> />
{/* Minimal close button — no filled bg, hover only. */} {/* Minimal close button — no filled bg, hover only. */}
<button <Tooltip>
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0" <TooltipTrigger asChild>
style={{ <button
color: `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`, className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
background: 'transparent', style={{
}} color: `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`,
onMouseEnter={(e) => { background: 'transparent',
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`; }}
e.currentTarget.style.color = resolvedFg; onMouseEnter={(e) => {
}} e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`;
onMouseLeave={(e) => { e.currentTarget.style.color = resolvedFg;
e.currentTarget.style.background = 'transparent'; }}
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`; onMouseLeave={(e) => {
}} e.currentTarget.style.background = 'transparent';
onClick={onClose} e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`;
title={t("terminal.composeBar.close")} }}
> onClick={onClose}
<X size={12} /> >
</button> <X size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t("terminal.composeBar.close")}</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
); );

View File

@@ -10,6 +10,7 @@ import { Host, SSHKey } from '../../types';
import { formatHostPort, resolveTelnetPort } from '../../domain/host'; import { formatHostPort, resolveTelnetPort } from '../../domain/host';
import { DistroAvatar } from '../DistroAvatar'; import { DistroAvatar } from '../DistroAvatar';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog'; import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
import { TerminalConnectionProgress, TerminalConnectionProgressProps } from './TerminalConnectionProgress'; import { TerminalConnectionProgress, TerminalConnectionProgressProps } from './TerminalConnectionProgress';
import { HostKeyInfo, TerminalHostKeyVerification } from './TerminalHostKeyVerification'; import { HostKeyInfo, TerminalHostKeyVerification } from './TerminalHostKeyVerification';
@@ -203,16 +204,20 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</Button> </Button>
)} )}
{canDismissDisconnected && ( {canDismissDisconnected && (
<Button <Tooltip>
size="icon" <TooltipTrigger asChild>
variant="ghost" <Button
className="h-7 w-7" size="icon"
aria-label={t('terminal.connection.dismissDisconnectedDialog')} variant="ghost"
title={t('terminal.connection.dismissDisconnectedDialog')} className="h-7 w-7"
onClick={onDismissDisconnected} aria-label={t('terminal.connection.dismissDisconnectedDialog')}
> onClick={onDismissDisconnected}
<X size={14} /> >
</Button> <X size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('terminal.connection.dismissDisconnectedDialog')}</TooltipContent>
</Tooltip>
)} )}
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@ import { ChevronUp, ChevronDown, Search } from 'lucide-react';
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider'; import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
export interface TerminalSearchBarProps { export interface TerminalSearchBarProps {
isOpen: boolean; isOpen: boolean;
@@ -115,48 +116,56 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
{/* Navigation buttons */} {/* Navigation buttons */}
<div className="flex items-center gap-0.5 flex-shrink-0"> <div className="flex items-center gap-0.5 flex-shrink-0">
<Button <Tooltip>
type="button" <TooltipTrigger asChild>
variant="ghost" <Button
size="icon" type="button"
className="h-6 w-6 disabled:opacity-30" variant="ghost"
style={{ size="icon"
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)', className="h-6 w-6 disabled:opacity-30"
}} style={{
onClick={(e) => { color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
e.preventDefault(); }}
e.stopPropagation(); onClick={(e) => {
onFindPrevious(); e.preventDefault();
}} e.stopPropagation();
onMouseDown={(e) => e.stopPropagation()} onFindPrevious();
onKeyDown={(e) => e.stopPropagation()} }}
disabled={!searchTerm} onMouseDown={(e) => e.stopPropagation()}
title={t("terminal.search.prevMatch")} onKeyDown={(e) => e.stopPropagation()}
tabIndex={-1} disabled={!searchTerm}
> tabIndex={-1}
<ChevronUp size={14} /> >
</Button> <ChevronUp size={14} />
<Button </Button>
type="button" </TooltipTrigger>
variant="ghost" <TooltipContent>{t("terminal.search.prevMatch")}</TooltipContent>
size="icon" </Tooltip>
className="h-6 w-6 disabled:opacity-30" <Tooltip>
style={{ <TooltipTrigger asChild>
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)', <Button
}} type="button"
onClick={(e) => { variant="ghost"
e.preventDefault(); size="icon"
e.stopPropagation(); className="h-6 w-6 disabled:opacity-30"
onFindNext(); style={{
}} color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
onMouseDown={(e) => e.stopPropagation()} }}
onKeyDown={(e) => e.stopPropagation()} onClick={(e) => {
disabled={!searchTerm} e.preventDefault();
title={t("terminal.search.nextMatch")} e.stopPropagation();
tabIndex={-1} onFindNext();
> }}
<ChevronDown size={14} /> onMouseDown={(e) => e.stopPropagation()}
</Button> onKeyDown={(e) => e.stopPropagation()}
disabled={!searchTerm}
tabIndex={-1}
>
<ChevronDown size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("terminal.search.nextMatch")}</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
); );

View File

@@ -15,6 +15,7 @@ import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore'; import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser'; import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
import { CustomThemeModal } from './CustomThemeModal'; import { CustomThemeModal } from './CustomThemeModal';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { TerminalTheme } from '../../domain/models'; import { TerminalTheme } from '../../domain/models';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '../ui/scroll-area';
@@ -581,26 +582,32 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
)} )}
</div> </div>
<div className="flex items-center gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}> <div className="flex items-center gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
<select <Select
value={currentFontWeight} value={String(currentFontWeight)}
onChange={(e) => onFontWeightChange(Number(e.target.value))} onValueChange={(v) => onFontWeightChange(Number(v))}
className="flex-1 h-7 rounded-md border text-xs px-2 cursor-pointer"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
> >
<option value={100}>100 Thin</option> <SelectTrigger
<option value={200}>200 ExtraLight</option> className="flex-1 h-7 text-xs"
<option value={300}>300 Light</option> style={{
<option value={400}>400 Normal</option> backgroundColor: 'var(--terminal-panel-bg)',
<option value={500}>500 Medium</option> color: 'var(--terminal-panel-fg)',
<option value={600}>600 SemiBold</option> borderColor: 'var(--terminal-panel-border)',
<option value={700}>700 Bold</option> }}
<option value={800}>800 ExtraBold</option> >
<option value={900}>900 Black</option> <SelectValue />
</select> </SelectTrigger>
<SelectContent>
<SelectItem value="100">100 Thin</SelectItem>
<SelectItem value="200">200 ExtraLight</SelectItem>
<SelectItem value="300">300 Light</SelectItem>
<SelectItem value="400">400 Normal</SelectItem>
<SelectItem value="500">500 Medium</SelectItem>
<SelectItem value="600">600 SemiBold</SelectItem>
<SelectItem value="700">700 Bold</SelectItem>
<SelectItem value="800">800 ExtraBold</SelectItem>
<SelectItem value="900">900 Black</SelectItem>
</SelectContent>
</Select>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,5 +1,7 @@
import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react'; import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react';
import React from 'react'; import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
interface ZmodemProgressIndicatorProps { interface ZmodemProgressIndicatorProps {
transferType: 'upload' | 'download' | null; transferType: 'upload' | 'download' | null;
@@ -30,9 +32,14 @@ export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = (
finalizing, finalizing,
onCancel, onCancel,
}) => { }) => {
const { t } = useI18n();
const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0; const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0;
const Icon = transferType === 'upload' ? ArrowUpFromLine : ArrowDownToLine; const Icon = transferType === 'upload' ? ArrowUpFromLine : ArrowDownToLine;
const label = finalizing ? 'Waiting for remote...' : transferType === 'upload' ? 'Uploading' : 'Downloading'; const label = finalizing
? t('zmodem.waitingForRemote')
: transferType === 'upload'
? t('zmodem.uploading')
: t('zmodem.downloading');
const fileInfo = fileCount > 0 ? ` (${fileIndex + 1}/${fileCount})` : ''; const fileInfo = fileCount > 0 ? ` (${fileIndex + 1}/${fileCount})` : '';
return ( return (
@@ -67,13 +74,17 @@ export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = (
{formatBytes(transferred)} / {formatBytes(total)} {formatBytes(transferred)} / {formatBytes(total)}
</div> </div>
</div> </div>
<button <Tooltip>
onClick={onCancel} <TooltipTrigger asChild>
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10" <button
title="Cancel transfer (Ctrl+C)" onClick={onCancel}
> className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
<X className="h-3.5 w-3.5 opacity-60" /> >
</button> <X className="h-3.5 w-3.5 opacity-60" />
</button>
</TooltipTrigger>
<TooltipContent>{t('zmodem.cancelTransfer')}</TooltipContent>
</Tooltip>
</div> </div>
); );
}; };

View File

@@ -9,6 +9,7 @@ import '@fontsource/jetbrains-mono/500.css';
import '@fontsource/jetbrains-mono/600.css'; import '@fontsource/jetbrains-mono/600.css';
import App from './App'; import App from './App';
import { ToastProvider } from './components/ui/toast'; import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
const LazySettingsPage = lazy(() => import('./components/SettingsPage')); const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
const LazyTrayPanel = lazy(() => import('./components/TrayPanel')); const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
@@ -103,17 +104,21 @@ const renderApp = () => {
if (route === 'settings') { if (route === 'settings') {
root.render( root.render(
<ToastProvider> <ToastProvider>
<Suspense fallback={<SettingsWindowFallback />}> <TooltipProvider delayDuration={300}>
<LazySettingsPage /> <Suspense fallback={<SettingsWindowFallback />}>
</Suspense> <LazySettingsPage />
</Suspense>
</TooltipProvider>
</ToastProvider> </ToastProvider>
); );
} else if (route === 'tray') { } else if (route === 'tray') {
root.render( root.render(
<ToastProvider> <ToastProvider>
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel</div>}> <TooltipProvider delayDuration={300}>
<LazyTrayPanel /> <Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel</div>}>
</Suspense> <LazyTrayPanel />
</Suspense>
</TooltipProvider>
</ToastProvider> </ToastProvider>
); );
} else { } else {