* 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:
5
App.tsx
5
App.tsx
@@ -55,6 +55,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Input } from './components/ui/input';
|
||||
import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
|
||||
import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog';
|
||||
@@ -2436,7 +2437,9 @@ function AppWithProviders() {
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<ToastProvider>
|
||||
<App settings={settings} />
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<App settings={settings} />
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -2054,6 +2054,32 @@ const en: Messages = {
|
||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||
'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.',
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -2063,6 +2063,32 @@ const zhCN: Messages = {
|
||||
'ai.safety.blocklist.reset': '恢复默认',
|
||||
'ai.safety.blocklist.add': '添加规则',
|
||||
'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;
|
||||
|
||||
@@ -38,6 +38,7 @@ import { matchesManagedAgentConfig } from '../infrastructure/ai/managedAgents';
|
||||
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import AgentSelector from './ai/AgentSelector';
|
||||
import ChatInput from './ai/ChatInput';
|
||||
import ChatMessageList from './ai/ChatMessageList';
|
||||
@@ -1035,24 +1036,32 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
title="Session history"
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
||||
onClick={handleNewChat}
|
||||
title="New chat"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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>
|
||||
|
||||
@@ -1199,13 +1208,17 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { toast } from './ui/toast';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
// ============================================================================
|
||||
// Provider Icons
|
||||
@@ -377,12 +378,14 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<p
|
||||
className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help"
|
||||
title={error}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-xs text-red-500 truncate mt-1 max-w-[360px] cursor-help">
|
||||
{error}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{error}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
|
||||
@@ -1904,9 +1907,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
{entry.error && (
|
||||
<span className="text-xs text-red-500 truncate max-w-24" title={entry.error}>
|
||||
{t('cloudSync.history.error')}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, Host } from "../types";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
interface ConnectionLogsManagerProps {
|
||||
logs: ConnectionLog[];
|
||||
@@ -108,31 +109,39 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
|
||||
|
||||
{/* Saved column */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSaved(log.id);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
log.saved
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
)}
|
||||
title={log.saved ? t("logs.action.unsave") : t("logs.action.save")}
|
||||
>
|
||||
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(log.id);
|
||||
}}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
title={t("logs.action.delete")}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSaved(log.id);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md transition-colors",
|
||||
log.saved
|
||||
? "text-primary bg-primary/10"
|
||||
: "text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<Bookmark size={16} fill={log.saved ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{log.saved ? t("logs.action.unsave") : t("logs.action.save")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(log.id);
|
||||
}}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("logs.action.delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,9 +51,11 @@ import { Combobox } from "./ui/combobox";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
|
||||
|
||||
@@ -814,29 +816,33 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title={t("hostDetails.credential.browseKeyFile")}
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -871,16 +877,20 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.backspaceBehavior ?? ""}
|
||||
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
|
||||
<option value="ctrl-h">^H (0x08)</option>
|
||||
</select>
|
||||
<SelectTrigger className="h-8 w-auto text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Proxy */}
|
||||
@@ -895,14 +905,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{(form.proxyConfig?.host || form.proxyProfileId) && (
|
||||
<div title={proxySummaryLabel} className="min-w-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-[160px] truncate text-xs"
|
||||
>
|
||||
{proxySummaryLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 cursor-default">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-[160px] truncate text-xs"
|
||||
>
|
||||
{proxySummaryLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{proxySummaryLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
import HostDetailsPanel, { parseOptionalPortInput } from "./HostDetailsPanel.tsx";
|
||||
import { TooltipProvider } from "./ui/tooltip.tsx";
|
||||
|
||||
const hostWithMissingProxyProfile: Host = {
|
||||
id: "host-1",
|
||||
@@ -26,20 +27,24 @@ const renderHostDetails = (initialData: Host = hostWithMissingProxyProfile) =>
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -111,29 +116,33 @@ test("HostDetailsPanel displays inherited telnet port before falling back to 23"
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: undefined,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: undefined,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
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(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "ssh",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: 2222,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "ssh",
|
||||
telnetEnabled: true,
|
||||
telnetPort: undefined,
|
||||
port: 2222,
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{ path: "network", telnetPort: 2325 }],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -181,35 +194,39 @@ test("HostDetailsPanel displays inherited telnet credentials", () => {
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
username: "ssh-user",
|
||||
password: "ssh-password",
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPassword: "group-telnet-password",
|
||||
}],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: {
|
||||
...hostWithMissingProxyProfile,
|
||||
protocol: "telnet",
|
||||
telnetEnabled: true,
|
||||
telnetUsername: undefined,
|
||||
telnetPassword: undefined,
|
||||
username: "ssh-user",
|
||||
password: "ssh-password",
|
||||
group: "network",
|
||||
proxyProfileId: undefined,
|
||||
},
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: ["network"],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
groupConfigs: [{
|
||||
path: "network",
|
||||
telnetUsername: "group-telnet-user",
|
||||
telnetPassword: "group-telnet-password",
|
||||
}],
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -938,15 +938,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{selectedIdentity.label}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
title={t("common.clear")}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.clear")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : form.identityId ? (
|
||||
<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")}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
title={t("common.clear")}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={clearIdentity}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.clear")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
@@ -1019,29 +1027,33 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}}
|
||||
className="h-10 pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
setIdentitySuggestionsOpen((prev) => {
|
||||
if (prev) return false;
|
||||
const q = (form.username || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
return matches.length > 0;
|
||||
});
|
||||
}}
|
||||
title={t("hostDetails.identity.suggestions")}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
setIdentitySuggestionsOpen((prev) => {
|
||||
if (prev) return false;
|
||||
const q = (form.username || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
const matches = q
|
||||
? identities.filter(
|
||||
(i) =>
|
||||
i.label.toLowerCase().includes(q) ||
|
||||
i.username.toLowerCase().includes(q),
|
||||
)
|
||||
: identities;
|
||||
return matches.length > 0;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.identity.suggestions")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -1123,14 +1135,18 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onChange={(e) => update("password", e.target.value)}
|
||||
className="h-10 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1153,9 +1169,14 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{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">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
|
||||
{keyPath}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs w-0 flex-1 truncate font-mono cursor-default">
|
||||
{keyPath}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{keyPath}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1366,26 +1387,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title={t("hostDetails.credential.browseKeyFile")}
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
addLocalKeyFilePath(filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
addLocalKeyFilePath(filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("hostDetails.credential.browseKeyFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1794,16 +1819,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</p>
|
||||
</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>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={form.backspaceBehavior ?? ""}
|
||||
onChange={(e) => update("backspaceBehavior", e.target.value || undefined)}
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
onValueChange={(v) => update("backspaceBehavior", v === "default" ? undefined : v)}
|
||||
>
|
||||
<option value="">{t("hostDetails.backspaceBehavior.default")}</option>
|
||||
<option value="ctrl-h">^H (0x08)</option>
|
||||
</select>
|
||||
<SelectTrigger className="h-8 w-auto text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">{t("hostDetails.backspaceBehavior.default")}</SelectItem>
|
||||
<SelectItem value="ctrl-h">^H (0x08)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
// Import utilities and components from keychain module
|
||||
import {
|
||||
@@ -1168,9 +1169,14 @@ echo $3 >> "$FILE"`);
|
||||
</Label>
|
||||
<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" />
|
||||
<span className="text-xs font-mono truncate" title={draftKey.filePath}>
|
||||
{draftKey.filePath}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs font-mono truncate cursor-default">
|
||||
{draftKey.filePath}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{draftKey.filePath}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
interface KnownHostsManagerProps {
|
||||
@@ -122,27 +123,35 @@ const HostItem = React.memo<HostItemProps>(
|
||||
{/* Quick action buttons on hover */}
|
||||
<div className="absolute top-1 right-1 flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!converted && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-primary/20 text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
title={t("action.convertToHost")}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-primary/20 text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-destructive/20 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(knownHost.id);
|
||||
}}
|
||||
title={t("action.remove")}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-destructive/20 text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(knownHost.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.remove")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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">
|
||||
@@ -193,18 +202,22 @@ const HostItem = React.memo<HostItemProps>(
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{!converted && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
title={t("action.convertToHost")}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConvertToHost(knownHost);
|
||||
}}
|
||||
>
|
||||
<ArrowRight size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("action.convertToHost")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -277,7 +277,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
className="gap-1.5 h-8 px-2"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
title={t("logView.export")}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="text-xs">{t("logView.export")}</span>
|
||||
@@ -290,7 +289,6 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
size="sm"
|
||||
className="gap-1.5 h-8 px-2"
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
title={t("logView.customizeAppearance")}
|
||||
>
|
||||
<Palette size={14} />
|
||||
<span className="text-xs">{t("logView.appearance")}</span>
|
||||
|
||||
@@ -298,7 +298,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
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"
|
||||
title="New Workspace"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
|
||||
@@ -249,15 +249,19 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
className="h-7 pl-7 text-xs bg-muted/30 border-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSnippet}
|
||||
title={t('snippets.action.newSnippet')}
|
||||
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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSnippet}
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('snippets.action.newSnippet')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
|
||||
interface SerialPort {
|
||||
@@ -262,35 +263,41 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<select
|
||||
id="data-bits"
|
||||
value={dataBits}
|
||||
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"
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<select
|
||||
id="stop-bits"
|
||||
value={stopBits}
|
||||
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"
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
@@ -302,35 +309,41 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<select
|
||||
id="parity"
|
||||
<Select
|
||||
value={parity}
|
||||
onChange={(e) => setParity(e.target.value 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"
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<select
|
||||
id="flow-control"
|
||||
<Select
|
||||
value={flowControl}
|
||||
onChange={(e) => setFlowControl(e.target.value 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"
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import {
|
||||
AsidePanel,
|
||||
@@ -291,35 +292,41 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
|
||||
<select
|
||||
id="data-bits"
|
||||
value={dataBits}
|
||||
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"
|
||||
<Select
|
||||
value={String(dataBits)}
|
||||
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
|
||||
>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="data-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATA_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stop Bits */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
|
||||
<select
|
||||
id="stop-bits"
|
||||
value={stopBits}
|
||||
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"
|
||||
<Select
|
||||
value={String(stopBits)}
|
||||
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
|
||||
>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<option key={bits} value={bits}>
|
||||
{bits}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="stop-bits">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STOP_BITS.map((bits) => (
|
||||
<SelectItem key={bits} value={String(bits)}>
|
||||
{bits}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isStopBits15 && (
|
||||
<p className="text-xs text-yellow-500">
|
||||
{t('serial.field.stopBits15Warning')}
|
||||
@@ -331,35 +338,41 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
|
||||
{/* Parity */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
|
||||
<select
|
||||
id="parity"
|
||||
<Select
|
||||
value={parity}
|
||||
onChange={(e) => setParity(e.target.value 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"
|
||||
onValueChange={(v) => setParity(v as SerialParity)}
|
||||
>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="parity">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PARITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.parity.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Flow Control */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
|
||||
<select
|
||||
id="flow-control"
|
||||
<Select
|
||||
value={flowControl}
|
||||
onChange={(e) => setFlowControl(e.target.value 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"
|
||||
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
|
||||
>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="flow-control">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FLOW_CONTROL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`serial.flowControl.${option}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Terminal Options */}
|
||||
|
||||
@@ -20,6 +20,7 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
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);
|
||||
|
||||
@@ -187,13 +188,17 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<h1 className="text-lg font-semibold">{t("settings.title")}</h1>
|
||||
{!isMac && (
|
||||
<button
|
||||
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"
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("common.close")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { DropEntry } from "../lib/sftpFileUtils";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import type { TransferTask } from "../types";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
|
||||
import { SftpPaneView } from "./sftp/SftpPaneView";
|
||||
@@ -653,18 +654,22 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
size="sm"
|
||||
className="h-5 w-5 rounded-sm shrink-0"
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
|
||||
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{displayHost.label}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground">·</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
|
||||
</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate cursor-default">
|
||||
<span className="font-medium">
|
||||
{displayHost.label}
|
||||
</span>
|
||||
<span className="mx-1 text-muted-foreground">·</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -745,21 +745,25 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
actions={
|
||||
<>
|
||||
{editingSnippet.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const id = editingSnippet.id;
|
||||
if (!id) return;
|
||||
onDelete(id);
|
||||
handleClosePanel();
|
||||
}}
|
||||
aria-label={t('common.delete')}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const id = editingSnippet.id;
|
||||
if (!id) return;
|
||||
onDelete(id);
|
||||
handleClosePanel();
|
||||
}}
|
||||
aria-label={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -839,18 +843,22 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
|
||||
{editingSnippet.shortkey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
title={t('snippets.shortkey.clear')}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('snippets.shortkey.clear')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -1269,7 +1277,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
value={newPackageName}
|
||||
onChange={(e) => setNewPackageName(e.target.value)}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from './ui/popover';
|
||||
import { toast } from './ui/toast';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
// ============================================================================
|
||||
// Provider Icons
|
||||
@@ -169,26 +170,30 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
|
||||
className
|
||||
)}
|
||||
title={t('sync.cloudSync')}
|
||||
>
|
||||
{getButtonIcon()}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{getButtonIcon()}
|
||||
|
||||
{/* Status indicator dot */}
|
||||
<StatusIndicator
|
||||
status={overallStatus}
|
||||
size="sm"
|
||||
className="absolute top-0.5 right-0.5 ring-2 ring-background"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{/* Status indicator dot */}
|
||||
<StatusIndicator
|
||||
status={overallStatus}
|
||||
size="sm"
|
||||
className="absolute top-0.5 right-0.5 ring-2 ring-background"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sync.cloudSync')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
key={syncStateKey}
|
||||
@@ -222,16 +227,20 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
</div>
|
||||
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title={t('sync.settings')}
|
||||
>
|
||||
<Settings size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
<Settings size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sync.settings')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
||||
// SFTPModal removed - SFTP is now handled by SftpSidePanel in TerminalLayer
|
||||
import { Button } from "./ui/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { toast } from "./ui/toast";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
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" && (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(host.hostname).then(() => {
|
||||
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")}
|
||||
>
|
||||
<Copy size={10} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(host.hostname).then(() => {
|
||||
toast.success(t("terminal.statusbar.copyHostname.toast", { hostname: host.hostname }));
|
||||
}).catch(() => {
|
||||
toast.error(t("terminal.statusbar.copyHostname.error"));
|
||||
});
|
||||
}}
|
||||
aria-label={t("terminal.statusbar.copyHostname.label")}
|
||||
>
|
||||
<Copy size={10} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.statusbar.copyHostname.tooltip", { hostname: host.hostname })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{/* Server Stats Display */}
|
||||
@@ -1902,7 +1907,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
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" />
|
||||
<span>
|
||||
@@ -1971,7 +1976,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
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" />
|
||||
<span>
|
||||
@@ -1993,12 +1998,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
{serverStats.memTotal !== null && (
|
||||
<div className="space-y-1.5">
|
||||
<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 && (
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memUsed")}: ${(serverStats.memUsed / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
{/* Buffers (blue) */}
|
||||
@@ -2006,7 +2010,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div
|
||||
className="h-full bg-blue-500"
|
||||
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memBuffers")}: ${(serverStats.memBuffers / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
{/* Cached (amber/orange) */}
|
||||
@@ -2014,7 +2017,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div
|
||||
className="h-full bg-amber-500"
|
||||
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.memCached")}: ${(serverStats.memCached / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -2048,7 +2050,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div
|
||||
className="h-full bg-rose-500"
|
||||
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -2081,9 +2082,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-shrink-0 font-mono truncate max-w-[140px]" title={proc.command}>
|
||||
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
@@ -2097,7 +2103,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
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" />
|
||||
<span className={cn(
|
||||
@@ -2125,9 +2131,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
{serverStats.disks.map((disk, index) => (
|
||||
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
|
||||
<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}>
|
||||
{disk.mountPoint}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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(
|
||||
"text-[11px] font-medium whitespace-nowrap",
|
||||
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>
|
||||
<button
|
||||
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" />
|
||||
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
|
||||
@@ -2203,40 +2214,48 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{inWorkspace && onToggleBroadcast && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
|
||||
"bg-transparent hover:bg-transparent",
|
||||
isBroadcastEnabled && "text-green-500",
|
||||
)}
|
||||
onClick={onToggleBroadcast}
|
||||
title={
|
||||
isBroadcastEnabled
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)]",
|
||||
"bg-transparent hover:bg-transparent",
|
||||
isBroadcastEnabled && "text-green-500",
|
||||
)}
|
||||
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.broadcastEnable")
|
||||
}
|
||||
aria-label={
|
||||
isBroadcastEnabled
|
||||
? t("terminal.toolbar.broadcastDisable")
|
||||
: t("terminal.toolbar.broadcastEnable")
|
||||
}
|
||||
>
|
||||
<Radio size={12} />
|
||||
</Button>
|
||||
: t("terminal.toolbar.broadcastEnable")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{inWorkspace && !isFocusMode && onExpandToFocus && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
|
||||
onClick={onExpandToFocus}
|
||||
title={t("terminal.toolbar.focusMode")}
|
||||
aria-label={t("terminal.toolbar.focusMode")}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
|
||||
onClick={onExpandToFocus}
|
||||
aria-label={t("terminal.toolbar.focusMode")}
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.toolbar.focusMode")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{renderControls({ showClose: inWorkspace })}
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,8 @@ import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
|
||||
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import Terminal from './Terminal';
|
||||
import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
@@ -507,6 +509,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
toggleScriptsSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// Subscribe to activeTabId from external store
|
||||
const activeTabId = useActiveTabId();
|
||||
const isVaultActive = activeTabId === 'vault';
|
||||
@@ -2099,27 +2102,35 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
/>
|
||||
</div>
|
||||
{onRequestAddToWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
|
||||
style={{ color: mutedFg }}
|
||||
onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}
|
||||
title="Add Terminal"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
|
||||
style={{ color: mutedFg }}
|
||||
onClick={() => onRequestAddToWorkspace(activeWorkspace.id)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.layer.addTerminal')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
|
||||
style={{ color: mutedFg }}
|
||||
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
|
||||
title="Switch to Split View"
|
||||
>
|
||||
<Columns2 size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:text-inherit"
|
||||
style={{ color: mutedFg }}
|
||||
onClick={() => onToggleWorkspaceViewMode?.(activeWorkspace.id)}
|
||||
>
|
||||
<Columns2 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.layer.switchToSplitView')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
@@ -2252,111 +2263,137 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
borderBottom: '1px solid var(--terminal-sidepanel-border)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-tab-id="sftp"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
backgroundColor: activeSidePanelTab === 'sftp'
|
||||
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
|
||||
: 'transparent',
|
||||
color: activeSidePanelTab === 'sftp'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleToggleSftpFromBar}
|
||||
title="SFTP"
|
||||
>
|
||||
<FolderTree size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-tab-id="scripts"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
backgroundColor: activeSidePanelTab === 'scripts'
|
||||
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
|
||||
: 'transparent',
|
||||
color: activeSidePanelTab === 'scripts'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleOpenScripts}
|
||||
title="Scripts"
|
||||
>
|
||||
<Zap size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-tab-id="theme"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
backgroundColor: activeSidePanelTab === 'theme'
|
||||
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
|
||||
: 'transparent',
|
||||
color: activeSidePanelTab === 'theme'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleOpenTheme}
|
||||
title="Theme"
|
||||
>
|
||||
<Palette size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-tab-id="ai"
|
||||
data-tab-type="sidepanel"
|
||||
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}
|
||||
title="AI Chat"
|
||||
>
|
||||
<MessageSquare size={15} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-tab-id="sftp"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
backgroundColor: activeSidePanelTab === 'sftp'
|
||||
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
|
||||
: 'transparent',
|
||||
color: activeSidePanelTab === 'sftp'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleToggleSftpFromBar}
|
||||
>
|
||||
<FolderTree size={15} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.layer.sftp')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-tab-id="scripts"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
backgroundColor: activeSidePanelTab === 'scripts'
|
||||
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
|
||||
: 'transparent',
|
||||
color: activeSidePanelTab === 'scripts'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleOpenScripts}
|
||||
>
|
||||
<Zap size={15} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.layer.scripts')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-tab-id="theme"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
backgroundColor: activeSidePanelTab === 'theme'
|
||||
? 'color-mix(in srgb, var(--terminal-sidepanel-accent) 24%, transparent)'
|
||||
: 'transparent',
|
||||
color: activeSidePanelTab === 'theme'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleOpenTheme}
|
||||
>
|
||||
<Palette size={15} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.layer.theme')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-tab-id="ai"
|
||||
data-tab-type="sidepanel"
|
||||
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" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
|
||||
title={sidePanelPosition === 'left' ? 'Move panel to right' : 'Move panel to left'}
|
||||
>
|
||||
{sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={handleCloseSidePanel}
|
||||
title="Close panel"
|
||||
>
|
||||
<X size={15} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: 'var(--terminal-sidepanel-muted)',
|
||||
}}
|
||||
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
|
||||
>
|
||||
{sidePanelPosition === 'left' ? <PanelRight size={15} /> : <PanelLeft size={15} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{sidePanelPosition === 'left' ? t('terminal.layer.movePanelRight') : t('terminal.layer.movePanelLeft')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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 className="flex-1 min-h-0 relative">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
|
||||
// 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}
|
||||
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)))' }}
|
||||
title="Minimize"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
@@ -213,20 +213,16 @@ const WindowControls: React.FC = memo(() => {
|
||||
onClick={handleMaximize}
|
||||
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)))' }}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
// Restore icon (two overlapping squares)
|
||||
<Copy size={14} />
|
||||
) : (
|
||||
// Maximize icon (single square)
|
||||
<Square size={14} />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
title="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -577,60 +573,63 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const FileIcon = CODE_EXTENSIONS_RE.test(editorTab.fileName) ? FileCode : FileText;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(tabId)}
|
||||
title={tooltip}
|
||||
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",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
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)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileIcon
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
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">
|
||||
{dirty && <span className="text-primary mr-0.5">●</span>}
|
||||
{editorTab.fileName}
|
||||
{suffix && <span className="text-muted-foreground ml-1">{suffix}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestCloseEditorTab(editorTab.id);
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
aria-label="Close editor tab"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<Tooltip key={tabId}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
data-tab-id={tabId}
|
||||
data-tab-type="editor"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(tabId)}
|
||||
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",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
|
||||
: 'transparent',
|
||||
color: isActive
|
||||
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
|
||||
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
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)))';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<FileIcon
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
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">
|
||||
{dirty && <span className="text-primary mr-0.5">●</span>}
|
||||
{editorTab.fileName}
|
||||
{suffix && <span className="text-muted-foreground ml-1">{suffix}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestCloseEditorTab(editorTab.id);
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
aria-label="Close editor tab"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1016,16 +1015,20 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{renderOrderedTabs()}
|
||||
{/* Add new tab button - follows last tab when not overflowing */}
|
||||
{!hasOverflow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="Open quick switcher"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.openQuickSwitcher')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Draggable spacer - fixed width handle at the end */}
|
||||
<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 */}
|
||||
{hasOverflow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
title="More tabs"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.moreTabs')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Fixed right controls */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
title="AI Assistant"
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
|
||||
>
|
||||
<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)))' }}>
|
||||
<Bell size={16} />
|
||||
</Button>
|
||||
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onToggleTheme}
|
||||
disabled={isImmersiveActive && !followAppTerminalTheme}
|
||||
title="Toggle theme"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onToggleTheme}
|
||||
disabled={isImmersiveActive && !followAppTerminalTheme}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.toggleTheme')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* 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">
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
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)))' }}
|
||||
title="Open Settings"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.openSettings')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Custom window controls for Windows/Linux */}
|
||||
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSessionState } from "../application/state/useSessionState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
@@ -78,28 +79,31 @@ const WorkspaceGroup: React.FC<{
|
||||
{expanded && (
|
||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
// Jump to session (using session id)
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
activeTabId === s.id ? "bg-muted/60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
<Tooltip key={s.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Jump to session (using session id)
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
activeTabId === s.id ? "bg-muted/60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -219,17 +223,20 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
<span className="text-sm font-medium">Netcatty</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenMain}
|
||||
title={t("tray.openMainWindow")}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenMain}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("tray.openMainWindow")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
@@ -277,27 +284,30 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
))}
|
||||
{/* Solo sessions */}
|
||||
{soloSessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
<Tooltip key={s.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{s.hostLabel || s.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,16 +317,20 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
{activeSession && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 h-8"
|
||||
title={activeSession.hostLabel || activeSession.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(activeSession.id);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 h-8"
|
||||
onClick={() => {
|
||||
void jumpToSession(activeSession.id);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{activeSession.hostLabel || activeSession.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -332,55 +346,58 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
: `${rule.localPort} → ${rule.remoteHost}:${rule.remotePort}`);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={rule.id}
|
||||
disabled={isConnecting}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!rawHost) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
const resolveEffectiveHost = (host: Host) => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
};
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
isConnecting ? "opacity-60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={
|
||||
rule.status === "active"
|
||||
? "success"
|
||||
: rule.status === "connecting"
|
||||
? "warning"
|
||||
: rule.status === "error"
|
||||
? "error"
|
||||
: "neutral"
|
||||
}
|
||||
spinning={rule.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`tray.status.${rule.status}`)}
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip key={rule.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
disabled={isConnecting}
|
||||
onClick={() => {
|
||||
const rawHost = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!rawHost) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
const resolveEffectiveHost = (host: Host) => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
};
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
isConnecting ? "opacity-60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={
|
||||
rule.status === "active"
|
||||
? "success"
|
||||
: rule.status === "connecting"
|
||||
? "warning"
|
||||
: rule.status === "error"
|
||||
? "error"
|
||||
: "neutral"
|
||||
}
|
||||
spinning={rule.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`tray.status.${rule.status}`)}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1907,21 +1907,25 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onChange={setSortMode}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Button
|
||||
variant={isMultiSelectMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearHostSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
title={t("vault.hosts.multiSelect")}
|
||||
>
|
||||
<CheckSquare size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isMultiSelectMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearHostSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckSquare size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("vault.hosts.multiSelect")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* New Host split button — collapses with an animation when the
|
||||
host details / new-host aside panel is open, since the button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '../../lib/utils';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
@@ -142,9 +143,14 @@ export const ToolCall = ({
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
{name === 'terminal_execute' && args?.command ? (
|
||||
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
|
||||
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface AgentSelectorProps {
|
||||
currentAgentId: string;
|
||||
@@ -80,6 +81,7 @@ const DiscoveredAgentRow: React.FC<{
|
||||
agent: DiscoveredAgent;
|
||||
onEnable: () => void;
|
||||
}> = ({ agent, onEnable }) => {
|
||||
const { t } = useI18n();
|
||||
const agentLike: AgentInfo = {
|
||||
id: `discovered_${agent.command}`,
|
||||
name: agent.name,
|
||||
@@ -98,13 +100,17 @@ const DiscoveredAgentRow: React.FC<{
|
||||
{agent.version || agent.path}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
title={`Enable ${agent.name}`}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.enableAgent', { name: agent.name })}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -250,14 +256,18 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
|
||||
<SectionLabel
|
||||
action={
|
||||
onRediscover && (
|
||||
<button
|
||||
onClick={onRediscover}
|
||||
disabled={isDiscovering}
|
||||
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
|
||||
title={t('ai.chat.rescan')}
|
||||
>
|
||||
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onRediscover}
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.rescan')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
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="flex flex-wrap gap-2">
|
||||
{selectedUserSkills.map((skill) => (
|
||||
<div
|
||||
key={skill.id}
|
||||
className={selectedSkillChipClassName}
|
||||
title={skill.description || skill.name || skill.slug}
|
||||
>
|
||||
<Package size={11} className="text-primary/72 shrink-0" />
|
||||
<span className="truncate max-w-[180px]">
|
||||
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
aria-label={`Remove skill ${skill.name || skill.slug}`}
|
||||
>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
<Tooltip key={skill.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={selectedSkillChipClassName}
|
||||
>
|
||||
<Package size={11} className="text-primary/72 shrink-0" />
|
||||
<span className="truncate max-w-[180px]">
|
||||
{skill.name && skill.name !== skill.slug ? skill.name : `/${skill.slug}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
aria-label={`Remove skill ${skill.name || skill.slug}`}
|
||||
>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{skill.description || skill.name || skill.slug}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,14 +454,18 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
].filter(Boolean).join(' ')}
|
||||
maxLength={100000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<Expand size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{expanded ? t('ai.chat.collapse') : t('ai.chat.expand')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* @ mention popover */}
|
||||
@@ -557,25 +565,29 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
{/* Footer toolbar */}
|
||||
<PromptInputFooter className="gap-1.5 border-t-0 bg-transparent px-3 pb-2 pt-0">
|
||||
<PromptInputTools className="gap-1 flex-wrap">
|
||||
<button
|
||||
ref={attachBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showAttachMenu) {
|
||||
const rect = attachBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('attach');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
title="Attach"
|
||||
aria-label="Attach file"
|
||||
aria-expanded={showAttachMenu}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={attachBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showAttachMenu) {
|
||||
const rect = attachBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('attach');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={iconButtonClassName}
|
||||
aria-label={t('ai.chat.attach')}
|
||||
aria-expanded={showAttachMenu}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.attach')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showAttachMenu && menuPos && createPortal(
|
||||
<>
|
||||
<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 */}
|
||||
{permissionMode && onPermissionModeChange && (
|
||||
<>
|
||||
<button
|
||||
ref={permBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showPermPicker) {
|
||||
const rect = permBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('perm');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
|
||||
title={t('ai.safety.permissionMode')}
|
||||
aria-label="Permission mode"
|
||||
aria-expanded={showPermPicker}
|
||||
>
|
||||
{permissionMode === 'observer' && <Eye size={11} className="text-blue-400/70" />}
|
||||
{permissionMode === 'confirm' && <ShieldCheck size={11} className="text-yellow-400/70" />}
|
||||
{permissionMode === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
|
||||
<span className="truncate max-w-[72px]">
|
||||
{permissionMode === 'observer' && t('ai.chat.permObserver')}
|
||||
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
|
||||
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
|
||||
</span>
|
||||
<ChevronDown size={9} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={permBtnRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!showPermPicker) {
|
||||
const rect = permBtnRef.current?.getBoundingClientRect();
|
||||
if (rect) setMenuPos({ left: rect.left, bottom: window.innerHeight - rect.top + 6 });
|
||||
setActiveMenu('perm');
|
||||
} else {
|
||||
closeAllMenus();
|
||||
}
|
||||
}}
|
||||
className={`${chipClassName} cursor-pointer hover:bg-muted/24 transition-colors`}
|
||||
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 === 'autonomous' && <Zap size={11} className="text-green-400/70" />}
|
||||
<span className="truncate max-w-[72px]">
|
||||
{permissionMode === 'observer' && t('ai.chat.permObserver')}
|
||||
{permissionMode === 'confirm' && t('ai.chat.permConfirm')}
|
||||
{permissionMode === 'autonomous' && t('ai.chat.permAuto')}
|
||||
</span>
|
||||
<ChevronDown size={9} className="text-muted-foreground/50" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.safety.permissionMode')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showPermPicker && menuPos && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[999]" onClick={closeAllMenus} />
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DropdownContent,
|
||||
DropdownTrigger,
|
||||
} from '../ui/dropdown';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface ConversationExportProps {
|
||||
session: AISession | null;
|
||||
@@ -45,17 +46,21 @@ const ConversationExport: React.FC<ConversationExportProps> = ({
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className ?? 'h-7 w-7 rounded-md text-muted-foreground/70 hover:bg-accent/60 hover:text-foreground'}
|
||||
disabled={!hasMessages}
|
||||
title={t('ai.chat.exportConversation')}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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>
|
||||
</DropdownTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.exportConversation')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
isTextEditorReadOnly,
|
||||
TextEditorPromoteButton,
|
||||
} 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", () => {
|
||||
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", () => {
|
||||
const savingMarkup = renderToStaticMarkup(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: true,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
wrap(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: true,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const idleMarkup = renderToStaticMarkup(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: false,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
wrap(
|
||||
React.createElement(TextEditorPromoteButton, {
|
||||
saving: false,
|
||||
onPromoteToTab: () => {},
|
||||
title: "Maximize",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
assert.match(savingMarkup, /disabled=""/);
|
||||
|
||||
@@ -28,6 +28,7 @@ import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models
|
||||
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Combobox } from '../ui/combobox';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
@@ -186,16 +187,20 @@ export const TextEditorPromoteButton: React.FC<{
|
||||
onPromoteToTab: () => void;
|
||||
title: string;
|
||||
}> = ({ saving, onPromoteToTab, title }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onPromoteToTab}
|
||||
disabled={!canPromoteTextEditor({ saving })}
|
||||
title={title}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onPromoteToTab}
|
||||
disabled={!canPromoteTextEditor({ saving })}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{title}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
@@ -479,34 +484,47 @@ export const TextEditorPane: React.FC<TextEditorPaneProps> = ({
|
||||
{fileName}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span className="text-xs text-muted-foreground truncate" title={subtitle}>
|
||||
{subtitle}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Search button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
title={t('common.search')}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.search')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={wordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={wordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('sftp.editor.wordWrap')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Combobox } from '../ui/combobox';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Popover,PopoverContent,PopoverTrigger } from '../ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface IdentityPanelProps {
|
||||
draftIdentity: Partial<Identity>;
|
||||
@@ -129,15 +130,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{selectedKey?.label || t('hostDetails.credential.missing')}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={clearSelectedKey}
|
||||
title={t('common.clear')}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={clearSelectedKey}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.clear')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -202,15 +207,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
|
||||
icon={<Key size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
title={t('common.cancel')}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.cancel')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -230,15 +239,19 @@ export const IdentityPanel: React.FC<IdentityPanelProps> = ({
|
||||
icon={<Shield size={14} className="text-muted-foreground" />}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
title={t('common.cancel')}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedCredentialType(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.cancel')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TrafficDiagram } from '../TrafficDiagram';
|
||||
import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { Label } from '../ui/label';
|
||||
import { Switch } from '../ui/switch';
|
||||
import { getTypeLabel } from './utils';
|
||||
@@ -183,14 +184,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<button
|
||||
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}
|
||||
title={t('pf.form.openWizardTitle')}
|
||||
>
|
||||
<Zap size={12} />
|
||||
{t('pf.form.openWizard')}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
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')}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('pf.form.openWizardTitle')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
|
||||
@@ -68,13 +68,26 @@ export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold truncate">{rule.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full flex-shrink-0",
|
||||
getStatusColor(rule.status)
|
||||
)}
|
||||
title={rule.status === 'error' && rule.error ? rule.error : undefined}
|
||||
/>
|
||||
{rule.status === 'error' && rule.error ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"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 className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
|
||||
export default function SettingsAppearanceTab(props: {
|
||||
theme: "dark" | "light" | "system";
|
||||
@@ -122,20 +123,23 @@ export default function SettingsAppearanceTab(props: {
|
||||
) => (
|
||||
<div className="flex flex-wrap gap-2 justify-end">
|
||||
{options.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => onChange(preset.id)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
|
||||
value === preset.id
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
style={getHslStyle(preset.tokens.background)}
|
||||
title={preset.name}
|
||||
>
|
||||
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
|
||||
</button>
|
||||
<Tooltip key={preset.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onChange(preset.id)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm border border-border/70",
|
||||
value === preset.id
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
style={getHslStyle(preset.tokens.background)}
|
||||
>
|
||||
{value === preset.id && <Check className="text-white drop-shadow-md" size={10} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{preset.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -212,42 +216,49 @@ export default function SettingsAppearanceTab(props: {
|
||||
<div className="text-sm font-medium">{t("settings.appearance.accentColor.custom")}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ACCENT_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
onClick={() => setCustomAccent(c.value)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
|
||||
customAccent === c.value
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
style={getHslStyle(c.value)}
|
||||
title={c.name}
|
||||
>
|
||||
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
|
||||
</button>
|
||||
<Tooltip key={c.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setCustomAccent(c.value)}
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm",
|
||||
customAccent === c.value
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
style={getHslStyle(c.value)}
|
||||
>
|
||||
{customAccent === c.value && <Check className="text-white drop-shadow-md" size={10} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{c.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<label
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
|
||||
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
|
||||
!ACCENT_COLORS.some((c) => c.value === customAccent)
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
title={t("settings.appearance.customColor")}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
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} />
|
||||
) : (
|
||||
<Palette size={12} className="text-white drop-shadow-md" />
|
||||
)}
|
||||
</label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<label
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-full flex items-center justify-center transition-all shadow-sm cursor-pointer",
|
||||
"bg-gradient-to-br from-pink-500 via-purple-500 to-blue-500",
|
||||
!ACCENT_COLORS.some((c) => c.value === customAccent)
|
||||
? "ring-2 ring-offset-2 ring-foreground scale-110"
|
||||
: "hover:scale-105",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
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} />
|
||||
) : (
|
||||
<Palette size={12} className="text-white drop-shadow-md" />
|
||||
)}
|
||||
</label>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.appearance.customColor")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Label } from "../../ui/label";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import { SectionHeader, SettingsTabContent } from "../settings-ui";
|
||||
|
||||
const getOpenerLabel = (
|
||||
@@ -527,31 +528,44 @@ export default function SettingsFileAssociationsTab() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{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)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(extension)}
|
||||
disabled={editingExtension === extension}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemove(extension)}
|
||||
title={t('settings.sftpFileAssociations.remove')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleEdit(extension)}
|
||||
disabled={editingExtension === extension}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('common.edit')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function SettingsShortcutsTab(props: {
|
||||
<button
|
||||
onClick={() => updateKeyBinding?.(binding.id, scheme, "Disabled")}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.shortcuts.setDisabled")}
|
||||
aria-label={t("settings.shortcuts.setDisabled")}
|
||||
>
|
||||
<Ban size={12} />
|
||||
</button>
|
||||
@@ -238,7 +238,7 @@ export default function SettingsShortcutsTab(props: {
|
||||
<button
|
||||
onClick={() => resetKeyBinding?.(binding.id, scheme)}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title="Reset to default"
|
||||
aria-label={t("settings.shortcuts.resetToDefault")}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { UpdateState } from '../../../application/state/useUpdateCheck';
|
||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import { Toggle, Select, SettingRow } from "../settings-ui";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
@@ -637,9 +638,14 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
|
||||
const text = parts.join(' ');
|
||||
return text ? (
|
||||
<div className="text-muted-foreground truncate" title={text}>
|
||||
{text}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-muted-foreground truncate cursor-default">
|
||||
{text}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{text}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
})()}
|
||||
{entry.stack && (
|
||||
@@ -678,14 +684,18 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
<Trash2 size={14} />
|
||||
{t("settings.system.crashLogs.clear")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenCrashLogsDir}
|
||||
title={t("settings.system.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenCrashLogsDir}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{crashLogClearResult && (
|
||||
@@ -716,16 +726,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
{isLoading ? "..." : (tempDirInfo?.path ?? "-")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={handleOpenTempDir}
|
||||
disabled={!tempDirInfo?.path}
|
||||
title={t("settings.system.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={handleOpenTempDir}
|
||||
disabled={!tempDirInfo?.path}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.system.openFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -823,15 +837,19 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
{t("settings.sessionLogs.browse")}
|
||||
</Button>
|
||||
{sessionLogsDir && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenSessionLogsDir}
|
||||
className="shrink-0"
|
||||
title={t("settings.sessionLogs.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenSessionLogsDir}
|
||||
className="shrink-0"
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.sessionLogs.openFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -902,13 +920,17 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("settings.globalHotkey.reset")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Button } from "../../ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog";
|
||||
import { Input } from "../../ui/input";
|
||||
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 { ThemeSelectModal } from "../ThemeSelectModal";
|
||||
import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
@@ -960,35 +961,41 @@ export default function SettingsTerminalTab(props: {
|
||||
description={t("settings.terminal.localShell.shell.desc")}
|
||||
>
|
||||
<div className="flex flex-col gap-1 items-end">
|
||||
<select
|
||||
className="h-9 w-48 rounded-md border border-input bg-background px-3 text-sm"
|
||||
<ShadcnSelect
|
||||
value={
|
||||
showCustomShellInput
|
||||
? "__custom__"
|
||||
: terminalSettings.localShell || ""
|
||||
: (terminalSettings.localShell || "__default__")
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
onValueChange={(value) => {
|
||||
if (value === "__custom__") {
|
||||
setCustomShellDraft(terminalSettings.localShell || "");
|
||||
setCustomShellModalOpen(true);
|
||||
} else if (value === "__default__") {
|
||||
setShowCustomShellInput(false);
|
||||
updateTerminalSetting("localShell", "");
|
||||
} else {
|
||||
setShowCustomShellInput(false);
|
||||
updateTerminalSetting("localShell", value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{t("settings.terminal.localShell.shell.default")}
|
||||
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
|
||||
</option>
|
||||
{discoveredShells.map((shell) => (
|
||||
<option key={shell.id} value={shell.id}>
|
||||
{shell.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom__">{t("settings.terminal.localShell.shell.custom")}</option>
|
||||
</select>
|
||||
<SelectTrigger className="h-9 w-48 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
{t("settings.terminal.localShell.shell.default")}
|
||||
{defaultShell ? ` (${defaultShell.split(/[/\\]/).pop()})` : ""}
|
||||
</SelectItem>
|
||||
{discoveredShells.map((shell) => (
|
||||
<SelectItem key={shell.id} value={shell.id}>
|
||||
{shell.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__custom__">{t("settings.terminal.localShell.shell.custom")}</SelectItem>
|
||||
</SelectContent>
|
||||
</ShadcnSelect>
|
||||
{showCustomShellInput && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-48">
|
||||
{terminalSettings.localShell}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import type { AIProviderId } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import type { FetchedModel } from "./types";
|
||||
import { getFetchBridge } from "./types";
|
||||
@@ -120,16 +121,20 @@ export const ModelSelector: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
{canFetch && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setHasFetched(false); void fetchModels(); }}
|
||||
disabled={isLoading}
|
||||
className="shrink-0 px-2"
|
||||
title={t('ai.providers.refreshModels')}
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setHasFetched(false); void fetchModels(); }}
|
||||
disabled={isLoading}
|
||||
className="shrink-0 px-2"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? "animate-spin" : ""} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.providers.refreshModels')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Toggle } from "../../settings-ui";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { ProviderIconBadge } from "./ProviderIconBadge";
|
||||
import { ProviderConfigForm } from "./ProviderConfigForm";
|
||||
@@ -61,20 +62,28 @@ export const ProviderCard: React.FC<{
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={t('ai.providers.configure')}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title={t('ai.providers.remove')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.providers.configure')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChevronDown, ChevronRight, Home, MoreHorizontal } from 'lucide-react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface SftpBreadcrumbProps {
|
||||
@@ -89,76 +90,90 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
|
||||
const showDriveDropdown = isWindowsPath && isLocal && !!onListDrives;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden"
|
||||
title={path}
|
||||
>
|
||||
<button
|
||||
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>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground overflow-hidden cursor-default">
|
||||
<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"
|
||||
)}
|
||||
title={part}
|
||||
onClick={onHome}
|
||||
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0"
|
||||
>
|
||||
{part}
|
||||
<Home size={12} />
|
||||
</button>
|
||||
)}
|
||||
{!isLast && <ChevronRight size={12} className="opacity-40 shrink-0" />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.goHome")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<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 && (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { Folder, Link } from 'lucide-react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SftpFileEntry } from '../../types';
|
||||
import { buildSftpColumnTemplate, ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
|
||||
@@ -106,17 +107,21 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
entry.type === 'symlink' && "italic pr-1",
|
||||
isSelectionVisible && "font-medium",
|
||||
)}
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate cursor-default",
|
||||
entry.type === 'symlink' && "italic pr-1",
|
||||
isSelectionVisible && "font-medium",
|
||||
)}
|
||||
>
|
||||
{entry.name}
|
||||
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{entry.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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")}>
|
||||
|
||||
@@ -477,22 +477,26 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={t("sftp.path.doubleClickToEdit")}
|
||||
>
|
||||
<SftpBreadcrumb
|
||||
path={displayPath}
|
||||
onNavigate={onNavigateTo}
|
||||
onHome={() =>
|
||||
pane.connection?.homeDir &&
|
||||
onNavigateTo(pane.connection.homeDir)
|
||||
}
|
||||
isLocal={!isRemote}
|
||||
onListDrives={onListDrives}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
>
|
||||
<SftpBreadcrumb
|
||||
path={displayPath}
|
||||
onNavigate={onNavigateTo}
|
||||
onHome={() =>
|
||||
pane.connection?.homeDir &&
|
||||
onNavigateTo(pane.connection.homeDir)
|
||||
}
|
||||
isLocal={!isRemote}
|
||||
onListDrives={onListDrives}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.path.doubleClickToEdit")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Bookmark button with dropdown */}
|
||||
@@ -555,15 +559,19 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
{bm.global && (
|
||||
<Globe size={10} className="shrink-0 text-primary" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-xs truncate font-mono"
|
||||
onClick={() => onNavigateToBookmark(bm.path)}
|
||||
title={bm.path}
|
||||
>
|
||||
{bm.label}
|
||||
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{bm.path}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -21,6 +21,7 @@ import React, {
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { useActiveTabId } from "./SftpContext";
|
||||
|
||||
@@ -395,13 +396,17 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Add tab button */}
|
||||
<button
|
||||
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}
|
||||
title={t("sftp.tabs.addTab")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.tabs.addTab")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import type { TransferTask } from "../../types";
|
||||
import { Button } from "../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { SftpTransferItem } from "./SftpTransferItem";
|
||||
|
||||
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"
|
||||
style={{ height: clampPanelHeight(panelHeight) }}
|
||||
>
|
||||
<div
|
||||
className="group flex h-3 cursor-row-resize items-center justify-center border-b border-border/30 text-muted-foreground/70"
|
||||
onMouseDown={handleResizeStart}
|
||||
title={t("sftp.transfers.dragToResize")}
|
||||
>
|
||||
<GripHorizontal size={14} className="transition-colors group-hover:text-foreground/80" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
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>
|
||||
</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">
|
||||
<span className="font-medium">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
export interface HostKeywordHighlightPopoverProps {
|
||||
host?: Host;
|
||||
@@ -120,18 +121,22 @@ export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverPr
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title={t('terminal.toolbar.hostHighlight.title')}
|
||||
aria-label={t('terminal.toolbar.hostHighlight.title')}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Highlighter size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
aria-label={t('terminal.toolbar.hostHighlight.title')}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Highlighter size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.toolbar.hostHighlight.title')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<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">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
@@ -175,18 +180,22 @@ export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverPr
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent/50 group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRule(rule.id)}
|
||||
className={`
|
||||
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
|
||||
${rule.enabled
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-transparent border-muted-foreground/50'
|
||||
}
|
||||
`}
|
||||
title={rule.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRule(rule.id)}
|
||||
className={`
|
||||
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
|
||||
${rule.enabled
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-transparent border-muted-foreground/50'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{rule.enabled ? t('common.enabled') : t('common.disabled')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Radio, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface TerminalComposeBarProps {
|
||||
@@ -83,12 +84,14 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Broadcast indicator */}
|
||||
{isBroadcastEnabled && (
|
||||
<div
|
||||
className="flex items-center"
|
||||
title={t("terminal.composeBar.broadcasting")}
|
||||
>
|
||||
<Radio size={14} className="text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center cursor-default">
|
||||
<Radio size={14} className="text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.composeBar.broadcasting")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<button
|
||||
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
|
||||
style={{
|
||||
color: `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`,
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`;
|
||||
e.currentTarget.style.color = resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`;
|
||||
}}
|
||||
onClick={onClose}
|
||||
title={t("terminal.composeBar.close")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
|
||||
style={{
|
||||
color: `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`,
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`;
|
||||
e.currentTarget.style.color = resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 50%, ${resolvedBg} 50%)`;
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.composeBar.close")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Host, SSHKey } from '../../types';
|
||||
import { formatHostPort, resolveTelnetPort } from '../../domain/host';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
|
||||
import { TerminalConnectionProgress, TerminalConnectionProgressProps } from './TerminalConnectionProgress';
|
||||
import { HostKeyInfo, TerminalHostKeyVerification } from './TerminalHostKeyVerification';
|
||||
@@ -203,16 +204,20 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
</Button>
|
||||
)}
|
||||
{canDismissDisconnected && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
|
||||
title={t('terminal.connection.dismissDisconnectedDialog')}
|
||||
onClick={onDismissDisconnected}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
|
||||
onClick={onDismissDisconnected}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.connection.dismissDisconnectedDialog')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ChevronUp, ChevronDown, Search } from 'lucide-react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
export interface TerminalSearchBarProps {
|
||||
isOpen: boolean;
|
||||
@@ -115,48 +116,56 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 disabled:opacity-30"
|
||||
style={{
|
||||
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFindPrevious();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
disabled={!searchTerm}
|
||||
title={t("terminal.search.prevMatch")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 disabled:opacity-30"
|
||||
style={{
|
||||
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFindNext();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
disabled={!searchTerm}
|
||||
title={t("terminal.search.nextMatch")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 disabled:opacity-30"
|
||||
style={{
|
||||
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFindPrevious();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
disabled={!searchTerm}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.search.prevMatch")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 disabled:opacity-30"
|
||||
style={{
|
||||
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFindNext();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
disabled={!searchTerm}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.search.nextMatch")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure
|
||||
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
|
||||
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
|
||||
import { CustomThemeModal } from './CustomThemeModal';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
@@ -581,26 +582,32 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
|
||||
<select
|
||||
value={currentFontWeight}
|
||||
onChange={(e) => onFontWeightChange(Number(e.target.value))}
|
||||
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)',
|
||||
}}
|
||||
<Select
|
||||
value={String(currentFontWeight)}
|
||||
onValueChange={(v) => onFontWeightChange(Number(v))}
|
||||
>
|
||||
<option value={100}>100 Thin</option>
|
||||
<option value={200}>200 ExtraLight</option>
|
||||
<option value={300}>300 Light</option>
|
||||
<option value={400}>400 Normal</option>
|
||||
<option value={500}>500 Medium</option>
|
||||
<option value={600}>600 SemiBold</option>
|
||||
<option value={700}>700 Bold</option>
|
||||
<option value={800}>800 ExtraBold</option>
|
||||
<option value={900}>900 Black</option>
|
||||
</select>
|
||||
<SelectTrigger
|
||||
className="flex-1 h-7 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-panel-bg)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
borderColor: 'var(--terminal-panel-border)',
|
||||
}}
|
||||
>
|
||||
<SelectValue />
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface ZmodemProgressIndicatorProps {
|
||||
transferType: 'upload' | 'download' | null;
|
||||
@@ -30,9 +32,14 @@ export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = (
|
||||
finalizing,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0;
|
||||
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})` : '';
|
||||
|
||||
return (
|
||||
@@ -67,13 +74,17 @@ export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = (
|
||||
{formatBytes(transferred)} / {formatBytes(total)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
|
||||
title="Cancel transfer (Ctrl+C)"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 opacity-60" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('zmodem.cancelTransfer')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
17
index.tsx
17
index.tsx
@@ -9,6 +9,7 @@ import '@fontsource/jetbrains-mono/500.css';
|
||||
import '@fontsource/jetbrains-mono/600.css';
|
||||
import App from './App';
|
||||
import { ToastProvider } from './components/ui/toast';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
|
||||
const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
|
||||
const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
|
||||
@@ -103,17 +104,21 @@ const renderApp = () => {
|
||||
if (route === 'settings') {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
<Suspense fallback={<SettingsWindowFallback />}>
|
||||
<LazySettingsPage />
|
||||
</Suspense>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Suspense fallback={<SettingsWindowFallback />}>
|
||||
<LazySettingsPage />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
} else if (route === 'tray') {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel…</div>}>
|
||||
<LazyTrayPanel />
|
||||
</Suspense>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel…</div>}>
|
||||
<LazyTrayPanel />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user